From c4597b87dd440a958d34c06e3a3e2c15dbb4cb2c Mon Sep 17 00:00:00 2001 From: vaw Date: Tue, 3 Jun 2025 22:13:38 +0200 Subject: [PATCH 1/4] Save all edit events internally --- src/base.rs | 104 +++++++++++------ src/message/mod.rs | 206 +++++++++++++++++++++++++-------- src/tests.rs | 2 +- src/windows/room/chat.rs | 53 ++++++--- src/windows/room/scrollback.rs | 38 +++--- 5 files changed, 283 insertions(+), 120 deletions(-) diff --git a/src/base.rs b/src/base.rs index d191b809..f091e3df 100644 --- a/src/base.rs +++ b/src/base.rs @@ -3,7 +3,7 @@ //! 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; @@ -690,6 +690,8 @@ pub type IambResult = UIResult; /// it's reacting to. pub type MessageReactions = HashMap; +pub type MessageEdits = BTreeMap; + /// Errors encountered during application use. #[derive(thiserror::Error, Debug)] pub enum IambError { @@ -839,6 +841,7 @@ pub enum RoomFetchStatus { } /// Indicates where an [EventId] lives in the [ChatStore]. +#[derive(Clone)] pub enum EventLocation { /// The [EventId] belongs to a message. /// @@ -851,6 +854,9 @@ pub enum EventLocation { /// The [EventId] belongs to a state event in the main timeline of the room. State(MessageKey), + + /// The [EventId] belongs to an edit for the given event and has key [MessageKey]. + Edit(OwnedEventId, MessageKey), } impl EventLocation { @@ -903,6 +909,8 @@ pub struct RoomInfo { pub user_receipts: HashMap>, /// A map of message identifiers to a map of reaction events. pub reactions: HashMap, + /// A map of message identifiers to a list of edit events for message that are not yet cached. + pub unloaded_edits: HashMap, /// A map of message identifiers to thread replies. threads: HashMap, @@ -944,6 +952,7 @@ impl Default for RoomInfo { users_typing: Default::default(), display_names: Default::default(), draw_last: Default::default(), + unloaded_edits: Default::default(), } } } @@ -970,6 +979,8 @@ impl RoomInfo { /// Get the event for the last message in a thread (or the thread root if there are no /// in-thread replies yet). /// + /// This does not apply edits to the returned event. + /// /// This returns `None` if the event identifier isn't in the room. pub fn get_thread_last<'a>( &'a self, @@ -986,7 +997,7 @@ impl RoomInfo { return None; }; - if let MessageEvent::Original(ev) = &msg { + if let MessageEvent::Original(ev, _) = &msg { Some(ev) } else { None @@ -1039,6 +1050,24 @@ impl RoomInfo { match self.keys.get(redacts) { None => return, + Some(EventLocation::Edit(msg_event_id, edit_key)) => { + let edit_key = edit_key.clone(); + let msg_loc = self.keys.get(msg_event_id).cloned(); + if let Some(EventLocation::Message(thread, msg_key)) = msg_loc { + if let Some(msg) = self.get_thread_mut(thread).get_mut(&msg_key) { + msg.remove_edit(&edit_key); + } + } else { + self.unloaded_edits + .get_mut(msg_event_id) + .and_then(|edits| edits.remove(&edit_key)); + } + + if let Some(msg) = self.messages.get_mut(&edit_key) { + let ev = SyncRoomRedactionEvent::Original(ev); + msg.redact(ev, rules); + } + }, Some(EventLocation::State(key)) => { if let Some(msg) = self.messages.get_mut(key) { let ev = SyncRoomRedactionEvent::Original(ev); @@ -1092,42 +1121,32 @@ impl RoomInfo { } /// Insert an edit. - pub fn insert_edit(&mut self, msg: Replacement) { - let event_id = msg.event_id; - let new_msgtype = msg.new_content; - - let Some(EventLocation::Message(thread, key)) = self.keys.get(&event_id) else { - return; - }; - - let source = if let Some(thread) = thread { - self.threads - .entry(thread.clone()) - .or_insert_with(|| Messages::thread(thread.clone())) - } else { - &mut self.messages - }; - - let Some(msg) = source.get_mut(key) else { + fn insert_edit( + &mut self, + edit_msg: RoomMessageEvent, + replacement: Replacement, + ) { + let RoomMessageEvent::Original(edit_msg) = edit_msg else { return; }; + let edit_key = (edit_msg.origin_server_ts.into(), edit_msg.event_id.clone()); + let msg_loc = self.keys.get(&replacement.event_id).cloned(); - match &mut msg.event { - MessageEvent::Original(orig) => { - orig.content.apply_replacement(new_msgtype); - }, - MessageEvent::Local(_, content) => { - content.apply_replacement(new_msgtype); - }, - MessageEvent::Redacted(_) | - MessageEvent::State(_) | - MessageEvent::EncryptedOriginal(_) | - MessageEvent::EncryptedRedacted(_) => { + if let Some(EventLocation::Message(thread, key)) = msg_loc { + // The edited message is already loaded in cache + let Some(msg) = self.get_thread_mut(thread).get_mut(&key) else { return; - }, + }; + msg.insert_edit(edit_key.clone(), replacement.new_content); + } else { + // The edited message is not yet loaded + let entry = self.unloaded_edits.entry(replacement.event_id.clone()); + entry.or_default().insert(edit_key.clone(), replacement.new_content); } - msg.html = msg.event.html(); + let loc = EventLocation::Edit(replacement.event_id.clone(), edit_key.clone()); + self.keys.insert(edit_msg.event_id.clone(), loc); + self.messages.insert_message(edit_key, Message::new_edit(edit_msg)); } pub fn insert_any_state(&mut self, msg: AnySyncStateEvent) { @@ -1150,7 +1169,7 @@ impl RoomInfo { let last_receipt = last_receipt.as_ref().and_then(|event_id| { match &self.keys.get(*event_id)? { EventLocation::Message(_, key) | EventLocation::State(key) => Some(key), - EventLocation::Reaction(_) => None, + EventLocation::Reaction(_) | EventLocation::Edit(_, _) => None, } }); @@ -1161,7 +1180,7 @@ impl RoomInfo { let last_unthreaded = last_unthreaded.as_ref().and_then(|event_id| { match &self.keys.get(*event_id)? { EventLocation::Message(_, key) | EventLocation::State(key) => Some(key), - EventLocation::Reaction(_) => None, + EventLocation::Reaction(_) | EventLocation::Edit(_, _) => None, } }); @@ -1195,8 +1214,12 @@ impl RoomInfo { let key = (msg.origin_server_ts().into(), event_id.clone()); let loc = EventLocation::Message(None, key.clone()); + let mut message: Message = msg.into(); + if let Some(edits) = self.unloaded_edits.remove(&event_id) { + message.set_edits(edits); + } self.keys.insert(event_id, loc); - self.messages.insert_message(key, msg); + self.messages.insert_message(key, message); } fn insert_thread(&mut self, msg: RoomMessageEvent, thread_root: OwnedEventId) { @@ -1208,8 +1231,12 @@ impl RoomInfo { .entry(thread_root.clone()) .or_insert_with(|| Messages::thread(thread_root.clone())); let loc = EventLocation::Message(Some(thread_root), key.clone()); + let mut message: Message = msg.into(); + if let Some(edits) = self.unloaded_edits.remove(&event_id) { + message.set_edits(edits); + } self.keys.insert(event_id, loc); - replies.insert_message(key, msg); + replies.insert_message(key, message); } /// Insert a new message event. @@ -1220,7 +1247,10 @@ impl RoomInfo { .. }) => { match relates_to { - Relation::Replacement(repl) => self.insert_edit(repl.clone()), + Relation::Replacement(repl) => { + let repl = repl.clone(); + self.insert_edit(msg, repl) + }, Relation::Thread(Thread { event_id, .. }) => { let event_id = event_id.clone(); self.insert_thread(msg, event_id); diff --git a/src/message/mod.rs b/src/message/mod.rs index 718c7a68..6c9d0e35 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -11,7 +11,9 @@ use std::ops::{Deref, DerefMut}; use chrono::{DateTime, Local as LocalTz}; use humansize::{format_size, DECIMAL}; use matrix_sdk::ruma::events::receipt::ReceiptThread; +use matrix_sdk::ruma::events::room::message::RoomMessageEventContentWithoutRelation; use matrix_sdk::ruma::room_version_rules::RedactionRules; +use ratatui::style::Color; use serde_json::json; use unicode_width::UnicodeWidthStr; @@ -57,6 +59,7 @@ use modalkit::editing::cursor::Cursor; use modalkit::prelude::*; use ratatui_image::protocol::Protocol; +use crate::base::MessageEdits; use crate::config::ImagePreviewSize; use crate::{ base::RoomInfo, @@ -111,7 +114,7 @@ impl Messages { let event_id = key.1.clone(); let msg = msg.into(); - self.0.insert(key, msg); + self.0.entry(key).or_insert(msg); // Remove any echo. let key = (MessageTimeStamp::LocalEcho, event_id); @@ -439,14 +442,27 @@ fn redaction_unsigned(ev: SyncRoomRedactionEvent) -> RedactedUnsigned { RedactedUnsigned::new(serde_json::from_value(redacted_because).unwrap()) } -#[derive(Clone)] +fn content_html(msgtype: &MessageType) -> Option { + if let MessageType::Text(content) = msgtype { + if let Some(FormattedBody { format: MessageFormat::Html, body }) = &content.formatted { + Some(parse_matrix_html(body.as_str())) + } else { + None + } + } else { + None + } +} + +#[derive(Clone, Debug)] pub enum MessageEvent { EncryptedOriginal(Box), EncryptedRedacted(Box), - Original(Box), + Original(Box, MessageEdits), + Edit(Box), Redacted(Box), State(Box), - Local(OwnedEventId, Box), + Local(OwnedEventId, Box, MessageEdits), } impl MessageEvent { @@ -454,61 +470,85 @@ impl MessageEvent { match self { MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(), MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(), - MessageEvent::Original(ev) => ev.event_id.as_ref(), + MessageEvent::Original(ev, _) => ev.event_id.as_ref(), MessageEvent::Redacted(ev) => ev.event_id.as_ref(), MessageEvent::State(ev) => ev.event_id(), - MessageEvent::Local(event_id, _) => event_id.as_ref(), + MessageEvent::Local(event_id, _, _) => event_id.as_ref(), + MessageEvent::Edit(ev) => ev.event_id.as_ref(), } } - pub fn content(&self) -> Option<&RoomMessageEventContent> { + pub fn msgtype(&self) -> Option<&MessageType> { match self { MessageEvent::EncryptedOriginal(_) => None, - MessageEvent::Original(ev) => Some(&ev.content), MessageEvent::EncryptedRedacted(_) => None, MessageEvent::Redacted(_) => None, MessageEvent::State(_) => None, - MessageEvent::Local(_, content) => Some(content), + MessageEvent::Edit(_) => None, + MessageEvent::Original(ev, edits) => { + edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .or(Some(&ev.content.msgtype)) + }, + MessageEvent::Local(_, content, edits) => { + edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .or(Some(&content.msgtype)) + }, } } pub fn is_emote(&self) -> bool { - matches!( - self.content(), - Some(RoomMessageEventContent { msgtype: MessageType::Emote(_), .. }) - ) + matches!(self.msgtype(), Some(MessageType::Emote(_))) } pub fn body(&self) -> Cow<'_, str> { match self { MessageEvent::EncryptedOriginal(_) => "[Unable to decrypt message]".into(), - MessageEvent::Original(ev) => body_cow_content(&ev.content), + MessageEvent::Original(ev, edits) => { + let msgtype = edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .unwrap_or(&ev.content.msgtype); + body_cow_content(msgtype) + }, + MessageEvent::Local(_, content, edits) => { + let msgtype = edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .unwrap_or(&content.msgtype); + body_cow_content(msgtype) + }, MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned), MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned), MessageEvent::State(ev) => body_cow_state(ev), - MessageEvent::Local(_, content) => body_cow_content(content), + MessageEvent::Edit(ev) => body_cow_content(&ev.content.msgtype), } } pub fn html(&self) -> Option { - let content = match self { + let msgtype = match self { MessageEvent::EncryptedOriginal(_) => return None, MessageEvent::EncryptedRedacted(_) => return None, - MessageEvent::Original(ev) => &ev.content, + MessageEvent::Original(ev, edits) => { + edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .unwrap_or(&ev.content.msgtype) + }, + MessageEvent::Local(_, content, edits) => { + edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .unwrap_or(&content.msgtype) + }, MessageEvent::Redacted(_) => return None, MessageEvent::State(ev) => return Some(html_state(ev)), - MessageEvent::Local(_, content) => content, + MessageEvent::Edit(_) => return None, }; - - if let MessageType::Text(content) = &content.msgtype { - if let Some(FormattedBody { format: MessageFormat::Html, body }) = &content.formatted { - Some(parse_matrix_html(body.as_str())) - } else { - None - } - } else { - None - } + content_html(msgtype) } fn redact(&mut self, redaction: SyncRoomRedactionEvent, rules: &RedactionRules) { @@ -517,8 +557,8 @@ impl MessageEvent { MessageEvent::EncryptedRedacted(_) => return, MessageEvent::Redacted(_) => return, MessageEvent::State(_) => return, - MessageEvent::Local(_, _) => return, - MessageEvent::Original(ev) => { + MessageEvent::Local(_, _, _) => return, + MessageEvent::Original(ev, _) | MessageEvent::Edit(ev) => { let redacted = RedactedRoomMessageEvent { content: ev.content.clone().redact(rules), event_id: ev.event_id.clone(), @@ -531,6 +571,14 @@ impl MessageEvent { }, } } + + fn is_edited(&self) -> bool { + if let MessageEvent::Original(_, edits) = self { + !edits.is_empty() + } else { + false + } + } } /// Macro rule converting a File / Image / Audio / Video to its text content with the shape: @@ -554,8 +602,8 @@ macro_rules! display_file_to_text { }; } -fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { - let s = match &content.msgtype { +fn body_cow_content(msgtype: &MessageType) -> Cow<'_, str> { + let s = match msgtype { MessageType::Text(content) => content.body.as_str(), MessageType::VerificationRequest(_) => "[Verification Request]", MessageType::Emote(content) => content.body.as_ref(), @@ -574,7 +622,7 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { MessageType::Video(content) => { display_file_to_text!(Video, content); }, - _ => content.body(), + _ => msgtype.body(), }; Cow::Borrowed(s) @@ -872,14 +920,23 @@ impl Message { } } + pub fn new_edit(event: OriginalRoomMessageEvent) -> Self { + let timestamp = event.origin_server_ts.into(); + let user_id = event.sender.clone(); + let content = MessageEvent::Edit(event.into()); + + Message::new(content, user_id, timestamp) + } + pub fn reply_to(&self) -> Option { let content = match &self.event { MessageEvent::EncryptedOriginal(_) => return None, MessageEvent::EncryptedRedacted(_) => return None, - MessageEvent::Local(_, content) => content, - MessageEvent::Original(ev) => &ev.content, + MessageEvent::Local(_, content, _) => content, + MessageEvent::Original(ev, _) => &ev.content, MessageEvent::Redacted(_) => return None, MessageEvent::State(_) => return None, + MessageEvent::Edit(_) => return None, }; match &content.relates_to { @@ -897,10 +954,11 @@ impl Message { let content = match &self.event { MessageEvent::EncryptedOriginal(_) => return None, MessageEvent::EncryptedRedacted(_) => return None, - MessageEvent::Local(_, content) => content, - MessageEvent::Original(ev) => &ev.content, + MessageEvent::Local(_, content, _) => content, + MessageEvent::Original(ev, _) => &ev.content, MessageEvent::Redacted(_) => return None, MessageEvent::State(_) => return None, + MessageEvent::Edit(_) => return None, }; match &content.relates_to { @@ -1038,6 +1096,18 @@ impl Message { fmt.push_spans(space_span(width, style).into(), style, &mut text); } + if self.event.is_edited() { + fmt.push_spans( + Span::styled( + "(edited)", + style.fg(Color::Gray).remove_modifier(StyleModifier::REVERSED), + ) + .into(), + style, + &mut text, + ); + } + if settings.tunables.reaction_display { let reactions = info.get_reactions(self.event.event_id()); fmt.push_reactions(reactions, style, &mut text); @@ -1145,6 +1215,42 @@ impl Message { self.downloaded = false; self.image_preview = ImageStatus::None; } + + pub fn set_edits(&mut self, new_edits: MessageEdits) { + match &mut self.event { + MessageEvent::Original(_, edits) | MessageEvent::Local(_, _, edits) => { + *edits = new_edits; + + self.html = content_html(&edits.last_key_value().unwrap().1.msgtype); + }, + _ => (), + } + } + + pub fn insert_edit(&mut self, key: MessageKey, edit: RoomMessageEventContentWithoutRelation) { + match &mut self.event { + MessageEvent::Original(_, edits) | MessageEvent::Local(_, _, edits) => { + edits.insert(key, edit); + + self.html = content_html(&edits.last_key_value().unwrap().1.msgtype); + }, + _ => (), + } + } + + pub fn remove_edit(&mut self, key: &MessageKey) { + let (orig_content, edits) = match &mut self.event { + MessageEvent::Original(content, edits) => (&content.content.msgtype, edits), + MessageEvent::Local(_, content, edits) => (&content.msgtype, edits), + _ => return, + }; + + edits.remove(key); + + let content = edits.last_key_value().map(|(_, msg)| &msg.msgtype).unwrap_or(orig_content); + + self.html = content_html(content); + } } impl From for Message { @@ -1164,7 +1270,7 @@ impl From for Message { fn from(event: OriginalRoomMessageEvent) -> Self { let timestamp = event.origin_server_ts.into(); let user_id = event.sender.clone(); - let content = MessageEvent::Original(event.into()); + let content = MessageEvent::Original(event.into(), Default::default()); Message::new(content, user_id, timestamp) } @@ -1434,78 +1540,78 @@ pub mod tests { #[test] fn test_display_attachment_size() { assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + body_cow_content(&MessageType::Image( ImageMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::default())) - ))), + )), "[Attached Image: Alt text]".to_string() ); let mut info = ImageInfo::default(); info.size = Some(442630_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + body_cow_content(&MessageType::Image( ImageMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) - ))), + )), "[Attached Image: Alt text (442.63 kB)]".to_string() ); let mut info = ImageInfo::default(); info.size = Some(12_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + body_cow_content(&MessageType::Image( ImageMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) - ))), + )), "[Attached Image: Alt text (12 B)]".to_string() ); let mut info = AudioInfo::default(); info.size = Some(4294967295_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Audio( + body_cow_content(&MessageType::Audio( AudioMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) - ))), + )), "[Attached Audio: Alt text (4.29 GB)]".to_string() ); let mut info = FileInfo::default(); info.size = Some(4426300_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::File( + body_cow_content(&MessageType::File( FileMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) - ))), + )), "[Attached File: Alt text (4.43 MB)]".to_string() ); let mut info = VideoInfo::default(); info.size = Some(44000_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Video( + body_cow_content(&MessageType::Video( VideoMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) - ))), + )), "[Attached Video: Alt text (44 kB)]".to_string() ); } diff --git a/src/tests.rs b/src/tests.rs index e9b05021..f1b50159 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -96,7 +96,7 @@ pub fn mock_room1_message( pub fn mock_message1() -> Message { let content = RoomMessageEventContent::text_plain("writhe"); - let content = MessageEvent::Local(MSG1_EVID.clone(), content.into()); + let content = MessageEvent::Local(MSG1_EVID.clone(), content.into(), Default::default()); Message::new(content, TEST_USER1.clone(), MSG1_KEY.0) } diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 3a518aa0..4a60ab19 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -2,7 +2,6 @@ use std::borrow::Cow; use std::ffi::{OsStr, OsString}; use std::fs; -use std::ops::Deref; use std::path::{Path, PathBuf}; use edit::edit_with_builder as external_edit; @@ -161,7 +160,7 @@ impl ChatState { let key = self.reply_to.as_ref()?; let msg = thread.get(key)?; - if let MessageEvent::Original(ev) = &msg.event { + if let MessageEvent::Original(ev, _) = &msg.event { Some(ev) } else { None @@ -212,7 +211,7 @@ impl ChatState { Err(UIError::NeedConfirm(prompt)) }, MessageAction::Download(filename, flags) => { - if let MessageEvent::Original(ev) = &msg.event { + if let MessageEvent::Original(ev, edits) = &msg.event { let media = client.media(); let mut filename = match (filename, &settings.dirs.downloads) { @@ -221,7 +220,11 @@ impl ChatState { (None, None) => return Err(IambError::NoDownloadDir.into()), }; - let (source, msg_filename) = match &ev.content.msgtype { + let msgtype = edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .unwrap_or(&ev.content.msgtype); + let (source, msg_filename) = match 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()), @@ -341,9 +344,16 @@ impl ChatState { return Err(err); } - let ev = match &msg.event { - MessageEvent::Original(ev) => &ev.content, - MessageEvent::Local(_, ev) => ev.deref(), + let msgtype = match &msg.event { + MessageEvent::Original(ev, edits) => { + edits + .last_key_value() + .map(|(_, ev)| &ev.msgtype) + .unwrap_or(&ev.content.msgtype) + }, + MessageEvent::Local(_, ev, edits) => { + edits.last_key_value().map(|(_, ev)| &ev.msgtype).unwrap_or(&ev.msgtype) + }, _ => { let msg = "Cannot edit a redacted message"; let err = UIError::Failure(msg.into()); @@ -352,7 +362,7 @@ impl ChatState { }, }; - let text = match &ev.msgtype { + let text = match msgtype { MessageType::Text(msg) => msg.body.as_str(), _ => { let msg = "Cannot edit a non-text message"; @@ -389,13 +399,19 @@ impl ChatState { 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::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); + }, + MessageEvent::Edit(_) => { + let msg = "Cannot react to an edit event"; + let err = UIError::Failure(msg.into()); + return Err(err); }, }; @@ -427,9 +443,10 @@ impl ChatState { 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::Original(ev, _) => ev.event_id.clone(), + MessageEvent::Local(event_id, _, _) => event_id.clone(), MessageEvent::State(ev) => ev.event_id().to_owned(), + MessageEvent::Edit(ev) => ev.event_id.to_owned(), MessageEvent::Redacted(_) => { let msg = "Cannot redact already redacted message"; let err = UIError::Failure(msg.into()); @@ -490,13 +507,19 @@ impl ChatState { 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::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); + }, + MessageEvent::Edit(_) => { + let msg = "Cannot unreact to an edit event"; + let err = UIError::Failure(msg.into()); + return Err(err); }, }; @@ -650,7 +673,7 @@ impl ChatState { if show_echo { let user = store.application.settings.profile.user_id.clone(); let key = (MessageTimeStamp::LocalEcho, event_id.clone()); - let msg = MessageEvent::Local(event_id, msg.into()); + let msg = MessageEvent::Local(event_id, msg.into(), Default::default()); let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); let thread = self.scrollback.get_thread_mut(info); thread.insert(key, msg); diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 02340573..ca5163b8 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -54,7 +54,7 @@ use crate::{ RoomInfo, }, config::ApplicationSettings, - message::{Message, MessageCursor, MessageKey, Messages}, + message::{Message, MessageCursor, MessageEvent, MessageKey, Messages}, }; fn no_msgs() -> EditError { @@ -64,7 +64,7 @@ fn no_msgs() -> EditError { fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { let mut end = &pos; - let iter = thread.range(..=&pos).rev().enumerate(); + let iter = thread.range(..=&pos).rev().filter(msg_not_hidden).enumerate(); for (i, (key, _)) in iter { end = key; @@ -89,7 +89,7 @@ fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> Option { let mut end = &pos; - let mut iter = thread.range(&pos..).enumerate(); + let mut iter = thread.range(&pos..).filter(msg_not_hidden).enumerate(); for (i, (key, _)) in iter.by_ref() { end = key; @@ -108,7 +108,11 @@ fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { } fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> { - thread.range(..key).next_back().map(|(_, v)| v) + thread.range(..key).filter(msg_not_hidden).next_back().map(|(_, v)| v) +} + +fn msg_not_hidden(item: &(&MessageKey, &Message)) -> bool { + !matches!(&item.1.event, MessageEvent::Edit(_)) } pub struct ScrollbackState { @@ -210,7 +214,7 @@ impl ScrollbackState { info: &'a RoomInfo, ) -> impl Iterator { let Some(thread) = self.get_thread(info) else { - return Default::default(); + return std::collections::btree_map::Range::default().filter(msg_not_hidden); }; let start = range.start.to_key(thread); @@ -221,13 +225,13 @@ impl ScrollbackState { } else if let Some((last, _)) = thread.last_key_value() { (last, last) } else { - return thread.range(..); + return thread.range(..).filter(msg_not_hidden); }; if range.inclusive { - thread.range(start..=end) + thread.range(start..=end).filter(msg_not_hidden) } else { - thread.range(start..end) + thread.range(start..end).filter(msg_not_hidden) } } @@ -289,7 +293,7 @@ impl ScrollbackState { let mut lines = 0; let target = self.viewctx.get_height() / 2; - for (key, item) in thread.range(..=&idx).rev() { + for (key, item) in thread.range(..=&idx).rev().filter(msg_not_hidden) { let sel = selidx == key; let prev = prevmsg(key, thread); let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len(); @@ -312,7 +316,7 @@ impl ScrollbackState { let mut lines = 0; let target = self.viewctx.get_height(); - for (key, item) in thread.range(..=&idx).rev() { + for (key, item) in thread.range(..=&idx).rev().filter(msg_not_hidden) { let sel = key == selidx; let prev = prevmsg(key, thread); let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len(); @@ -362,7 +366,7 @@ impl ScrollbackState { let cursor_key = self.cursor.timestamp.as_ref().unwrap_or(last_key); let mut prev = prevmsg(cursor_key, thread); - for (idx, item) in thread.range(corner_key.clone()..) { + for (idx, item) in thread.range(corner_key.clone()..).filter(msg_not_hidden) { if idx == cursor_key { // Cursor is already within the viewport. break; @@ -493,7 +497,7 @@ impl ScrollbackState { let mut end = &pos; - for (i, (key, _)) in thread.range(&pos..).enumerate() { + for (i, (key, _)) in thread.range(&pos..).filter(msg_not_hidden).enumerate() { if i >= count { break; } @@ -521,7 +525,7 @@ impl ScrollbackState { let thread = self.get_thread(info)?; let mut mc = None; - for (key, msg) in thread.range(&start..) { + for (key, msg) in thread.range(&start..).filter(msg_not_hidden) { if count == 0 { break; } @@ -552,7 +556,7 @@ impl ScrollbackState { return (None, false); }; - for (key, msg) in thread.range(..&end).rev() { + for (key, msg) in thread.range(..&end).rev().filter(msg_not_hidden) { if count == 0 { break; } @@ -1091,7 +1095,7 @@ impl ScrollActions for ScrollbackState { MoveDir2D::Up => { let first_key = thread.first_key_value().map(|f| f.0.clone()); - for (key, item) in thread.range(..=&corner_key).rev() { + for (key, item) in thread.range(..=&corner_key).rev().filter(msg_not_hidden) { let sel = key == cursor_key; let prev = prevmsg(key, thread); let txt = item.show(prev, sel, &self.viewctx, info, settings); @@ -1120,7 +1124,7 @@ impl ScrollActions for ScrollbackState { MoveDir2D::Down => { let mut prev = prevmsg(&corner_key, thread); - for (key, item) in thread.range(&corner_key..) { + for (key, item) in thread.range(&corner_key..).filter(msg_not_hidden) { let sel = key == cursor_key; let txt = item.show(prev, sel, &self.viewctx, info, settings); let len = txt.height().max(1); @@ -1345,7 +1349,7 @@ impl StatefulWidget for Scrollback<'_> { let mut sawit = false; let mut prev = prevmsg(&corner_key, thread); - for (key, item) in thread.range(&corner_key..) { + for (key, item) in thread.range(&corner_key..).filter(msg_not_hidden) { let sel = key == cursor_key; let (txt, [mut msg_preview, mut reply_preview]) = item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings); From 41e0b32fbbd71a297f291b05e4fe712da685b426 Mon Sep 17 00:00:00 2001 From: vaw Date: Wed, 9 Jul 2025 13:55:17 +0200 Subject: [PATCH 2/4] Make note for edited messages also highlight on hover --- src/message/mod.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/message/mod.rs b/src/message/mod.rs index 6c9d0e35..748c986b 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1098,11 +1098,7 @@ impl Message { if self.event.is_edited() { fmt.push_spans( - Span::styled( - "(edited)", - style.fg(Color::Gray).remove_modifier(StyleModifier::REVERSED), - ) - .into(), + Span::styled("(edited)", style.fg(Color::Gray)).into(), style, &mut text, ); From 0ad26707e8d5af498c855a888fc7ed37ced91bb0 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 27 Jul 2025 21:48:45 +0200 Subject: [PATCH 3/4] Hightlight whole width on message hover --- src/message/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/message/mod.rs b/src/message/mod.rs index 748c986b..28084dcc 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1098,7 +1098,10 @@ impl Message { if self.event.is_edited() { fmt.push_spans( - Span::styled("(edited)", style.fg(Color::Gray)).into(), + Line::from(vec![ + Span::styled("(edited)", style.fg(Color::Gray)), + space_span(fmt.width() - 8, style), + ]), style, &mut text, ); From 457d36188304d645f06f55d251d9f4dc425730ae Mon Sep 17 00:00:00 2001 From: vaw Date: Fri, 19 Sep 2025 01:37:50 +0200 Subject: [PATCH 4/4] Don't select invisible messages --- src/windows/room/scrollback.rs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index ca5163b8..24213bad 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -80,7 +80,7 @@ fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { let key = nth_key_before(pos, n, thread); - if matches!(thread.last_key_value(), Some((last, _)) if &key == last) { + if matches!(last_key_value(thread), Some((last, _)) if &key == last) { MessageCursor::latest() } else { MessageCursor::from(key) @@ -115,6 +115,14 @@ fn msg_not_hidden(item: &(&MessageKey, &Message)) -> bool { !matches!(&item.1.event, MessageEvent::Edit(_)) } +fn first_key(thread: &Messages) -> Option<&MessageKey> { + thread.iter().find(msg_not_hidden).map(|(k, _)| k) +} + +fn last_key_value(thread: &Messages) -> Option<(&MessageKey, &Message)> { + thread.iter().filter(msg_not_hidden).next_back() +} + pub struct ScrollbackState { /// The room identifier. room_id: OwnedRoomId, @@ -183,7 +191,7 @@ impl ScrollbackState { self.cursor .timestamp .clone() - .or_else(|| self.get_thread(info)?.last_key_value().map(|kv| kv.0.clone())) + .or_else(|| last_key_value(self.get_thread(info)?).map(|kv| kv.0.clone())) } pub fn get_mut<'a>(&mut self, info: &'a mut RoomInfo) -> Option<&'a mut Message> { @@ -244,7 +252,7 @@ impl ScrollbackState { _ => {}, } - let first_key = self.get_thread(info).and_then(|t| t.first_key_value()).map(|(k, _)| k); + let first_key = self.get_thread(info).and_then(|t| first_key(t)); let at_top = first_key == self.viewctx.corner.timestamp.as_ref(); match (at_top, self.thread.as_ref()) { @@ -347,7 +355,7 @@ impl ScrollbackState { return; }; - let last_key = if let Some(k) = thread.last_key_value() { + let last_key = if let Some(k) = last_key_value(thread) { k.0 } else { return; @@ -415,7 +423,7 @@ impl ScrollbackState { MoveType::BufferLineOffset => None, MoveType::BufferLinePercent => None, MoveType::BufferPos(MovePosition::Beginning) => { - let start = self.get_thread(info)?.first_key_value()?.0.clone(); + let start = first_key(self.get_thread(info)?)?.clone(); Some(start.into()) }, @@ -482,8 +490,8 @@ impl ScrollbackState { RangeType::Buffer => { let thread = self.get_thread(info)?; - let start = thread.first_key_value()?.0.clone(); - let end = thread.last_key_value()?.0.clone(); + let start = first_key(thread)?.clone(); + let end = last_key_value(thread)?.0.clone(); Some(EditRange::inclusive(start.into(), end.into(), TargetShape::LineWise)) }, @@ -1074,7 +1082,7 @@ impl ScrollActions for ScrollbackState { let mut corner = self.viewctx.corner.clone(); let thread = self.get_thread(info).ok_or_else(no_msgs)?; - let last_key = if let Some(k) = thread.last_key_value() { + let last_key = if let Some(k) = last_key_value(thread) { k.0 } else { return Ok(None); @@ -1093,7 +1101,7 @@ impl ScrollActions for ScrollbackState { match dir { MoveDir2D::Up => { - let first_key = thread.first_key_value().map(|f| f.0.clone()); + let first_key = first_key(thread).cloned(); for (key, item) in thread.range(..=&corner_key).rev().filter(msg_not_hidden) { let sel = key == cursor_key; @@ -1427,7 +1435,7 @@ impl StatefulWidget for Scrollback<'_> { state.cursor.timestamp.is_none() { // If the cursor is at the last message, then update the read marker. - if let Some((k, _)) = thread.last_key_value() { + if let Some((k, _)) = last_key_value(thread) { info.set_receipt(thread.1.clone(), settings.profile.user_id.clone(), k.1.clone()); } }