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.
This commit is contained in:
919
patches/flare/0003-feat-messages-Implement-edited-messages.patch
Normal file
919
patches/flare/0003-feat-messages-Implement-edited-messages.patch
Normal file
@@ -0,0 +1,919 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user