From eb39774ba1b8919ff869d5b66ec4e689e8722905 Mon Sep 17 00:00:00 2001 From: vaw Date: Sat, 21 Jun 2025 13:43:25 +0200 Subject: [PATCH 1/7] Replace most `&ApplicationSettings` with `&TunableValues` --- src/base.rs | 37 +++++----- src/config.rs | 129 ++++++++++++++++----------------- src/message/html.rs | 109 +++++++++++++--------------- src/message/mod.rs | 107 +++++++++++++-------------- src/message/printer.rs | 24 +++--- src/windows/mod.rs | 6 +- src/windows/room/chat.rs | 8 +- src/windows/room/mod.rs | 3 +- src/windows/room/scrollback.rs | 40 ++++++---- 9 files changed, 232 insertions(+), 231 deletions(-) diff --git a/src/base.rs b/src/base.rs index d191b809..35028e70 100644 --- a/src/base.rs +++ b/src/base.rs @@ -91,6 +91,7 @@ use modalkit::{ }; use crate::config::ImagePreviewProtocolValues; +use crate::config::TunableValues; use crate::message::ImageStatus; use crate::notifications::NotificationHandle; use crate::preview::{source_from_event, spawn_insert_preview}; @@ -1347,20 +1348,20 @@ impl RoomInfo { } } - fn get_typing_spans<'a>(&'a self, settings: &'a ApplicationSettings) -> Line<'a> { + fn get_typing_spans<'a>(&'a self, tunables: &'a TunableValues) -> Line<'a> { let typers = self.get_typers(); let n = typers.len(); match n { 0 => Line::from(vec![]), 1 => { - let user = settings.get_user_span(typers[0].as_ref(), self); + let user = tunables.get_user_span(typers[0].as_ref(), self); Line::from(vec![user, Span::from(" is typing...")]) }, 2 => { - let user1 = settings.get_user_span(typers[0].as_ref(), self); - let user2 = settings.get_user_span(typers[1].as_ref(), self); + let user1 = tunables.get_user_span(typers[0].as_ref(), self); + let user2 = tunables.get_user_span(typers[1].as_ref(), self); Line::from(vec![ user1, @@ -1384,13 +1385,13 @@ impl RoomInfo { &mut self, area: Rect, buf: &mut Buffer, - settings: &ApplicationSettings, + tunables: &TunableValues, ) -> Rect { if area.height <= 2 || area.width <= 20 { return area; } - if !settings.tunables.typing_notice_display { + if !tunables.typing_notice_display { // still keep one line blank, so `render_jump_to_recent` doesn't immediately hide the // last line in scrollback return Rect::new(area.x, area.y, area.width, area.height - 1); @@ -1399,7 +1400,7 @@ impl RoomInfo { let top = Rect::new(area.x, area.y, area.width, area.height - 1); let bar = Rect::new(area.x, area.y + top.height, area.width, 1); - Paragraph::new(self.get_typing_spans(settings)) + Paragraph::new(self.get_typing_spans(tunables)) .alignment(Alignment::Center) .render(bar, buf); @@ -1460,8 +1461,8 @@ fn picker_from_termios(_: Option) -> Option { None } -fn picker_from_settings(settings: &ApplicationSettings) -> Option { - let image_preview = settings.tunables.image_preview.as_ref()?; +fn picker_from_tunables(tunables: &TunableValues) -> Option { + let image_preview = tunables.image_preview.as_ref()?; let image_preview_protocol = image_preview.protocol.as_ref(); if let Some(&ImagePreviewProtocolValues { @@ -1623,7 +1624,7 @@ pub struct ChatStore { impl ChatStore { /// Create a new [ChatStore]. pub fn new(worker: Requester, settings: ApplicationSettings) -> Self { - let picker = picker_from_settings(&settings); + let picker = picker_from_tunables(&settings.tunables); ChatStore { worker, @@ -2250,7 +2251,7 @@ pub mod tests { #[test] fn test_typing_spans() { let mut info = RoomInfo::default(); - let settings = mock_settings(); + let tunables = mock_tunables(); let users0 = vec![]; let users1 = vec![TEST_USER1.clone()]; @@ -2271,18 +2272,18 @@ pub mod tests { // Nothing set. assert_eq!(info.users_typing, None); - assert_eq!(info.get_typing_spans(&settings), Line::from(vec![])); + assert_eq!(info.get_typing_spans(&tunables), Line::from(vec![])); // Empty typing list. info.set_typing(users0); assert!(info.users_typing.is_some()); - assert_eq!(info.get_typing_spans(&settings), Line::from(vec![])); + assert_eq!(info.get_typing_spans(&tunables), Line::from(vec![])); // Single user typing. info.set_typing(users1); assert!(info.users_typing.is_some()); assert_eq!( - info.get_typing_spans(&settings), + info.get_typing_spans(&tunables), Line::from(vec![ Span::styled("@user1:example.com", user_style("@user1:example.com")), Span::from(" is typing...") @@ -2293,7 +2294,7 @@ pub mod tests { info.set_typing(users2); assert!(info.users_typing.is_some()); assert_eq!( - info.get_typing_spans(&settings), + info.get_typing_spans(&tunables), Line::from(vec![ Span::styled("@user1:example.com", user_style("@user1:example.com")), Span::raw(" and "), @@ -2305,18 +2306,18 @@ pub mod tests { // Four users typing. info.set_typing(users4); assert!(info.users_typing.is_some()); - assert_eq!(info.get_typing_spans(&settings), Line::from("Several people are typing...")); + assert_eq!(info.get_typing_spans(&tunables), Line::from("Several people are typing...")); // Five users typing. info.set_typing(users5); assert!(info.users_typing.is_some()); - assert_eq!(info.get_typing_spans(&settings), Line::from("Many people are typing...")); + assert_eq!(info.get_typing_spans(&tunables), Line::from("Many people are typing...")); // Test that USER5 gets rendered using the configured color and name. info.set_typing(vec![TEST_USER5.clone()]); assert!(info.users_typing.is_some()); assert_eq!( - info.get_typing_spans(&settings), + info.get_typing_spans(&tunables), Line::from(vec![ Span::styled("USER 5", user_style_from_color(Color::Black)), Span::from(" is typing...") diff --git a/src/config.rs b/src/config.rs index e7a4a47b..e3326fee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -583,6 +583,70 @@ pub struct TunableValues { pub tabstop: usize, } +impl TunableValues { + pub fn get_user_char_span(&self, user_id: &UserId) -> Span<'_> { + let (color, c) = self + .users + .get(user_id) + .map(|user| { + ( + user.color.as_ref().map(|c| c.0), + user.name.as_ref().and_then(|s| s.chars().next()), + ) + }) + .unwrap_or_default(); + + let color = color.unwrap_or_else(|| user_color(user_id.as_str())); + let style = user_style_from_color(color); + + let c = c.unwrap_or_else(|| user_id.localpart().chars().next().unwrap_or(' ')); + + Span::styled(String::from(c), style) + } + + pub fn get_user_overrides( + &self, + user_id: &UserId, + ) -> (Option, Option>) { + self.users + .get(user_id) + .map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned))) + .unwrap_or_default() + } + + pub fn get_user_color(&self, user_id: &UserId) -> Color { + self.users + .get(user_id) + .and_then(|user| user.color.as_ref().map(|c| c.0)) + .unwrap_or_else(|| user_color(user_id.as_str())) + } + + pub fn get_user_style(&self, user_id: &UserId) -> Style { + user_style_from_color(self.get_user_color(user_id)) + } + + pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> { + let (color, name) = self.get_user_overrides(user_id); + + let color = color.unwrap_or_else(|| user_color(user_id.as_str())); + let style = user_style_from_color(color); + let name = match (name, &self.username_display) { + (Some(name), _) => name, + (None, UserDisplayStyle::Username) => Cow::Borrowed(user_id.as_str()), + (None, UserDisplayStyle::LocalPart) => Cow::Borrowed(user_id.localpart()), + (None, UserDisplayStyle::DisplayName) => { + if let Some(display) = info.display_names.get(user_id) { + Cow::Borrowed(display.as_str()) + } else { + Cow::Borrowed(user_id.as_str()) + } + }, + }; + + Span::styled(name, style) + } +} + #[derive(Clone, Default, Deserialize)] pub struct Tunables { pub log_level: Option, @@ -1030,71 +1094,6 @@ impl ApplicationSettings { serde_json::to_writer(writer, &session).map_err(IambError::from)?; Ok(()) } - - pub fn get_user_char_span(&self, user_id: &UserId) -> Span<'_> { - let (color, c) = self - .tunables - .users - .get(user_id) - .map(|user| { - ( - user.color.as_ref().map(|c| c.0), - user.name.as_ref().and_then(|s| s.chars().next()), - ) - }) - .unwrap_or_default(); - - let color = color.unwrap_or_else(|| user_color(user_id.as_str())); - let style = user_style_from_color(color); - - let c = c.unwrap_or_else(|| user_id.localpart().chars().next().unwrap_or(' ')); - - Span::styled(String::from(c), style) - } - - pub fn get_user_overrides( - &self, - user_id: &UserId, - ) -> (Option, Option>) { - self.tunables - .users - .get(user_id) - .map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned))) - .unwrap_or_default() - } - - pub fn get_user_color(&self, user_id: &UserId) -> Color { - self.tunables - .users - .get(user_id) - .and_then(|user| user.color.as_ref().map(|c| c.0)) - .unwrap_or_else(|| user_color(user_id.as_str())) - } - - pub fn get_user_style(&self, user_id: &UserId) -> Style { - user_style_from_color(self.get_user_color(user_id)) - } - - pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> { - let (color, name) = self.get_user_overrides(user_id); - - let color = color.unwrap_or_else(|| user_color(user_id.as_str())); - let style = user_style_from_color(color); - let name = match (name, &self.tunables.username_display) { - (Some(name), _) => name, - (None, UserDisplayStyle::Username) => Cow::Borrowed(user_id.as_str()), - (None, UserDisplayStyle::LocalPart) => Cow::Borrowed(user_id.localpart()), - (None, UserDisplayStyle::DisplayName) => { - if let Some(display) = info.display_names.get(user_id) { - Cow::Borrowed(display.as_str()) - } else { - Cow::Borrowed(user_id.as_str()) - } - }, - }; - - Span::styled(name, style) - } } #[cfg(test)] diff --git a/src/message/html.rs b/src/message/html.rs index 1aa1fd6c..32173c0f 100644 --- a/src/message/html.rs +++ b/src/message/html.rs @@ -36,7 +36,7 @@ use ratatui::{ }; use crate::{ - config::ApplicationSettings, + config::TunableValues, message::printer::TextPrinter, util::{join_cell_text, space_text}, }; @@ -153,12 +153,7 @@ impl Table { } } - fn to_text<'a>( - &'a self, - width: usize, - style: Style, - settings: &'a ApplicationSettings, - ) -> Text<'a> { + fn to_text<'a>(&'a self, width: usize, style: Style, tunables: &'a TunableValues) -> Text<'a> { let mut text = Text::default(); let columns = self.columns(); let cell_total = width.saturating_sub(columns).saturating_sub(1); @@ -177,7 +172,7 @@ impl Table { if let Some(caption) = &self.caption { let subw = width.saturating_sub(6); let mut printer = - TextPrinter::new(subw, style, true, settings).align(Alignment::Center); + TextPrinter::new(subw, style, true, tunables).align(Alignment::Center); caption.print(&mut printer, style); for mut line in printer.finish().lines { @@ -224,7 +219,7 @@ impl Table { CellType::Data => style, }; - cell.to_text(*w, style, settings) + cell.to_text(*w, style, tunables) } else { space_text(*w, style) }; @@ -294,9 +289,9 @@ impl StyleTreeNode { &'a self, width: usize, style: Style, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> Text<'a> { - let mut printer = TextPrinter::new(width, style, true, settings); + let mut printer = TextPrinter::new(width, style, true, tunables); self.print(&mut printer, style); printer.finish() } @@ -458,7 +453,7 @@ impl StyleTreeNode { } }, StyleTreeNode::Table(table) => { - let text = table.to_text(width, style, printer.settings); + let text = table.to_text(width, style, printer.tunables); printer.push_text(text); }, StyleTreeNode::Break => { @@ -476,11 +471,11 @@ impl StyleTreeNode { }, StyleTreeNode::UserId(user_id) => { - let style = printer.settings().get_user_style(user_id); + let style = printer.tunables().get_user_style(user_id); printer.push_str(user_id.as_str(), style); }, StyleTreeNode::DisplayName(display_name, user_id) => { - let style = printer.settings().get_user_style(user_id); + let style = printer.tunables().get_user_style(user_id); printer.push_str(display_name.as_str(), style); }, StyleTreeNode::RoomId(room_id) => { @@ -516,9 +511,9 @@ impl StyleTree { width: usize, style: Style, hide_reply: bool, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> Text<'a> { - let mut printer = TextPrinter::new(width, style, hide_reply, settings); + let mut printer = TextPrinter::new(width, style, hide_reply, tunables); for child in self.children.iter() { child.print(&mut printer, style); @@ -857,19 +852,19 @@ pub fn parse_matrix_html(s: &str) -> StyleTree { #[cfg(test)] pub mod tests { use super::*; - use crate::tests::mock_settings; + use crate::tests::mock_tunables; use crate::util::space_span; use pretty_assertions::assert_eq; use unicode_width::UnicodeWidthStr; #[test] fn test_header() { - let settings = mock_settings(); + let tunables = mock_tunables(); 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, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled(" ", bold), @@ -881,7 +876,7 @@ pub mod tests { let s = "

Header 2

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

Header 3

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

Header 4

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -923,7 +918,7 @@ pub mod tests { let s = "
Header 5
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -939,7 +934,7 @@ pub mod tests { let s = "
Header 6
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -957,7 +952,7 @@ pub mod tests { #[test] fn test_style() { - let settings = mock_settings(); + let tunables = mock_tunables(); let def = Style::default(); let bold = def.add_modifier(StyleModifier::BOLD); let italic = def.add_modifier(StyleModifier::ITALIC); @@ -967,7 +962,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -976,7 +971,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -985,7 +980,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -994,7 +989,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -1003,7 +998,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -1012,7 +1007,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -1021,7 +1016,7 @@ pub mod tests { let s = "Underline!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Underline", underl), Span::styled("!", underl), @@ -1030,7 +1025,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -1039,7 +1034,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &tunables); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -1049,10 +1044,10 @@ pub mod tests { #[test] fn test_paragraph() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = "

Hello world!

Content

Goodbye world!

"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, &settings); + let text = tree.to_text(10, Style::default(), false, &tunables); assert_eq!(text.lines.len(), 7); assert_eq!( text.lines[0], @@ -1077,10 +1072,10 @@ pub mod tests { #[test] fn test_blockquote() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = "
Hello world!
"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, &settings); + let text = tree.to_text(10, Style::default(), false, &tunables); let style = Style::new().fg(QUOTE_COLOR); assert_eq!(text.lines.len(), 2); assert_eq!( @@ -1109,10 +1104,10 @@ pub mod tests { #[test] fn test_list_unordered() { - let settings = mock_settings(); + let tunables = mock_tunables(); 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, &settings); + let text = tree.to_text(8, Style::default(), false, &tunables); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1172,10 +1167,10 @@ pub mod tests { #[test] fn test_list_ordered() { - let settings = mock_settings(); + let tunables = mock_tunables(); 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, &settings); + let text = tree.to_text(9, Style::default(), false, &tunables); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1235,7 +1230,7 @@ pub mod tests { #[test] fn test_table() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = "\ \ @@ -1246,7 +1241,7 @@ pub mod tests { \
Column 1Column 2Column 3
abc
"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), false, &settings); + let text = tree.to_text(15, Style::default(), false, &tunables); let bold = Style::default().add_modifier(StyleModifier::BOLD); assert_eq!(text.lines.len(), 11); @@ -1336,11 +1331,11 @@ pub mod tests { #[test] fn test_matrix_reply() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = "This was replied toThis is the reply"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, &settings); + let text = tree.to_text(10, Style::default(), false, &tunables); assert_eq!(text.lines.len(), 4); assert_eq!( text.lines[0], @@ -1377,7 +1372,7 @@ pub mod tests { ); let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), true, &settings); + let text = tree.to_text(10, Style::default(), true, &tunables); assert_eq!(text.lines.len(), 2); assert_eq!( text.lines[0], @@ -1402,10 +1397,10 @@ pub mod tests { #[test] fn test_self_closing() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = "Hello
World
Goodbye"; let tree = parse_matrix_html(s); - let text = tree.to_text(7, Style::default(), true, &settings); + let text = tree.to_text(7, Style::default(), true, &tunables); 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(" "),])); @@ -1414,10 +1409,10 @@ pub mod tests { #[test] fn test_embedded_newline() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = "

Hello\nWorld

"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), true, &settings); + let text = tree.to_text(15, Style::default(), true, &tunables); assert_eq!(text.lines.len(), 1); assert_eq!( text.lines[0], @@ -1432,7 +1427,7 @@ pub mod tests { #[test] fn test_pre_tag() { - let settings = mock_settings(); + let tunables = mock_tunables(); let s = concat!( "
",
             "fn hello() -> usize {\n",
@@ -1442,7 +1437,7 @@ pub mod tests {
             "
\n" ); let tree = parse_matrix_html(s); - let text = tree.to_text(25, Style::default(), true, &settings); + let text = tree.to_text(25, Style::default(), true, &tunables); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1520,10 +1515,10 @@ 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; + let mut enabled = mock_tunables(); + enabled.message_shortcode_display = true; + let mut disabled = mock_tunables(); + disabled.message_shortcode_display = false; for shortcode in ["exploding_head", "polar_bear", "canada"] { let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str(); diff --git a/src/message/mod.rs b/src/message/mod.rs index 718c7a68..6f412686 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -54,13 +54,11 @@ use ratatui::{ }; use modalkit::editing::cursor::Cursor; -use modalkit::prelude::*; use ratatui_image::protocol::Protocol; -use crate::config::ImagePreviewSize; +use crate::config::{ImagePreviewSize, TunableValues}; use crate::{ base::RoomInfo, - config::ApplicationSettings, message::html::{parse_matrix_html, StyleTree}, util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text}, }; @@ -609,17 +607,17 @@ enum MessageColumns { } impl MessageColumns { - fn user_gutter_width(&self, settings: &ApplicationSettings) -> u16 { + fn user_gutter_width(&self, tunables: &TunableValues) -> u16 { if let MessageColumns::One = self { 0 } else { - settings.tunables.user_gutter_width as u16 + tunables.user_gutter_width as u16 } } } struct MessageFormatter<'a> { - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, /// How many columns to print. cols: MessageColumns, @@ -659,12 +657,11 @@ impl<'a> MessageFormatter<'a> { text.lines.push(Line::from(vec![leading, date, trailing])); } - let user_gutter_empty_span = - space_span(self.settings.tunables.user_gutter_width, Style::default()); + let user_gutter_empty_span = space_span(self.tunables.user_gutter_width, Style::default()); match self.cols { MessageColumns::Four => { - let settings = self.settings; + let tunables = self.tunables; let user = self.user.take().unwrap_or(user_gutter_empty_span); let time = self.time.take().unwrap_or(TIME_GUTTER_EMPTY_SPAN); @@ -673,7 +670,7 @@ impl<'a> MessageFormatter<'a> { line.push(time); // Show read receipts. - let user_char = |user: OwnedUserId| -> Span { settings.get_user_char_span(&user) }; + let user_char = |user: OwnedUserId| -> Span { tunables.get_user_char_span(&user) }; let a = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" ")); let b = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" ")); @@ -730,18 +727,18 @@ impl<'a> MessageFormatter<'a> { style: Style, text: &mut Text<'a>, info: &'a RoomInfo, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> Option> { - let reply_style = if settings.tunables.message_user_color { - style.patch(settings.get_user_color(&msg.sender)) + let reply_style = if tunables.message_user_color { + style.patch(tunables.get_user_color(&msg.sender)) } else { style }; let width = self.width(); let w = width.saturating_sub(2); - let (mut replied, proto) = msg.show_msg(w, reply_style, true, settings); - let mut sender = msg.sender_span(info, self.settings); + let (mut replied, proto) = msg.show_msg(w, reply_style, true, tunables); + let mut sender = msg.sender_span(info, self.tunables); let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); let trailing = w.saturating_sub(sender_width + 1); @@ -763,7 +760,7 @@ impl<'a> MessageFormatter<'a> { let proto = proto.map(|p| { let y_off = text.lines.len() as u16; // Adjust x_off by 2 to account for the vertical line and indent - let x_off = self.cols.user_gutter_width(settings) + 2; + let x_off = self.cols.user_gutter_width(tunables) + 2; (p, x_off, y_off) }); @@ -778,7 +775,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, self.settings); + let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.tunables); let mut reactions = 0; for (key, count) in counts { @@ -786,7 +783,7 @@ impl<'a> MessageFormatter<'a> { emojis.push_str(" ", style); } - let name = if self.settings.tunables.reaction_shortcode_display { + let name = if self.tunables.reaction_shortcode_display { if let Some(emoji) = emojis::get(key) { if let Some(short) = emoji.shortcode() { short @@ -827,7 +824,7 @@ impl<'a> MessageFormatter<'a> { let plural = len != 1; let style = Style::default(); let mut threaded = - printer::TextPrinter::new(self.width(), style, false, self.settings).literal(true); + printer::TextPrinter::new(self.width(), style, false, self.tunables).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); @@ -914,7 +911,7 @@ impl Message { } } - fn get_render_style(&self, selected: bool, settings: &ApplicationSettings) -> Style { + fn get_render_style(&self, selected: bool, tunables: &TunableValues) -> Style { let mut style = Style::default(); if selected { @@ -925,8 +922,8 @@ impl Message { style = style.add_modifier(StyleModifier::ITALIC); } - if settings.tunables.message_user_color { - let color = settings.get_user_color(&self.sender); + if tunables.message_user_color { + let color = tunables.get_user_color(&self.sender); style = style.fg(color); } @@ -938,21 +935,21 @@ impl Message { prev: Option<&Message>, width: usize, info: &'a RoomInfo, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> MessageFormatter<'a> { let orig = width; let date = match &prev { Some(prev) if prev.timestamp.same_day(&self.timestamp) => None, _ => self.timestamp.show_date(), }; - let user_gutter = settings.tunables.user_gutter_width; + let user_gutter = tunables.user_gutter_width; if user_gutter + TIME_GUTTER + READ_GUTTER + MIN_MSG_LEN <= width && - settings.tunables.read_receipt_display + tunables.read_receipt_display { let cols = MessageColumns::Four; let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER; - let user = self.show_sender(prev, true, info, settings); + let user = self.show_sender(prev, true, info, tunables); let time = self.timestamp.show_time(); let read = info .event_receipts @@ -962,31 +959,31 @@ impl Message { .map(|user_id| user_id.to_owned()) .collect(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { tunables, cols, orig, fill, user, date, time, read } } else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width { let cols = MessageColumns::Three; let fill = width - user_gutter - TIME_GUTTER; - let user = self.show_sender(prev, true, info, settings); + let user = self.show_sender(prev, true, info, tunables); let time = self.timestamp.show_time(); let read = Vec::new(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { tunables, cols, orig, fill, user, date, time, read } } else if user_gutter + MIN_MSG_LEN <= width { let cols = MessageColumns::Two; let fill = width - user_gutter; - let user = self.show_sender(prev, true, info, settings); + let user = self.show_sender(prev, true, info, tunables); let time = None; let read = Vec::new(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { tunables, cols, orig, fill, user, date, time, read } } else { let cols = MessageColumns::One; let fill = width.saturating_sub(2); - let user = self.show_sender(prev, false, info, settings); + let user = self.show_sender(prev, false, info, tunables); let time = None; let read = Vec::new(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { tunables, cols, orig, fill, user, date, time, read } } } @@ -997,14 +994,12 @@ impl Message { &'a self, prev: Option<&Message>, selected: bool, - vwctx: &ViewportContext, + width: usize, info: &'a RoomInfo, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> (Text<'a>, [Option>; 2]) { - let width = vwctx.get_width(); - - let style = self.get_render_style(selected, settings); - let mut fmt = self.get_render_format(prev, width, info, settings); + let style = self.get_render_style(selected, tunables); + let mut fmt = self.get_render_format(prev, width, info, tunables); let mut text = Text::default(); let width = fmt.width(); @@ -1015,16 +1010,16 @@ impl Message { .and_then(|e| info.get_event(&e)); let proto_reply = reply.as_ref().and_then(|r| { // Format the reply header, push it into the `Text` buffer, and get any image. - fmt.push_in_reply(r, style, &mut text, info, settings) + fmt.push_in_reply(r, style, &mut text, info, tunables) }); // 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); + let (msg, proto) = self.show_msg(width, style, reply.is_some(), tunables); // Given our text so far, determine the image offset. let proto_main = proto.map(|p| { let y_off = text.lines.len() as u16; - let x_off = fmt.cols.user_gutter_width(settings); + let x_off = fmt.cols.user_gutter_width(tunables); // Adjust y_off by 1 if a date was printed before the message to account for // the extra line we're going to print. let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off }; @@ -1038,7 +1033,7 @@ impl Message { fmt.push_spans(space_span(width, style).into(), style, &mut text); } - if settings.tunables.reaction_display { + if tunables.reaction_display { let reactions = info.get_reactions(self.event.event_id()); fmt.push_reactions(reactions, style, &mut text); } @@ -1054,11 +1049,11 @@ impl Message { &'a self, prev: Option<&Message>, selected: bool, - vwctx: &ViewportContext, + width: usize, info: &'a RoomInfo, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> Text<'a> { - self.show_with_preview(prev, selected, vwctx, info, settings).0 + self.show_with_preview(prev, selected, width, info, tunables).0 } fn show_msg<'a>( @@ -1066,13 +1061,13 @@ impl Message { width: usize, style: Style, hide_reply: bool, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> (Text<'a>, Option<&'a Protocol>) { if let Some(html) = &self.html { - (html.to_text(width, style, hide_reply, settings), None) + (html.to_text(width, style, hide_reply, tunables), None) } else { let mut msg = self.event.body(); - if settings.tunables.message_shortcode_display { + if tunables.message_shortcode_display { msg = Cow::Owned(replace_emojis_in_str(msg.as_ref())); } @@ -1101,12 +1096,8 @@ impl Message { } } - fn sender_span<'a>( - &'a self, - info: &'a RoomInfo, - settings: &'a ApplicationSettings, - ) -> Span<'a> { - settings.get_user_span(self.sender.as_ref(), info) + fn sender_span<'a>(&'a self, info: &'a RoomInfo, tunables: &'a TunableValues) -> Span<'a> { + tunables.get_user_span(self.sender.as_ref(), info) } fn show_sender<'a>( @@ -1114,7 +1105,7 @@ impl Message { prev: Option<&Message>, align_right: bool, info: &'a RoomInfo, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> Option> { if let Some(prev) = prev { if self.sender == prev.sender && @@ -1125,8 +1116,8 @@ impl Message { } } - let Span { content, style } = self.sender_span(info, settings); - let user_gutter = settings.tunables.user_gutter_width; + let Span { content, style } = self.sender_span(info, tunables); + let user_gutter = tunables.user_gutter_width; let ((truncated, width), _) = take_width(content, user_gutter - 2); let padding = user_gutter - 2 - width; diff --git a/src/message/printer.rs b/src/message/printer.rs index 34187521..b52765d2 100644 --- a/src/message/printer.rs +++ b/src/message/printer.rs @@ -11,7 +11,7 @@ use ratatui::text::{Line, Span, Text}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::config::{ApplicationSettings, TunableValues}; +use crate::config::TunableValues; use crate::util::{ replace_emojis_in_line, replace_emojis_in_span, @@ -32,7 +32,7 @@ pub struct TextPrinter<'a> { curr_width: usize, literal: bool, - pub(super) settings: &'a ApplicationSettings, + pub(super) tunables: &'a TunableValues, } impl<'a> TextPrinter<'a> { @@ -41,7 +41,7 @@ impl<'a> TextPrinter<'a> { width: usize, base_style: Style, hide_reply: bool, - settings: &'a ApplicationSettings, + tunables: &'a TunableValues, ) -> Self { TextPrinter { text: Text::default(), @@ -53,7 +53,7 @@ impl<'a> TextPrinter<'a> { curr_spans: vec![], curr_width: 0, literal: false, - settings, + tunables, } } @@ -79,12 +79,8 @@ impl<'a> TextPrinter<'a> { self.tunables().message_shortcode_display } - pub fn settings(&self) -> &ApplicationSettings { - self.settings - } - pub fn tunables(&self) -> &TunableValues { - &self.settings.tunables + self.tunables } /// Indicates the current printer's width. @@ -104,7 +100,7 @@ impl<'a> TextPrinter<'a> { curr_spans: vec![], curr_width: 0, literal: self.literal, - settings: self.settings, + tunables: self.tunables, } } @@ -216,7 +212,7 @@ impl<'a> TextPrinter<'a> { return; } - let tabstop = self.settings().tunables.tabstop; + let tabstop = self.tunables().tabstop; for mut word in UnicodeSegmentation::split_word_bounds(s) { if let "\n" | "\r\n" = word { @@ -303,12 +299,12 @@ impl<'a> TextPrinter<'a> { #[cfg(test)] pub mod tests { use super::*; - use crate::tests::mock_settings; + use crate::tests::mock_tunables; #[test] fn test_push_nobreak() { - let settings = mock_settings(); - let mut printer = TextPrinter::new(5, Style::default(), false, &settings); + let tunables = mock_tunables(); + let mut printer = TextPrinter::new(5, Style::default(), false, &tunables); printer.push_span_nobreak("hello world".into()); let text = printer.finish(); assert_eq!(text.lines.len(), 1); diff --git a/src/windows/mod.rs b/src/windows/mod.rs index bb10415b..863da7ad 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -1549,7 +1549,11 @@ impl ListItem for MemberItem { let info = store.application.rooms.get_or_default(self.room_id.clone()); let user_id = self.member.user_id(); - let (color, name) = store.application.settings.get_user_overrides(self.member.user_id()); + let (color, name) = store + .application + .settings + .tunables + .get_user_overrides(self.member.user_id()); let color = color.unwrap_or_else(|| super::config::user_color(user_id.as_str())); let mut style = super::config::user_style_from_color(color); diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 3a518aa0..d0b290f0 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -957,8 +957,12 @@ impl StatefulWidget for Chat<'_> { (editing, Some(_), thread) => { self.store.application.rooms.get(state.id()).and_then(|room| { let msg = state.get_reply_to(room)?; - let user = - self.store.application.settings.get_user_span(msg.sender.as_ref(), room); + let user = self + .store + .application + .settings + .tunables + .get_user_span(msg.sender.as_ref(), room); let prefix = match (editing.is_some(), thread.is_some()) { (true, false) => Span::from("Editing reply to "), (true, true) => Span::from("Editing reply in thread to "), diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index 6230052b..43db2ffb 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -189,7 +189,8 @@ impl RoomState { if let Ok(Some(inviter)) = &inviter { let info = store.application.rooms.get_or_default(self.id().to_owned()); invited.push(Span::from(" by ")); - invited.push(store.application.settings.get_user_span(inviter.user_id(), info)); + invited + .push(store.application.settings.tunables.get_user_span(inviter.user_id(), info)); } let l1 = Line::from(invited); diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 02340573..a7680e39 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -53,7 +53,7 @@ use crate::{ RoomFocus, RoomInfo, }, - config::ApplicationSettings, + config::TunableValues, message::{Message, MessageCursor, MessageKey, Messages}, }; @@ -269,7 +269,7 @@ impl ScrollbackState { idx: MessageKey, pos: MovePosition, info: &RoomInfo, - settings: &ApplicationSettings, + tunables: &TunableValues, ) { let Some(thread) = self.get_thread(info) else { return; @@ -292,7 +292,8 @@ impl ScrollbackState { for (key, item) in thread.range(..=&idx).rev() { let sel = selidx == key; let prev = prevmsg(key, thread); - let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len(); + let len = + item.show(prev, sel, self.viewctx.get_width(), info, tunables).lines.len(); if key == &idx { lines += len / 2; @@ -315,7 +316,8 @@ impl ScrollbackState { for (key, item) in thread.range(..=&idx).rev() { let sel = key == selidx; let prev = prevmsg(key, thread); - let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len(); + let len = + item.show(prev, sel, self.viewctx.get_width(), info, tunables).lines.len(); lines += len; @@ -338,7 +340,7 @@ impl ScrollbackState { self.jumped.push(self.cursor.clone()); } - fn shift_cursor(&mut self, info: &RoomInfo, settings: &ApplicationSettings) { + fn shift_cursor(&mut self, info: &RoomInfo, tunables: &TunableValues) { let Some(thread) = self.get_thread(info) else { return; }; @@ -368,7 +370,10 @@ impl ScrollbackState { break; } - lines += item.show(prev, false, &self.viewctx, info, settings).height().max(1); + lines += item + .show(prev, false, self.viewctx.get_width(), info, tunables) + .height() + .max(1); if lines >= self.viewctx.get_height() { // We've reached the end of the viewport; move cursor into it. @@ -1066,7 +1071,7 @@ impl ScrollActions for ScrollbackState { store: &mut ProgramStore, ) -> EditResult { let info = store.application.rooms.get_or_default(self.room_id.clone()); - let settings = &store.application.settings; + let tunables = &store.application.settings.tunables; let mut corner = self.viewctx.corner.clone(); let thread = self.get_thread(info).ok_or_else(no_msgs)?; @@ -1094,7 +1099,7 @@ impl ScrollActions for ScrollbackState { for (key, item) in thread.range(..=&corner_key).rev() { let sel = key == cursor_key; let prev = prevmsg(key, thread); - let txt = item.show(prev, sel, &self.viewctx, info, settings); + let txt = item.show(prev, sel, self.viewctx.get_width(), info, tunables); let len = txt.height().max(1); let max = len.saturating_sub(1); @@ -1122,7 +1127,7 @@ impl ScrollActions for ScrollbackState { for (key, item) in thread.range(&corner_key..) { let sel = key == cursor_key; - let txt = item.show(prev, sel, &self.viewctx, info, settings); + let txt = item.show(prev, sel, self.viewctx.get_width(), info, tunables); let len = txt.height().max(1); let max = len.saturating_sub(1); @@ -1160,7 +1165,7 @@ impl ScrollActions for ScrollbackState { } self.viewctx.corner = corner; - self.shift_cursor(info, settings); + self.shift_cursor(info, tunables); Ok(None) } @@ -1181,11 +1186,11 @@ impl ScrollActions for ScrollbackState { }, Axis::Vertical => { let info = store.application.rooms.get_or_default(self.room_id.clone()); - let settings = &store.application.settings; + let tunables = &store.application.settings.tunables; let thread = self.get_thread(info).ok_or_else(no_msgs)?; if let Some(key) = self.cursor.to_key(thread).cloned() { - self.scrollview(key, pos, info, settings); + self.scrollview(key, pos, info, tunables); } Ok(None) @@ -1303,7 +1308,7 @@ impl StatefulWidget for Scrollback<'_> { let area = if state.cursor.timestamp.is_some() { render_jump_to_recent(area, buf, self.focused) } else { - info.render_typing(area, buf, &self.store.application.settings) + info.render_typing(area, buf, &settings.tunables) }; state.set_term_info(area); @@ -1347,8 +1352,13 @@ impl StatefulWidget for Scrollback<'_> { for (key, item) in thread.range(&corner_key..) { let sel = key == cursor_key; - let (txt, [mut msg_preview, mut reply_preview]) = - item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings); + let (txt, [mut msg_preview, mut reply_preview]) = item.show_with_preview( + prev, + foc && sel, + state.viewctx.get_width(), + info, + &settings.tunables, + ); let incomplete_ok = !full || !sel; From e747ab6819222cebb66588c30a8c23c53414d915 Mon Sep 17 00:00:00 2001 From: vaw Date: Sat, 21 Jun 2025 16:02:07 +0200 Subject: [PATCH 2/7] Add `message_time_display` setting --- docs/iamb.5 | 3 +++ src/config.rs | 4 ++++ src/message/mod.rs | 23 +++++++++++++++++++++-- src/tests.rs | 1 + 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/iamb.5 b/docs/iamb.5 index 8357f965..f2c1dbe8 100644 --- a/docs/iamb.5 +++ b/docs/iamb.5 @@ -199,6 +199,9 @@ Defines whether or not read confirmations are sent. .It Sy read_receipt_display Defines whether or not read confirmations are displayed. +.It Sy message_time_display +Defines whether or not the time a message wast sent is displayed. + .It Sy request_timeout Defines the maximum time per request in seconds. diff --git a/src/config.rs b/src/config.rs index e3326fee..33a60916 100644 --- a/src/config.rs +++ b/src/config.rs @@ -565,6 +565,7 @@ pub struct TunableValues { pub reaction_shortcode_display: bool, pub read_receipt_send: bool, pub read_receipt_display: bool, + pub message_time_display: bool, pub request_timeout: u64, pub sort: SortValues, pub state_event_display: bool, @@ -662,6 +663,7 @@ pub struct Tunables { pub state_event_display: Option, pub typing_notice_send: Option, pub typing_notice_display: Option, + pub message_time_display: Option, pub users: Option, pub username_display: Option, pub message_user_color: Option, @@ -689,6 +691,7 @@ impl Tunables { .or(other.reaction_shortcode_display), read_receipt_send: self.read_receipt_send.or(other.read_receipt_send), read_receipt_display: self.read_receipt_display.or(other.read_receipt_display), + message_time_display: self.message_time_display.or(other.message_time_display), request_timeout: self.request_timeout.or(other.request_timeout), sort: merge_sorts(self.sort, other.sort), state_event_display: self.state_event_display.or(other.state_event_display), @@ -719,6 +722,7 @@ impl Tunables { reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false), read_receipt_send: self.read_receipt_send.unwrap_or(true), read_receipt_display: self.read_receipt_display.unwrap_or(true), + message_time_display: self.message_time_display.unwrap_or(true), request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT), sort: self.sort.values(), state_event_display: self.state_event_display.unwrap_or(true), diff --git a/src/message/mod.rs b/src/message/mod.rs index 6f412686..0325f02e 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -945,7 +945,8 @@ impl Message { let user_gutter = tunables.user_gutter_width; if user_gutter + TIME_GUTTER + READ_GUTTER + MIN_MSG_LEN <= width && - tunables.read_receipt_display + tunables.read_receipt_display && + tunables.message_time_display { let cols = MessageColumns::Four; let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER; @@ -960,7 +961,25 @@ impl Message { .collect(); MessageFormatter { tunables, cols, orig, fill, user, date, time, read } - } else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width { + } else if user_gutter + READ_GUTTER + MIN_MSG_LEN <= width && + tunables.read_receipt_display && + !tunables.message_time_display + { + let cols = MessageColumns::Three; + let fill = width - user_gutter - READ_GUTTER; + let user = self.show_sender(prev, true, info, tunables); + let time = None; + let read = info + .event_receipts + .values() + .filter_map(|receipts| receipts.get(self.event.event_id())) + .flat_map(|read| read.iter()) + .map(|user_id| user_id.to_owned()) + .collect(); + + MessageFormatter { tunables, cols, orig, fill, user, date, time, read } + } else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width && tunables.message_time_display + { let cols = MessageColumns::Three; let fill = width - user_gutter - TIME_GUTTER; let user = self.show_sender(prev, true, info, tunables); diff --git a/src/tests.rs b/src/tests.rs index e9b05021..87ab3843 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -182,6 +182,7 @@ pub fn mock_tunables() -> TunableValues { state_event_display: true, typing_notice_send: true, typing_notice_display: true, + message_time_display: true, users: vec![(TEST_USER5.clone(), UserDisplayTunables { color: Some(UserColor(Color::Black)), name: Some("USER 5".into()), From 24a8664ef7aa0bfe5cf4257abb393602695397ab Mon Sep 17 00:00:00 2001 From: vaw Date: Sat, 31 Jan 2026 22:57:17 +0100 Subject: [PATCH 3/7] dep/modalkit: Use fork to allow hack --- Cargo.lock | 18 ++++++------------ Cargo.toml | 8 ++++---- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3810ed04..2fd5ab28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1492,8 +1492,7 @@ dependencies = [ [[package]] name = "editor-types" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e99679670f67825fcd24a23cb4eb655a0f92c82bd4d1c1a1357c0cd71e87" +source = "git+https://github.com/vawvaw/modalkit?rev=6bbe8a483f39c61d56fdb2cdef6a110abab05508#6bbe8a483f39c61d56fdb2cdef6a110abab05508" dependencies = [ "bitflags 2.10.0", "editor-types-macros", @@ -1504,8 +1503,7 @@ dependencies = [ [[package]] name = "editor-types-macros" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42680de76cf91f231abd90cc623750d39077f7d2fadb7962325fb082871f4c66" +source = "git+https://github.com/vawvaw/modalkit?rev=6bbe8a483f39c61d56fdb2cdef6a110abab05508#6bbe8a483f39c61d56fdb2cdef6a110abab05508" dependencies = [ "editor-types-parser", "nom 7.1.3", @@ -1517,8 +1515,7 @@ dependencies = [ [[package]] name = "editor-types-parser" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac4b91fe830fbbe0a60c37ba0264b6e9ffc70e3664c028234dac59e79299ad4" +source = "git+https://github.com/vawvaw/modalkit?rev=6bbe8a483f39c61d56fdb2cdef6a110abab05508#6bbe8a483f39c61d56fdb2cdef6a110abab05508" dependencies = [ "nom 7.1.3", "thiserror 1.0.69", @@ -2871,8 +2868,7 @@ dependencies = [ [[package]] name = "keybindings" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a726307ed87e05155c31329676130e6a237e62dda80211f7e1ed811e47630f" +source = "git+https://github.com/vawvaw/modalkit?rev=6bbe8a483f39c61d56fdb2cdef6a110abab05508#6bbe8a483f39c61d56fdb2cdef6a110abab05508" dependencies = [ "textwrap", "unicode-segmentation", @@ -3504,8 +3500,7 @@ dependencies = [ [[package]] name = "modalkit" version = "0.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cbb03c35f23ec7d13f7870049803cd8829f5e60b69d38fa98f5e7876de9f34e" +source = "git+https://github.com/vawvaw/modalkit?rev=6bbe8a483f39c61d56fdb2cdef6a110abab05508#6bbe8a483f39c61d56fdb2cdef6a110abab05508" dependencies = [ "anymap2", "arboard", @@ -3527,8 +3522,7 @@ dependencies = [ [[package]] name = "modalkit-ratatui" version = "0.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9b01555837e6d34c67ba887aee1d3d83e4622c42cbad0da1d90de00230d2aa" +source = "git+https://github.com/vawvaw/modalkit?rev=6bbe8a483f39c61d56fdb2cdef6a110abab05508#6bbe8a483f39c61d56fdb2cdef6a110abab05508" dependencies = [ "crossterm", "intervaltree", diff --git a/Cargo.toml b/Cargo.toml index 19f54cd3..b938ac84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,13 +81,13 @@ optional = true [dependencies.modalkit] version = "0.0.24" default-features = false -#git = "https://github.com/ulyssa/modalkit" -#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" +git = "https://github.com/vawvaw/modalkit" +rev = "6bbe8a483f39c61d56fdb2cdef6a110abab05508" [dependencies.modalkit-ratatui] version = "0.0.24" -#git = "https://github.com/ulyssa/modalkit" -#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" +git = "https://github.com/vawvaw/modalkit" +rev = "6bbe8a483f39c61d56fdb2cdef6a110abab05508" [dependencies.matrix-sdk] version = "0.14.0" From d1f257584748e6721d5be56648d6f66ceeb8ea1f Mon Sep 17 00:00:00 2001 From: vaw Date: Sat, 13 Sep 2025 12:56:52 +0200 Subject: [PATCH 4/7] Add Message info --- src/base.rs | 121 ++++++--- src/commands.rs | 16 ++ src/config.rs | 2 +- src/main.rs | 9 +- src/message/mod.rs | 39 ++- src/preview.rs | 3 +- src/windows/mod.rs | 9 +- src/windows/room/chat.rs | 22 +- src/windows/room/message.rs | 442 +++++++++++++++++++++++++++++++++ src/windows/room/mod.rs | 127 ++++++---- src/windows/room/scrollback.rs | 14 +- src/windows/room/space.rs | 3 +- src/worker.rs | 126 +++++++++- 13 files changed, 818 insertions(+), 115 deletions(-) create mode 100644 src/windows/room/message.rs diff --git a/src/base.rs b/src/base.rs index 35028e70..ce72f978 100644 --- a/src/base.rs +++ b/src/base.rs @@ -3,10 +3,11 @@ //! The types defined here get used throughout iamb. use std::borrow::Cow; use std::collections::hash_map::IntoIter; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; use std::fmt::{self, Display}; use std::hash::Hash; +use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -460,6 +461,9 @@ pub enum RoomAction { /// Open the members window. Members(Box), + /// Open the message info window. + Message(Box), + /// Set whether a room is a direct message. SetDirect(bool), @@ -995,24 +999,19 @@ impl RoomInfo { } /// Get the reactions and their counts for a message. - pub fn get_reactions(&self, event_id: &EventId) -> Vec<(&str, usize)> { + pub fn get_reactions(&self, event_id: &EventId) -> Vec<(&str, Vec<&UserId>)> { if let Some(reacts) = self.reactions.get(event_id) { - let mut counts = HashMap::new(); - - let mut seen_user_reactions = BTreeSet::new(); + let mut reactions = BTreeMap::new(); for (key, user) in reacts.values() { - if !seen_user_reactions.contains(&(key, user)) { - seen_user_reactions.insert((key, user)); - let count = counts.entry(key.as_str()).or_default(); - *count += 1; - } + let react = reactions.entry(key.as_str()).or_insert_with(BTreeSet::new); + react.insert(user.deref()); } - let mut reactions = counts.into_iter().collect::>(); - reactions.sort(); - reactions + .into_iter() + .map(|(key, users)| (key, users.into_iter().collect::>())) + .collect::>() } else { vec![] } @@ -1214,7 +1213,7 @@ impl RoomInfo { } /// Insert a new message event. - pub fn insert(&mut self, msg: RoomMessageEvent) { + pub fn insert(&mut self, msg: RoomMessageEvent, need_load: &mut RoomNeeds) { match msg { RoomMessageEvent::Original(OriginalRoomMessageEvent { content: RoomMessageEventContent { relates_to: Some(ref relates_to), .. }, @@ -1226,7 +1225,13 @@ impl RoomInfo { let event_id = event_id.clone(); self.insert_thread(msg, event_id); }, - Relation::Reply { .. } => self.insert_message(msg), + Relation::Reply { in_reply_to } => { + if self.get_message_key(&in_reply_to.event_id).is_none() { + need_load + .need_event(msg.room_id().to_owned(), in_reply_to.event_id.clone()); + } + self.insert_message(msg) + }, _ => self.insert_message(msg), } }, @@ -1236,6 +1241,7 @@ impl RoomInfo { /// Insert a new message event, and spawn a task for image-preview if it has an image /// attachment. + #[allow(clippy::too_many_arguments)] pub fn insert_with_preview( &mut self, room_id: OwnedRoomId, @@ -1244,9 +1250,10 @@ impl RoomInfo { ev: RoomMessageEvent, settings: &mut ApplicationSettings, media: matrix_sdk::Media, + need_load: &mut RoomNeeds, ) { let source = picker.and_then(|_| source_from_event(&ev)); - self.insert(ev); + self.insert(ev, need_load); if let Some((event_id, source)) = source { if let (Some(msg), Some(image_preview)) = @@ -1520,6 +1527,7 @@ pub struct MessageNeed { pub struct Need { pub members: bool, pub messages: Option>, + pub events: Vec, } /// Things that need loading for different rooms. @@ -1547,6 +1555,11 @@ impl RoomNeeds { messages.push(MessageNeed { event_id, ttl: MESSAGE_NEED_TTL }); } + /// Load a single event in the room history. + pub fn need_event(&mut self, room_id: OwnedRoomId, event_id: OwnedEventId) { + self.needs.entry(room_id).or_default().events.push(event_id); + } + pub fn need_messages_all(&mut self, room_id: OwnedRoomId, message_needs: Vec) { self.needs .entry(room_id) @@ -1687,11 +1700,39 @@ impl ChatStore { impl ApplicationStore for ChatStore {} +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum RoomView { + /// The main timeline is shown. + Main, + /// A thread is shown. + Thread(OwnedEventId), + /// A single message is shown. + Message(OwnedEventId), +} + +impl From> for RoomView { + fn from(thread: Option) -> Self { + match thread { + Some(thread) => Self::Thread(thread), + None => Self::Main, + } + } +} + +impl From> for RoomView { + fn from(thread: Option<&OwnedEventId>) -> Self { + match thread { + Some(thread) => Self::Thread(thread.to_owned()), + None => Self::Main, + } + } +} + /// Identified used to track window content. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum IambId { - /// A Matrix room, with an optional thread to show. - Room(OwnedRoomId, Option), + /// A Matrix room, with an item that is shown. + Room(OwnedRoomId, RoomView), /// The `:dms` window. DirectList, @@ -1721,12 +1762,15 @@ pub enum IambId { impl Display for IambId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - IambId::Room(room_id, None) => { + IambId::Room(room_id, RoomView::Main) => { write!(f, "iamb://room/{room_id}") }, - IambId::Room(room_id, Some(thread)) => { + IambId::Room(room_id, RoomView::Thread(thread)) => { write!(f, "iamb://room/{room_id}/threads/{thread}") }, + IambId::Room(room_id, RoomView::Message(message)) => { + write!(f, "iamb://room/{room_id}/messages/{message}") + }, IambId::MemberList(room_id) => { write!(f, "iamb://members/{room_id}") }, @@ -1795,7 +1839,7 @@ impl Visitor<'_> for IambIdVisitor { return Err(E::custom("Invalid room identifier")); }; - Ok(IambId::Room(room_id, None)) + Ok(IambId::Room(room_id, RoomView::Main)) }, [room_id, "threads", thread_root] => { let Ok(room_id) = OwnedRoomId::try_from(room_id) else { @@ -1806,7 +1850,18 @@ impl Visitor<'_> for IambIdVisitor { return Err(E::custom("Invalid thread root identifier")); }; - Ok(IambId::Room(room_id, Some(thread_root))) + Ok(IambId::Room(room_id, RoomView::Thread(thread_root))) + }, + [room_id, "messages", message] => { + let Ok(room_id) = OwnedRoomId::try_from(room_id) else { + return Err(E::custom("Invalid room identifier")); + }; + + let Ok(message) = OwnedEventId::try_from(message) else { + return Err(E::custom("Invalid message identifier")); + }; + + Ok(IambId::Room(room_id, RoomView::Message(message))) }, _ => return Err(E::custom("Invalid members window URL")), } @@ -1920,7 +1975,7 @@ pub enum IambBufferId { Command(CommandType), /// The message buffer or a specific message in a room. - Room(OwnedRoomId, Option, RoomFocus), + Room(OwnedRoomId, RoomView, RoomFocus), /// The `:dms` window. DirectList, @@ -1952,7 +2007,7 @@ impl IambBufferId { pub fn to_window(&self) -> Option { let id = match self { IambBufferId::Command(_) => return None, - IambBufferId::Room(room, thread, _) => IambId::Room(room.clone(), thread.clone()), + IambBufferId::Room(room, view, _) => IambId::Room(room.clone(), view.clone()), IambBufferId::DirectList => IambId::DirectList, IambBufferId::MemberList(room) => IambId::MemberList(room.clone()), IambBufferId::RoomList => IambId::RoomList, @@ -2242,9 +2297,13 @@ pub mod tests { )); } - assert_eq!(info.get_reactions(&owned_event_id!("$my_reaction")), vec![ - ("🏠", 1), - ("🙂", 2) + let reacts = info.get_reactions(&owned_event_id!("$my_reaction")); + assert_eq!(reacts, vec![ + ("🏠", vec![owned_user_id!("@foo:example.org").deref()]), + ("🙂", vec![ + owned_user_id!("@bar:example.org").deref(), + owned_user_id!("@foo:example.org").deref(), + ]) ]); } @@ -2328,15 +2387,21 @@ pub mod tests { #[test] fn test_need_load() { let room_id = TEST_ROOM1_ID.clone(); + let event_id = MSG1_EVID.clone(); let mut need_load = RoomNeeds::default(); need_load.need_messages(room_id.clone()); need_load.need_members(room_id.clone()); + need_load.need_event(room_id.clone(), event_id.clone()); assert_eq!(need_load.into_iter().collect::>(), vec![( room_id, - Need { members: true, messages: Some(Vec::new()) } + Need { + members: true, + messages: Some(Vec::new()), + events: vec![event_id] + } )],); } diff --git a/src/commands.rs b/src/commands.rs index d355aca4..6f0040ee 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -354,6 +354,17 @@ fn iamb_unreads(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { } } +fn iamb_message(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = IambAction::Room(RoomAction::Message(ctx.clone().into())); + let step = CommandStep::Continue(open.into(), ctx.context.clone()); + + return Ok(step); +} + fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { if !desc.arg.text.is_empty() { return Result::Err(CommandError::InvalidArgument); @@ -789,6 +800,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) { f: iamb_rooms, }); cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room }); + cmds.add_command(ProgramCommand { + name: "message".into(), + aliases: vec![], + f: iamb_message, + }); cmds.add_command(ProgramCommand { name: "space".into(), aliases: vec![], diff --git a/src/config.rs b/src/config.rs index 33a60916..1a8dc9fb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -713,7 +713,7 @@ impl Tunables { } } - fn values(self) -> TunableValues { + pub fn values(self) -> TunableValues { TunableValues { log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO), message_shortcode_display: self.message_shortcode_display.unwrap_or(false), diff --git a/src/main.rs b/src/main.rs index 0a316c76..39ded156 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,7 @@ mod worker; #[cfg(test)] mod tests; +use crate::base::RoomView; use crate::{ base::{ AsyncProgramStore, @@ -153,14 +154,14 @@ fn config_tab_to_desc( let name = user_id.to_string(); let room_id = worker.join_room(name.clone())?; names.insert(name, room_id.clone()); - IambId::Room(room_id, None) + IambId::Room(room_id, RoomView::Main) }, - config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None), + config::WindowPath::RoomId(room_id) => IambId::Room(room_id, RoomView::Main), config::WindowPath::AliasId(alias) => { let name = alias.to_string(); let room_id = worker.join_room(name.clone())?; names.insert(name, room_id.clone()); - IambId::Room(room_id, None) + IambId::Room(room_id, RoomView::Main) }, config::WindowPath::Window(id) => id, }; @@ -642,7 +643,7 @@ impl Application { HomeserverAction::CreateRoom(alias, vis, flags) => { let client = &store.application.worker.client; let room_id = create_room(client, alias, vis, flags).await?; - let room = IambId::Room(room_id, None); + let room = IambId::Room(room_id, RoomView::Main); let target = OpenTarget::Application(room); let action = WindowAction::Switch(target); diff --git a/src/message/mod.rs b/src/message/mod.rs index 0325f02e..fe6aeaaa 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -7,11 +7,14 @@ use std::convert::{TryFrom, TryInto}; use std::fmt::{self, Display}; use std::hash::{Hash, Hasher}; use std::ops::{Deref, DerefMut}; +use std::sync::Arc; use chrono::{DateTime, Local as LocalTz}; use humansize::{format_size, DECIMAL}; use matrix_sdk::ruma::events::receipt::ReceiptThread; +use matrix_sdk::ruma::events::Mentions; use matrix_sdk::ruma::room_version_rules::RedactionRules; +use matrix_sdk::ruma::UserId; use serde_json::json; use unicode_width::UnicodeWidthStr; @@ -72,7 +75,7 @@ pub use self::compose::text_to_message; use self::state::{body_cow_state, html_state}; pub use html::TreeGenState; -type ProtocolPreview<'a> = (&'a Protocol, u16, u16); +type ProtocolPreview = (Arc, u16, u16); pub type MessageKey = (MessageTimeStamp, OwnedEventId); @@ -143,7 +146,7 @@ const READ_GUTTER: usize = 5; const MIN_MSG_LEN: usize = 30; const TIME_GUTTER_EMPTY: &str = " "; -const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); +pub const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS; @@ -196,7 +199,7 @@ fn placeholder_frame( } #[inline] -fn millis_to_datetime(ms: UInt) -> DateTime { +pub fn millis_to_datetime(ms: UInt) -> DateTime { let time = i64::from(ms) / 1000; let time = DateTime::from_timestamp(time, 0).unwrap_or_default(); time.into() @@ -509,6 +512,17 @@ impl MessageEvent { } } + pub fn mentions(&self) -> &Option { + match self { + MessageEvent::EncryptedOriginal(_) | + MessageEvent::EncryptedRedacted(_) | + MessageEvent::Redacted(_) | + MessageEvent::State(_) => &None, + MessageEvent::Original(ev) => &ev.content.mentions, + MessageEvent::Local(_, ev) => &ev.mentions, + } + } + fn redact(&mut self, redaction: SyncRoomRedactionEvent, rules: &RedactionRules) { match self { MessageEvent::EncryptedOriginal(_) => return, @@ -728,7 +742,7 @@ impl<'a> MessageFormatter<'a> { text: &mut Text<'a>, info: &'a RoomInfo, tunables: &'a TunableValues, - ) -> Option> { + ) -> Option { let reply_style = if tunables.message_user_color { style.patch(tunables.get_user_color(&msg.sender)) } else { @@ -774,7 +788,12 @@ impl<'a> MessageFormatter<'a> { proto } - fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) { + fn push_reactions( + &mut self, + counts: Vec<(&'a str, Vec<&'a UserId>)>, + style: Style, + text: &mut Text<'a>, + ) { let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.tunables); let mut reactions = 0; @@ -804,7 +823,7 @@ impl<'a> MessageFormatter<'a> { emojis.push_str("[", style); emojis.push_str(name, style); emojis.push_str(" ", style); - emojis.push_span_nobreak(Span::styled(count.to_string(), style)); + emojis.push_span_nobreak(Span::styled(count.len().to_string(), style)); emojis.push_str("]", style); reactions += 1; @@ -841,7 +860,7 @@ impl<'a> MessageFormatter<'a> { pub enum ImageStatus { None, Downloading(ImagePreviewSize), - Loaded(Protocol), + Loaded(Arc), Error(String), } @@ -1016,7 +1035,7 @@ impl Message { width: usize, info: &'a RoomInfo, tunables: &'a TunableValues, - ) -> (Text<'a>, [Option>; 2]) { + ) -> (Text<'a>, [Option; 2]) { let style = self.get_render_style(selected, tunables); let mut fmt = self.get_render_format(prev, width, info, tunables); let mut text = Text::default(); @@ -1081,7 +1100,7 @@ impl Message { style: Style, hide_reply: bool, tunables: &'a TunableValues, - ) -> (Text<'a>, Option<&'a Protocol>) { + ) -> (Text<'a>, Option>) { if let Some(html) = &self.html { (html.to_text(width, style, hide_reply, tunables), None) } else { @@ -1101,7 +1120,7 @@ impl Message { placeholder_frame(Some("Downloading..."), width, image_preview_size) }, ImageStatus::Loaded(backend) => { - proto = Some(backend); + proto = Some(Arc::clone(backend)); placeholder_frame(Some("No Space..."), width, &backend.area().into()) }, ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")), diff --git a/src/preview.rs b/src/preview.rs index f2c620a5..6097a11d 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -2,6 +2,7 @@ use std::{ fs::File, io::{Read, Write}, path::{Path, PathBuf}, + sync::Arc, }; use matrix_sdk::{ @@ -108,7 +109,7 @@ pub fn spawn_insert_preview( try_set_msg_preview_error(&mut locked.application, room_id, event_id, err); }, Ok((backend, msg)) => { - msg.image_preview = ImageStatus::Loaded(backend); + msg.image_preview = ImageStatus::Loaded(Arc::new(backend)); }, } }, diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 863da7ad..ea33702e 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -70,6 +70,7 @@ use crate::base::{ ProgramContext, ProgramStore, RoomAction, + RoomView, SendAction, SortColumn, SortFieldRoom, @@ -296,7 +297,7 @@ fn room_prompt( ) -> EditResult, IambInfo> { match act { PromptAction::Submit => { - let room = IambId::Room(room_id.to_owned(), None); + let room = IambId::Room(room_id.to_owned(), RoomView::Main); let open = WindowAction::Switch(OpenTarget::Application(room)); let acts = vec![(open.into(), ctx.clone())]; @@ -730,7 +731,7 @@ impl WindowOps for IambWindow { impl Window for IambWindow { fn id(&self) -> IambId { match self { - IambWindow::Room(room) => IambId::Room(room.id().to_owned(), room.thread().cloned()), + IambWindow::Room(room) => IambId::Room(room.id().to_owned(), room.view()), IambWindow::DirectList(_) => IambId::DirectList, IambWindow::MemberList(_, room_id, _) => IambId::MemberList(room_id.clone()), IambWindow::RoomList(_) => IambId::RoomList, @@ -852,7 +853,7 @@ impl Window for IambWindow { let ChatStore { names, worker, .. } = &mut store.application; if let Some(room) = names.get_mut(&name) { - let id = IambId::Room(room.clone(), None); + let id = IambId::Room(room.clone(), RoomView::Main); IambWindow::open(id, store) } else { @@ -860,7 +861,7 @@ impl Window for IambWindow { names.insert(name, room_id.clone()); let (room, name, tags) = store.application.worker.get_room(room_id)?; - let room = RoomState::new(room, None, name, tags, store); + let room = RoomState::new(room, RoomView::Main, name, tags, store); store.application.need_load.need_members(room.id().to_owned()); Ok(room.into()) diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index d0b290f0..ef76eb40 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -120,7 +120,7 @@ impl ChatState { pub fn new(room: MatrixRoom, thread: Option, store: &mut ProgramStore) -> Self { let room_id = room.room_id().to_owned(); let scrollback = ScrollbackState::new(room_id.clone(), thread.clone()); - let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar); + let id = IambBufferId::Room(room_id.clone(), thread.into(), RoomFocus::MessageBar); let ebuf = store.load_buffer(id); let tbox = TextBoxState::new(ebuf); @@ -674,6 +674,11 @@ impl ChatState { &self.room_id } + pub fn current_message(&self, store: &mut ProgramStore) -> Option { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + self.scrollback.get_key(info).map(|(_, id)| id) + } + pub fn auto_toggle_focus( &mut self, act: &EditorAction, @@ -726,8 +731,8 @@ impl WindowOps for ChatState { // XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to // find a good way to pass that info here so that it can be part of the content id. let room_id = self.room_id.clone(); - let thread = self.thread().cloned(); - let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar); + let view = self.thread().cloned().into(); + let id = IambBufferId::Room(room_id.clone(), view, RoomFocus::MessageBar); let ebuf = store.load_buffer(id); let tbox = TextBoxState::new(ebuf); @@ -795,9 +800,9 @@ impl Editable for ChatState { // And now we can finally run the editor command. match delegate!(self, w => w.editor_command(act, ctx, store)) { res @ Ok(_) => res, - Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus))) + Err(EditError::WrongBuffer(IambBufferId::Room(room_id, view, focus))) if room_id == self.room_id && - thread.as_ref() == self.thread() && + view == self.thread().into() && act.is_switchable(ctx) => { // Switch focus. @@ -1123,7 +1128,10 @@ mod tests { use modalkit::actions::{EditAction, InsertTextAction}; - use crate::tests::{mock_store, TEST_ROOM1_ID}; + use crate::{ + base::RoomView, + tests::{mock_store, TEST_ROOM1_ID}, + }; macro_rules! move_line { ($dir: expr, $count: expr) => { @@ -1142,7 +1150,7 @@ mod tests { let room_id = TEST_ROOM1_ID.clone(); let scrollback = ScrollbackState::new(room_id.clone(), None); - let id = IambBufferId::Room(room_id, None, RoomFocus::MessageBar); + let id = IambBufferId::Room(room_id, RoomView::Main, RoomFocus::MessageBar); let ebuf = store.load_buffer(id); let mut tbox = TextBoxState::new(ebuf); diff --git a/src/windows/room/message.rs b/src/windows/room/message.rs new file mode 100644 index 00000000..a6346a3e --- /dev/null +++ b/src/windows/room/message.rs @@ -0,0 +1,442 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use matrix_sdk::room::Room as MatrixRoom; +use matrix_sdk::ruma::{EventId, OwnedEventId, OwnedRoomId, RoomId}; +use modalkit::editing::completion::CompletionList; +use modalkit::editing::rope::EditRope; +use modalkit::prelude::{CloseFlags, EditInfo, WordStyle, WriteFlags}; +use modalkit_ratatui::textbox::TextBoxState; +use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{StatefulWidget, Widget}; +use ratatui_image::{protocol::Protocol, Image}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; +use url::Url; + +use crate::base::{ + IambBufferId, + IambInfo, + IambResult, + MessageAction, + ProgramContext, + ProgramStore, + RoomFocus, + RoomInfo, + RoomView, +}; +use crate::config::{TunableValues, UserDisplayStyle}; +use crate::message::{millis_to_datetime, Message, MessageTimeStamp}; +use crate::util::space; + +type ImagePreview = (Arc, u16); + +fn user_date_line( + msg: &Message, + width: usize, + info: &RoomInfo, + tunables: &TunableValues, +) -> Line<'static> { + let user_id = msg.sender.as_ref(); + let Span { content: user, style: user_style } = tunables.get_user_span(user_id, info); + let mut user = user.to_string(); + if let UserDisplayStyle::Username = tunables.username_display { + } else { + user.push_str(&format!(" ({})", user_id.as_str())); + } + user.push(' '); + + let mut date = if let MessageTimeStamp::OriginServer(ms) = msg.timestamp { + millis_to_datetime(ms).format("%T %A, %B %d %Y").to_string() + } else { + String::new() + }; + + // truncate if needed + if user.len() > width { + date.clear(); + std::mem::drop(user.drain(width.saturating_sub(2)..)); + if width >= 2 { + user.push_str(".."); + } + } else if user.len() + date.len() >= width { + let date_width = width - user.len(); + std::mem::drop(date.drain(..=date.len().saturating_sub(date_width) + 2)); + if date_width >= 2 { + date.insert_str(0, ".."); + } + } + + let padding = width - user.len() - date.len(); + + Span::styled(user, user_style) + + Span::raw(space(padding)) + + Span::styled(date, Style::new().add_modifier(Modifier::BOLD)) +} + +// TODO: store possible actions per line +/// State needed for rendering [`MessageWidget`]. +pub struct MessageState { + room_id: OwnedRoomId, + room: MatrixRoom, + + message_id: OwnedEventId, + + tbox: TextBoxState, +} + +impl MessageState { + pub fn new(store: &mut ProgramStore, room: MatrixRoom, message_id: OwnedEventId) -> Self { + let room_id = room.room_id().to_owned(); + + let buf = store.buffers.load(IambBufferId::Room( + room_id.clone(), + RoomView::Message(message_id.to_owned()), + RoomFocus::Scrollback, + )); + let mut tbox = TextBoxState::new(buf); + tbox.set_readonly(true); + tbox.set_wrap(false); + + Self { room_id, room, message_id, tbox } + } + + pub fn refresh_room(&mut self, store: &mut ProgramStore) { + if let Some(room) = store.application.worker.client.get_room(self.room_id()) { + self.room = room; + } + } + + pub async fn message_command( + &mut self, + _: MessageAction, + _: ProgramContext, + _: &mut ProgramStore, + ) -> IambResult { + todo!() + } + + pub fn room(&self) -> &MatrixRoom { + &self.room + } + + pub fn id(&self) -> &EventId { + &self.message_id + } + + pub fn room_id(&self) -> &RoomId { + &self.room_id + } + + fn render<'b>( + &self, + widget: &'b mut MessageWidget<'_>, + width: usize, + ) -> Vec<(Line<'b>, Option)> { + let info = widget.store.application.rooms.get_or_default(self.room_id.clone()); + let settings = &widget.store.application.settings; + + let bold = Style::new().add_modifier(Modifier::BOLD); + + let Some(msg) = info.get_event(&self.message_id) else { + widget + .store + .application + .need_load + .need_event(self.room_id.clone(), self.message_id.clone()); + + return vec![ + (Line::raw(""), None), + (Line::styled(" Loading...", bold), None), + ]; + }; + + let mut lines = vec![]; + + // header + lines.push((user_date_line(msg, width, info, &settings.tunables), None)); + + // message + let (txt, [mut msg_preview, mut reply_preview]) = + msg.show_with_preview(Some(msg), false, width, info, &widget.message_tunables); + + for (row, mut line) in txt.lines.into_iter().enumerate() { + // Only take the previews into the matching row number. + // `reply` and `msg` previews are on rows, + // so an `or` works to pick the one that matches (if any) + let line_preview = match msg_preview { + Some((_, _, y)) if y as usize == row => msg_preview.take(), + _ => None, + } + .or(match reply_preview { + Some((_, _, y)) if y as usize == row => reply_preview.take(), + _ => None, + }) + .map(|(backend, x, _)| (backend, x)); + + // remove trailing whitespace from printer + if let Some(last) = line.spans.last() { + if last.content.trim().is_empty() { + line.spans.remove(line.spans.len() - 1); + } + } + + lines.push((line, line_preview)); + } + + // mentions + if let Some(mentions) = msg.event.mentions() { + if mentions.room || !mentions.user_ids.is_empty() { + lines.push((Line::raw(""), None)); + lines.push((Line::styled("Mentions:", bold), None)); + if mentions.room { + lines.push((Span::raw("- ") + Span::styled("@room", bold), None)); + } + for user_id in &mentions.user_ids { + let user = settings.tunables.get_user_span(user_id, info); + lines.push((Span::raw("- ") + user, None)); + } + } + } + + // links + let links = if let Some(html) = &msg.html { + html.get_links() + } else if let Ok(url) = Url::parse(&msg.event.body()) { + vec![('0', url)] + } else { + vec![] + }; + + if !links.is_empty() { + lines.push((Line::raw(""), None)); + lines.push((Line::styled("Links:", bold), None)); + + for (c, url) in links { + lines.push((Line::raw(format!("[{c}] {url}")), None)); + } + } + + // reactions + if settings.tunables.reaction_display { + for (key, users) in info.get_reactions(&self.message_id) { + let short = emojis::get(key).and_then(|emoji| emoji.shortcode()).or( + if key.chars().all(|c| c.is_ascii_alphanumeric()) { + Some(key) + } else { + None + }, + ); + + let (text, desc) = if settings.tunables.reaction_shortcode_display { + if let Some(short) = short { + (short, None) + } else { + (key, None) + } + } else { + (key, short) + }; + + let content = if let Some(desc) = desc { + format!("[{text} {}] ({desc})", users.len()) + } else { + format!("[{text} {}]", users.len()) + }; + + lines.push((Line::raw(""), None)); + lines.push((Line::raw(content), None)); + + for id in users { + let user = settings.tunables.get_user_span(id, info); + lines.push((Span::raw("- ") + user, None)); + } + } + } + + // read receipts + if settings.tunables.read_receipt_display { + lines.push((Line::raw(""), None)); + lines.push((Line::styled("Last message seen by:", bold), None)); + for user in info + .event_receipts + .values() + .filter_map(|receipts| receipts.get(msg.event.event_id())) + .flat_map(|read| read.iter()) + { + let user = settings.tunables.get_user_span(user, info); + lines.push((Span::raw("- ") + user, None)) + } + } + + lines + } +} + +impl Deref for MessageState { + type Target = TextBoxState; + + fn deref(&self) -> &Self::Target { + &self.tbox + } +} +impl DerefMut for MessageState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.tbox + } +} + +impl TerminalCursor for MessageState { + fn get_term_cursor(&self) -> Option { + self.tbox.get_term_cursor() + } +} + +impl WindowOps for MessageState { + fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { + self.tbox.draw(area, buf, focused, store) + } + + fn dup(&self, store: &mut ProgramStore) -> Self { + Self { + room_id: self.room_id.clone(), + room: self.room.clone(), + message_id: self.message_id.clone(), + tbox: self.tbox.dup(store), + } + } + + fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { + self.tbox.close(flags, store) + } + + fn write( + &mut self, + path: Option<&str>, + flags: WriteFlags, + store: &mut ProgramStore, + ) -> IambResult { + self.tbox.write(path, flags, store) + } + + fn get_completions(&self) -> Option { + self.tbox.get_completions() + } + + fn get_cursor_word(&self, style: &WordStyle) -> Option { + self.tbox.get_cursor_word(style) + } + + fn get_selected_word(&self) -> Option { + self.tbox.get_selected_word() + } +} + +pub struct MessageWidget<'a> { + store: &'a mut ProgramStore, + message_tunables: TunableValues, + focused: bool, +} + +impl<'a> MessageWidget<'a> { + pub fn new(store: &'a mut ProgramStore) -> Self { + let mut message_tunables = store.application.settings.tunables.clone(); + message_tunables.user_gutter_width = 2; + message_tunables.read_receipt_display = false; + message_tunables.message_time_display = false; + message_tunables.message_user_color = false; + message_tunables.reaction_display = false; + + Self { store, message_tunables, focused: false } + } + + pub fn focus(mut self, focused: bool) -> Self { + self.focused = focused; + self + } +} + +impl<'a> StatefulWidget for MessageWidget<'a> { + type State = MessageState; + + fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let lines = state.render(&mut self, area.width as usize); + + // update text + let buffer = state.tbox.buffer(); + let mut locked = buffer.write().unwrap(); + + let mut text = EditRope::default(); + for (line, _) in lines.iter() { + for span in line { + text += span.content.as_ref().into(); + } + text += '\n'.into(); + } + locked.text = text; + + std::mem::drop(locked); + + // only store style info + let lines: Vec<_> = lines + .into_iter() + .map(|(line, preview)| { + let styles: Vec<(_, u16)> = line + .iter() + .map(|span| { + let width = UnicodeSegmentation::graphemes(span.content.as_ref(), true) + .filter(|symbol| !symbol.contains(|char: char| char.is_control())) + .map(|symbol| symbol.width() as u16) + .sum(); + (span.style, width) + }) + .collect(); + (line.style, styles, preview) + }) + .collect(); + + // draw text + state.draw(area, buf, self.focused, self.store); + + // set highlighting + let mut image_previews = vec![]; + + let mut draw_lines = lines.into_iter().fuse().skip(state.tbox.viewctx.corner.y); + let draw_area = area.intersection(buf.area); + for y in draw_area.top()..draw_area.top() + draw_area.height { + let mut x = draw_area.left(); + if let Some((line_style, styles, line_preview)) = draw_lines.next() { + if let Some((backend, msg_x)) = line_preview { + image_previews.push((x + msg_x, y, backend)); + } + for (style, width) in styles { + let remaining_width = draw_area.right().saturating_sub(x); + + for i in 0..remaining_width.min(width) { + let old_style = buf[(x + i, y)].style(); + let new_style = old_style.patch(line_style).patch(style); + buf[(x + i, y)].set_style(new_style); + } + x += width; + } + } + } + + // Render image previews after all text lines have been drawn, as the render might draw below the current + // line. + for (x, y, backend) in image_previews { + let image_widget = Image::new(&backend); + let mut rect = backend.area(); + rect.x = x; + rect.y = y; + // Don't render outside of scrollback area + if rect.bottom() <= area.bottom() && rect.right() <= area.right() { + image_widget.render(rect, buf); + } + } + } +} diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index 43db2ffb..45b736a3 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -21,7 +21,6 @@ use matrix_sdk::{ }, tag::{TagInfo, Tags}, }, - OwnedEventId, OwnedRoomAliasId, OwnedUserId, RoomId, @@ -46,35 +45,43 @@ use modalkit::actions::{ PromptAction, Promptable, Scrollable, + WindowAction, }; use modalkit::errors::{EditResult, UIError}; use modalkit::prelude::*; use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo}; use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps}; -use crate::base::{ - IambAction, - IambError, - IambId, - IambInfo, - IambResult, - MemberUpdateAction, - MessageAction, - ProgramAction, - ProgramContext, - ProgramStore, - RoomAction, - RoomField, - SendAction, - SpaceAction, +use crate::{ + base::{ + IambAction, + IambError, + IambId, + IambInfo, + IambResult, + MemberUpdateAction, + MessageAction, + ProgramAction, + ProgramContext, + ProgramStore, + RoomAction, + RoomField, + RoomView, + SendAction, + SpaceAction, + }, + windows::room::message::MessageWidget, }; +pub use message::MessageState; + use self::chat::ChatState; use self::space::{Space, SpaceState}; use std::convert::TryFrom; mod chat; +mod message; mod scrollback; mod space; @@ -83,6 +90,7 @@ macro_rules! delegate { match $s { RoomState::Chat($id) => $e, RoomState::Space($id) => $e, + RoomState::Message($id) => $e, } }; } @@ -122,6 +130,7 @@ fn hist_visibility_mode(name: impl Into) -> IambResult), Space(Box), + Message(MessageState), } impl From for RoomState { @@ -136,10 +145,16 @@ impl From for RoomState { } } +impl From for RoomState { + fn from(msg: MessageState) -> Self { + RoomState::Message(msg) + } +} + impl RoomState { pub fn new( room: MatrixRoom, - thread: Option, + view: RoomView, name: RoomDisplayName, tags: Option, store: &mut ProgramStore, @@ -152,22 +167,24 @@ impl RoomState { if room.is_space() { SpaceState::new(room).into() } else { - ChatState::new(room, thread, store).into() + match view { + RoomView::Main => ChatState::new(room, None, store).into(), + RoomView::Thread(thread) => ChatState::new(room, Some(thread), store).into(), + RoomView::Message(message) => MessageState::new(store, room, message).into(), + } } } - pub fn thread(&self) -> Option<&OwnedEventId> { + pub fn view(&self) -> RoomView { match self { - RoomState::Chat(chat) => chat.thread(), - RoomState::Space(_) => None, + RoomState::Chat(chat) => chat.thread().into(), + RoomState::Space(_) => RoomView::Main, + RoomState::Message(msg) => RoomView::Message(msg.id().to_owned()), } } pub fn refresh_room(&mut self, store: &mut ProgramStore) { - match self { - RoomState::Chat(chat) => chat.refresh_room(store), - RoomState::Space(space) => space.refresh_room(store), - } + delegate!(self, w => w.refresh_room(store)) } fn draw_invite( @@ -213,6 +230,7 @@ impl RoomState { match self { RoomState::Chat(chat) => chat.message_command(act, ctx, store).await, RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()), + RoomState::Message(msg) => msg.message_command(act, ctx, store).await, } } @@ -224,7 +242,7 @@ impl RoomState { ) -> IambResult { match self { RoomState::Space(space) => space.space_command(act, ctx, store).await, - RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()), + RoomState::Chat(_) | RoomState::Message(_) => Err(IambError::NoSelectedSpace.into()), } } @@ -236,7 +254,7 @@ impl RoomState { ) -> IambResult { match self { RoomState::Chat(chat) => chat.send_command(act, ctx, store).await, - RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()), + RoomState::Space(_) | RoomState::Message(_) => Err(IambError::NoSelectedRoom.into()), } } @@ -351,6 +369,21 @@ impl RoomState { Ok(vec![(act, cmd.context.clone())]) }, + RoomAction::Message(cmd) => { + let id = match self { + RoomState::Chat(chat) => chat.current_message(store), + RoomState::Space(_) => None, + RoomState::Message(message) => Some(message.id().to_owned()), + }; + let Some(id) = id else { + return Err(UIError::Failure("No message selected".into())); + }; + let act = Action::Window(WindowAction::Switch(OpenTarget::Application( + IambId::Room(self.id().to_owned(), RoomView::Message(id)), + ))); + + Ok(vec![(act, cmd.context.clone())]) + }, RoomAction::SetDirect(is_direct) => { let room = store .application @@ -669,6 +702,9 @@ impl RoomState { spans.push("Thread in ".into()); } } + if let RoomState::Message(_) = self { + spans.push("Message in ".into()); + } spans.push(Span::styled(title, style)); @@ -687,21 +723,19 @@ impl RoomState { pub fn focus_toggle(&mut self) { match self { RoomState::Chat(chat) => chat.focus_toggle(), - RoomState::Space(_) => return, + RoomState::Space(_) | RoomState::Message(_) => return, } } pub fn room(&self) -> &MatrixRoom { - match self { - RoomState::Chat(chat) => chat.room(), - RoomState::Space(space) => space.room(), - } + delegate!(self, w => w.room()) } pub fn id(&self) -> &RoomId { match self { RoomState::Chat(chat) => chat.id(), RoomState::Space(space) => space.id(), + RoomState::Message(msg) => msg.room_id(), } } } @@ -769,6 +803,9 @@ impl WindowOps for RoomState { match self { RoomState::Chat(chat) => chat.draw(area, buf, focused, store), + RoomState::Message(msg) => { + MessageWidget::new(store).focus(focused).render(area, buf, msg) + }, RoomState::Space(space) => { Space::new(store).focus(focused).render(area, buf, space); }, @@ -779,14 +816,12 @@ impl WindowOps for RoomState { match self { RoomState::Chat(chat) => RoomState::Chat(Box::new(chat.dup(store))), RoomState::Space(space) => RoomState::Space(Box::new(space.dup(store))), + RoomState::Message(msg) => RoomState::Message(msg.dup(store)), } } fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { - match self { - RoomState::Chat(chat) => chat.close(flags, store), - RoomState::Space(space) => space.close(flags, store), - } + delegate!(self, w => w.close(flags, store)) } fn write( @@ -795,31 +830,19 @@ impl WindowOps for RoomState { flags: WriteFlags, store: &mut ProgramStore, ) -> IambResult { - match self { - RoomState::Chat(chat) => chat.write(path, flags, store), - RoomState::Space(space) => space.write(path, flags, store), - } + delegate!(self, w => w.write(path, flags, store)) } fn get_completions(&self) -> Option { - match self { - RoomState::Chat(chat) => chat.get_completions(), - RoomState::Space(space) => space.get_completions(), - } + delegate!(self, w => w.get_completions()) } fn get_cursor_word(&self, style: &WordStyle) -> Option { - match self { - RoomState::Chat(chat) => chat.get_cursor_word(style), - RoomState::Space(space) => space.get_cursor_word(style), - } + delegate!(self, w => w.get_cursor_word(style)) } fn get_selected_word(&self) -> Option { - match self { - RoomState::Chat(chat) => chat.get_selected_word(), - RoomState::Space(space) => space.get_selected_word(), - } + delegate!(self, w => w.get_selected_word()) } } diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index a7680e39..42eceece 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -52,6 +52,7 @@ use crate::{ RoomFetchStatus, RoomFocus, RoomInfo, + RoomView, }, config::TunableValues, message::{Message, MessageCursor, MessageKey, Messages}, @@ -139,7 +140,8 @@ pub struct ScrollbackState { impl ScrollbackState { pub fn new(room_id: OwnedRoomId, thread: Option) -> ScrollbackState { - let id = IambBufferId::Room(room_id.to_owned(), thread.clone(), RoomFocus::Scrollback); + let id = + IambBufferId::Room(room_id.to_owned(), thread.clone().into(), RoomFocus::Scrollback); let cursor = MessageCursor::default(); let viewctx = ViewportContext::default(); let jumped = HistoryList::default(); @@ -1042,7 +1044,7 @@ impl Promptable for ScrollbackState { } else { let root = key.1.clone(); let room_id = self.room_id.clone(); - let id = IambId::Room(room_id, Some(root)); + let id = IambId::Room(room_id, RoomView::Thread(root)); let open = WindowAction::Switch(OpenTarget::Application(id)); Ok(vec![(open.into(), ctx.clone())]) } @@ -1418,7 +1420,7 @@ impl StatefulWidget for Scrollback<'_> { // Render image previews after all text lines have been drawn, as the render might draw below the current // line. for (x, y, backend) in image_previews { - let image_widget = Image::new(backend); + let image_widget = Image::new(&backend); let mut rect = backend.area(); rect.x = x; rect.y = y; @@ -1496,7 +1498,11 @@ mod tests { std::mem::take(&mut store.application.need_load) .into_iter() .collect::>(), - vec![(room_id.clone(), Need { messages: Some(Vec::new()), members: false })] + vec![(room_id.clone(), Need { + messages: Some(Vec::new()), + members: false, + events: Vec::new() + })] ); // Search forward twice to MSG1. diff --git a/src/windows/room/space.rs b/src/windows/room/space.rs index 27b9fb9c..1bb8a7a0 100644 --- a/src/windows/room/space.rs +++ b/src/windows/room/space.rs @@ -35,6 +35,7 @@ use crate::base::{ ProgramContext, ProgramStore, RoomFocus, + RoomView, SpaceAction, }; @@ -53,7 +54,7 @@ pub struct SpaceState { impl SpaceState { pub fn new(room: MatrixRoom) -> Self { let room_id = room.room_id().to_owned(); - let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback); + let content = IambBufferId::Room(room_id.clone(), RoomView::Main, RoomFocus::Scrollback); let list = ListState::new(content, vec![]); let last_fetch = None; diff --git a/src/worker.rs b/src/worker.rs index f3c9791a..3c5185cd 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -11,12 +11,14 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; use std::sync::Arc; use std::time::{Duration, Instant}; +use futures::future::join_all; use futures::{stream::FuturesUnordered, StreamExt}; use gethostname::gethostname; +use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::Semaphore; use tokio::task::JoinHandle; -use tracing::{error, warn}; +use tracing::{debug, error, warn}; use url::Url; use matrix_sdk::{ @@ -218,6 +220,7 @@ async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id: enum Plan { Messages(OwnedRoomId, Option, Vec), Members(OwnedRoomId), + Events(OwnedRoomId, Vec), } async fn load_plans(store: &AsyncProgramStore) -> Vec { @@ -245,6 +248,9 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec { if need.members { plan.push(Plan::Members(room_id.to_owned())); } + if !need.events.is_empty() { + plan.push(Plan::Events(room_id, need.events)); + } } return plan; @@ -267,6 +273,13 @@ async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permit let mut locked = store.lock().await; members_insert(room_id, res, locked.deref_mut()); }, + Plan::Events(room_id, events) => { + let store_clone = store.clone(); + + let res = events_load(client, &room_id, events).await; + let mut locked = store.lock().await; + events_insert(room_id, res, locked.deref_mut(), store_clone); + }, } drop(permit); } @@ -325,7 +338,15 @@ fn load_insert( store: AsyncProgramStore, message_needs: Vec, ) { - let ChatStore { presences, rooms, worker, picker, settings, .. } = &mut locked.application; + let ChatStore { + presences, + rooms, + worker, + picker, + settings, + need_load, + .. + } = &mut locked.application; let info = rooms.get_or_default(room_id.clone()); info.fetching = false; let client = &worker.client; @@ -352,6 +373,7 @@ fn load_insert( msg, settings, client.media(), + need_load, ); }, AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Reaction(ev)) => { @@ -422,6 +444,25 @@ async fn members_load(client: &Client, room_id: &RoomId) -> IambResult, +) -> IambResult> { + if let Some(room) = client.get_room(room_id) { + let res = join_all( + events + .into_iter() + .map(async |event_id| room.load_or_fetch_event(&event_id, None).await), + ) + .await; + + Ok(res.into_iter().filter_map(Result::ok).collect()) + } else { + Err(IambError::UnknownRoom(room_id.to_owned()).into()) + } +} + fn members_insert( room_id: OwnedRoomId, res: IambResult>, @@ -441,6 +482,83 @@ fn members_insert( // else ??? } +fn events_insert( + room_id: OwnedRoomId, + res: IambResult>, + locked: &mut ProgramStore, + store: AsyncProgramStore, +) { + if let Ok(events) = res { + let ChatStore { rooms, worker, picker, settings, need_load, .. } = &mut locked.application; + let info = rooms.get_or_default(room_id.clone()); + let client = &worker.client; + + for event in events { + let event = match event.kind { + TimelineEventKind::Decrypted(event) => { + match event.event.deserialize() { + Ok(event) => event, + Err(err) => { + warn!( + err = %err, + room_id = room_id.as_str(), + raw_event = ?event, + "Failed to deserialize event" + ); + continue; + }, + } + }, + TimelineEventKind::UnableToDecrypt { event, utd_info: _ } | + TimelineEventKind::PlainText { event } => { + let event = match event.deserialize() { + Ok(event) => event, + Err(err) => { + warn!( + err = %err, + room_id = room_id.as_str(), + raw_event = ?event, + "Failed to deserialize event" + ); + continue; + }, + }; + event.into_full_event(room_id.clone()) + }, + }; + match event { + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomEncrypted(msg)) => { + info.insert_encrypted(msg); + }, + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(msg)) => { + info.insert_with_preview( + room_id.clone(), + store.clone(), + picker.clone(), + msg, + settings, + client.media(), + need_load, + ); + }, + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Reaction(ev)) => { + info.insert_reaction(ev); + }, + AnyTimelineEvent::MessageLike(ev) => { + debug!("Ignoring unimplemented event type {}", ev.event_type()); + continue; + }, + AnyTimelineEvent::State(msg) => { + if settings.tunables.state_event_display { + info.insert_any_state(msg.into()); + } + }, + } + } + } + // else ??? +} + async fn load_older_forever(client: &Client, store: &AsyncProgramStore) { // Load any pending older messages or members every 2 seconds. let mut interval = tokio::time::interval(Duration::from_secs(2)); @@ -1012,7 +1130,8 @@ impl ClientWorker { let sender = ev.sender().to_owned(); let _ = locked.application.presences.get_or_default(sender); - let ChatStore { rooms, picker, settings, .. } = &mut locked.application; + let ChatStore { rooms, picker, settings, need_load, .. } = + &mut locked.application; let info = rooms.get_or_default(room_id.to_owned()); update_event_receipts(info, &room, ev.event_id()).await; @@ -1025,6 +1144,7 @@ impl ClientWorker { full_ev, settings, client.media(), + need_load, ); } }, From 179648aa399bd500ae93063cfde41918a7c565ba Mon Sep 17 00:00:00 2001 From: vaw Date: Mon, 15 Sep 2025 02:33:30 +0200 Subject: [PATCH 5/7] Implement message actions --- src/base.rs | 2 +- src/main.rs | 6 +- src/windows/mod.rs | 3 +- src/windows/room/chat.rs | 603 ++++++++++++++++++--------------- src/windows/room/message.rs | 67 +++- src/windows/room/mod.rs | 25 +- src/windows/room/scrollback.rs | 10 + 7 files changed, 423 insertions(+), 293 deletions(-) diff --git a/src/base.rs b/src/base.rs index ce72f978..e1a76017 100644 --- a/src/base.rs +++ b/src/base.rs @@ -1416,7 +1416,7 @@ impl RoomInfo { /// Checks if a given user has reacted with the given emoji on the given event pub fn user_reactions_contains( - &mut self, + &self, user_id: &UserId, event_id: &EventId, emoji: &str, diff --git a/src/main.rs b/src/main.rs index 39ded156..f88c645c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -588,7 +588,11 @@ impl Application { }, IambAction::Keys(act) => self.keys_command(act, ctx, store).await?, IambAction::Message(act) => { - self.screen.current_window_mut()?.message_command(act, ctx, store).await? + let acts = + self.screen.current_window_mut()?.message_command(act, ctx, store).await?; + self.action_prepend(acts); + + None }, IambAction::Space(act) => { self.screen.current_window_mut()?.space_command(act, ctx, store).await? diff --git a/src/windows/mod.rs b/src/windows/mod.rs index ea33702e..fac7d04e 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -26,6 +26,7 @@ use matrix_sdk::{ RoomState as MatrixRoomState, }; +use modalkit::editing::context::EditContext; use ratatui::{ buffer::Buffer, layout::{Alignment, Rect}, @@ -360,7 +361,7 @@ impl IambWindow { act: MessageAction, ctx: ProgramContext, store: &mut ProgramStore, - ) -> IambResult { + ) -> IambResult, EditContext)>> { if let IambWindow::Room(w) = self { w.message_command(act, ctx, store).await } else { diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index ef76eb40..429f0dbc 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -7,7 +7,8 @@ use std::path::{Path, PathBuf}; use edit::edit_with_builder as external_edit; use edit::Builder; -use matrix_sdk::EncryptionState; +use matrix_sdk::{Client, EncryptionState}; +use modalkit::editing::context::EditContext; use modalkit::editing::store::RegisterError; use ratatui::style::{Color, Style}; use std::process::Command; @@ -88,6 +89,7 @@ use crate::base::{ SendAction, }; +use crate::config::ApplicationSettings; use crate::message::{ text_to_message, Message, @@ -144,18 +146,6 @@ impl ChatState { self.scrollback.thread() } - fn get_joined(&self, worker: &Requester) -> Result { - let Some(room) = worker.client.get_room(self.id()) else { - return Err(IambError::NotJoined); - }; - - if room.state() == RoomState::Joined { - Ok(room) - } else { - Err(IambError::NotJoined) - } - } - fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> { let thread = self.scrollback.get_thread(info)?; let key = self.reply_to.as_ref()?; @@ -183,9 +173,9 @@ impl ChatState { pub async fn message_command( &mut self, act: MessageAction, - _: ProgramContext, + ctx: ProgramContext, store: &mut ProgramStore, - ) -> IambResult { + ) -> IambResult, EditContext)>> { let client = &store.application.worker.client; let settings = &store.application.settings; @@ -198,7 +188,7 @@ impl ChatState { if skip_confirm { self.reset(); - return Ok(None); + return Ok(vec![]); } self.reply_to = None; @@ -212,126 +202,7 @@ impl ChatState { Err(UIError::NeedConfirm(prompt)) }, MessageAction::Download(filename, flags) => { - if let MessageEvent::Original(ev) = &msg.event { - let media = client.media(); - - let mut filename = match (filename, &settings.dirs.downloads) { - (Some(f), _) => PathBuf::from(f), - (None, Some(downloads)) => downloads.clone(), - (None, None) => return Err(IambError::NoDownloadDir.into()), - }; - - let (source, msg_filename) = match &ev.content.msgtype { - MessageType::Audio(c) => (c.source.clone(), c.filename()), - MessageType::File(c) => (c.source.clone(), c.filename()), - MessageType::Image(c) => (c.source.clone(), c.filename()), - MessageType::Video(c) => (c.source.clone(), c.filename()), - _ => { - if !flags.contains(DownloadFlags::OPEN) { - return Err(IambError::NoAttachment.into()); - } - - let links = if let Some(html) = &msg.html { - html.get_links() - } else { - linkify::LinkFinder::new() - .links(&msg.event.body()) - .filter_map(|u| Url::parse(u.as_str()).ok()) - .scan(TreeGenState { link_num: 0 }, |state, u| { - state.next_link_char().map(|c| (c, u)) - }) - .collect() - }; - - if links.is_empty() { - return Err(IambError::NoAttachment.into()); - } - - let choices = links - .into_iter() - .map(|l| { - let url = l.1.to_string(); - let act = IambAction::OpenLink(url.clone()).into(); - MultiChoiceItem::new(l.0, url, vec![act]) - }) - .collect(); - let dialog = MultiChoice::new(choices); - let err = UIError::NeedConfirm(Box::new(dialog)); - - return Err(err); - }, - }; - - if filename.is_dir() { - filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_")); - } - - if filename.exists() && !flags.contains(DownloadFlags::FORCE) { - // Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg - if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) { - let ext = filename.extension(); - let mut filename_incr = filename.clone(); - for n in 1..=1000 { - if let Some(ext) = ext.and_then(OsStr::to_str) { - filename_incr.set_file_name(format!("{stem}-{n}.{ext}")); - } else { - filename_incr.set_file_name(format!("{stem}-{n}")); - } - - if !filename_incr.exists() { - filename = filename_incr; - break; - } - } - } - } - - if !filename.exists() || flags.contains(DownloadFlags::FORCE) { - let req = MediaRequestParameters { source, format: MediaFormat::File }; - - let bytes = - media.get_media_content(&req, true).await.map_err(IambError::from)?; - - fs::write(filename.as_path(), bytes.as_slice())?; - - msg.downloaded = true; - } else if !flags.contains(DownloadFlags::OPEN) { - let msg = format!( - "The file {} already exists; add ! to end of command to overwrite it.", - filename.display() - ); - let err = UIError::Failure(msg); - - return Err(err); - } - - let info = if flags.contains(DownloadFlags::OPEN) { - let target = filename.clone().into_os_string(); - match open_command( - store.application.settings.tunables.open_command.as_ref(), - target, - ) { - Ok(_) => { - InfoMessage::from(format!( - "Attachment downloaded to {} and opened", - filename.display() - )) - }, - Err(err) => { - return Err(err); - }, - } - } else { - InfoMessage::from(format!( - "Attachment downloaded to {}", - filename.display() - )) - }; - - return Ok(info.into()); - } - - Err(IambError::NoAttachment.into()) + msg_download(ctx, client, msg, &store.application.settings, filename, flags).await }, MessageAction::Edit => { if msg.sender != settings.profile.user_id { @@ -367,88 +238,29 @@ impl ChatState { self.editing = self.scrollback.get_key(info); self.focus = RoomFocus::MessageBar; - Ok(None) + Ok(vec![]) }, MessageAction::React(reaction, literal) => { - let emoji = if literal { - reaction - } else if let Some(emoji) = - emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction)) - { - emoji.to_string() - } else { - let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?"); - let act = IambAction::Message(MessageAction::React(reaction, true)); - let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); - let prompt = Box::new(prompt); - - return Err(UIError::NeedConfirm(prompt)); - }; - - let room = self.get_joined(&store.application.worker)?; - let event_id = match &msg.event { - MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), - 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()); - - return Err(err); - }, - }; - - if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) { - let msg = format!("You’ve already reacted to this message with {emoji}"); - let err = UIError::Failure(msg); - - return Err(err); - } - - let reaction = Annotation::new(event_id, emoji); - let msg = ReactionEventContent::new(reaction); - let _ = room.send(msg).await.map_err(IambError::from)?; - - Ok(None) + let msg = self.scrollback.get(info).unwrap(); + msg_react( + msg, + settings, + info, + &store.application.worker, + self.id(), + reaction, + literal, + ) + .await }, MessageAction::Redact(reason, skip_confirm) => { - if !skip_confirm { - let msg = "Are you sure you want to redact this message?"; - let act = IambAction::Message(MessageAction::Redact(reason, true)); - let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); - let prompt = Box::new(prompt); - - return Err(UIError::NeedConfirm(prompt)); - } - - let room = self.get_joined(&store.application.worker)?; - let event_id = match &msg.event { - MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), - 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()); - - return Err(err); - }, - }; - - let event_id = event_id.as_ref(); - let reason = reason.as_deref(); - let _ = room.redact(event_id, reason, None).await.map_err(IambError::from)?; - - Ok(None) + msg_redact(msg, &store.application.worker, self.id(), reason, skip_confirm).await }, MessageAction::Reply => { self.reply_to = self.scrollback.get_key(info); self.focus = RoomFocus::MessageBar; - Ok(None) + Ok(vec![]) }, MessageAction::Replied => { let Some(reply) = msg.reply_to() else { @@ -463,70 +275,20 @@ impl ChatState { }; self.scrollback.goto_message(key.clone()); - Ok(None) + Ok(vec![]) }, MessageAction::Unreact(reaction, literal) => { - let emoji = match reaction { - reaction if literal => reaction, - Some(reaction) => { - if let Some(emoji) = - emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction)) - { - Some(emoji.to_string()) - } else { - let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?"); - let act = - IambAction::Message(MessageAction::Unreact(Some(reaction), true)); - let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); - let prompt = Box::new(prompt); - - return Err(UIError::NeedConfirm(prompt)); - } - }, - None => None, - }; - - let room = self.get_joined(&store.application.worker)?; - let event_id = match &msg.event { - MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), - 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()); - - return Err(err); - }, - }; - - let reactions = match info.reactions.get(&event_id) { - Some(r) => r, - None => return Ok(None), - }; - - let reactions = reactions.iter().filter_map(|(event_id, (reaction, user_id))| { - if user_id != &settings.profile.user_id { - return None; - } - - if let Some(emoji) = &emoji { - if emoji == reaction { - return Some(event_id); - } else { - return None; - } - } else { - return Some(event_id); - } - }); - - for reaction in reactions { - let _ = room.redact(reaction, None, None).await.map_err(IambError::from)?; - } - - Ok(None) + let msg = self.scrollback.get(info).unwrap(); + msg_unreact( + msg, + settings, + info, + &store.application.worker, + self.id(), + reaction, + literal, + ) + .await }, } } @@ -537,7 +299,7 @@ impl ChatState { _: ProgramContext, store: &mut ProgramStore, ) -> IambResult { - let room = self.get_joined(&store.application.worker)?; + let room = get_joined(self.id(), &store.application.worker)?; let info = store.application.rooms.get_or_default(self.id().to_owned()); let mut show_echo = true; @@ -705,6 +467,303 @@ impl ChatState { } } +fn get_joined(id: &RoomId, worker: &Requester) -> Result { + let Some(room) = worker.client.get_room(id) else { + return Err(IambError::NotJoined); + }; + + if room.state() == RoomState::Joined { + Ok(room) + } else { + Err(IambError::NotJoined) + } +} + +fn open_links(msg: &Message) -> UIError { + let links = if let Some(html) = &msg.html { + html.get_links() + } else { + linkify::LinkFinder::new() + .links(&msg.event.body()) + .filter_map(|u| Url::parse(u.as_str()).ok()) + .scan(TreeGenState { link_num: 0 }, |state, u| state.next_link_char().map(|c| (c, u))) + .collect() + }; + + if links.is_empty() { + return IambError::NoAttachment.into(); + } + + let choices = links + .into_iter() + .map(|l| { + let url = l.1.to_string(); + let act = IambAction::OpenLink(url.clone()).into(); + MultiChoiceItem::new(l.0, url, vec![act]) + }) + .collect(); + let dialog = MultiChoice::new(choices); + UIError::NeedConfirm(Box::new(dialog)) +} + +pub async fn msg_download( + ctx: ProgramContext, + client: &Client, + msg: &mut Message, + settings: &ApplicationSettings, + filename: Option, + flags: DownloadFlags, +) -> IambResult, EditContext)>> { + match &msg.event { + MessageEvent::Original(ev) => { + let media = client.media(); + let mut filename = match (filename, &settings.dirs.downloads) { + (Some(f), _) => PathBuf::from(f), + (None, Some(downloads)) => downloads.clone(), + (None, None) => return Err(IambError::NoDownloadDir.into()), + }; + let (source, msg_filename) = match &ev.content.msgtype { + MessageType::Audio(c) => (c.source.clone(), c.filename()), + MessageType::File(c) => (c.source.clone(), c.filename()), + MessageType::Image(c) => (c.source.clone(), c.filename()), + MessageType::Video(c) => (c.source.clone(), c.filename()), + _ => { + if !flags.contains(DownloadFlags::OPEN) { + return Err(IambError::NoAttachment.into()); + } + let err = open_links(msg); + return Err(err); + }, + }; + if filename.is_dir() { + filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_")); + } + if filename.exists() && !flags.contains(DownloadFlags::FORCE) { + // Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg + if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) { + let ext = filename.extension(); + let mut filename_incr = filename.clone(); + for n in 1..=1000 { + if let Some(ext) = ext.and_then(OsStr::to_str) { + filename_incr.set_file_name(format!("{stem}-{n}.{ext}")); + } else { + filename_incr.set_file_name(format!("{stem}-{n}")); + } + + if !filename_incr.exists() { + filename = filename_incr; + break; + } + } + } + } + if !filename.exists() || flags.contains(DownloadFlags::FORCE) { + let req = MediaRequestParameters { source, format: MediaFormat::File }; + + let bytes = media.get_media_content(&req, true).await.map_err(IambError::from)?; + + fs::write(filename.as_path(), bytes.as_slice())?; + + msg.downloaded = true; + } else if !flags.contains(DownloadFlags::OPEN) { + let msg = format!( + "The file {} already exists; add ! to end of command to overwrite it.", + filename.display() + ); + let err = UIError::Failure(msg); + + return Err(err); + } + let info = if flags.contains(DownloadFlags::OPEN) { + let target = filename.clone().into_os_string(); + match open_command(settings.tunables.open_command.as_ref(), target) { + Ok(_) => { + InfoMessage::from(format!( + "Attachment downloaded to {} and opened", + filename.display() + )) + }, + Err(err) => { + return Err(err); + }, + } + } else { + InfoMessage::from(format!("Attachment downloaded to {}", filename.display())) + }; + return Ok(vec![(Action::ShowInfoMessage(info), ctx)]); + }, + MessageEvent::State(_) => { + let err = open_links(msg); + return Err(err); + }, + _ => (), + } + + Err(IambError::NoAttachment.into()) +} + +pub async fn msg_react( + msg: &Message, + settings: &ApplicationSettings, + info: &RoomInfo, + worker: &Requester, + id: &RoomId, + reaction: String, + literal: bool, +) -> IambResult, EditContext)>> { + let emoji = if literal { + reaction + } else if let Some(emoji) = + emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction)) + { + emoji.to_string() + } else { + let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?"); + let act = IambAction::Message(MessageAction::React(reaction, true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + }; + + let room = get_joined(id, worker)?; + let event_id = match &msg.event { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), + 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()); + + return Err(err); + }, + }; + + if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) { + let msg = format!("You’ve already reacted to this message with {emoji}"); + let err = UIError::Failure(msg); + + return Err(err); + } + + let reaction = Annotation::new(event_id, emoji); + let msg = ReactionEventContent::new(reaction); + let _ = room.send(msg).await.map_err(IambError::from)?; + + Ok(vec![]) +} + +pub async fn msg_redact( + msg: &Message, + worker: &Requester, + id: &RoomId, + reason: Option, + skip_confirm: bool, +) -> IambResult, EditContext)>> { + if !skip_confirm { + let msg = "Are you sure you want to redact this message?"; + let act = IambAction::Message(MessageAction::Redact(reason, true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + } + + let room = get_joined(id, worker)?; + let event_id = match &msg.event { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), + 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()); + + return Err(err); + }, + }; + + let event_id = event_id.as_ref(); + let reason = reason.as_deref(); + let _ = room.redact(event_id, reason, None).await.map_err(IambError::from)?; + + Ok(vec![]) +} + +pub async fn msg_unreact( + msg: &Message, + settings: &ApplicationSettings, + info: &RoomInfo, + worker: &Requester, + id: &RoomId, + reaction: Option, + literal: bool, +) -> IambResult, EditContext)>> { + let emoji = match reaction { + reaction if literal => reaction, + Some(reaction) => { + if let Some(emoji) = + emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction)) + { + Some(emoji.to_string()) + } else { + let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?"); + let act = IambAction::Message(MessageAction::Unreact(Some(reaction), true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + } + }, + None => None, + }; + + let room = get_joined(id, worker)?; + let event_id = match &msg.event { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), + 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()); + + return Err(err); + }, + }; + + let reactions = match info.reactions.get(&event_id) { + Some(r) => r, + None => return Ok(vec![]), + }; + + let reactions = reactions.iter().filter_map(|(event_id, (reaction, user_id))| { + if user_id != &settings.profile.user_id { + return None; + } + + if let Some(emoji) = &emoji { + if emoji == reaction { + return Some(event_id); + } else { + return None; + } + } else { + return Some(event_id); + } + }); + + for reaction in reactions { + let _ = room.redact(reaction, None, None).await.map_err(IambError::from)?; + } + + Ok(vec![]) +} + macro_rules! delegate { ($s: expr, $id: ident => $e: expr) => { match $s.focus { diff --git a/src/windows/room/message.rs b/src/windows/room/message.rs index a6346a3e..7bb50040 100644 --- a/src/windows/room/message.rs +++ b/src/windows/room/message.rs @@ -3,9 +3,12 @@ use std::sync::Arc; use matrix_sdk::room::Room as MatrixRoom; use matrix_sdk::ruma::{EventId, OwnedEventId, OwnedRoomId, RoomId}; +use modalkit::actions::{Action, WindowAction}; use modalkit::editing::completion::CompletionList; +use modalkit::editing::context::EditContext; use modalkit::editing::rope::EditRope; -use modalkit::prelude::{CloseFlags, EditInfo, WordStyle, WriteFlags}; +use modalkit::errors::UIError; +use modalkit::prelude::{CloseFlags, EditInfo, OpenTarget, WordStyle, WriteFlags}; use modalkit_ratatui::textbox::TextBoxState; use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps}; use ratatui::buffer::Buffer; @@ -20,6 +23,8 @@ use url::Url; use crate::base::{ IambBufferId, + IambError, + IambId, IambInfo, IambResult, MessageAction, @@ -32,6 +37,7 @@ use crate::base::{ use crate::config::{TunableValues, UserDisplayStyle}; use crate::message::{millis_to_datetime, Message, MessageTimeStamp}; use crate::util::space; +use crate::windows::room::chat; type ImagePreview = (Arc, u16); @@ -78,7 +84,6 @@ fn user_date_line( Span::styled(date, Style::new().add_modifier(Modifier::BOLD)) } -// TODO: store possible actions per line /// State needed for rendering [`MessageWidget`]. pub struct MessageState { room_id: OwnedRoomId, @@ -113,11 +118,59 @@ impl MessageState { pub async fn message_command( &mut self, - _: MessageAction, - _: ProgramContext, - _: &mut ProgramStore, - ) -> IambResult { - todo!() + act: MessageAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult, EditContext)>> { + let worker = &store.application.worker; + + let settings = &store.application.settings; + let info = store.application.rooms.get_or_default(self.room_id.clone()); + + let msg = info.get_event(self.id()).ok_or(IambError::NoSelectedMessage)?; + + match act { + MessageAction::Download(filename, flags) => { + let msg = info.get_event_mut(self.id()).unwrap(); + chat::msg_download( + ctx, + &store.application.worker.client, + msg, + settings, + filename, + flags, + ) + .await + }, + MessageAction::React(reaction, literal) => { + chat::msg_react(msg, settings, info, worker, &self.room_id, reaction, literal).await + }, + MessageAction::Redact(reason, skip_confirm) => { + chat::msg_redact(msg, worker, &self.room_id, reason, skip_confirm).await + }, + MessageAction::Unreact(reaction, literal) => { + chat::msg_unreact(msg, settings, info, worker, &self.room_id, reaction, literal) + .await + }, + MessageAction::Replied => { + let Some(reply) = msg.reply_to() else { + let msg = "Selected message is not a reply"; + return Err(UIError::Failure(msg.into())); + }; + let act = Action::Window(WindowAction::Switch(OpenTarget::Application( + IambId::Room(self.room_id.clone(), RoomView::Message(reply)), + ))); + + Ok(vec![(act, ctx)]) + }, + MessageAction::Edit | MessageAction::Reply => { + let msg = "Cannot write message in this view."; + let err = UIError::Failure(msg.into()); + + Err(err) + }, + _ => Ok(vec![]), + } } pub fn room(&self) -> &MatrixRoom { diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index 45b736a3..41df6881 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -37,18 +37,21 @@ use ratatui::{ widgets::{Paragraph, StatefulWidget, Widget}, }; -use modalkit::actions::{ - Action, - Editable, - EditorAction, - Jumpable, - PromptAction, - Promptable, - Scrollable, - WindowAction, -}; use modalkit::errors::{EditResult, UIError}; use modalkit::prelude::*; +use modalkit::{ + actions::{ + Action, + Editable, + EditorAction, + Jumpable, + PromptAction, + Promptable, + Scrollable, + WindowAction, + }, + editing::context::EditContext, +}; use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo}; use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps}; @@ -226,7 +229,7 @@ impl RoomState { act: MessageAction, ctx: ProgramContext, store: &mut ProgramStore, - ) -> IambResult { + ) -> IambResult, EditContext)>> { match self { RoomState::Chat(chat) => chat.message_command(act, ctx, store).await, RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()), diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 42eceece..9bd17f5a 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -184,6 +184,16 @@ impl ScrollbackState { .or_else(|| self.get_thread(info)?.last_key_value().map(|kv| kv.0.clone())) } + pub fn get<'a>(&self, info: &'a RoomInfo) -> Option<&'a Message> { + let thread = self.get_thread(info); + + if let Some(k) = &self.cursor.timestamp { + thread.and_then(|t| t.get(k)) + } else { + thread.and_then(|t| t.last_key_value()).map(|(_, v)| v) + } + } + pub fn get_mut<'a>(&mut self, info: &'a mut RoomInfo) -> Option<&'a mut Message> { let thread = self.get_thread_mut(info); From 27c93a00721f880855497921d324bf40c05fa0c6 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 1 Feb 2026 00:00:28 +0100 Subject: [PATCH 6/7] Document `:message` --- docs/iamb.1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/iamb.1 b/docs/iamb.1 index e7036e37..67a27857 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -95,6 +95,8 @@ Request a new verification with the specified user. .Sh "MESSAGE COMMANDS" .Bl -tag -width Ds +.It Sy ":message" +View more information about the focused message. .It Sy ":download [path]" Download an attachment from the selected message and save it to the optional path. .It Sy ":open [path]" From ad747dba7ddb006cdb490c7b82a5921f6db5bc66 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 1 Feb 2026 00:16:25 +0100 Subject: [PATCH 7/7] Fix CI --- src/windows/room/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index 41df6881..e38bdea9 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -133,7 +133,7 @@ fn hist_visibility_mode(name: impl Into) -> IambResult), Space(Box), - Message(MessageState), + Message(Box), } impl From for RoomState { @@ -150,7 +150,7 @@ impl From for RoomState { impl From for RoomState { fn from(msg: MessageState) -> Self { - RoomState::Message(msg) + RoomState::Message(Box::new(msg)) } } @@ -819,7 +819,7 @@ impl WindowOps for RoomState { match self { RoomState::Chat(chat) => RoomState::Chat(Box::new(chat.dup(store))), RoomState::Space(space) => RoomState::Space(Box::new(space.dup(store))), - RoomState::Message(msg) => RoomState::Message(msg.dup(store)), + RoomState::Message(msg) => RoomState::Message(Box::new(msg.dup(store))), } }