Files
nixos/patches/flare/0003-feat-messages-Implement-edited-messages.patch
Simon Gardling bcdfd8cdf5 flare: add patched flare-signal with five local feature patches
- patches/flare/000{1..5}-*.patch: typing indicators, formatted
  messages, edited messages, multi-select with delete-for-me, and
  in-channel message search. Mirror the matching commits in
  ~/projects/forks/flare and apply cleanly on top of upstream 0.20.4
  (which is what nixpkgs ships).
- home/profiles/gui.nix: include a flare-signal override that appends
  the patches via overrideAttrs. None of them touch Cargo.lock so the
  cargoDeps hash stays valid; signal-desktop stays alongside it.
2026-04-30 18:41:35 -04:00

920 lines
34 KiB
Diff

From 2437960d0fe4daf512ae77fd99bba77e86c70ce9 Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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 <bool>;
+
+ 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 <string>;
+ 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 <string>;
visible: bind $not_empty(template.message as <$FlTextMessage>.body) as <bool>;
+ 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::<EditMessageItem>() {
+ 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::<TextMessage>().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<libsignal_service::content::ContentBody>,
+ 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<Vec<Contact>>,
pub(super) pending_reactions: RefCell<HashMap<u64, Vec<ReactionMessage>>>,
+ /// 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<HashMap<u64, Vec<DataMessage>>>,
pub(super) typing: RefCell<HashMap<Uuid, TypingNotification>>,
#[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<imp::EditMessageItem>) @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::<Self>()
+ .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<u64> {
+ 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<EditMessage> {
+ 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<Option<EditMessage>>,
+ }
+
+ #[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<BodyRange>) {
// 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<S: AsRef<str>>(&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<S: AsRef<str>>(
&self,
@@ -462,6 +522,8 @@ mod imp {
pub(super) message_attributes: RefCell<AttrList>,
#[property(get, set)]
pub(super) is_deleted: RefCell<bool>,
+ #[property(get, set)]
+ pub(super) is_edited: RefCell<bool>,
}
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<TextMessage>) {
+ if let Some(msg) = msg.as_ref() {
+ self.set_reply_message(None::<TextMessage>);
+ 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<Option<Channel>>,
#[property(get, set, nullable)]
reply_message: RefCell<Option<TextMessage>>,
+ #[property(get, set, nullable)]
+ editing_message: RefCell<Option<TextMessage>>,
#[property(get, set, default = true)]
sticky: Cell<bool>,
@@ -482,6 +498,13 @@ pub mod imp {
self.obj().set_reply_message(None::<TextMessage>);
}
+ #[template_callback]
+ fn cancel_edit(&self) {
+ log::trace!("Unsetting editing message");
+ self.obj().set_editing_message(None::<TextMessage>);
+ 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::<TextMessage>);
+ 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::<crate::gui::Window>()
+ .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::<Option<TextMessage>>().expect(
+ "Type of signal `edit` of `ItemRow` to be `TextMessage`.",
+ );
+ obj.start_editing(msg);
+ None
+ }
+ ),
+ );
let list_item = object.downcast_ref::<gtk::ListItem>().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::<TextMessage>);
+ s.obj().set_editing_message(None::<TextMessage>);
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<String>,
+ #[property(get, set)]
+ pub(super) edited: Cell<bool>,
//TODO: Implement sending state
//#[template_child]
//pub(super) sending_state_icon: TemplateChild<gtk::Image>,
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<gtk::Widget> {
if let Some(message) = item.dynamic_cast_ref::<TextMessage>() {
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::<TextMessage>()
- .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::<TextMessage>()
+ .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::<CallMessage>() {
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<Option<SignalHandlerId>>,
+ pub(super) handlers: RefCell<Vec<SignalHandlerId>>,
}
#[glib::object_subclass]
@@ -116,12 +110,15 @@ mod imp {
.get::<Option<TimelineItem>>()
.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<TextMessage>| {
+ 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<Attachment>| 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<String>) -> Option<String> {
@@ -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