diff --git a/README.md b/README.md index c2eeb16..532ca44 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ two other TUI clients and Element Web: | New user registration | ❌ | ❌ | ❌ | ✔️ | | VOIP | ❌ | ❌ | ❌ | ✔️ | | Reactions | ❌ ([#2]) | ✔️ | ❌ | ✔️ | -| Message editing | ❌ ([#4]) | ✔️ | ❌ | ✔️ | +| Message editing | ✔️ | ✔️ | ❌ | ✔️ | | Room upgrades | ❌ | ✔️ | ❌ | ✔️ | | Localisations | ❌ | 1 | ❌ | 44 | | SSO Support | ❌ | ✔️ | ✔️ | ✔️ | diff --git a/src/base.rs b/src/base.rs index be74d1b..9198931 100644 --- a/src/base.rs +++ b/src/base.rs @@ -8,7 +8,20 @@ use tracing::warn; use matrix_sdk::{ encryption::verification::SasVerification, - ruma::{OwnedRoomId, OwnedUserId, RoomId}, + ruma::{ + events::room::message::{ + OriginalRoomMessageEvent, + Relation, + Replacement, + RoomMessageEvent, + RoomMessageEventContent, + }, + EventId, + OwnedEventId, + OwnedRoomId, + OwnedUserId, + RoomId, + }, }; use modalkit::{ @@ -41,7 +54,7 @@ use modalkit::{ }; use crate::{ - message::{Message, Messages}, + message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages}, worker::Requester, ApplicationSettings, }; @@ -61,9 +74,22 @@ pub enum VerifyAction { #[derive(Clone, Debug, Eq, PartialEq)] pub enum MessageAction { + /// Cance the current reply or edit. Cancel, + + /// Download an attachment to the given path. + /// + /// The [bool] argument controls whether to overwrite any already existing file at the + /// destination path. Download(Option, bool), + + /// Edit a sent message. + Edit, + + /// Redact a message. Redact(Option), + + /// Reply to a message. Reply, } @@ -251,13 +277,72 @@ pub enum RoomFetchStatus { #[derive(Default)] pub struct RoomInfo { pub name: Option, + + pub keys: HashMap, pub messages: Messages, + pub fetch_id: RoomFetchStatus, pub fetch_last: Option, pub users_typing: Option<(Instant, Vec)>, } impl RoomInfo { + pub fn get_event(&self, event_id: &EventId) -> Option<&Message> { + self.messages.get(self.keys.get(event_id)?) + } + + pub fn insert_edit(&mut self, msg: Replacement) { + let event_id = msg.event_id; + let new_content = msg.new_content; + + let key = if let Some(k) = self.keys.get(&event_id) { + k + } else { + return; + }; + + let msg = if let Some(msg) = self.messages.get_mut(key) { + msg + } else { + return; + }; + + match &mut msg.event { + MessageEvent::Original(orig) => { + orig.content = *new_content; + }, + MessageEvent::Local(content) => { + *content = new_content; + }, + MessageEvent::Redacted(_) => { + return; + }, + } + } + + pub fn insert_message(&mut self, msg: RoomMessageEvent) { + let event_id = msg.event_id().to_owned(); + let key = (msg.origin_server_ts().into(), event_id.clone()); + + self.keys.insert(event_id.clone(), key.clone()); + self.messages.insert(key, msg.into()); + + // Remove any echo. + let key = (MessageTimeStamp::LocalEcho, event_id); + let _ = self.messages.remove(&key); + } + + pub fn insert(&mut self, msg: RoomMessageEvent) { + match msg { + RoomMessageEvent::Original(OriginalRoomMessageEvent { + content: + RoomMessageEventContent { relates_to: Some(Relation::Replacement(repl)), .. }, + .. + }) => self.insert_edit(repl), + _ => self.insert_message(msg), + } + } + fn recently_fetched(&self) -> bool { self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE) } @@ -388,9 +473,7 @@ impl ChatStore { match res { Ok((fetch_id, msgs)) => { for msg in msgs.into_iter() { - let key = (msg.origin_server_ts().into(), msg.event_id().to_owned()); - - info.messages.insert(key, Message::from(msg)); + info.insert(msg); } info.fetch_id = diff --git a/src/commands.rs b/src/commands.rs index 9ea350d..fde8f7f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -144,6 +144,17 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } +fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let ract = IambAction::from(MessageAction::Edit); + let step = CommandStep::Continue(ract.into(), ctx.context.take()); + + return Ok(step); +} + fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { let args = desc.arg.strings()?; @@ -269,6 +280,7 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { names: vec!["cancel".into()], f: iamb_cancel }); cmds.add_command(ProgramCommand { names: vec!["dms".into()], f: iamb_dms }); cmds.add_command(ProgramCommand { names: vec!["download".into()], f: iamb_download }); + cmds.add_command(ProgramCommand { names: vec!["edit".into()], f: iamb_edit }); cmds.add_command(ProgramCommand { names: vec!["invite".into()], f: iamb_invite }); cmds.add_command(ProgramCommand { names: vec!["join".into()], f: iamb_join }); cmds.add_command(ProgramCommand { names: vec!["members".into()], f: iamb_members }); diff --git a/src/tests.rs b/src/tests.rs index 65e57b6..ecacbc5 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -111,6 +111,18 @@ pub fn mock_message5() -> Message { mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone()) } +pub fn mock_keys() -> HashMap { + let mut keys = HashMap::new(); + + keys.insert(MSG1_EVID.clone(), MSG1_KEY.clone()); + keys.insert(MSG2_EVID.clone(), MSG2_KEY.clone()); + keys.insert(MSG3_EVID.clone(), MSG3_KEY.clone()); + keys.insert(MSG4_EVID.clone(), MSG4_KEY.clone()); + keys.insert(MSG5_EVID.clone(), MSG5_KEY.clone()); + + keys +} + pub fn mock_messages() -> Messages { let mut messages = BTreeMap::new(); @@ -126,7 +138,10 @@ pub fn mock_messages() -> Messages { pub fn mock_room() -> RoomInfo { RoomInfo { name: Some("Watercooler Discussion".into()), + + keys: mock_keys(), messages: mock_messages(), + fetch_id: RoomFetchStatus::NotStarted, fetch_last: None, users_typing: None, diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 705bf07..6e557c4 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::ffi::OsStr; use std::fs; +use std::ops::Deref; use std::path::{Path, PathBuf}; use matrix_sdk::{ @@ -11,6 +12,8 @@ use matrix_sdk::{ events::room::message::{ MessageType, OriginalRoomMessageEvent, + Relation, + Replacement, RoomMessageEventContent, TextMessageEventContent, }, @@ -82,6 +85,7 @@ pub struct ChatState { focus: RoomFocus, reply_to: Option, + editing: Option, } impl ChatState { @@ -104,6 +108,7 @@ impl ChatState { focus: RoomFocus::MessageBar, reply_to: None, + editing: None, } } @@ -120,6 +125,7 @@ impl ChatState { fn reset(&mut self) -> EditRope { self.reply_to = None; + self.editing = None; self.tbox.reset() } @@ -145,6 +151,7 @@ impl ChatState { match act { MessageAction::Cancel => { self.reply_to = None; + self.editing = None; Ok(None) }, @@ -224,6 +231,40 @@ impl ChatState { Err(IambError::NoAttachment.into()) }, + MessageAction::Edit => { + if msg.sender != settings.profile.user_id { + let msg = "Cannot edit messages sent by someone else"; + let err = UIError::Failure(msg.into()); + + return Err(err); + } + + let ev = match &msg.event { + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Local(ev) => ev.deref(), + _ => { + let msg = "Cannot edit a redacted message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + let text = match &ev.msgtype { + MessageType::Text(msg) => msg.body.as_str(), + _ => { + let msg = "Cannot edit a non-text message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + self.tbox.set_text(text); + self.editing = self.scrollback.get_key(info); + + Ok(None) + }, MessageAction::Redact(reason) => { let room = store .application @@ -273,6 +314,7 @@ impl ChatState { .get_joined_room(self.id()) .ok_or(IambError::NotJoined)?; let info = store.application.rooms.entry(self.id().to_owned()).or_default(); + let mut show_echo = true; let (event_id, msg) = match act { SendAction::Submit => { @@ -287,7 +329,14 @@ impl ChatState { let mut msg = RoomMessageEventContent::new(msg); - if let Some(m) = self.get_reply_to(info) { + if let Some((_, event_id)) = &self.editing { + msg.relates_to = Some(Relation::Replacement(Replacement::new( + event_id.clone(), + Box::new(msg.clone()), + ))); + + show_echo = false; + } else if let Some(m) = self.get_reply_to(info) { // XXX: Switch to RoomMessageEventContent::reply() once it's stable? msg = msg.make_reply_to(m); } @@ -327,11 +376,13 @@ impl ChatState { }, }; - let user = store.application.settings.profile.user_id.clone(); - let key = (MessageTimeStamp::LocalEcho, event_id); - let msg = MessageEvent::Local(msg.into()); - let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); - info.messages.insert(key, msg); + if show_echo { + let user = store.application.settings.profile.user_id.clone(); + let key = (MessageTimeStamp::LocalEcho, event_id); + let msg = MessageEvent::Local(msg.into()); + let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); + info.messages.insert(key, msg); + } // Jump to the end of the scrollback to show the message. self.scrollback.goto_latest(); @@ -413,6 +464,7 @@ impl WindowOps for ChatState { focus: self.focus, reply_to: None, + editing: None, } } @@ -601,14 +653,20 @@ impl<'a> StatefulWidget for Chat<'a> { let scrollback = Scrollback::new(self.store).focus(scrollback_focused); scrollback.render(scrollarea, buf, &mut state.scrollback); - let desc_spans = state.reply_to.as_ref().and_then(|k| { - let room = self.store.application.rooms.get(state.id())?; - let msg = room.messages.get(k)?; - let user = self.store.application.settings.get_user_span(msg.sender.as_ref()); - let spans = Spans(vec![Span::from("Replying to "), user]); + let desc_spans = match (&state.editing, &state.reply_to) { + (None, None) => None, + (Some(_), _) => Some(Spans::from("Editing message")), + (_, Some(_)) => { + state.reply_to.as_ref().and_then(|k| { + let room = self.store.application.rooms.get(state.id())?; + let msg = room.messages.get(k)?; + let user = self.store.application.settings.get_user_span(msg.sender.as_ref()); + let spans = Spans(vec![Span::from("Replying to "), user]); - spans.into() - }); + spans.into() + }) + }, + }; if let Some(desc_spans) = desc_spans { Paragraph::new(desc_spans).render(descarea, buf); diff --git a/src/worker.rs b/src/worker.rs index d07c622..8eccbdc 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -58,7 +58,7 @@ use modalkit::editing::action::{EditInfo, InfoMessage, UIError}; use crate::{ base::{AsyncProgramStore, IambError, IambResult, SetRoomField, VerifyAction}, - message::{Message, MessageFetchResult, MessageTimeStamp}, + message::MessageFetchResult, ApplicationSettings, }; @@ -536,15 +536,7 @@ impl ClientWorker { let mut locked = store.lock().await; let mut info = locked.application.get_room_info(room_id.to_owned()); info.name = room_name; - - let event_id = ev.event_id().to_owned(); - let key = (ev.origin_server_ts().into(), event_id.clone()); - let msg = Message::from(ev.into_full_event(room_id.to_owned())); - info.messages.insert(key, msg); - - // Remove the echo. - let key = (MessageTimeStamp::LocalEcho, event_id); - let _ = info.messages.remove(&key); + info.insert(ev.into_full_event(room_id.to_owned())); } }, ); @@ -561,17 +553,15 @@ impl ClientWorker { let mut locked = store.lock().await; let info = locked.application.get_room_info(room_id.to_owned()); - // XXX: need to store a mapping of EventId -> MessageKey somewhere - // to avoid having to iterate over the messages here. - for ((_, id), msg) in info.messages.iter_mut().rev() { - if id != &ev.redacts { - continue; - } + let key = if let Some(k) = info.keys.get(&ev.redacts) { + k + } else { + return; + }; + if let Some(msg) = info.messages.get_mut(key) { let ev = SyncRoomRedactionEvent::Original(ev); msg.event.redact(ev, room_version); - - break; } } },