From 84eaadc09a739ad486ed0e645b00737eadf179d9 Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Fri, 30 May 2025 23:06:19 -0700 Subject: [PATCH] Show state events in the timeline (#437) --- src/base.rs | 20 + src/message/html.rs | 135 ++++-- src/message/mod.rs | 47 +- src/message/printer.rs | 37 +- src/message/state.rs | 944 +++++++++++++++++++++++++++++++++++++++ src/windows/room/chat.rs | 3 + src/worker.rs | 38 +- 7 files changed, 1145 insertions(+), 79 deletions(-) create mode 100644 src/message/state.rs diff --git a/src/base.rs b/src/base.rs index 754dec9..b8b0978 100644 --- a/src/base.rs +++ b/src/base.rs @@ -47,6 +47,7 @@ use matrix_sdk::{ }, room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent}, tag::{TagName, Tags}, + AnySyncStateEvent, MessageLikeEvent, }, presence::PresenceState, @@ -836,6 +837,9 @@ pub enum EventLocation { /// The [EventId] belongs to a reaction to the given event. Reaction(OwnedEventId), + + /// The [EventId] belongs to a state event in the main timeline of the room. + State(MessageKey), } impl EventLocation { @@ -1003,6 +1007,12 @@ impl RoomInfo { match self.keys.get(redacts) { None => return, + Some(EventLocation::State(key)) => { + if let Some(msg) = self.messages.get_mut(key) { + let ev = SyncRoomRedactionEvent::Original(ev); + msg.redact(ev, room_version); + } + }, Some(EventLocation::Message(None, key)) => { if let Some(msg) = self.messages.get_mut(key) { let ev = SyncRoomRedactionEvent::Original(ev); @@ -1076,6 +1086,7 @@ impl RoomInfo { content.apply_replacement(new_msgtype); }, MessageEvent::Redacted(_) | + MessageEvent::State(_) | MessageEvent::EncryptedOriginal(_) | MessageEvent::EncryptedRedacted(_) => { return; @@ -1085,6 +1096,15 @@ impl RoomInfo { msg.html = msg.event.html(); } + pub fn insert_any_state(&mut self, msg: AnySyncStateEvent) { + let event_id = msg.event_id().to_owned(); + let key = (msg.origin_server_ts().into(), event_id.clone()); + + let loc = EventLocation::State(key.clone()); + self.keys.insert(event_id, loc); + self.messages.insert_message(key, msg); + } + /// Indicates whether this room has unread messages. pub fn unreads(&self, settings: &ApplicationSettings) -> UnreadInfo { let last_message = self.messages.last_key_value(); diff --git a/src/message/html.rs b/src/message/html.rs index ff62aae..5ba9830 100644 --- a/src/message/html.rs +++ b/src/message/html.rs @@ -10,10 +10,12 @@ //! //! This isn't as important for iamb, since it isn't a browser environment, but we do still map //! input onto an enum of the safe list of tags to keep it easy to understand and process. +use std::borrow::Cow; use std::ops::Deref; use css_color_parser::Color as CssColor; use markup5ever_rcdom::{Handle, NodeData, RcDom}; +use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId}; use unicode_segmentation::UnicodeSegmentation; use url::Url; @@ -34,6 +36,7 @@ use ratatui::{ }; use crate::{ + config::ApplicationSettings, message::printer::TextPrinter, util::{join_cell_text, space_text}, }; @@ -148,7 +151,12 @@ impl Table { } } - fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { + fn to_text<'a>( + &'a self, + width: usize, + style: Style, + settings: &'a ApplicationSettings, + ) -> Text<'a> { let mut text = Text::default(); let columns = self.columns(); let cell_total = width.saturating_sub(columns).saturating_sub(1); @@ -167,7 +175,7 @@ impl Table { if let Some(caption) = &self.caption { let subw = width.saturating_sub(6); let mut printer = - TextPrinter::new(subw, style, true, emoji_shortcodes).align(Alignment::Center); + TextPrinter::new(subw, style, true, settings).align(Alignment::Center); caption.print(&mut printer, style); for mut line in printer.finish().lines { @@ -214,7 +222,7 @@ impl Table { CellType::Data => style, }; - cell.to_text(*w, style, emoji_shortcodes) + cell.to_text(*w, style, settings) } else { space_text(*w, style) }; @@ -271,13 +279,21 @@ pub enum StyleTreeNode { Ruler, Style(Box, Style), Table(Table), - Text(String), + Text(Cow<'static, str>), Sequence(StyleTreeChildren), + RoomAlias(OwnedRoomAliasId), + RoomId(OwnedRoomId), + UserId(OwnedUserId), } impl StyleTreeNode { - pub fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { - let mut printer = TextPrinter::new(width, style, true, emoji_shortcodes); + pub fn to_text<'a>( + &'a self, + width: usize, + style: Style, + settings: &'a ApplicationSettings, + ) -> Text<'a> { + let mut printer = TextPrinter::new(width, style, true, settings); self.print(&mut printer, style); printer.finish() } @@ -312,6 +328,11 @@ impl StyleTreeNode { StyleTreeNode::Ruler => {}, StyleTreeNode::Text(_) => {}, StyleTreeNode::Break => {}, + + // TODO: eventually these should turn into internal links: + StyleTreeNode::UserId(_) => {}, + StyleTreeNode::RoomId(_) => {}, + StyleTreeNode::RoomAlias(_) => {}, } } @@ -430,14 +451,14 @@ impl StyleTreeNode { } }, StyleTreeNode::Table(table) => { - let text = table.to_text(width, style, printer.emoji_shortcodes()); + let text = table.to_text(width, style, printer.settings); printer.push_text(text); }, StyleTreeNode::Break => { printer.push_break(); }, StyleTreeNode::Text(s) => { - printer.push_str(s.as_str(), style); + printer.push_str(s.as_ref(), style); }, StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)), @@ -446,13 +467,26 @@ impl StyleTreeNode { child.print(printer, style); } }, + + StyleTreeNode::UserId(user_id) => { + let style = printer.settings().get_user_style(user_id); + printer.push_str(user_id.as_str(), style); + }, + StyleTreeNode::RoomId(room_id) => { + let bold = style.add_modifier(StyleModifier::BOLD); + printer.push_str(room_id.as_str(), bold); + }, + StyleTreeNode::RoomAlias(alias) => { + let bold = style.add_modifier(StyleModifier::BOLD); + printer.push_str(alias.as_str(), bold); + }, } } } /// A processed HTML document. pub struct StyleTree { - children: StyleTreeChildren, + pub(super) children: StyleTreeChildren, } impl StyleTree { @@ -466,14 +500,14 @@ impl StyleTree { return links; } - pub fn to_text( - &self, + pub fn to_text<'a>( + &'a self, width: usize, style: Style, hide_reply: bool, - emoji_shortcodes: bool, - ) -> Text<'_> { - let mut printer = TextPrinter::new(width, style, hide_reply, emoji_shortcodes); + settings: &'a ApplicationSettings, + ) -> Text<'a> { + let mut printer = TextPrinter::new(width, style, hide_reply, settings); for child in self.children.iter() { child.print(&mut printer, style); @@ -661,7 +695,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren { let tree = match &node.data { NodeData::Document => *c2t(node.children.borrow().as_slice(), state), - NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string()), + NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string().into()), NodeData::Element { name, attrs, .. } => { match name.local.as_ref() { // Message that this one replies to. @@ -811,17 +845,19 @@ pub fn parse_matrix_html(s: &str) -> StyleTree { #[cfg(test)] pub mod tests { use super::*; + use crate::tests::mock_settings; use crate::util::space_span; use pretty_assertions::assert_eq; use unicode_width::UnicodeWidthStr; #[test] fn test_header() { + let settings = mock_settings(); let bold = Style::default().add_modifier(StyleModifier::BOLD); let s = "

Header 1

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled(" ", bold), @@ -833,7 +869,7 @@ pub mod tests { let s = "

Header 2

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -846,7 +882,7 @@ pub mod tests { let s = "

Header 3

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -860,7 +896,7 @@ pub mod tests { let s = "

Header 4

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -875,7 +911,7 @@ pub mod tests { let s = "
Header 5
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -891,7 +927,7 @@ pub mod tests { let s = "
Header 6
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -909,6 +945,7 @@ pub mod tests { #[test] fn test_style() { + let settings = mock_settings(); let def = Style::default(); let bold = def.add_modifier(StyleModifier::BOLD); let italic = def.add_modifier(StyleModifier::ITALIC); @@ -918,7 +955,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -927,7 +964,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -936,7 +973,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -945,7 +982,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -954,7 +991,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -963,7 +1000,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -972,7 +1009,7 @@ pub mod tests { let s = "Underline!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Underline", underl), Span::styled("!", underl), @@ -981,7 +1018,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -990,7 +1027,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &settings); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -1000,9 +1037,10 @@ pub mod tests { #[test] fn test_paragraph() { + let settings = mock_settings(); let s = "

Hello world!

Content

Goodbye world!

"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, false); + let text = tree.to_text(10, Style::default(), false, &settings); assert_eq!(text.lines.len(), 7); assert_eq!( text.lines[0], @@ -1027,9 +1065,10 @@ pub mod tests { #[test] fn test_blockquote() { + let settings = mock_settings(); let s = "
Hello world!
"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, false); + let text = tree.to_text(10, Style::default(), false, &settings); assert_eq!(text.lines.len(), 2); assert_eq!( text.lines[0], @@ -1043,9 +1082,10 @@ pub mod tests { #[test] fn test_list_unordered() { + let settings = mock_settings(); let s = "
  • List Item 1
  • List Item 2
  • List Item 3
"; let tree = parse_matrix_html(s); - let text = tree.to_text(8, Style::default(), false, false); + let text = tree.to_text(8, Style::default(), false, &settings); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1105,9 +1145,10 @@ pub mod tests { #[test] fn test_list_ordered() { + let settings = mock_settings(); let s = "
  1. List Item 1
  2. List Item 2
  3. List Item 3
"; let tree = parse_matrix_html(s); - let text = tree.to_text(9, Style::default(), false, false); + let text = tree.to_text(9, Style::default(), false, &settings); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1167,6 +1208,7 @@ pub mod tests { #[test] fn test_table() { + let settings = mock_settings(); let s = "\ \ @@ -1177,7 +1219,7 @@ pub mod tests { \
Column 1Column 2Column 3
abc
"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), false, false); + let text = tree.to_text(15, Style::default(), false, &settings); let bold = Style::default().add_modifier(StyleModifier::BOLD); assert_eq!(text.lines.len(), 11); @@ -1267,10 +1309,11 @@ pub mod tests { #[test] fn test_matrix_reply() { + let settings = mock_settings(); let s = "This was replied toThis is the reply"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, false); + let text = tree.to_text(10, Style::default(), false, &settings); assert_eq!(text.lines.len(), 4); assert_eq!( text.lines[0], @@ -1307,7 +1350,7 @@ pub mod tests { ); let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), true, false); + let text = tree.to_text(10, Style::default(), true, &settings); assert_eq!(text.lines.len(), 2); assert_eq!( text.lines[0], @@ -1332,9 +1375,10 @@ pub mod tests { #[test] fn test_self_closing() { + let settings = mock_settings(); let s = "Hello
World
Goodbye"; let tree = parse_matrix_html(s); - let text = tree.to_text(7, Style::default(), true, false); + let text = tree.to_text(7, Style::default(), true, &settings); assert_eq!(text.lines.len(), 3); assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),])); assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),])); @@ -1343,9 +1387,10 @@ pub mod tests { #[test] fn test_embedded_newline() { + let settings = mock_settings(); let s = "

Hello\nWorld

"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), true, false); + let text = tree.to_text(15, Style::default(), true, &settings); assert_eq!(text.lines.len(), 1); assert_eq!( text.lines[0], @@ -1360,6 +1405,7 @@ pub mod tests { #[test] fn test_pre_tag() { + let settings = mock_settings(); let s = concat!( "
",
             "fn hello() -> usize {\n",
@@ -1368,7 +1414,7 @@ pub mod tests {
             "
\n" ); let tree = parse_matrix_html(s); - let text = tree.to_text(25, Style::default(), true, false); + let text = tree.to_text(25, Style::default(), true, &settings); assert_eq!(text.lines.len(), 5); assert_eq!( text.lines[0], @@ -1432,6 +1478,11 @@ pub mod tests { #[test] fn test_emoji_shortcodes() { + let mut enabled = mock_settings(); + enabled.tunables.message_shortcode_display = true; + let mut disabled = mock_settings(); + disabled.tunables.message_shortcode_display = false; + for shortcode in ["exploding_head", "polar_bear", "canada"] { let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str(); let emoji_width = UnicodeWidthStr::width(emoji); @@ -1440,13 +1491,13 @@ pub mod tests { let s = format!("

{emoji}

"); let tree = parse_matrix_html(s.as_str()); // Test with emojis_shortcodes set to false - let text = tree.to_text(20, Style::default(), false, false); + let text = tree.to_text(20, Style::default(), false, &disabled); assert_eq!(text.lines, vec![Line::from(vec![ Span::raw(emoji), space_span(20 - emoji_width, Style::default()), ]),]); // Test with emojis_shortcodes set to true - let text = tree.to_text(20, Style::default(), false, true); + let text = tree.to_text(20, Style::default(), false, &enabled); assert_eq!(text.lines, vec![Line::from(vec![ Span::raw(replacement.as_str()), space_span(20 - replacement_width, Style::default()), diff --git a/src/message/mod.rs b/src/message/mod.rs index 39d6734..3e6ae73 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -35,6 +35,7 @@ use matrix_sdk::ruma::{ }, redaction::SyncRoomRedactionEvent, }, + AnySyncStateEvent, RedactContent, RedactedUnsigned, }, @@ -67,8 +68,10 @@ use crate::{ mod compose; mod html; mod printer; +mod state; pub use self::compose::text_to_message; +use self::state::{body_cow_state, html_state}; type ProtocolPreview<'a> = (&'a Protocol, u16, u16); @@ -427,6 +430,7 @@ pub enum MessageEvent { EncryptedRedacted(Box), Original(Box), Redacted(Box), + State(Box), Local(OwnedEventId, Box), } @@ -437,6 +441,7 @@ impl MessageEvent { MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(), MessageEvent::Original(ev) => ev.event_id.as_ref(), MessageEvent::Redacted(ev) => ev.event_id.as_ref(), + MessageEvent::State(ev) => ev.event_id(), MessageEvent::Local(event_id, _) => event_id.as_ref(), } } @@ -447,6 +452,7 @@ impl MessageEvent { MessageEvent::Original(ev) => Some(&ev.content), MessageEvent::EncryptedRedacted(_) => None, MessageEvent::Redacted(_) => None, + MessageEvent::State(_) => None, MessageEvent::Local(_, content) => Some(content), } } @@ -464,6 +470,7 @@ impl MessageEvent { MessageEvent::Original(ev) => body_cow_content(&ev.content), MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned), MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned), + MessageEvent::State(ev) => body_cow_state(ev), MessageEvent::Local(_, content) => body_cow_content(content), } } @@ -474,6 +481,7 @@ impl MessageEvent { MessageEvent::EncryptedRedacted(_) => return None, MessageEvent::Original(ev) => &ev.content, MessageEvent::Redacted(_) => return None, + MessageEvent::State(ev) => return Some(html_state(ev)), MessageEvent::Local(_, content) => content, }; @@ -493,6 +501,7 @@ impl MessageEvent { MessageEvent::EncryptedOriginal(_) => return, MessageEvent::EncryptedRedacted(_) => return, MessageEvent::Redacted(_) => return, + MessageEvent::State(_) => return, MessageEvent::Local(_, _) => return, MessageEvent::Original(ev) => { let redacted = RedactedRoomMessageEvent { @@ -721,8 +730,7 @@ impl<'a> MessageFormatter<'a> { ) -> Option> { let width = self.width(); let w = width.saturating_sub(2); - let shortcodes = self.settings.tunables.message_shortcode_display; - let (mut replied, proto) = msg.show_msg(w, style, true, shortcodes); + let (mut replied, proto) = msg.show_msg(w, style, true, settings); let mut sender = msg.sender_span(info, self.settings); let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); let trailing = w.saturating_sub(sender_width + 1); @@ -760,7 +768,7 @@ impl<'a> MessageFormatter<'a> { } fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) { - let mut emojis = printer::TextPrinter::new(self.width(), style, false, false); + let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.settings); let mut reactions = 0; for (key, count) in counts { @@ -809,7 +817,7 @@ impl<'a> MessageFormatter<'a> { let plural = len != 1; let style = Style::default(); let mut threaded = - printer::TextPrinter::new(self.width(), style, false, false).literal(true); + printer::TextPrinter::new(self.width(), style, false, self.settings).literal(true); let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD)); threaded.push_str(" \u{2937} ", style); threaded.push_span_nobreak(len); @@ -861,6 +869,7 @@ impl Message { MessageEvent::Local(_, content) => content, MessageEvent::Original(ev) => &ev.content, MessageEvent::Redacted(_) => return None, + MessageEvent::State(_) => return None, }; match &content.relates_to { @@ -881,6 +890,7 @@ impl Message { MessageEvent::Local(_, content) => content, MessageEvent::Original(ev) => &ev.content, MessageEvent::Redacted(_) => return None, + MessageEvent::State(_) => return None, }; match &content.relates_to { @@ -993,12 +1003,7 @@ impl Message { }); // Now show the message contents, and the inlined reply if we couldn't find it above. - let (msg, proto) = self.show_msg( - width, - style, - reply.is_some(), - settings.tunables.message_shortcode_display, - ); + let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings); // Given our text so far, determine the image offset. let proto_main = proto.map(|p| { @@ -1040,18 +1045,18 @@ impl Message { self.show_with_preview(prev, selected, vwctx, info, settings).0 } - fn show_msg( - &self, + fn show_msg<'a>( + &'a self, width: usize, style: Style, hide_reply: bool, - emoji_shortcodes: bool, - ) -> (Text, Option<&Protocol>) { + settings: &'a ApplicationSettings, + ) -> (Text<'a>, Option<&'a Protocol>) { if let Some(html) = &self.html { - (html.to_text(width, style, hide_reply, emoji_shortcodes), None) + (html.to_text(width, style, hide_reply, settings), None) } else { let mut msg = self.event.body(); - if emoji_shortcodes { + if settings.tunables.message_shortcode_display { msg = Cow::Owned(replace_emojis_in_str(msg.as_ref())); } @@ -1166,6 +1171,16 @@ impl From for Message { } } +impl From for Message { + fn from(event: AnySyncStateEvent) -> Self { + let timestamp = event.origin_server_ts().into(); + let user_id = event.sender().to_owned(); + let event = MessageEvent::State(event.into()); + + Message::new(event, user_id, timestamp) + } +} + impl Display for Message { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.event.body()) diff --git a/src/message/printer.rs b/src/message/printer.rs index 12d03be..d2a2dd0 100644 --- a/src/message/printer.rs +++ b/src/message/printer.rs @@ -11,6 +11,7 @@ use ratatui::text::{Line, Span, Text}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +use crate::config::{ApplicationSettings, TunableValues}; use crate::util::{ replace_emojis_in_line, replace_emojis_in_span, @@ -25,28 +26,34 @@ pub struct TextPrinter<'a> { width: usize, base_style: Style, hide_reply: bool, - emoji_shortcodes: bool, alignment: Alignment, curr_spans: Vec>, curr_width: usize, literal: bool, + + pub(super) settings: &'a ApplicationSettings, } impl<'a> TextPrinter<'a> { /// Create a new printer. - pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self { + pub fn new( + width: usize, + base_style: Style, + hide_reply: bool, + settings: &'a ApplicationSettings, + ) -> Self { TextPrinter { text: Text::default(), width, base_style, hide_reply, - emoji_shortcodes, alignment: Alignment::Left, curr_spans: vec![], curr_width: 0, literal: false, + settings, } } @@ -69,7 +76,15 @@ impl<'a> TextPrinter<'a> { /// Indicates whether emojis should be replaced by shortcodes pub fn emoji_shortcodes(&self) -> bool { - self.emoji_shortcodes + self.tunables().message_shortcode_display + } + + pub fn settings(&self) -> &ApplicationSettings { + self.settings + } + + pub fn tunables(&self) -> &TunableValues { + &self.settings.tunables } /// Indicates the current printer's width. @@ -84,12 +99,12 @@ impl<'a> TextPrinter<'a> { width: self.width.saturating_sub(indent), base_style: self.base_style, hide_reply: self.hide_reply, - emoji_shortcodes: self.emoji_shortcodes, alignment: self.alignment, curr_spans: vec![], curr_width: 0, literal: self.literal, + settings: self.settings, } } @@ -179,7 +194,7 @@ impl<'a> TextPrinter<'a> { /// Push a [Span] that isn't allowed to break across lines. pub fn push_span_nobreak(&mut self, mut span: Span<'a>) { - if self.emoji_shortcodes { + if self.emoji_shortcodes() { replace_emojis_in_span(&mut span); } let sw = UnicodeWidthStr::width(span.content.as_ref()); @@ -217,7 +232,7 @@ impl<'a> TextPrinter<'a> { continue; } - let cow = if self.emoji_shortcodes { + let cow = if self.emoji_shortcodes() { Cow::Owned(replace_emojis_in_str(word)) } else { Cow::Borrowed(word) @@ -253,7 +268,7 @@ impl<'a> TextPrinter<'a> { /// Push a [Line] into the printer. pub fn push_line(&mut self, mut line: Line<'a>) { self.commit(); - if self.emoji_shortcodes { + if self.emoji_shortcodes() { replace_emojis_in_line(&mut line); } self.text.lines.push(line); @@ -262,7 +277,7 @@ impl<'a> TextPrinter<'a> { /// Push multiline [Text] into the printer. pub fn push_text(&mut self, mut text: Text<'a>) { self.commit(); - if self.emoji_shortcodes { + if self.emoji_shortcodes() { for line in &mut text.lines { replace_emojis_in_line(line); } @@ -280,10 +295,12 @@ impl<'a> TextPrinter<'a> { #[cfg(test)] pub mod tests { use super::*; + use crate::tests::mock_settings; #[test] fn test_push_nobreak() { - let mut printer = TextPrinter::new(5, Style::default(), false, false); + let settings = mock_settings(); + let mut printer = TextPrinter::new(5, Style::default(), false, &settings); printer.push_span_nobreak("hello world".into()); let text = printer.finish(); assert_eq!(text.lines.len(), 1); diff --git a/src/message/state.rs b/src/message/state.rs new file mode 100644 index 0000000..8f19f0c --- /dev/null +++ b/src/message/state.rs @@ -0,0 +1,944 @@ +//! Code for displaying state events. +use std::borrow::Cow; +use std::str::FromStr; + +use matrix_sdk::ruma::{ + events::{ + room::member::MembershipChange, + AnyFullStateEventContent, + AnySyncStateEvent, + FullStateEventContent, + }, + OwnedRoomId, + UserId, +}; + +use super::html::{StyleTree, StyleTreeNode}; +use ratatui::style::{Modifier as StyleModifier, Style}; + +fn bold(s: impl Into>) -> StyleTreeNode { + let bold = Style::default().add_modifier(StyleModifier::BOLD); + let text = StyleTreeNode::Text(s.into()); + StyleTreeNode::Style(Box::new(text), bold) +} + +pub fn body_cow_state(ev: &AnySyncStateEvent) -> Cow<'static, str> { + let event = match ev.content() { + AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original { + content, + .. + }) => { + let mut m = format!( + "* updated the room policy rule for {:?} to {:?}", + content.0.entity, + content.0.recommendation.as_str() + ); + + if !content.0.reason.is_empty() { + m.push_str(" (reason: "); + m.push_str(&content.0.reason); + m.push(')'); + } + + m + }, + AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original { + content, + .. + }) => { + let mut m = format!( + "* updated the server policy rule for {:?} to {:?}", + content.0.entity, + content.0.recommendation.as_str() + ); + + if !content.0.reason.is_empty() { + m.push_str(" (reason: "); + m.push_str(&content.0.reason); + m.push(')'); + } + + m + }, + AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original { + content, + .. + }) => { + let mut m = format!( + "* updated the user policy rule for {:?} to {:?}", + content.0.entity, + content.0.recommendation.as_str() + ); + + if !content.0.reason.is_empty() { + m.push_str(" (reason: "); + m.push_str(&content.0.reason); + m.push(')'); + } + + m + }, + AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original { + content, .. + }) => { + let mut m = String::from("* set the room aliases to: "); + + for (i, alias) in content.aliases.iter().enumerate() { + if i != 0 { + m.push_str(", "); + } + + m.push_str(alias.as_str()); + } + + m + }, + AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original { + content, + prev_content, + }) => { + let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref()); + + match (prev_url, content.url) { + (None, Some(_)) => return Cow::Borrowed("* added a room avatar"), + (Some(old), Some(new)) => { + if old != &new { + return Cow::Borrowed("* replaced the room avatar"); + } + + return Cow::Borrowed("* updated the room avatar state"); + }, + (Some(_), None) => return Cow::Borrowed("* removed the room avatar"), + (None, None) => return Cow::Borrowed("* updated the room avatar state"), + } + }, + AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original { + content, + prev_content, + }) => { + let old_canon = prev_content.as_ref().and_then(|p| p.alias.as_ref()); + let new_canon = content.alias.as_ref(); + + match (old_canon, new_canon) { + (None, Some(canon)) => { + format!("* updated the canonical alias for the room to: {}", canon) + }, + (Some(old), Some(new)) => { + if old != new { + format!("* updated the canonical alias for the room to: {}", new) + } else { + return Cow::Borrowed("* removed the canonical alias for the room"); + } + }, + (Some(_), None) => { + return Cow::Borrowed("* removed the canonical alias for the room"); + }, + (None, None) => { + return Cow::Borrowed("* did not change the canonical alias"); + }, + } + }, + AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original { + content, .. + }) => { + if content.federate { + return Cow::Borrowed("* created a federated room"); + } else { + return Cow::Borrowed("* created a non-federated room"); + } + }, + AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => { + return Cow::Borrowed("* updated the encryption settings for the room"); + }, + AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original { + content, + .. + }) => { + format!("* set guest access for the room to {:?}", content.guest_access.as_str()) + }, + AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original { + content, + .. + }) => { + format!( + "* updated history visibility for the room to {:?}", + content.history_visibility.as_str() + ) + }, + AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original { + content, + .. + }) => { + format!("* update the join rules for the room to {:?}", content.join_rule.as_str()) + }, + AnyFullStateEventContent::RoomMember(FullStateEventContent::Original { + content, + prev_content, + }) => { + let Ok(state_key) = UserId::parse(ev.state_key()) else { + return Cow::Owned(format!( + "* failed to calculate membership change for {:?}", + ev.state_key() + )); + }; + + let prev_details = prev_content.as_ref().map(|p| p.details()); + let change = content.membership_change(prev_details, ev.sender(), &state_key); + + match change { + MembershipChange::None => { + format!("* did nothing to {}", state_key) + }, + MembershipChange::Error => { + format!("* failed to calculate membership change to {}", state_key) + }, + MembershipChange::Joined => { + return Cow::Borrowed("* joined the room"); + }, + MembershipChange::Left => { + return Cow::Borrowed("* left the room"); + }, + MembershipChange::Banned => { + format!("* banned {} from the room", state_key) + }, + MembershipChange::Unbanned => { + format!("* unbanned {} from the room", state_key) + }, + MembershipChange::Kicked => { + format!("* kicked {} from the room", state_key) + }, + MembershipChange::Invited => { + format!("* invited {} to the room", state_key) + }, + MembershipChange::KickedAndBanned => { + format!("* kicked and banned {} from the room", state_key) + }, + MembershipChange::InvitationAccepted => { + return Cow::Borrowed("* accepted an invitation to join the room"); + }, + MembershipChange::InvitationRejected => { + return Cow::Borrowed("* rejected an invitation to join the room"); + }, + MembershipChange::InvitationRevoked => { + format!("* revoked an invitation for {} to join the room", state_key) + }, + MembershipChange::Knocked => { + return Cow::Borrowed("* would like to join the room"); + }, + MembershipChange::KnockAccepted => { + format!("* accepted the room knock from {}", state_key) + }, + MembershipChange::KnockRetracted => { + return Cow::Borrowed("* retracted their room knock"); + }, + MembershipChange::KnockDenied => { + format!("* rejected the room knock from {}", state_key) + }, + MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => { + match (displayname_change, avatar_url_change) { + (Some(change), avatar_change) => { + let mut m = match (change.old, change.new) { + (None, Some(new)) => { + format!("* set their display name to {:?}", new) + }, + (Some(old), Some(new)) => { + format!( + "* changed their display name from {:?} to {:?}", + old, new + ) + }, + (Some(_), None) => "* unset their display name".to_string(), + (None, None) => { + "* made an unknown change to their display name".to_string() + }, + }; + + if avatar_change.is_some() { + m.push_str(" and changed their user avatar"); + } + + m + }, + (None, Some(change)) => { + match (change.old, change.new) { + (None, Some(_)) => { + return Cow::Borrowed("* added a user avatar"); + }, + (Some(_), Some(_)) => { + return Cow::Borrowed("* changed their user avatar"); + }, + (Some(_), None) => { + return Cow::Borrowed("* removed their user avatar"); + }, + (None, None) => { + return Cow::Borrowed( + "* made an unknown change to their user avatar", + ); + }, + } + }, + (None, None) => { + return Cow::Borrowed("* changed their user profile"); + }, + } + }, + ev => { + format!("* made an unknown membership change to {}: {:?}", state_key, ev) + }, + } + }, + AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => { + format!("* updated the room name to {:?}", content.name) + }, + AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => { + return Cow::Borrowed("* updated the pinned events for the room"); + }, + AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => { + return Cow::Borrowed("* updated the power levels for the room"); + }, + AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => { + return Cow::Borrowed("* updated the room's server ACLs"); + }, + AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original { + content, + .. + }) => { + format!("* sent a third-party invite to {:?}", content.display_name) + }, + AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original { + content, + .. + }) => { + format!( + "* upgraded the room; replacement room is {}", + content.replacement_room.as_str() + ) + }, + AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original { + content, .. + }) => { + format!("* set the room topic to {:?}", content.topic) + }, + AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => { + format!("* added a space child: {}", ev.state_key()) + }, + AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original { + content, .. + }) => { + if content.canonical { + format!("* added a canonical parent space: {}", ev.state_key()) + } else { + format!("* added a parent space: {}", ev.state_key()) + } + }, + AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => { + return Cow::Borrowed("* shared beacon information"); + }, + AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => { + return Cow::Borrowed("* updated membership for room call"); + }, + AnyFullStateEventContent::MemberHints(FullStateEventContent::Original { + content, .. + }) => { + let mut m = String::from("* updated the list of service members in the room hints: "); + + for (i, member) in content.service_members.iter().enumerate() { + if i != 0 { + m.push_str(", "); + } + + m.push_str(member.as_str()); + } + + m + }, + + // Redacted variants of state events: + AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated a room policy rule (redacted)"); + }, + AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated a server policy rule (redacted)"); + }, + AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated a user policy rule (redacted)"); + }, + AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the room aliases for the room (redacted)"); + }, + AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the room avatar (redacted)"); + }, + AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the canonical alias for the room (redacted)"); + }, + AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* created the room (redacted)"); + }, + AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the encryption settings for the room (redacted)"); + }, + AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed( + "* updated the guest access configuration for the room (redacted)", + ); + }, + AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated history visilibity for the room (redacted)"); + }, + AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the join rules for the room (redacted)"); + }, + AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the room membership (redacted)"); + }, + AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the room name (redacted)"); + }, + AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the pinned events for the room (redacted)"); + }, + AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the power levels for the room (redacted)"); + }, + AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the room's server ACLs (redacted)"); + }, + AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* sent a third-party invite (redacted)"); + }, + AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* upgraded the room (redacted)"); + }, + AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* updated the room topic (redacted)"); + }, + AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* added a space child (redacted)"); + }, + AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* added a parent space (redacted)"); + }, + AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("* shared beacon information (redacted)"); + }, + AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("Call membership changed"); + }, + AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => { + return Cow::Borrowed("Member hints changed"); + }, + + // Handle unknown events: + e => { + format!("* sent an unknown state event: {:?}", e.event_type()) + }, + }; + + return Cow::Owned(event); +} + +pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { + let children = match ev.content() { + AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = StyleTreeNode::Text("* updated the room policy rule for ".into()); + let entity = bold(format!("{:?}", content.0.entity)); + let middle = StyleTreeNode::Text(" to ".into()); + let rec = + StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into()); + let mut cs = vec![prefix, entity, middle, rec]; + + if !content.0.reason.is_empty() { + let reason = format!(" (reason: {})", content.0.reason); + cs.push(StyleTreeNode::Text(reason.into())); + } + + cs + }, + AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = StyleTreeNode::Text("* updated the server policy rule for ".into()); + let entity = bold(format!("{:?}", content.0.entity)); + let middle = StyleTreeNode::Text(" to ".into()); + let rec = + StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into()); + let mut cs = vec![prefix, entity, middle, rec]; + + if !content.0.reason.is_empty() { + let reason = format!(" (reason: {})", content.0.reason); + cs.push(StyleTreeNode::Text(reason.into())); + } + + cs + }, + AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = StyleTreeNode::Text("* updated the user policy rule for ".into()); + let entity = bold(format!("{:?}", content.0.entity)); + let middle = StyleTreeNode::Text(" to ".into()); + let rec = + StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into()); + let mut cs = vec![prefix, entity, middle, rec]; + + if !content.0.reason.is_empty() { + let reason = format!(" (reason: {})", content.0.reason); + cs.push(StyleTreeNode::Text(reason.into())); + } + + cs + }, + AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original { + content, .. + }) => { + let prefix = StyleTreeNode::Text("* set the room aliases to: ".into()); + let mut cs = vec![prefix]; + + for (i, alias) in content.aliases.iter().enumerate() { + if i != 0 { + cs.push(StyleTreeNode::Text(", ".into())); + } + + cs.push(StyleTreeNode::RoomAlias(alias.clone())); + } + + cs + }, + AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original { + content, + prev_content, + }) => { + let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref()); + + let node = match (prev_url, content.url) { + (None, Some(_)) => StyleTreeNode::Text("* added a room avatar".into()), + (Some(old), Some(new)) => { + if old != &new { + StyleTreeNode::Text("* replaced the room avatar".into()) + } else { + StyleTreeNode::Text("* updated the room avatar state".into()) + } + }, + (Some(_), None) => StyleTreeNode::Text("* removed the room avatar".into()), + (None, None) => StyleTreeNode::Text("* updated the room avatar state".into()), + }; + + vec![node] + }, + AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original { + content, + .. + }) => { + if let Some(canon) = content.alias.as_ref() { + let canon = bold(canon.to_string()); + let prefix = + StyleTreeNode::Text("* updated the canonical alias for the room to: ".into()); + vec![prefix, canon] + } else { + vec![StyleTreeNode::Text( + "* removed the canonical alias for the room".into(), + )] + } + }, + AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original { + content, .. + }) => { + if content.federate { + vec![StyleTreeNode::Text("* created a federated room".into())] + } else { + vec![StyleTreeNode::Text("* created a non-federated room".into())] + } + }, + AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => { + vec![StyleTreeNode::Text( + "* updated the encryption settings for the room".into(), + )] + }, + AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original { + content, + .. + }) => { + let access = bold(format!("{:?}", content.guest_access.as_str())); + let prefix = StyleTreeNode::Text("* set guest access for the room to ".into()); + vec![prefix, access] + }, + AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = + StyleTreeNode::Text("* updated history visibility for the room to ".into()); + let vis = bold(format!("{:?}", content.history_visibility.as_str())); + vec![prefix, vis] + }, + AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = StyleTreeNode::Text("* update the join rules for the room to ".into()); + let rule = bold(format!("{:?}", content.join_rule.as_str())); + vec![prefix, rule] + }, + AnyFullStateEventContent::RoomMember(FullStateEventContent::Original { + content, + prev_content, + }) => { + let Ok(state_key) = UserId::parse(ev.state_key()) else { + let prefix = + StyleTreeNode::Text("* failed to calculate membership change for ".into()); + let user_id = bold(format!("{:?}", ev.state_key())); + let children = vec![prefix, user_id]; + + return StyleTree { children }; + }; + + let prev_details = prev_content.as_ref().map(|p| p.details()); + let change = content.membership_change(prev_details, ev.sender(), &state_key); + let user_id = StyleTreeNode::UserId(state_key); + + match change { + MembershipChange::None => { + let prefix = StyleTreeNode::Text("* did nothing to ".into()); + vec![prefix, user_id] + }, + MembershipChange::Error => { + let prefix = + StyleTreeNode::Text("* failed to calculate membership change to ".into()); + vec![prefix, user_id] + }, + MembershipChange::Joined => { + vec![StyleTreeNode::Text("* joined the room".into())] + }, + MembershipChange::Left => { + vec![StyleTreeNode::Text("* left the room".into())] + }, + MembershipChange::Banned => { + let prefix = StyleTreeNode::Text("* banned ".into()); + let suffix = StyleTreeNode::Text(" from the room".into()); + vec![prefix, user_id, suffix] + }, + MembershipChange::Unbanned => { + let prefix = StyleTreeNode::Text("* unbanned ".into()); + let suffix = StyleTreeNode::Text(" from the room".into()); + vec![prefix, user_id, suffix] + }, + MembershipChange::Kicked => { + let prefix = StyleTreeNode::Text("* kicked ".into()); + let suffix = StyleTreeNode::Text(" from the room".into()); + vec![prefix, user_id, suffix] + }, + MembershipChange::Invited => { + let prefix = StyleTreeNode::Text("* invited ".into()); + let suffix = StyleTreeNode::Text(" to the room".into()); + vec![prefix, user_id, suffix] + }, + MembershipChange::KickedAndBanned => { + let prefix = StyleTreeNode::Text("* kicked and banned ".into()); + let suffix = StyleTreeNode::Text(" from the room".into()); + vec![prefix, user_id, suffix] + }, + MembershipChange::InvitationAccepted => { + vec![StyleTreeNode::Text( + "* accepted an invitation to join the room".into(), + )] + }, + MembershipChange::InvitationRejected => { + vec![StyleTreeNode::Text( + "* rejected an invitation to join the room".into(), + )] + }, + MembershipChange::InvitationRevoked => { + let prefix = StyleTreeNode::Text("* revoked an invitation for ".into()); + let suffix = StyleTreeNode::Text(" to join the room".into()); + vec![prefix, user_id, suffix] + }, + MembershipChange::Knocked => { + vec![StyleTreeNode::Text("* would like to join the room".into())] + }, + MembershipChange::KnockAccepted => { + let prefix = StyleTreeNode::Text("* accepted the room knock from ".into()); + vec![prefix, user_id] + }, + MembershipChange::KnockRetracted => { + vec![StyleTreeNode::Text("* retracted their room knock".into())] + }, + MembershipChange::KnockDenied => { + let prefix = StyleTreeNode::Text("* rejected the room knock from ".into()); + vec![prefix, user_id] + }, + MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => { + let m = match (displayname_change, avatar_url_change) { + (Some(change), avatar_change) => { + let mut m = match (change.old, change.new) { + (None, Some(new)) => { + format!("* set their display name to {:?}", new) + }, + (Some(old), Some(new)) => { + format!( + "* changed their display name from {:?} to {:?}", + old, new + ) + }, + (Some(_), None) => "* unset their display name".to_string(), + (None, None) => { + "* made an unknown change to their display name".to_string() + }, + }; + + if avatar_change.is_some() { + m.push_str(" and changed their user avatar"); + } + + Cow::Owned(m) + }, + (None, Some(change)) => { + match (change.old, change.new) { + (None, Some(_)) => Cow::Borrowed("* added a user avatar"), + (Some(_), Some(_)) => Cow::Borrowed("* changed their user avatar"), + (Some(_), None) => Cow::Borrowed("* removed their user avatar"), + (None, None) => { + Cow::Borrowed("* made an unknown change to their user avatar") + }, + } + }, + (None, None) => Cow::Borrowed("* changed their user profile"), + }; + + vec![StyleTreeNode::Text(m)] + }, + ev => { + let prefix = + StyleTreeNode::Text("* made an unknown membership change to ".into()); + let suffix = StyleTreeNode::Text(format!(": {:?}", ev).into()); + vec![prefix, user_id, suffix] + }, + } + }, + AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => { + let prefix = StyleTreeNode::Text("* updated the room name to ".into()); + let name = bold(format!("{:?}", content.name)); + vec![prefix, name] + }, + AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => { + vec![StyleTreeNode::Text( + "* updated the pinned events for the room".into(), + )] + }, + AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => { + vec![StyleTreeNode::Text( + "* updated the power levels for the room".into(), + )] + }, + AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => { + vec![StyleTreeNode::Text( + "* updated the room's server ACLs".into(), + )] + }, + AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = StyleTreeNode::Text("* sent a third-party invite to ".into()); + let name = bold(format!("{:?}", content.display_name)); + vec![prefix, name] + }, + AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original { + content, + .. + }) => { + let prefix = StyleTreeNode::Text("* upgraded the room; replacement room is ".into()); + let room = StyleTreeNode::RoomId(content.replacement_room.clone()); + vec![prefix, room] + }, + AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original { + content, .. + }) => { + let prefix = StyleTreeNode::Text("* set the room topic to ".into()); + let topic = bold(format!("{:?}", content.topic)); + vec![prefix, topic] + }, + AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => { + let prefix = StyleTreeNode::Text("* added a space child: ".into()); + + let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) { + StyleTreeNode::RoomId(room_id) + } else { + bold(ev.state_key().to_string()) + }; + + vec![prefix, room_id] + }, + AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original { + content, .. + }) => { + let prefix = if content.canonical { + StyleTreeNode::Text("* added a canonical parent space: ".into()) + } else { + StyleTreeNode::Text("* added a parent space: ".into()) + }; + + let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) { + StyleTreeNode::RoomId(room_id) + } else { + bold(ev.state_key().to_string()) + }; + + vec![prefix, room_id] + }, + AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => { + vec![StyleTreeNode::Text("* shared beacon information".into())] + }, + AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => { + vec![StyleTreeNode::Text( + "* updated membership for room call".into(), + )] + }, + AnyFullStateEventContent::MemberHints(FullStateEventContent::Original { + content, .. + }) => { + let prefix = StyleTreeNode::Text( + "* updated the list of service members in the room hints: ".into(), + ); + let mut cs = vec![prefix]; + + for (i, member) in content.service_members.iter().enumerate() { + if i != 0 { + cs.push(StyleTreeNode::Text(", ".into())); + } + + cs.push(StyleTreeNode::UserId(member.clone())); + } + + cs + }, + + // Redacted variants of state events: + AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated a room policy rule (redacted)".into(), + )] + }, + AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated a server policy rule (redacted)".into(), + )] + }, + AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated a user policy rule (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the room aliases for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the room avatar (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the canonical alias for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text("* created the room (redacted)".into())] + }, + AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the encryption settings for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the guest access configuration for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated history visilibity for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the join rules for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the room membership (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the room name (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the pinned events for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the power levels for the room (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the room's server ACLs (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* sent a third-party invite (redacted)".into(), + )] + }, + AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text("* upgraded the room (redacted)".into())] + }, + AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* updated the room topic (redacted)".into(), + )] + }, + AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* added a space child (redacted)".into(), + )] + }, + AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* added a parent space (redacted)".into(), + )] + }, + AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text( + "* shared beacon information (redacted)".into(), + )] + }, + AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text("Call membership changed".into())] + }, + AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => { + vec![StyleTreeNode::Text("Member hints changed".into())] + }, + + // Handle unknown events: + e => { + let prefix = StyleTreeNode::Text("* sent an unknown state event: ".into()); + let event = bold(format!("{:?}", e.event_type())); + vec![prefix, event] + }, + }; + + StyleTree { children } +} diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 45bd17e..0b83df6 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -380,6 +380,7 @@ impl ChatState { MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(), + MessageEvent::State(ev) => ev.event_id().to_owned(), MessageEvent::Redacted(_) => { let msg = "Cannot react to a redacted message"; let err = UIError::Failure(msg.into()); @@ -417,6 +418,7 @@ impl ChatState { MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(), + MessageEvent::State(ev) => ev.event_id().to_owned(), MessageEvent::Redacted(_) => { let msg = "Cannot redact already redacted message"; let err = UIError::Failure(msg.into()); @@ -464,6 +466,7 @@ impl ChatState { MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(), + MessageEvent::State(ev) => ev.event_id().to_owned(), MessageEvent::Redacted(_) => { let msg = "Cannot unreact to a redacted message"; let err = UIError::Failure(msg.into()); diff --git a/src/worker.rs b/src/worker.rs index 1ef633c..3e136cc 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -13,7 +13,6 @@ use std::time::{Duration, Instant}; use futures::{stream::FuturesUnordered, StreamExt}; use gethostname::gethostname; -use matrix_sdk::ruma::events::AnySyncTimelineEvent; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::Semaphore; use tokio::task::JoinHandle; @@ -60,6 +59,8 @@ use matrix_sdk::{ typing::SyncTypingEvent, AnyInitialStateEvent, AnyMessageLikeEvent, + AnySyncStateEvent, + AnyTimelineEvent, EmptyStateKey, InitialStateEvent, SyncEphemeralRoomEvent, @@ -115,8 +116,7 @@ const IAMB_DEVICE_NAME: &str = "iamb"; const IAMB_USER_AGENT: &str = "iamb"; const MIN_MSG_LOAD: u32 = 50; -type MessageFetchResult = - IambResult<(Option, Vec<(AnyMessageLikeEvent, Vec)>)>; +type MessageFetchResult = IambResult<(Option, Vec<(AnyTimelineEvent, Vec)>)>; fn initial_devname() -> String { format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy()) @@ -294,10 +294,8 @@ async fn load_older_one( let mut msgs = vec![]; for ev in chunk.into_iter() { - let deserialized = ev.into_raw().deserialize().map_err(IambError::Serde)?; - let msg: AnyMessageLikeEvent = match deserialized { - AnySyncTimelineEvent::MessageLike(e) => e.into_full_event(room_id.to_owned()), - AnySyncTimelineEvent::State(_) => continue, + let Ok(msg) = ev.into_raw().deserialize() else { + continue; }; let event_id = msg.event_id(); @@ -312,6 +310,7 @@ async fn load_older_one( }, }; + let msg = msg.into_full_event(room_id.to_owned()); msgs.push((msg, receipts)); } @@ -343,10 +342,10 @@ fn load_insert( } match msg { - AnyMessageLikeEvent::RoomEncrypted(msg) => { + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomEncrypted(msg)) => { info.insert_encrypted(msg); }, - AnyMessageLikeEvent::RoomMessage(msg) => { + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(msg)) => { info.insert_with_preview( room_id.clone(), store.clone(), @@ -356,10 +355,15 @@ fn load_insert( client.media(), ); }, - AnyMessageLikeEvent::Reaction(ev) => { + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Reaction(ev)) => { info.insert_reaction(ev); }, - _ => continue, + AnyTimelineEvent::MessageLike(_) => { + continue; + }, + AnyTimelineEvent::State(msg) => { + info.insert_any_state(msg.into()); + }, } } @@ -1055,6 +1059,18 @@ impl ClientWorker { }, ); + let _ = self.client.add_event_handler( + |ev: AnySyncStateEvent, room: MatrixRoom, store: Ctx| { + async move { + let room_id = room.room_id(); + let mut locked = store.lock().await; + + let info = locked.application.get_room_info(room_id.to_owned()); + info.insert_any_state(ev); + } + }, + ); + let _ = self.client.add_event_handler( |ev: OriginalSyncRoomRedactionEvent, room: MatrixRoom,