tauri_plugin_matrix_svelte/matrix/
event_preview.rs

1use std::borrow::Cow;
2
3use matrix_sdk::ruma::events::{
4    room::{
5        guest_access::GuestAccess,
6        history_visibility::HistoryVisibility,
7        join_rules::JoinRule,
8        message::{MessageFormat, MessageType},
9    },
10    FullStateEventContent,
11};
12use matrix_sdk_ui::timeline::{
13    AnyOtherFullStateEventContent, EventTimelineItem, MembershipChange, MsgLikeKind,
14    RoomMembershipChange, TimelineItemContent,
15};
16
17use super::utils::{get_or_fetch_event_sender, trim_start_html_whitespace};
18
19/// What should be displayed before the text preview of an event.
20pub enum BeforeText {
21    /// Nothing should be displayed before the text preview.
22    Nothing,
23    /// The sender's username with a colon should be displayed before the text preview.
24    UsernameWithColon,
25    /// The sender's username (without a colon) should be displayed before the text preview.
26    UsernameWithoutColon,
27}
28
29/// A text preview of a timeline event, plus how a username should be displayed before it.
30///
31/// Call [`TextPreview::format_with()`] to generate displayable text
32/// with the appropriately-formatted preceding username.
33pub struct TextPreview {
34    text: String,
35    before_text: BeforeText,
36}
37impl From<(String, BeforeText)> for TextPreview {
38    fn from((text, before_text): (String, BeforeText)) -> Self {
39        Self { text, before_text }
40    }
41}
42impl TextPreview {
43    /// Formats the text preview with the appropriate preceding username.
44    pub fn format_with(self, username: &str, as_html: bool) -> String {
45        let Self { text, before_text } = self;
46        match before_text {
47            BeforeText::Nothing => text,
48            BeforeText::UsernameWithColon => format!(
49                "<b>{}</b>: {}",
50                if as_html {
51                    htmlize::escape_text(username)
52                } else {
53                    username.into()
54                },
55                text,
56            ),
57            BeforeText::UsernameWithoutColon => format!(
58                "{} {}",
59                if as_html {
60                    htmlize::escape_text(username)
61                } else {
62                    username.into()
63                },
64                text,
65            ),
66        }
67    }
68}
69
70/// Returns a text preview of the given timeline event as an Html-formatted string.
71pub fn text_preview_of_timeline_item(
72    content: &TimelineItemContent,
73    sender_username: &str,
74) -> TextPreview {
75    match content {
76        TimelineItemContent::MsgLike(m) => {
77            let message = m.clone();
78            match message.kind {
79                MsgLikeKind::Message(a) => text_preview_of_message(&a, sender_username),
80                MsgLikeKind::Sticker(sticker) => TextPreview::from((
81                    format!(
82                        "[Sticker]: <i>{}</i>",
83                        htmlize::escape_text(&sticker.content().body)
84                    ),
85                    BeforeText::UsernameWithColon,
86                )),
87                MsgLikeKind::Poll(poll_state) => TextPreview::from((
88                    format!(
89                        "[Poll]: {}",
90                        htmlize::escape_text(
91                            poll_state
92                                .fallback_text()
93                                .unwrap_or_else(|| poll_state.results().question)
94                        ),
95                    ),
96                    BeforeText::UsernameWithColon,
97                )),
98                MsgLikeKind::Redacted => TextPreview::from((
99                    String::from("[Message was deleted]"),
100                    BeforeText::UsernameWithColon,
101                )),
102                MsgLikeKind::UnableToDecrypt(_encrypted_message) => TextPreview::from((
103                    String::from("[Unable to decrypt message]"),
104                    BeforeText::UsernameWithColon,
105                )),
106            }
107        }
108        TimelineItemContent::MembershipChange(membership_change) => {
109            text_preview_of_room_membership_change(membership_change, true).unwrap_or_else(|| {
110                TextPreview::from((
111                    String::from("<i>underwent a membership change</i>"),
112                    BeforeText::UsernameWithoutColon,
113                ))
114            })
115        }
116        TimelineItemContent::ProfileChange(profile_change) => {
117            text_preview_of_member_profile_change(profile_change, sender_username, true)
118        }
119        TimelineItemContent::OtherState(other_state) => {
120            text_preview_of_other_state(other_state, true).unwrap_or_else(|| {
121                TextPreview::from((
122                    String::from("<i>initiated another state change</i>"),
123                    BeforeText::UsernameWithoutColon,
124                ))
125            })
126        }
127        TimelineItemContent::FailedToParseMessageLike { event_type, .. } => TextPreview::from((
128            format!("[Failed to parse <i>{}</i> message]", event_type),
129            BeforeText::UsernameWithColon,
130        )),
131        TimelineItemContent::FailedToParseState { event_type, .. } => TextPreview::from((
132            format!("[Failed to parse <i>{}</i> state]", event_type),
133            BeforeText::UsernameWithColon,
134        )),
135        TimelineItemContent::CallInvite => TextPreview::from((
136            String::from("[Call Invitation]"),
137            BeforeText::UsernameWithColon,
138        )),
139        TimelineItemContent::CallNotify => TextPreview::from((
140            String::from("[Call Notification]"),
141            BeforeText::UsernameWithColon,
142        )),
143    }
144}
145
146/// Returns the plaintext `body` of the given timeline event.
147pub fn plaintext_body_of_timeline_item(event_tl_item: &EventTimelineItem) -> String {
148    match event_tl_item.content() {
149        TimelineItemContent::MsgLike(m) => {
150            let message = m.clone();
151            match message.kind {
152                MsgLikeKind::Message(msg) => msg.body().into(),
153                MsgLikeKind::Redacted => "[Message was deleted]".into(),
154                MsgLikeKind::Sticker(sticker) => sticker.content().body.clone(),
155                MsgLikeKind::UnableToDecrypt(_encrypted_msg) => "[Unable to Decrypt]".into(),
156                MsgLikeKind::Poll(poll_state) => {
157                    format!(
158                        "[Poll]: {}",
159                        poll_state
160                            .fallback_text()
161                            .unwrap_or_else(|| poll_state.results().question)
162                    )
163                }
164            }
165        }
166        TimelineItemContent::MembershipChange(membership_change) => {
167            text_preview_of_room_membership_change(membership_change, false)
168                .unwrap_or_else(|| {
169                    TextPreview::from((
170                        String::from("underwent a membership change."),
171                        BeforeText::UsernameWithoutColon,
172                    ))
173                })
174                .format_with(&get_or_fetch_event_sender(event_tl_item, None), false)
175        }
176        TimelineItemContent::ProfileChange(profile_change) => {
177            text_preview_of_member_profile_change(
178                profile_change,
179                &get_or_fetch_event_sender(event_tl_item, None),
180                false,
181            )
182            .text
183        }
184        TimelineItemContent::OtherState(other_state) => {
185            text_preview_of_other_state(other_state, false)
186                .unwrap_or_else(|| {
187                    TextPreview::from((
188                        String::from("initiated another state change."),
189                        BeforeText::UsernameWithoutColon,
190                    ))
191                })
192                .format_with(&get_or_fetch_event_sender(event_tl_item, None), false)
193        }
194        TimelineItemContent::FailedToParseMessageLike { event_type, error } => {
195            format!("Failed to parse {} message. Error: {}", event_type, error)
196        }
197        TimelineItemContent::FailedToParseState {
198            event_type,
199            error,
200            state_key,
201        } => {
202            format!(
203                "Failed to parse {} state; key: {}. Error: {}",
204                event_type, state_key, error
205            )
206        }
207        TimelineItemContent::CallInvite => String::from("[Call Invitation]"),
208        TimelineItemContent::CallNotify => String::from("[Call Notification]"),
209    }
210}
211
212/// Returns a text preview of the given message as an Html-formatted string.
213pub fn text_preview_of_message(
214    message: &matrix_sdk_ui::timeline::Message,
215    sender_username: &str,
216) -> TextPreview {
217    let text = match message.msgtype() {
218        MessageType::Audio(audio) => format!(
219            "[Audio]: <i>{}</i>",
220            if let Some(formatted_body) = audio.formatted.as_ref() {
221                Cow::Borrowed(formatted_body.body.as_str())
222            } else {
223                htmlize::escape_text(audio.body.as_str())
224            }
225        ),
226        MessageType::Emote(emote) => format!(
227            "* {} {}",
228            sender_username,
229            if let Some(formatted_body) = emote.formatted.as_ref() {
230                Cow::Borrowed(formatted_body.body.as_str())
231            } else {
232                htmlize::escape_text(emote.body.as_str())
233            }
234        ),
235        MessageType::File(file) => format!(
236            "[File]: <i>{}</i>",
237            if let Some(formatted_body) = file.formatted.as_ref() {
238                Cow::Borrowed(formatted_body.body.as_str())
239            } else {
240                htmlize::escape_text(file.body.as_str())
241            }
242        ),
243        MessageType::Image(image) => format!(
244            "[Image]: <i>{}</i>",
245            if let Some(formatted_body) = image.formatted.as_ref() {
246                Cow::Borrowed(formatted_body.body.as_str())
247            } else {
248                htmlize::escape_text(image.body.as_str())
249            }
250        ),
251        MessageType::Location(location) => format!(
252            "[Location]: <i>{}</i>",
253            htmlize::escape_text(&location.body),
254        ),
255        MessageType::Notice(notice) => format!(
256            "<i>{}</i>",
257            if let Some(formatted_body) = notice.formatted.as_ref() {
258                trim_start_html_whitespace(&formatted_body.body).into()
259            } else {
260                htmlize::escape_text(notice.body.as_str())
261            }
262        ),
263        MessageType::ServerNotice(notice) => format!(
264            "[Server Notice]: <i>{} -- {}</i>",
265            notice.server_notice_type.as_str(),
266            notice.body,
267        ),
268        MessageType::Text(text) => text
269            .formatted
270            .as_ref()
271            .and_then(|fb| {
272                (fb.format == MessageFormat::Html).then(|| {
273                    crate::matrix::utils::linkify(trim_start_html_whitespace(&fb.body), true)
274                        .to_string()
275                })
276            })
277            .unwrap_or_else(|| match crate::matrix::utils::linkify(&text.body, false) {
278                Cow::Borrowed(plaintext) => htmlize::escape_text(plaintext).to_string(),
279                Cow::Owned(linkified) => linkified,
280            }),
281        MessageType::VerificationRequest(verification) => {
282            format!("[Verification Request] <i>to user {}</i>", verification.to,)
283        }
284        MessageType::Video(video) => format!(
285            "[Video]: <i>{}</i>",
286            if let Some(formatted_body) = video.formatted.as_ref() {
287                Cow::Borrowed(formatted_body.body.as_str())
288            } else {
289                htmlize::escape_text(&video.body)
290            }
291        ),
292        MessageType::_Custom(custom) => format!("[Custom message]: {:?}", custom,),
293        other => format!(
294            "[Unknown message type]: {}",
295            htmlize::escape_text(other.body()),
296        ),
297    };
298    TextPreview::from((text, BeforeText::UsernameWithColon))
299}
300
301/// Returns a text preview of the given other state event as an Html-formatted string.
302pub fn text_preview_of_other_state(
303    other_state: &matrix_sdk_ui::timeline::OtherState,
304    format_as_html: bool,
305) -> Option<TextPreview> {
306    let text = match other_state.content() {
307        AnyOtherFullStateEventContent::RoomAliases(FullStateEventContent::Original {
308            content,
309            ..
310        }) => {
311            let mut s = String::from("set this room's aliases to ");
312            let last_alias = content.aliases.len() - 1;
313            for (i, alias) in content.aliases.iter().enumerate() {
314                s.push_str(alias.as_str());
315                if i != last_alias {
316                    s.push_str(", ");
317                }
318            }
319            s.push('.');
320            Some(s)
321        }
322        AnyOtherFullStateEventContent::RoomAvatar(_) => {
323            Some(String::from("set this room's avatar picture."))
324        }
325        AnyOtherFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
326            content,
327            ..
328        }) => Some(format!(
329            "set the main address of this room to {}.",
330            content.alias.as_ref().map(|a| a.as_str()).unwrap_or("none")
331        )),
332        AnyOtherFullStateEventContent::RoomCreate(FullStateEventContent::Original {
333            content,
334            ..
335        }) => Some(format!(
336            "created this room (v{}).",
337            content.room_version.as_str()
338        )),
339        AnyOtherFullStateEventContent::RoomEncryption(_) => {
340            Some(String::from("enabled encryption in this room."))
341        }
342        AnyOtherFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
343            content,
344            ..
345        }) => Some(match &content.guest_access {
346            GuestAccess::CanJoin => String::from("has allowed guests to join this room."),
347            GuestAccess::Forbidden => String::from("has forbidden guests from joining this room."),
348            custom => format!(
349                "has set custom guest access rules for this room: {}",
350                custom.as_str()
351            ),
352        }),
353        AnyOtherFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
354            content,
355            ..
356        }) => Some(format!(
357            "set this room's history to be visible by {}",
358            match &content.history_visibility {
359                HistoryVisibility::Invited => "invited users, since they were invited.",
360                HistoryVisibility::Joined => "joined users, since they joined.",
361                HistoryVisibility::Shared => "joined users, for all of time.",
362                HistoryVisibility::WorldReadable => "anyone for all time.",
363                custom => custom.as_str(),
364            },
365        )),
366        AnyOtherFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
367            content,
368            ..
369        }) => Some(match &content.join_rule {
370            JoinRule::Public => String::from("set this room to be joinable by anyone."),
371            JoinRule::Knock => {
372                String::from("set this room to be joinable by invite only or by request.")
373            }
374            JoinRule::Private => String::from("set this room to be private."),
375            JoinRule::Restricted(_) => {
376                String::from("set this room to be joinable by invite only or with restrictions.")
377            }
378            JoinRule::KnockRestricted(_) => String::from(
379                "set this room to be joinable by invite only or requestable with restrictions.",
380            ),
381            JoinRule::Invite => String::from("set this room to be joinable by invite only."),
382            custom => format!("set custom join rules for this room: {}", custom.as_str()),
383        }),
384        AnyOtherFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original {
385            content,
386            ..
387        }) => Some(format!(
388            "pinned {} events in this room.",
389            content.pinned.len()
390        )),
391        AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original {
392            content,
393            ..
394        }) => {
395            let name = if format_as_html {
396                htmlize::escape_text(&content.name)
397            } else {
398                Cow::Borrowed(content.name.as_str())
399            };
400            Some(format!("changed this room's name to \"{name}\"."))
401        }
402        AnyOtherFullStateEventContent::RoomPowerLevels(_) => {
403            Some(String::from("set the power levels for this room."))
404        }
405        AnyOtherFullStateEventContent::RoomServerAcl(_) => Some(String::from(
406            "set the server access control list for this room.",
407        )),
408        AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
409            content,
410            ..
411        }) => Some(format!(
412            "closed this room and upgraded it to {}",
413            content.replacement_room.matrix_to_uri()
414        )),
415        AnyOtherFullStateEventContent::RoomTopic(FullStateEventContent::Original {
416            content,
417            ..
418        }) => {
419            let topic = if format_as_html {
420                htmlize::escape_text(&content.topic)
421            } else {
422                Cow::Borrowed(content.topic.as_str())
423            };
424            Some(format!("changed this room's topic to \"{topic}\"."))
425        }
426        AnyOtherFullStateEventContent::SpaceParent(_) => {
427            let state_key = if format_as_html {
428                htmlize::escape_text(other_state.state_key())
429            } else {
430                Cow::Borrowed(other_state.state_key())
431            };
432            Some(format!("set this room's parent space to \"{state_key}\"."))
433        }
434        AnyOtherFullStateEventContent::SpaceChild(_) => {
435            let state_key = if format_as_html {
436                htmlize::escape_text(other_state.state_key())
437            } else {
438                Cow::Borrowed(other_state.state_key())
439            };
440            Some(format!("added a new child to this space: \"{state_key}\"."))
441        }
442        _other => {
443            // log!("*** Unhandled: {:?}.", _other);
444            None
445        }
446    };
447    text.map(|t| TextPreview::from((t, BeforeText::UsernameWithoutColon)))
448}
449
450/// Returns a text preview of the given member profile change
451/// as a plaintext or HTML-formatted string.
452pub fn text_preview_of_member_profile_change(
453    change: &matrix_sdk_ui::timeline::MemberProfileChange,
454    username: &str,
455    format_as_html: bool,
456) -> TextPreview {
457    let name_text = if let Some(name_change) = change.displayname_change() {
458        let old = name_change.old.as_deref().unwrap_or(username);
459        let old_un = if format_as_html {
460            htmlize::escape_text(old)
461        } else {
462            old.into()
463        };
464        if let Some(new) = name_change.new.as_ref() {
465            let new_un = if format_as_html {
466                htmlize::escape_text(new)
467            } else {
468                new.into()
469            };
470            format!("{old_un} changed their display name to \"{new_un}\"")
471        } else {
472            format!("{old_un} removed their display name")
473        }
474    } else {
475        String::new()
476    };
477    let avatar_text = if let Some(_avatar_change) = change.avatar_url_change() {
478        if name_text.is_empty() {
479            let un = if format_as_html {
480                htmlize::escape_text(username)
481            } else {
482                username.into()
483            };
484            format!("{un} changed their profile picture")
485        } else {
486            String::from(" and changed their profile picture")
487        }
488    } else {
489        String::new()
490    };
491
492    TextPreview::from((
493        format!("{}{}.", name_text, avatar_text),
494        BeforeText::Nothing,
495    ))
496}
497
498/// Returns a text preview of the given room membership change
499/// as a plaintext or HTML-formatted string.
500pub fn text_preview_of_room_membership_change(
501    change: &RoomMembershipChange,
502    format_as_html: bool,
503) -> Option<TextPreview> {
504    let dn = change.display_name();
505    let change_user_id = dn.as_deref().unwrap_or_else(|| change.user_id().as_str());
506    let change_user_id = if format_as_html {
507        htmlize::escape_text(change_user_id)
508    } else {
509        change_user_id.into()
510    };
511    let text = match change.change() {
512        None
513        | Some(MembershipChange::NotImplemented)
514        | Some(MembershipChange::None)
515        | Some(MembershipChange::Error) => {
516            // Don't actually display anything for nonexistent/unimportant membership changes.
517            return None;
518        }
519        Some(MembershipChange::Joined) => String::from("joined this room."),
520        Some(MembershipChange::Left) => String::from("left this room."),
521        Some(MembershipChange::Banned) => format!("banned {} from this room.", change_user_id),
522        Some(MembershipChange::Unbanned) => format!("unbanned {} from this room.", change_user_id),
523        Some(MembershipChange::Kicked) => format!("kicked {} from this room.", change_user_id),
524        Some(MembershipChange::Invited) => format!("invited {} to this room.", change_user_id),
525        Some(MembershipChange::KickedAndBanned) => {
526            format!("kicked and banned {} from this room.", change_user_id)
527        }
528        Some(MembershipChange::InvitationAccepted) => {
529            String::from("accepted an invitation to this room.")
530        }
531        Some(MembershipChange::InvitationRejected) => {
532            String::from("rejected an invitation to this room.")
533        }
534        Some(MembershipChange::InvitationRevoked) => {
535            format!("revoked {}'s invitation to this room.", change_user_id)
536        }
537        Some(MembershipChange::Knocked) => String::from("requested to join this room."),
538        Some(MembershipChange::KnockAccepted) => {
539            format!("accepted {}'s request to join this room.", change_user_id)
540        }
541        Some(MembershipChange::KnockRetracted) => {
542            String::from("retracted their request to join this room.")
543        }
544        Some(MembershipChange::KnockDenied) => {
545            format!("denied {}'s request to join this room.", change_user_id)
546        }
547    };
548    Some(TextPreview::from((text, BeforeText::UsernameWithoutColon)))
549}