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
19pub enum BeforeText {
21 Nothing,
23 UsernameWithColon,
25 UsernameWithoutColon,
27}
28
29pub 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 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
70pub 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
146pub 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
212pub 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
301pub 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 None
445 }
446 };
447 text.map(|t| TextPreview::from((t, BeforeText::UsernameWithoutColon)))
448}
449
450pub 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
498pub 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 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}