use std::borrow::Cow;
use matrix_sdk::ruma::events::{
StateEventContentChange,
room::{
guest_access::GuestAccess,
history_visibility::HistoryVisibility,
join_rules::JoinRule,
message::{MessageFormat, MessageType},
},
};
use matrix_sdk_ui::timeline::{
AnyOtherStateEventContentChange, EventTimelineItem, MembershipChange, MsgLikeKind,
RoomMembershipChange, TimelineItemContent,
};
use crate::utils::{get_or_fetch_event_sender, trim_start_html_whitespace};
pub enum BeforeText {
Nothing,
UsernameWithColon,
UsernameWithoutColon,
}
pub struct TextPreview {
text: String,
before_text: BeforeText,
}
impl From<(String, BeforeText)> for TextPreview {
fn from((text, before_text): (String, BeforeText)) -> Self {
Self { text, before_text }
}
}
impl TextPreview {
pub fn format_with(self, username: &str, as_html: bool) -> String {
let Self { text, before_text } = self;
match before_text {
BeforeText::Nothing => text,
BeforeText::UsernameWithColon => format!(
"<b>{}</b>: {}",
if as_html {
htmlize::escape_text(username)
} else {
username.into()
},
text,
),
BeforeText::UsernameWithoutColon => format!(
"{} {}",
if as_html {
htmlize::escape_text(username)
} else {
username.into()
},
text,
),
}
}
}
pub fn text_preview_of_timeline_item(
content: &TimelineItemContent,
sender_username: &str,
) -> TextPreview {
match content {
TimelineItemContent::MsgLike(m) => {
let message = m.clone();
match message.kind {
MsgLikeKind::Message(a) => text_preview_of_message(&a, sender_username),
MsgLikeKind::Sticker(sticker) => TextPreview::from((
format!(
"[Sticker]: <i>{}</i>",
htmlize::escape_text(&sticker.content().body)
),
BeforeText::UsernameWithColon,
)),
MsgLikeKind::Poll(poll_state) => TextPreview::from((
format!(
"[Poll]: {}",
htmlize::escape_text(
poll_state
.fallback_text()
.unwrap_or_else(|| poll_state.results().question)
),
),
BeforeText::UsernameWithColon,
)),
MsgLikeKind::Redacted => TextPreview::from((
String::from("[Message was deleted]"),
BeforeText::UsernameWithColon,
)),
MsgLikeKind::LiveLocation(_) => TextPreview::from((
String::from("[Live Location]"),
BeforeText::UsernameWithColon,
)),
MsgLikeKind::UnableToDecrypt(_encrypted_message) => TextPreview::from((
String::from("[Unable to decrypt message]"),
BeforeText::UsernameWithColon,
)),
MsgLikeKind::Other(kind) => TextPreview::from((
format!("[Unsupported event: {} ]", kind.event_type()),
BeforeText::UsernameWithColon,
)),
}
}
TimelineItemContent::MembershipChange(membership_change) => {
text_preview_of_room_membership_change(membership_change, true).unwrap_or_else(|| {
TextPreview::from((
String::from("<i>underwent a membership change</i>"),
BeforeText::UsernameWithoutColon,
))
})
}
TimelineItemContent::ProfileChange(profile_change) => {
text_preview_of_member_profile_change(profile_change, sender_username, true)
}
TimelineItemContent::OtherState(other_state) => {
text_preview_of_other_state(other_state, true).unwrap_or_else(|| {
TextPreview::from((
String::from("<i>initiated another state change</i>"),
BeforeText::UsernameWithoutColon,
))
})
}
TimelineItemContent::FailedToParseMessageLike { event_type, .. } => TextPreview::from((
format!("[Failed to parse <i>{}</i> message]", event_type),
BeforeText::UsernameWithColon,
)),
TimelineItemContent::FailedToParseState { event_type, .. } => TextPreview::from((
format!("[Failed to parse <i>{}</i> state]", event_type),
BeforeText::UsernameWithColon,
)),
TimelineItemContent::CallInvite => TextPreview::from((
String::from("[Call Invitation]"),
BeforeText::UsernameWithColon,
)),
TimelineItemContent::RtcNotification { .. } => TextPreview::from((
String::from("[Call Notification]"),
BeforeText::UsernameWithColon,
)),
}
}
pub fn _plaintext_body_of_timeline_item(event_tl_item: &EventTimelineItem) -> String {
match event_tl_item.content() {
TimelineItemContent::MsgLike(m) => {
let message = m.clone();
match message.kind {
MsgLikeKind::Message(msg) => msg.body().into(),
MsgLikeKind::Redacted => "[Message was deleted]".into(),
MsgLikeKind::Sticker(sticker) => sticker.content().body.clone(),
MsgLikeKind::UnableToDecrypt(_encrypted_msg) => "[Unable to Decrypt]".into(),
MsgLikeKind::Poll(poll_state) => {
format!(
"[Poll]: {}",
poll_state
.fallback_text()
.unwrap_or_else(|| poll_state.results().question)
)
}
MsgLikeKind::LiveLocation(_) => String::from("[Live location]"),
MsgLikeKind::Other(kind) => kind.event_type().to_string(),
}
}
TimelineItemContent::MembershipChange(membership_change) => {
text_preview_of_room_membership_change(membership_change, false)
.unwrap_or_else(|| {
TextPreview::from((
String::from("underwent a membership change."),
BeforeText::UsernameWithoutColon,
))
})
.format_with(&get_or_fetch_event_sender(event_tl_item, None), false)
}
TimelineItemContent::ProfileChange(profile_change) => {
text_preview_of_member_profile_change(
profile_change,
&get_or_fetch_event_sender(event_tl_item, None),
false,
)
.text
}
TimelineItemContent::OtherState(other_state) => {
text_preview_of_other_state(other_state, false)
.unwrap_or_else(|| {
TextPreview::from((
String::from("initiated another state change."),
BeforeText::UsernameWithoutColon,
))
})
.format_with(&get_or_fetch_event_sender(event_tl_item, None), false)
}
TimelineItemContent::FailedToParseMessageLike { event_type, error } => {
format!("Failed to parse {} message. Error: {}", event_type, error)
}
TimelineItemContent::FailedToParseState {
event_type,
error,
state_key,
} => {
format!(
"Failed to parse {} state; key: {}. Error: {}",
event_type, state_key, error
)
}
TimelineItemContent::CallInvite => String::from("[Call Invitation]"),
TimelineItemContent::RtcNotification { .. } => String::from("[Call Notification]"),
}
}
pub fn text_preview_of_message(
message: &matrix_sdk_ui::timeline::Message,
sender_username: &str,
) -> TextPreview {
let text = match message.msgtype() {
MessageType::Audio(audio) => format!(
"[Audio]: <i>{}</i>",
if let Some(formatted_body) = audio.formatted.as_ref() {
Cow::Borrowed(formatted_body.body.as_str())
} else {
htmlize::escape_text(audio.body.as_str())
}
),
MessageType::Emote(emote) => format!(
"* {} {}",
sender_username,
if let Some(formatted_body) = emote.formatted.as_ref() {
Cow::Borrowed(formatted_body.body.as_str())
} else {
htmlize::escape_text(emote.body.as_str())
}
),
MessageType::File(file) => format!(
"[File]: <i>{}</i>",
if let Some(formatted_body) = file.formatted.as_ref() {
Cow::Borrowed(formatted_body.body.as_str())
} else {
htmlize::escape_text(file.body.as_str())
}
),
MessageType::Image(image) => format!(
"[Image]: <i>{}</i>",
if let Some(formatted_body) = image.formatted.as_ref() {
Cow::Borrowed(formatted_body.body.as_str())
} else {
htmlize::escape_text(image.body.as_str())
}
),
MessageType::Location(location) => format!(
"[Location]: <i>{}</i>",
htmlize::escape_text(&location.body),
),
MessageType::Notice(notice) => format!(
"<i>{}</i>",
if let Some(formatted_body) = notice.formatted.as_ref() {
trim_start_html_whitespace(&formatted_body.body).into()
} else {
htmlize::escape_text(notice.body.as_str())
}
),
MessageType::ServerNotice(notice) => format!(
"[Server Notice]: <i>{} -- {}</i>",
notice.server_notice_type.as_str(),
notice.body,
),
MessageType::Text(text) => text
.formatted
.as_ref()
.and_then(|fb| {
(fb.format == MessageFormat::Html).then(|| {
crate::utils::linkify(trim_start_html_whitespace(&fb.body), true).to_string()
})
})
.unwrap_or_else(|| match crate::utils::linkify(&text.body, false) {
Cow::Borrowed(plaintext) => htmlize::escape_text(plaintext).to_string(),
Cow::Owned(linkified) => linkified,
}),
MessageType::VerificationRequest(verification) => {
format!("[Verification Request] <i>to user {}</i>", verification.to,)
}
MessageType::Video(video) => format!(
"[Video]: <i>{}</i>",
if let Some(formatted_body) = video.formatted.as_ref() {
Cow::Borrowed(formatted_body.body.as_str())
} else {
htmlize::escape_text(&video.body)
}
),
MessageType::_Custom(custom) => format!("[Custom message]: {:?}", custom,),
other => format!(
"[Unknown message type]: {}",
htmlize::escape_text(other.body()),
),
};
TextPreview::from((text, BeforeText::UsernameWithColon))
}
pub fn text_preview_of_other_state(
other_state: &matrix_sdk_ui::timeline::OtherState,
format_as_html: bool,
) -> Option<TextPreview> {
let text = match other_state.content() {
AnyOtherStateEventContentChange::RoomAvatar(_) => {
Some(String::from("set this room's avatar picture."))
}
AnyOtherStateEventContentChange::RoomCanonicalAlias(
StateEventContentChange::Original { content, .. },
) => Some(format!(
"set the main address of this room to {}.",
content.alias.as_ref().map(|a| a.as_str()).unwrap_or("none")
)),
AnyOtherStateEventContentChange::RoomCreate(StateEventContentChange::Original {
content,
..
}) => Some(format!(
"created this room (v{}).",
content.room_version.as_str()
)),
AnyOtherStateEventContentChange::RoomEncryption(_) => {
Some(String::from("enabled encryption in this room."))
}
AnyOtherStateEventContentChange::RoomGuestAccess(StateEventContentChange::Original {
content,
..
}) => Some(match &content.guest_access {
GuestAccess::CanJoin => String::from("has allowed guests to join this room."),
GuestAccess::Forbidden => String::from("has forbidden guests from joining this room."),
custom => format!(
"has set custom guest access rules for this room: {}",
custom.as_str()
),
}),
AnyOtherStateEventContentChange::RoomHistoryVisibility(
StateEventContentChange::Original { content, .. },
) => Some(format!(
"set this room's history to be visible by {}",
match &content.history_visibility {
HistoryVisibility::Invited => "invited users, since they were invited.",
HistoryVisibility::Joined => "joined users, since they joined.",
HistoryVisibility::Shared => "joined users, for all of time.",
HistoryVisibility::WorldReadable => "anyone for all time.",
custom => custom.as_str(),
},
)),
AnyOtherStateEventContentChange::RoomJoinRules(StateEventContentChange::Original {
content,
..
}) => Some(match &content.join_rule {
JoinRule::Public => String::from("set this room to be joinable by anyone."),
JoinRule::Knock => {
String::from("set this room to be joinable by invite only or by request.")
}
JoinRule::Private => String::from("set this room to be private."),
JoinRule::Restricted(_) => {
String::from("set this room to be joinable by invite only or with restrictions.")
}
JoinRule::KnockRestricted(_) => String::from(
"set this room to be joinable by invite only or requestable with restrictions.",
),
JoinRule::Invite => String::from("set this room to be joinable by invite only."),
custom => format!("set custom join rules for this room: {}", custom.as_str()),
}),
AnyOtherStateEventContentChange::RoomPinnedEvents(StateEventContentChange::Original {
content,
..
}) => Some(format!(
"pinned {} events in this room.",
content.pinned.len()
)),
AnyOtherStateEventContentChange::RoomName(StateEventContentChange::Original {
content,
..
}) => {
let name = if format_as_html {
htmlize::escape_text(&content.name)
} else {
Cow::Borrowed(content.name.as_str())
};
Some(format!("changed this room's name to \"{name}\"."))
}
AnyOtherStateEventContentChange::RoomPowerLevels(_) => {
Some(String::from("set the power levels for this room."))
}
AnyOtherStateEventContentChange::RoomServerAcl(_) => Some(String::from(
"set the server access control list for this room.",
)),
AnyOtherStateEventContentChange::RoomTombstone(StateEventContentChange::Original {
content,
..
}) => Some(format!(
"closed this room and upgraded it to {}",
content.replacement_room.matrix_to_uri()
)),
AnyOtherStateEventContentChange::RoomTopic(StateEventContentChange::Original {
content,
..
}) => {
let topic = if format_as_html {
htmlize::escape_text(&content.topic)
} else {
Cow::Borrowed(content.topic.as_str())
};
Some(format!("changed this room's topic to \"{topic}\"."))
}
AnyOtherStateEventContentChange::SpaceParent(_) => {
let state_key = if format_as_html {
htmlize::escape_text(other_state.state_key())
} else {
Cow::Borrowed(other_state.state_key())
};
Some(format!("set this room's parent space to \"{state_key}\"."))
}
AnyOtherStateEventContentChange::SpaceChild(_) => {
let state_key = if format_as_html {
htmlize::escape_text(other_state.state_key())
} else {
Cow::Borrowed(other_state.state_key())
};
Some(format!("added a new child to this space: \"{state_key}\"."))
}
_other => {
None
}
};
text.map(|t| TextPreview::from((t, BeforeText::UsernameWithoutColon)))
}
pub fn text_preview_of_member_profile_change(
change: &matrix_sdk_ui::timeline::MemberProfileChange,
username: &str,
format_as_html: bool,
) -> TextPreview {
let name_text = if let Some(name_change) = change.displayname_change() {
let old = name_change.old.as_deref().unwrap_or(username);
let old_un = if format_as_html {
htmlize::escape_text(old)
} else {
old.into()
};
if let Some(new) = name_change.new.as_ref() {
let new_un = if format_as_html {
htmlize::escape_text(new)
} else {
new.into()
};
format!("{old_un} changed their display name to \"{new_un}\"")
} else {
format!("{old_un} removed their display name")
}
} else {
String::new()
};
let avatar_text = if let Some(_avatar_change) = change.avatar_url_change() {
if name_text.is_empty() {
let un = if format_as_html {
htmlize::escape_text(username)
} else {
username.into()
};
format!("{un} changed their profile picture")
} else {
String::from(" and changed their profile picture")
}
} else {
String::new()
};
TextPreview::from((
format!("{}{}.", name_text, avatar_text),
BeforeText::Nothing,
))
}
pub fn text_preview_of_room_membership_change(
change: &RoomMembershipChange,
format_as_html: bool,
) -> Option<TextPreview> {
let dn = change.display_name();
let change_user_id = dn.as_deref().unwrap_or_else(|| change.user_id().as_str());
let change_user_id = if format_as_html {
htmlize::escape_text(change_user_id)
} else {
change_user_id.into()
};
let text = match change.change() {
None
| Some(MembershipChange::NotImplemented)
| Some(MembershipChange::None)
| Some(MembershipChange::Error) => {
return None;
}
Some(MembershipChange::Joined) => String::from("joined this room."),
Some(MembershipChange::Left) => String::from("left this room."),
Some(MembershipChange::Banned) => format!("banned {} from this room.", change_user_id),
Some(MembershipChange::Unbanned) => format!("unbanned {} from this room.", change_user_id),
Some(MembershipChange::Kicked) => format!("kicked {} from this room.", change_user_id),
Some(MembershipChange::Invited) => format!("invited {} to this room.", change_user_id),
Some(MembershipChange::KickedAndBanned) => {
format!("kicked and banned {} from this room.", change_user_id)
}
Some(MembershipChange::InvitationAccepted) => {
String::from("accepted an invitation to this room.")
}
Some(MembershipChange::InvitationRejected) => {
String::from("rejected an invitation to this room.")
}
Some(MembershipChange::InvitationRevoked) => {
format!("revoked {}'s invitation to this room.", change_user_id)
}
Some(MembershipChange::Knocked) => String::from("requested to join this room."),
Some(MembershipChange::KnockAccepted) => {
format!("accepted {}'s request to join this room.", change_user_id)
}
Some(MembershipChange::KnockRetracted) => {
String::from("retracted their request to join this room.")
}
Some(MembershipChange::KnockDenied) => {
format!("denied {}'s request to join this room.", change_user_id)
}
};
Some(TextPreview::from((text, BeforeText::UsernameWithoutColon)))
}