From 2437960d0fe4daf512ae77fd99bba77e86c70ce9 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:33:06 -0400 Subject: [PATCH 3/6] feat(messages): Implement edited messages - Receive incoming EditMessage (1-1 and sync) and replace the body, body_ranges, and attachments of the targeted message in place. The receive path uses an EditMessageItem wrapper that mirrors the DeletionMessage flow. - Send EditMessage from the message context menu: an Edit action loads the original body into the input bar and a dedicated indicator takes the place of the reply hint while editing. Submitting the edited text dispatches an EditMessage to the channel via a new Channel::send_internal_content helper that forwards any ContentBody to the right send path. - Display an 'edited' label in the message indicators when the local copy of a message has been replaced by an edit. --- CHANGELOG.md | 1 + data/resources/ui/channel_messages.blp | 89 +++++++++++++++++++ data/resources/ui/components/indicators.blp | 10 +++ data/resources/ui/message_item.blp | 10 +++ src/backend/channel.rs | 94 ++++++++++++++++++++- src/backend/message/edit_message_item.rs | 66 +++++++++++++++ src/backend/message/formatting.rs | 11 ++- src/backend/message/mod.rs | 71 ++++++++++++++++ src/backend/message/text_message.rs | 62 ++++++++++++++ src/gui/channel_messages.rs | 67 +++++++++++++++ src/gui/components/indicators.rs | 2 + src/gui/components/item_row.rs | 58 ++++++------- src/gui/message_item.rs | 27 ++++++ 13 files changed, 530 insertions(+), 38 deletions(-) create mode 100644 src/backend/message/edit_message_item.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cd5f5..0338ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Settings to enable or disable sending and showing typing indicators. - Render formatted message styles (bold, italic, strikethrough, spoiler, monospace) on incoming messages. - Send formatted messages with markdown-style markers (`**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, `` `monospace` ``). +- Display incoming edited messages with an `edited` indicator and edit your own sent messages from their context menu. ## [0.20.4] - 2026-04-22 diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp index 6c3948f..f3d2348 100644 --- a/data/resources/ui/channel_messages.blp +++ b/data/resources/ui/channel_messages.blp @@ -238,6 +238,95 @@ template $FlChannelMessages: Box { } } + // Editing indicator + Box { + vexpand-set: true; + + styles [ + "currently-replied-box", + ] + + visible: bind $is_some(template.editing-message) as ; + + Image { + styles [ + "accent", + ] + + icon-name: "document-edit-symbolic"; + width-request: 34; + } + + Separator { + styles [ + "accent", + ] + + margin-start: 6; + width-request: 2; + } + + Grid { + hexpand: true; + margin-start: 12; + margin-end: 12; + + Label { + styles [ + "heading", + ] + + halign: start; + label: _("Editing message"); + wrap: true; + wrap-mode: word_char; + lines: 1; + ellipsize: end; + + layout { + row: 0; + column: 0; + } + } + + Label { + styles [ + "message-text", + ] + + halign: fill; + label: bind template.editing-message as <$FlTextMessage>.body; + wrap: true; + wrap-mode: word_char; + lines: 2; + ellipsize: end; + xalign: 0; + + layout { + row: 1; + column: 0; + } + } + } + + Button { + accessibility { + label: C_("accessibility", "Cancel editing"); + } + + tooltip-text: C_("tooltip", "Cancel editing"); + + styles [ + "flat", + "circular", + ] + + valign: center; + clicked => $cancel_edit() swapped; + icon-name: "window-close-symbolic"; + } + } + // Box for attachments Box { vexpand-set: true; diff --git a/data/resources/ui/components/indicators.blp b/data/resources/ui/components/indicators.blp index f6c51f6..977f1c4 100644 --- a/data/resources/ui/components/indicators.blp +++ b/data/resources/ui/components/indicators.blp @@ -8,6 +8,16 @@ template $FlMessageIndicators { halign: end; valign: end; + Label edited_label { + styles [ + "dim-label", + "caption", + ] + + visible: bind template.edited; + label: _("edited"); + } + Label message_info_label { styles [ "dim-label", diff --git a/data/resources/ui/message_item.blp b/data/resources/ui/message_item.blp index 82c018b..2c21b8b 100644 --- a/data/resources/ui/message_item.blp +++ b/data/resources/ui/message_item.blp @@ -16,6 +16,14 @@ menu message-menu { icon: "mail-reply-sender-symbolic"; } + item { + label: _("Edit"); + action: "msg.edit"; + verb-icon: "document-edit-symbolic"; + icon: "document-edit-symbolic"; + hidden-when: "action-disabled"; + } + item { label: _("Delete"); action: "msg.delete"; @@ -243,6 +251,7 @@ template $FlMessageItem: $ContextMenuBin { valign: end; halign: end; timestamp: bind $format_time_human(template.message as <$FlTextMessage>.datetime) as ; + edited: bind template.message as <$FlTextMessage>.is-edited; } } @@ -253,6 +262,7 @@ template $FlMessageItem: $ContextMenuBin { indicators: $FlMessageIndicators timestamp { timestamp: bind $format_time_human(template.message as <$FlTextMessage>.datetime) as ; visible: bind $not_empty(template.message as <$FlTextMessage>.body) as ; + edited: bind template.message as <$FlTextMessage>.is-edited; }; } diff --git a/src/backend/channel.rs b/src/backend/channel.rs index 4bb1d38..94fa6bb 100644 --- a/src/backend/channel.rs +++ b/src/backend/channel.rs @@ -1,8 +1,8 @@ use crate::backend::{ Contact, Manager, Message, message::{ - DeletionMessage, DisplayMessage, DisplayMessageExt, MessageExt, ReactionMessage, - TextMessage, + DeletionMessage, DisplayMessage, DisplayMessageExt, EditMessageItem, MessageExt, + ReactionMessage, TextMessage, }, timeline::{TimelineItem, TimelineItemExt}, }; @@ -336,6 +336,17 @@ impl Channel { message.react(reaction); } } + + // Apply pending edits queued while the original was unloaded. + // Edits are stored ordered by ascending edit-timestamp so + // applying them in sequence converges on the latest content. + let pending = self.imp().pending_edits.borrow_mut().remove(&id); + if let Some(edits) = pending { + log::trace!("Applying {} pending edit(s) to message {id}", edits.len()); + for edit in edits { + message.apply_edit(edit).await; + } + } } // Apply reactions or store them. @@ -420,6 +431,46 @@ impl Channel { log::trace!("Deletion message aimed at a unloaded message. Will be ignored"); } } + + // Edit messages: replace the original message's body with the new + // content and remember that the message was edited. + if let Some(edit_item) = message.dynamic_cast_ref::() { + let Some(target_ts) = edit_item.target_timestamp() else { + log::warn!("Got an EditMessage without a target timestamp; ignoring."); + return Ok(()); + }; + let Some(new_data) = edit_item.edit().and_then(|e| e.data_message) else { + log::warn!("Got an EditMessage without a data_message; ignoring."); + return Ok(()); + }; + crate::trace!( + "Channel {} got an edit message targeting timestamp: {}", + self.title(), + target_ts + ); + let edited_msg = self + .imp() + .timeline + .borrow() + .get_by_timestamp(target_ts) + .and_then(|o| o.dynamic_cast::().ok()); + if let Some(edited_msg) = edited_msg { + edited_msg.apply_edit(new_data).await; + self.notify("last-message"); + } else { + log::trace!( + "Edit target {target_ts} not loaded yet; queueing for when it lands." + ); + let edit_ts = new_data.timestamp.unwrap_or(0); + let mut pending = self.imp().pending_edits.borrow_mut(); + let entry = pending.entry(target_ts).or_default(); + let to_insert = entry + .binary_search_by_key(&edit_ts, |d| d.timestamp.unwrap_or(0)) + .unwrap_or_else(|e| e); + entry.insert(to_insert, new_data); + } + } + Ok(()) } @@ -482,6 +533,38 @@ impl Channel { Ok(()) } + /// Send an arbitrary [ContentBody] to this channel, dispatching to the + /// appropriate single-contact or group send path. + pub(super) async fn send_internal_content( + &self, + body: impl Into, + timestamp: u64, + ) -> Result<(), crate::ApplicationError> { + let manager = self.manager(); + let body = body.into(); + let receiver_contact = self + .imp() + .contact + .borrow() + .as_ref() + .and_then(|c| c.address()); + + if let Some(contact) = receiver_contact { + manager.send_message(contact, body, timestamp).await?; + } else { + let group_master_key = self + .imp() + .group_context + .borrow() + .as_ref() + .and_then(|c| c.master_key.clone()); + if let Some(key) = group_master_key { + manager.send_message_to_group(key, body, timestamp).await?; + } + } + Ok(()) + } + /// Send a message to the channel and add it to the channel. pub async fn send_message(&self, msg: Message) -> Result<(), crate::ApplicationError> { msg.mark_as_read(); @@ -749,7 +832,7 @@ mod imp { use gdk::Paintable; - use libsignal_service::proto::GroupContextV2; + use libsignal_service::proto::{DataMessage, GroupContextV2}; use presage::model::groups::Group; #[derive(Default, glib::Properties)] @@ -762,6 +845,11 @@ mod imp { pub(super) participants: RefCell>, pub(super) pending_reactions: RefCell>>, + /// Edits whose target message is not yet in the timeline. Cold + /// scrollback walks newest-first, so an EditMessage may arrive in + /// `do_new_message` before its original TextMessage has been + /// loaded; the original picks up any queued edits when it lands. + pub(super) pending_edits: RefCell>>, pub(super) typing: RefCell>, #[property(name = "avatar", get = Self::avatar)] diff --git a/src/backend/message/edit_message_item.rs b/src/backend/message/edit_message_item.rs new file mode 100644 index 0000000..9655f50 --- /dev/null +++ b/src/backend/message/edit_message_item.rs @@ -0,0 +1,66 @@ +use crate::backend::timeline::TimelineItem; +use crate::backend::{Channel, Contact}; +use crate::prelude::*; + +use libsignal_service::proto::EditMessage; + +use super::{Manager, Message}; + +gtk::glib::wrapper! { + /// An incoming edit-message wrapper carrying the new content for a + /// previously-sent message identified by its sent-timestamp. + pub struct EditMessageItem(ObjectSubclass) @extends Message, TimelineItem; +} + +impl EditMessageItem { + pub fn from_edit( + sender: &Contact, + channel: &Channel, + timestamp: u64, + manager: &Manager, + edit: EditMessage, + ) -> Self { + let s: Self = Object::builder::() + .property("sender", sender) + .property("channel", channel) + .property("timestamp", timestamp) + .property("manager", manager) + .build(); + s.imp().edit.swap(&RefCell::new(Some(edit))); + s + } + + /// Sent-timestamp of the message this edit replaces. + pub fn target_timestamp(&self) -> Option { + self.edit().and_then(|e| e.target_sent_timestamp) + } + + /// The new [DataMessage] payload that replaces the targeted message's + /// content. + pub fn edit(&self) -> Option { + self.imp().edit.borrow().clone() + } +} + +mod imp { + use crate::backend::{Message, message::MessageImpl, timeline::TimelineItemImpl}; + use crate::prelude::*; + + use libsignal_service::proto::EditMessage; + + #[derive(Default)] + pub struct EditMessageItem { + pub(super) edit: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for EditMessageItem { + const NAME: &'static str = "FlEditMessageItem"; + type Type = super::EditMessageItem; + type ParentType = Message; + } + + impl TimelineItemImpl for EditMessageItem {} + impl MessageImpl for EditMessageItem {} + impl ObjectImpl for EditMessageItem {} +} diff --git a/src/backend/message/formatting.rs b/src/backend/message/formatting.rs index 5a1d596..ed12a85 100644 --- a/src/backend/message/formatting.rs +++ b/src/backend/message/formatting.rs @@ -108,13 +108,12 @@ pub fn parse_formatting(input: &str) -> (String, Vec) { // Mark which character positions are part of a matched marker token and // therefore must be removed from the cleaned output. let mut skip = vec![false; chars.len()]; + let total = chars.len(); for sp in &spans { - for k in sp.open_pos..(sp.open_pos + sp.marker_len).min(chars.len()) { - skip[k] = true; - } - for k in sp.close_pos..(sp.close_pos + sp.marker_len).min(chars.len()) { - skip[k] = true; - } + let open_end = (sp.open_pos + sp.marker_len).min(total); + skip[sp.open_pos..open_end].fill(true); + let close_end = (sp.close_pos + sp.marker_len).min(total); + skip[sp.close_pos..close_end].fill(true); } // Build the cleaned output and a per-input-char map into the output's diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs index 4e0f584..f3a0537 100644 --- a/src/backend/message/mod.rs +++ b/src/backend/message/mod.rs @@ -1,6 +1,7 @@ mod call_message; mod deletion_message; mod display_message; +mod edit_message_item; mod formatting; mod reaction_message; mod text_message; @@ -8,6 +9,7 @@ mod text_message; pub use call_message::{CallMessage, CallMessageType}; pub use deletion_message::DeletionMessage; pub use display_message::{DisplayMessage, DisplayMessageExt}; +pub use edit_message_item::EditMessageItem; pub use formatting::parse_formatting; pub use reaction_message::ReactionMessage; pub use text_message::TextMessage; @@ -253,6 +255,75 @@ impl Message { .upcast(), ) } + // An edit-message replacing the body of an earlier message. + ContentBody::EditMessage(edit) => { + let Some(data_message) = edit.data_message.as_ref() else { + log::warn!("Got an EditMessage without a data_message; ignoring."); + return None; + }; + let channel = manager + .channel_from_uuid_or_group(metadata.sender, &data_message.group_v2) + .await; + let contact = channel + .participant_by_uuid(metadata.sender.raw_uuid()) + .await; + if contact.is_blocked() { + log::debug!("Got message from a blocked contact. Ignoring"); + return None; + } + log::trace!("Got an edit message"); + Some( + EditMessageItem::from_edit( + &contact, + &channel, + timestamp, + manager, + edit.clone(), + ) + .upcast(), + ) + } + // An edit-message sent from another device of the same account. + ContentBody::SynchronizeMessage(SyncMessage { + sent: + Some( + sent @ Sent { + edit_message: Some(edit), + .. + }, + ), + .. + }) => { + let Some(data_message) = edit.data_message.as_ref() else { + log::warn!("Got a sync EditMessage without a data_message; ignoring."); + return None; + }; + let channel = manager + .channel_from_uuid_or_group( + sent.parse_destination_service_id() + .unwrap_or(metadata.sender), + &data_message.group_v2, + ) + .await; + let contact = channel + .participant_by_uuid(metadata.sender.raw_uuid()) + .await; + if contact.is_blocked() { + log::debug!("Got message from a blocked contact. Ignoring"); + return None; + } + log::trace!("Got an edit message (sync)"); + Some( + EditMessageItem::from_edit( + &contact, + &channel, + timestamp, + manager, + edit.clone(), + ) + .upcast(), + ) + } // Call message. ContentBody::CallMessage(c) => { // TODO: Group calls? diff --git a/src/backend/message/text_message.rs b/src/backend/message/text_message.rs index c06bcfa..ff7aaaa 100644 --- a/src/backend/message/text_message.rs +++ b/src/backend/message/text_message.rs @@ -199,6 +199,66 @@ impl TextMessage { self.set_property("is-deleted", true); } + /// Replace the message's body with `new_data` and mark the message as + /// edited so the UI can surface this to the user. + pub async fn apply_edit(&self, new_data: DataMessage) { + let new_body = new_data.body.clone(); + let new_body_ranges = new_data.body_ranges.clone(); + let edit_attachments = new_data.attachments.clone(); + if let Some(data) = self.internal_data_mut().as_mut() { + data.body = new_body; + data.body_ranges = new_body_ranges; + // Replacing attachments matches Signal Desktop's behaviour; + // a typical edit only changes the body but the protocol allows + // updating the attachments as well. + if !edit_attachments.is_empty() { + data.attachments = edit_attachments; + } + } + self.set_property("is-edited", true); + self.prepare_format_body().await; + } + + /// Send an edit for this message, replacing its body with `text`. + pub async fn send_edit>(&self, text: S) -> Result<(), crate::ApplicationError> { + let target_sent_timestamp = Some(self.timestamp()); + let send_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_millis() as u64; + + let cleaned = text.as_ref().to_owned(); + let (body, body_ranges) = if cleaned.is_empty() { + (None, Vec::new()) + } else { + let (body, ranges) = super::parse_formatting(&cleaned); + (Some(body), ranges) + }; + + // Carry forward the original message's structural fields (quote, + // attachments, expire_timer, sticker, group_v2, etc.) so peers do + // not see them cleared when applying the edit. Only body, + // body_ranges, and timestamp differ. + let mut inner = self.internal_data().unwrap_or_default(); + inner.body = body; + inner.body_ranges = body_ranges; + inner.timestamp = Some(send_timestamp); + + let edit = libsignal_service::proto::EditMessage { + target_sent_timestamp, + data_message: Some(inner.clone()), + }; + + self.channel() + .send_internal_content(edit, send_timestamp) + .await?; + + // Mirror the change locally. + self.apply_edit(inner).await; + self.channel().notify("last-message"); + Ok(()) + } + /// Send a reaction for a message and apply it. pub async fn send_reaction>( &self, @@ -462,6 +522,8 @@ mod imp { pub(super) message_attributes: RefCell, #[property(get, set)] pub(super) is_deleted: RefCell, + #[property(get, set)] + pub(super) is_edited: RefCell, } impl TextMessage { diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs index c6684fc..b929957 100644 --- a/src/gui/channel_messages.rs +++ b/src/gui/channel_messages.rs @@ -2,6 +2,7 @@ use crate::prelude::*; use gio::SettingsBindFlags; use crate::ApplicationError; +use crate::backend::message::TextMessage; const MESSAGES_REQUEST_LOAD: usize = 10; @@ -24,6 +25,19 @@ glib::wrapper! { } impl ChannelMessages { + /// Begin editing `msg`: load its body into the text entry, mark it as + /// the editing target, and clear any pending reply. + pub fn start_editing(&self, msg: Option) { + if let Some(msg) = msg.as_ref() { + self.set_reply_message(None::); + self.imp() + .text_entry + .set_text(msg.body().unwrap_or_default()); + } + self.set_editing_message(msg); + self.imp().text_entry.grab_focus(); + } + pub fn focus_input(&self) { self.imp().text_entry.grab_focus(); } @@ -360,6 +374,8 @@ pub mod imp { active_channel: RefCell>, #[property(get, set, nullable)] reply_message: RefCell>, + #[property(get, set, nullable)] + editing_message: RefCell>, #[property(get, set, default = true)] sticky: Cell, @@ -482,6 +498,13 @@ pub mod imp { self.obj().set_reply_message(None::); } + #[template_callback] + fn cancel_edit(&self) { + log::trace!("Unsetting editing message"); + self.obj().set_editing_message(None::); + self.text_entry.clear(); + } + #[template_callback] fn remove_attachments(&self) { log::trace!("Unsetting attachments"); @@ -587,6 +610,33 @@ pub mod imp { }; self.obj().notify("has-attachments"); + // If we are editing an existing message, send an EditMessage + // instead of constructing a new one. + if let Some(target) = self.obj().editing_message() { + self.obj().set_editing_message(None::); + if text.is_empty() { + log::warn!("Refusing to send an empty edit; dropping the change."); + return; + } + let obj = self.obj(); + gspawn!(clone!( + #[strong] + obj, + async move { + if let Err(e) = target.send_edit(text).await { + let root = obj + .root() + .expect("`ChannelMessages` to have a root") + .dynamic_cast::() + .expect("Root of `ChannelMessages` to be a `Window`."); + let dialog = ErrorDialog::new(&e, &root); + dialog.present(Some(&root)); + } + } + )); + return; + } + if text.is_empty() && attachments.is_empty() { log::warn!("Got requested to send empty message, skipping"); } @@ -685,6 +735,22 @@ pub mod imp { } ), ); + widget.connect_local( + "edit", + false, + clone!( + #[weak] + obj, + #[upgrade_or_default] + move |args| { + let msg = args[1].get::>().expect( + "Type of signal `edit` of `ItemRow` to be `TextMessage`.", + ); + obj.start_editing(msg); + None + } + ), + ); let list_item = object.downcast_ref::().unwrap(); list_item.set_activatable(false); list_item.set_selectable(false); @@ -737,6 +803,7 @@ pub mod imp { self, move |_, _| { s.obj().set_reply_message(None::); + s.obj().set_editing_message(None::); if let Some(channel) = s.active_channel.borrow().as_ref() { let draft = channel.property("draft"); // Block the typing buffer-changed handler so diff --git a/src/gui/components/indicators.rs b/src/gui/components/indicators.rs index ce38221..4356607 100644 --- a/src/gui/components/indicators.rs +++ b/src/gui/components/indicators.rs @@ -26,6 +26,8 @@ mod imp { pub struct MessageIndicators { #[property(get, set)] pub(super) timestamp: RefCell, + #[property(get, set)] + pub(super) edited: Cell, //TODO: Implement sending state //#[template_child] //pub(super) sending_state_icon: TemplateChild, diff --git a/src/gui/components/item_row.rs b/src/gui/components/item_row.rs index b2c20d3..538b1bb 100644 --- a/src/gui/components/item_row.rs +++ b/src/gui/components/item_row.rs @@ -1,5 +1,3 @@ -use glib::SignalHandlerId; - use crate::prelude::*; use crate::{ @@ -27,23 +25,25 @@ impl ItemRow { fn timeline_item_to_widget(&self, item: &TimelineItem) -> Option { if let Some(message) = item.dynamic_cast_ref::() { let widget = MessageItem::new(message); - let handler = widget.connect_local( - "reply", - false, - clone!( - #[weak(rename_to = s)] - self, - #[upgrade_or_default] - move |args| { - let msg = args[1] - .get::() - .expect("Type of signal `reply` of `MessageItem` to be `TextMessage`."); - s.emit_by_name::<()>("reply", &[&msg]); - None - } - ), - ); - self.set_handler(handler); + for signal in ["reply", "edit"] { + let handler = widget.connect_local( + signal, + false, + clone!( + #[weak(rename_to = s)] + self, + #[upgrade_or_default] + move |args| { + let msg = args[1] + .get::() + .expect("Type of signal of `MessageItem` to be `TextMessage`."); + s.emit_by_name::<()>(signal, &[&msg]); + None + } + ), + ); + self.imp().handlers.borrow_mut().push(handler); + } Some(widget.dynamic_cast().unwrap()) } else if let Some(message) = item.dynamic_cast_ref::() { let widget = CallMessageItem::new(message); @@ -53,12 +53,6 @@ impl ItemRow { None } } - - /// Set the pending handler of the ItemRow. - /// At most one handler may be pending. - fn set_handler(&self, handler: SignalHandlerId) { - self.imp().handler.replace(Some(handler)); - } } mod imp { @@ -77,7 +71,7 @@ mod imp { #[derive(Debug, Default, CompositeTemplate)] #[template(resource = "/ui/components/item_row.ui")] pub struct ItemRow { - pub(super) handler: RefCell>, + pub(super) handlers: RefCell>, } #[glib::object_subclass] @@ -116,12 +110,15 @@ mod imp { .get::>() .expect("ItemRow to only get TimelineItem"); - if let Some(handler) = self.handler.take() { + let handlers = self.handlers.take(); + if !handlers.is_empty() { if let Some(child) = obj.child() { - child.disconnect(handler); + for handler in handlers { + child.disconnect(handler); + } } else { log::warn!( - "A handler was set for an item row, but no child registered. This should not happen." + "Handlers were set for an item row, but no child registered. This should not happen." ); } } @@ -143,6 +140,9 @@ mod imp { Signal::builder("reply") .param_types([TextMessage::static_type()]) .build(), + Signal::builder("edit") + .param_types([TextMessage::static_type()]) + .build(), ] }); SIGNALS.as_ref() diff --git a/src/gui/message_item.rs b/src/gui/message_item.rs index 21f504a..59d2778 100644 --- a/src/gui/message_item.rs +++ b/src/gui/message_item.rs @@ -94,6 +94,14 @@ impl MessageItem { s.get_pressed_attachment().imp().open(); } )); + let action_edit = SimpleAction::new("edit", None); + action_edit.connect_activate(clone!( + #[weak(rename_to = s)] + self, + move |_, _| { + s.imp().handle_edit(); + } + )); let actions = SimpleActionGroup::new(); self.insert_action_group("msg", Some(&actions)); @@ -101,6 +109,7 @@ impl MessageItem { actions.add_action(&action_delete); actions.add_action(&action_copy); actions.add_action(&action_download); + actions.add_action(&action_edit); actions.add_action(&action_open); self.bind_property("message", &action_delete, "enabled") @@ -108,6 +117,13 @@ impl MessageItem { .sync_create() .build(); + self.bind_property("message", &action_edit, "enabled") + .transform_to(|_, msg: Option| { + msg.map(|m| m.sender().is_self() && m.body().is_some()) + }) + .sync_create() + .build(); + self.bind_property("pressed-attachment", &action_download, "enabled") .transform_to(|_, att: Option| Some(att.is_some())) .sync_create() @@ -550,6 +566,14 @@ pub mod imp { gspawn!(async move { msg.delete().await }); } + #[template_callback] + pub(super) fn handle_edit(&self) { + let obj = self.obj(); + let msg = obj.message(); + crate::trace!("Editing a message: {}", msg.body().unwrap_or_default()); + obj.emit_by_name::<()>("edit", &[&msg]); + } + // Signal uses the old unicode for the heart emoji, which is recognized as a black heart by gtk. This function converts it to the standard red heart #[template_callback(function)] pub(super) fn fix_emoji(emoji: Option) -> Option { @@ -637,6 +661,9 @@ pub mod imp { Signal::builder("reply") .param_types([TextMessage::static_type()]) .build(), + Signal::builder("edit") + .param_types([TextMessage::static_type()]) + .build(), ] }); SIGNALS.as_ref() -- 2.53.0