diff --git a/home/profiles/gui.nix b/home/profiles/gui.nix index 5436d59..dd9c54e 100644 --- a/home/profiles/gui.nix +++ b/home/profiles/gui.nix @@ -86,6 +86,21 @@ signal-desktop + # alternative GTK signal client; carries five local feature patches + # under patches/flare/ on top of upstream 0.20.4 (typing indicators, + # formatted messages, edited messages, multi-select with delete-for-me, + # and in-channel message search). + (pkgs.flare-signal.overrideAttrs (old: { + patches = (old.patches or [ ]) ++ [ + ../../patches/flare/0001-feat-typing-Implement-typing-indicators.patch + ../../patches/flare/0002-feat-messages-Implement-formatted-messages.patch + ../../patches/flare/0003-feat-messages-Implement-edited-messages.patch + ../../patches/flare/0004-feat-messages-Multi-select-messages-and-delete-for-m.patch + ../../patches/flare/0005-feat-messages-In-channel-message-search.patch + ../../patches/flare/0006-feat-messages-Show-This-message-was-deleted.-placeho.patch + ]; + })) + # accounting # gnucash diff --git a/patches/flare/0001-feat-typing-Implement-typing-indicators.patch b/patches/flare/0001-feat-typing-Implement-typing-indicators.patch new file mode 100644 index 0000000..0f8ea3b --- /dev/null +++ b/patches/flare/0001-feat-typing-Implement-typing-indicators.patch @@ -0,0 +1,732 @@ +From 733ad6e63fa6408e47d87a22cf51a784f5ce103f Mon Sep 17 00:00:00 2001 +From: Simon Gardling +Date: Wed, 29 Apr 2026 19:00:12 -0400 +Subject: [PATCH 1/6] feat(typing): Implement typing indicators + +- Send TypingMessage Started/Stopped events as the user composes a + message, including a periodic refresh and an idle-stop timer so the + indicator follows actual composition activity. +- Display a typing indicator strip above the message input, gated on + the active channel's is-typing state. +- Add the show-typing-indicators and send-typing-indicators settings, + exposed through a new preferences group, and honour them both for + display and outbound events. +- Generalise Channel-level send_message_to_group to accept any + ContentBody so the new TypingMessage path can reuse it. +--- + CHANGELOG.md | 5 + + data/de.schmidhuberj.Flare.gschema.xml | 9 + + data/resources/style.css | 6 + + data/resources/ui/channel_messages.blp | 33 +++ + data/resources/ui/preferences_window.blp | 15 ++ + src/backend/channel.rs | 59 +++++- + src/backend/manager.rs | 43 +++- + src/backend/manager_thread.rs | 8 +- + src/backend/message/mod.rs | 12 +- + src/gui/channel_messages.rs | 251 ++++++++++++++++++++++- + src/gui/preferences_window.rs | 23 +++ + 11 files changed, 443 insertions(+), 21 deletions(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 20dc578..2bde927 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + + ## [Unreleased] + ++### Added ++ ++- Send typing indicators while composing a message and display them above the message input. ++- Settings to enable or disable sending and showing typing indicators. ++ + ## [0.20.4] - 2026-04-22 + + ### Fixed +diff --git a/data/de.schmidhuberj.Flare.gschema.xml b/data/de.schmidhuberj.Flare.gschema.xml +index 8a58415..0705a73 100644 +--- a/data/de.schmidhuberj.Flare.gschema.xml ++++ b/data/de.schmidhuberj.Flare.gschema.xml +@@ -58,6 +58,15 @@ + Send a message when the Enter-key is pressed + + ++ ++ true ++ Show typing indicators of other users ++ ++ ++ true ++ Send typing indicators while composing ++ ++ + + "firstname" + How to sort contacts, e.g with "firstname" or "surname" +diff --git a/data/resources/style.css b/data/resources/style.css +index dcd0569..00e4783 100644 +--- a/data/resources/style.css ++++ b/data/resources/style.css +@@ -13,6 +13,12 @@ + border-top: 1px solid @borders; + } + ++.typing-indicator { ++ background-color: @window_bg_color; ++ border-top: 1px solid @borders; ++ min-height: 18px; ++} ++ + .message-list row { + padding:0; + } +diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp +index 53be7ab..7f438e4 100644 +--- a/data/resources/ui/channel_messages.blp ++++ b/data/resources/ui/channel_messages.blp +@@ -102,6 +102,39 @@ template $FlChannelMessages: Box { + } + } + ++ // Typing indicator ++ Box typing_indicator { ++ styles [ ++ "typing-indicator", ++ ] ++ ++ orientation: horizontal; ++ hexpand: true; ++ visible: bind template.show-typing as ; ++ ++ Adw.Clamp { ++ maximum-size: 800; ++ tightening-threshold: 600; ++ hexpand: true; ++ ++ Label { ++ styles [ ++ "caption", ++ "dim-label", ++ ] ++ ++ halign: start; ++ ellipsize: end; ++ xalign: 0; ++ margin-start: 12; ++ margin-end: 12; ++ margin-top: 2; ++ margin-bottom: 2; ++ label: bind template.active-channel as <$FlChannel>.typing-label; ++ } ++ } ++ } ++ + Box { + styles [ + "toolbar", +diff --git a/data/resources/ui/preferences_window.blp b/data/resources/ui/preferences_window.blp +index dd84f74..2068cab 100644 +--- a/data/resources/ui/preferences_window.blp ++++ b/data/resources/ui/preferences_window.blp +@@ -66,6 +66,21 @@ template $FlPreferencesWindow: Adw.PreferencesDialog { + ); + } + } ++ ++ Adw.PreferencesGroup { ++ title: _("Typing Indicators"); ++ description: _("Inform other users when you are composing a message and show indicators when they are"); ++ ++ Adw.SwitchRow row_send_typing_indicators { ++ title: _("Send Typing Indicators"); ++ subtitle: _("Notify others while you are composing a message"); ++ } ++ ++ Adw.SwitchRow row_show_typing_indicators { ++ title: _("Show Typing Indicators"); ++ subtitle: _("Display when other users are composing a message"); ++ } ++ } + } + } + +diff --git a/src/backend/channel.rs b/src/backend/channel.rs +index 73e82f3..4bb1d38 100644 +--- a/src/backend/channel.rs ++++ b/src/backend/channel.rs +@@ -15,8 +15,9 @@ use glib::Bytes; + use glib::{Object, prelude::Cast}; + + use libsignal_service::{ +- proto::{DataMessage, GroupContextV2}, ++ proto::{DataMessage, GroupContextV2, TypingMessage, typing_message::Action as TypingAction}, + protocol::ServiceId, ++ zkgroup::groups::{GroupMasterKey, GroupSecretParams}, + }; + use presage::model::groups::Group; + use presage::store::Thread; +@@ -230,6 +231,62 @@ impl Channel { + self.manager().send_session_reset(uuid, ts).await + } + ++ /// Send a typing indicator (started/stopped) to the channel. ++ /// ++ /// Returns `Ok(())` without sending if the user has disabled the ++ /// `send-typing-indicators` setting or the channel has no resolvable peer. ++ pub async fn send_typing(&self, started: bool) -> Result<(), ApplicationError> { ++ // Note-to-self has no useful peer to inform, and routing the ++ // event through `send_message(self_uuid, …)` would fan it out to ++ // every other linked device on the account where flare's own ++ // receive path lights up an "is typing" indicator on its copy of ++ // Note-to-self. ++ if self.is_self() { ++ return Ok(()); ++ } ++ let manager = self.manager(); ++ if !manager.settings().boolean("send-typing-indicators") { ++ return Ok(()); ++ } ++ ++ let timestamp = std::time::SystemTime::now() ++ .duration_since(std::time::UNIX_EPOCH) ++ .expect("Time went backwards") ++ .as_millis() as u64; ++ ++ let action = if started { ++ TypingAction::Started ++ } else { ++ TypingAction::Stopped ++ }; ++ ++ let group_id = self ++ .group_context() ++ .and_then(|c| c.master_key) ++ .and_then(|k| <[u8; 32]>::try_from(k).ok()) ++ .map(|master_key| { ++ GroupSecretParams::derive_from_master_key(GroupMasterKey::new(master_key)) ++ .get_group_identifier() ++ .to_vec() ++ }); ++ ++ let typing = TypingMessage { ++ timestamp: Some(timestamp), ++ action: Some(action as i32), ++ group_id: group_id.clone(), ++ }; ++ ++ if let Some(group_master_key) = self.group_context().and_then(|c| c.master_key) { ++ manager ++ .send_message_to_group(group_master_key, typing, timestamp) ++ .await?; ++ } else if let Some(uuid) = self.uuid() { ++ manager.send_message(uuid, typing, timestamp).await?; ++ } ++ ++ Ok(()) ++ } ++ + /// Register a new message with the channel. + /// This does the following (based on the type of message): + /// - Add a quote to the message if needed. +diff --git a/src/backend/manager.rs b/src/backend/manager.rs +index c25fba0..eaa41e0 100644 +--- a/src/backend/manager.rs ++++ b/src/backend/manager.rs +@@ -8,7 +8,7 @@ use libsignal_service::protocol::DeviceId; + use libsignal_service::{ + Profile, + content::ContentBody, +- proto::{AttachmentPointer, DataMessage, GroupContextV2}, ++ proto::{AttachmentPointer, GroupContextV2}, + protocol::ServiceId, + sender::{AttachmentSpec, AttachmentUploadError}, + websocket::account::DeviceInfo, +@@ -490,20 +490,42 @@ impl Manager { + Thread::Contact(uuid) + }; + ++ // Fast path: return the cached channel if we already know it. ++ // Without this, callers that arrive after initial channel discovery ++ // (incoming TypingMessage routing, in particular) would receive a ++ // freshly-built Channel object whose property notifications never ++ // reach widgets bound to the cached one in the UI — typing ++ // indicators on both the header bar and the channel-messages view ++ // would silently never light up. ++ if let Some(cached) = self.imp().channels.borrow().get(&thread).cloned() { ++ return cached; ++ } ++ + let contact = Contact::from_service_address(&uuid, self).await; + let channel = Channel::from_contact_or_group(contact, group, self).await; + channel.initialize_avatar().await; + +- let mut known_channels = self.imp().channels.borrow_mut(); +- known_channels.entry(thread).or_insert_with(|| { +- log::trace!("Got a contact from the storage"); ++ // Another task may have inserted the same thread while we were ++ // awaiting; pick whichever is already there or insert ours. ++ let stored = { ++ let mut known = self.imp().channels.borrow_mut(); ++ known ++ .entry(thread) ++ .or_insert_with(|| { ++ log::trace!("Got a contact from the storage"); ++ channel.clone() ++ }) ++ .clone() ++ }; ++ ++ if stored == channel { + self.emit_by_name::<()>("channel", &[&channel]); +- channel.clone() +- }); ++ } + +- // No need to initialize avatar or last messages in here, will be done when initializing contacts. ++ // No need to initialize avatar or last messages in here, will be ++ // done when initializing contacts. + +- channel ++ stored + } + + pub fn channel_from_thread(&self, thread: Thread) -> Option { +@@ -737,14 +759,15 @@ impl Manager { + pub(super) async fn send_message_to_group( + &self, + group_key: Vec, +- message: DataMessage, ++ message: impl Into, + timestamp: u64, + ) -> Result<(), ApplicationError> { + log::trace!("`Manager::send_message_to_group` start"); ++ let body = message.into(); + let internal = self.internal(); + let r = tspawn!(async move { + internal +- .send_message_to_group(group_key, message.clone(), timestamp) ++ .send_message_to_group(group_key, body.clone(), timestamp) + .await + }) + .await +diff --git a/src/backend/manager_thread.rs b/src/backend/manager_thread.rs +index 1f6a885..cba62ae 100644 +--- a/src/backend/manager_thread.rs ++++ b/src/backend/manager_thread.rs +@@ -21,7 +21,7 @@ use libsignal_service::{ + configuration::SignalServers, + content::ContentBody, + prelude::{ProfileKey, Uuid, phonenumber}, +- proto::{AttachmentPointer, DataMessage, GroupContextV2}, ++ proto::{AttachmentPointer, GroupContextV2}, + protocol::ServiceId, + sender::{AttachmentSpec, AttachmentUploadError}, + websocket::account::DeviceInfo, +@@ -65,7 +65,7 @@ enum Command { + ), + SendMessageToGroup( + Vec, +- Box, ++ Box, + u64, + oneshot::Sender>, + ), +@@ -353,7 +353,7 @@ impl ManagerThread { + pub async fn send_message_to_group( + &self, + group_key: Vec, +- message: DataMessage, ++ message: impl Into, + timestamp: u64, + ) -> Result<(), Error> { + let (sender, receiver) = oneshot::channel(); +@@ -361,7 +361,7 @@ impl ManagerThread { + .clone() + .send(Command::SendMessageToGroup( + group_key, +- Box::new(message), ++ Box::new(message.into()), + timestamp, + sender, + )) +diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs +index 11ccd7c..74952ac 100644 +--- a/src/backend/message/mod.rs ++++ b/src/backend/message/mod.rs +@@ -270,14 +270,16 @@ impl Message { + // Typing messages. + // Note that they are currently only implemented for contacts, this requires upstream updates to fix. + ContentBody::TypingMessage(t) => { ++ // Both group and contact branches stay cache-only: we only ++ // surface typing for conversations the user already knows ++ // about. Going through `channel_from_uuid_or_group` here ++ // would mint a new Channel object on the first typing ++ // event from a stranger and add them to the sidebar with ++ // no actual messages. + let channel = if let Some(id) = &t.group_id { + manager.channel_from_group_id(id) + } else { +- Some( +- manager +- .channel_from_uuid_or_group(metadata.sender, &None) +- .await, +- ) ++ manager.channel_from_thread(presage::store::Thread::Contact(metadata.sender)) + }; + + let Some(channel) = channel else { +diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs +index 0e8ae4e..c6684fc 100644 +--- a/src/gui/channel_messages.rs ++++ b/src/gui/channel_messages.rs +@@ -5,6 +5,16 @@ use crate::ApplicationError; + + const MESSAGES_REQUEST_LOAD: usize = 10; + ++/// Re-send the `Started` typing event at this interval so the receiver ++/// does not let the indicator expire while the user keeps composing. ++/// Must stay strictly below `TYPING_NOTIFICATION_DURATION_SECONDS` in ++/// `crate::backend::channel`. ++const TYPING_REFRESH_SECONDS: u32 = 8; ++ ++/// Send `Stopped` if no buffer change has happened in this many seconds. ++/// Mirrors how Signal apps treat composition pauses as the end of typing. ++const TYPING_IDLE_SECONDS: u32 = 5; ++ + glib::wrapper! { + /// [ChannelMessages] is the right pane displaying the list of messages and the entry-bar. + pub struct ChannelMessages(ObjectSubclass) +@@ -103,6 +113,200 @@ impl ChannelMessages { + )); + } + ++ /// Connect the `show-typing-indicators` setting so the typing indicator ++ /// updates immediately when the user toggles the preference. ++ fn setup_typing_settings(&self) { ++ self.manager().settings().connect_changed( ++ Some("show-typing-indicators"), ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |_, _| s.refresh_show_typing() ++ ), ++ ); ++ } ++ ++ /// Re-evaluate `show-typing` for the current channel based on the channel's ++ /// `is-typing` state and the user's `show-typing-indicators` setting. ++ fn refresh_show_typing(&self) { ++ // The active-channel bind runs during template init before the ++ // manager bind, so `self.manager()` (typed as Manager, not ++ // Option) would panic here. Read the manager directly so ++ // a null intermediate state is harmless: with no manager we don't ++ // know the user's preference, so default to showing the indicator. ++ let allowed = self ++ .imp() ++ .manager ++ .borrow() ++ .as_ref() ++ .is_none_or(|m| m.settings().boolean("show-typing-indicators")); ++ let typing = self ++ .active_channel() ++ .map(|c| c.is_typing()) ++ .unwrap_or(false); ++ self.set_show_typing(allowed && typing); ++ } ++ ++ /// Wire the `show-typing` property to the active channel's `is-typing`. ++ /// Called whenever the active channel changes. ++ fn setup_typing_indicator(&self) { ++ self.refresh_show_typing(); ++ ++ // Disconnect the handler we attached on the previous active ++ // channel so we don't accumulate one per channel switch. ++ if let Some((prev_channel, handler)) = self.imp().typing_handler.take() { ++ prev_channel.disconnect(handler); ++ } ++ ++ if let Some(channel) = self.active_channel() { ++ let handler = channel.connect_notify_local( ++ Some("is-typing"), ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |_, _| s.refresh_show_typing() ++ ), ++ ); ++ self.imp() ++ .typing_handler ++ .replace(Some((channel, handler))); ++ } ++ } ++ ++ /// Send a `Started` typing event for the active channel. ++ /// ++ /// Schedules a periodic refresh so the receiver does not let the ++ /// indicator expire while the user is still composing. ++ fn send_typing_started(&self) { ++ let imp = self.imp(); ++ let Some(channel) = self.active_channel() else { ++ return; ++ }; ++ if !self.manager().settings().boolean("send-typing-indicators") { ++ return; ++ } ++ ++ // Mark this channel as the current typing target so a later channel ++ // switch can still emit a matching `Stopped` event. ++ imp.typing_target.replace(Some(channel.clone())); ++ ++ // Refresh `Started` periodically while the user keeps composing. ++ let needs_initial = !imp.sending_typing.replace(true); ++ ++ if let Some(source) = imp.typing_refresh.borrow_mut().take() { ++ source.remove(); ++ } ++ let refresh = glib::timeout_add_seconds_local( ++ TYPING_REFRESH_SECONDS, ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ #[upgrade_or] ++ glib::ControlFlow::Break, ++ move || { ++ if !s.imp().sending_typing.get() { ++ return glib::ControlFlow::Break; ++ } ++ s.dispatch_send_typing(true); ++ glib::ControlFlow::Continue ++ } ++ ), ++ ); ++ imp.typing_refresh.replace(Some(refresh)); ++ ++ if needs_initial { ++ self.dispatch_send_typing(true); ++ } ++ } ++ ++ /// Send a `Stopped` typing event for the channel that was last targeted. ++ fn send_typing_stopped(&self) { ++ let imp = self.imp(); ++ if let Some(source) = imp.typing_refresh.borrow_mut().take() { ++ source.remove(); ++ } ++ if let Some(source) = imp.typing_idle.borrow_mut().take() { ++ source.remove(); ++ } ++ if !imp.sending_typing.replace(false) { ++ // Nothing to do — we never told anyone we were typing. ++ imp.typing_target.replace(None); ++ return; ++ } ++ let Some(channel) = imp.typing_target.replace(None) else { ++ return; ++ }; ++ gspawn!(async move { ++ if let Err(e) = channel.send_typing(false).await { ++ log::warn!("Failed to send `Stopped` typing event: {e}"); ++ } ++ }); ++ } ++ ++ /// Dispatch the actual `Started` typing event to whichever channel is ++ /// currently considered the typing target. ++ fn dispatch_send_typing(&self, started: bool) { ++ let Some(channel) = self.imp().typing_target.borrow().clone() else { ++ return; ++ }; ++ gspawn!(async move { ++ if let Err(e) = channel.send_typing(started).await { ++ log::warn!("Failed to send typing event (started={started}): {e}"); ++ } ++ }); ++ } ++ ++ /// Connect the text entry's buffer so we can emit `Started`/`Stopped` ++ /// typing events as the user composes a message. ++ fn setup_typing_send(&self) { ++ let buffer = self.imp().text_entry.buffer(); ++ let handler = buffer.connect_changed(clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |buf| { ++ let (start, end) = buf.bounds(); ++ if start == end { ++ s.send_typing_stopped(); ++ return; ++ } ++ // Both the Started event and the idle-stop timer are ++ // outbound-typing-only behaviours; if the user has ++ // disabled outgoing typing, do nothing and don't churn ++ // a timer per keystroke. ++ let allowed = s ++ .imp() ++ .manager ++ .borrow() ++ .as_ref() ++ .is_none_or(|m| m.settings().boolean("send-typing-indicators")); ++ if !allowed { ++ return; ++ } ++ s.send_typing_started(); ++ s.reset_typing_idle_timer(); ++ } ++ )); ++ self.imp().typing_buffer_handler.replace(Some(handler)); ++ } ++ ++ /// Schedule a one-shot timer that sends `Stopped` if the user lets the ++ /// composition idle for more than `TYPING_IDLE_SECONDS` seconds. ++ fn reset_typing_idle_timer(&self) { ++ let imp = self.imp(); ++ if let Some(source) = imp.typing_idle.borrow_mut().take() { ++ source.remove(); ++ } ++ let source = glib::timeout_add_seconds_local_once( ++ TYPING_IDLE_SECONDS, ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ move || s.send_typing_stopped() ++ ), ++ ); ++ imp.typing_idle.replace(Some(source)); ++ } ++ + pub async fn clear_messages(&self) -> Result<(), ApplicationError> { + if let Some(channel) = self.active_channel() { + channel.clear_messages().await?; +@@ -165,9 +369,36 @@ pub mod imp { + filling_screen: Cell, + #[property(get = Self::has_attachments)] + has_attachments: PhantomData, ++ #[property(get, set)] ++ show_typing: Cell, ++ ++ /// Whether we currently believe the user is composing a message in the ++ /// active channel and have informed the peer with a `Started` event. ++ pub(super) sending_typing: Cell, ++ /// Channel to which we last sent a `Started` typing event, kept so we ++ /// can send a matching `Stopped` even after the active channel changes. ++ pub(super) typing_target: RefCell>, ++ /// Periodic refresh of the `Started` typing event so it does not ++ /// expire on the receiver side while the user is still composing. ++ pub(super) typing_refresh: RefCell>, ++ /// One-shot timer that emits `Stopped` after a stretch of no ++ /// further buffer changes. ++ pub(super) typing_idle: RefCell>, ++ /// Notify handler installed on the active channel's `is-typing` ++ /// property so we can disconnect it before re-attaching when the ++ /// active channel changes. ++ pub(super) typing_handler: RefCell>, ++ /// Notify + selection-changed handlers installed on the active ++ /// channel by `setup_selection_listener`, kept so we can disconnect ++ /// them before re-attaching on the next channel change. ++ pub(super) selection_handlers: RefCell>, ++ /// Buffer change handler that drives the typing-send logic; we ++ /// block it while restoring a draft so loading a draft does not ++ /// transmit a Started typing event. ++ pub(super) typing_buffer_handler: RefCell>, + + #[property(get, set = Self::set_manager, type = Manager)] +- manager: RefCell>, ++ pub(super) manager: RefCell>, + } + + #[gtk::template_callbacks] +@@ -181,10 +412,16 @@ pub mod imp { + self.manager.replace(man); + if initialized { + self.obj().setup_send_on_enter(); ++ self.obj().setup_typing_settings(); ++ self.obj().setup_typing_send(); + } + } + + fn set_active_channel(&self, chan: Option) { ++ // Inform the previous channel we have stopped typing before we ++ // forget about it. ++ self.obj().send_typing_stopped(); ++ + if let Some(active_chan) = self.active_channel.borrow().as_ref() { + active_chan.set_property("draft", self.text_entry.text()); + } +@@ -195,6 +432,7 @@ pub mod imp { + } + + self.obj().focus_input(); ++ self.obj().setup_typing_indicator(); + } + + #[template_callback(function)] +@@ -501,7 +739,18 @@ pub mod imp { + s.obj().set_reply_message(None::); + if let Some(channel) = s.active_channel.borrow().as_ref() { + let draft = channel.property("draft"); ++ // Block the typing buffer-changed handler so ++ // restoring a stored draft does not transmit ++ // a Started typing event to the peer. ++ let buffer = s.text_entry.buffer(); ++ let handler_guard = s.typing_buffer_handler.borrow(); ++ if let Some(handler) = handler_guard.as_ref() { ++ buffer.block_signal(handler); ++ } + s.text_entry.set_text(draft); ++ if let Some(handler) = handler_guard.as_ref() { ++ buffer.unblock_signal(handler); ++ } + }; + } + ), +diff --git a/src/gui/preferences_window.rs b/src/gui/preferences_window.rs +index 8137af7..b2b6405 100644 +--- a/src/gui/preferences_window.rs ++++ b/src/gui/preferences_window.rs +@@ -78,6 +78,11 @@ pub mod imp { + #[template_child] + row_send_on_enter: TemplateChild, + ++ #[template_child] ++ row_send_typing_indicators: TemplateChild, ++ #[template_child] ++ row_show_typing_indicators: TemplateChild, ++ + settings: Settings, + } + +@@ -173,6 +178,22 @@ pub mod imp { + .bind("send-on-enter", &self.row_send_on_enter.get(), "active") + .flags(SettingsBindFlags::DEFAULT) + .build(); ++ self.settings ++ .bind( ++ "send-typing-indicators", ++ &self.row_send_typing_indicators.get(), ++ "active", ++ ) ++ .flags(SettingsBindFlags::DEFAULT) ++ .build(); ++ self.settings ++ .bind( ++ "show-typing-indicators", ++ &self.row_show_typing_indicators.get(), ++ "active", ++ ) ++ .flags(SettingsBindFlags::DEFAULT) ++ .build(); + } + } + +@@ -194,6 +215,8 @@ pub mod imp { + row_background: TemplateChild::default(), + row_messages_selectable: TemplateChild::default(), + row_send_on_enter: TemplateChild::default(), ++ row_send_typing_indicators: TemplateChild::default(), ++ row_show_typing_indicators: TemplateChild::default(), + } + } + +-- +2.53.0 + diff --git a/patches/flare/0002-feat-messages-Implement-formatted-messages.patch b/patches/flare/0002-feat-messages-Implement-formatted-messages.patch new file mode 100644 index 0000000..6ab38e3 --- /dev/null +++ b/patches/flare/0002-feat-messages-Implement-formatted-messages.patch @@ -0,0 +1,622 @@ +From 45b21cee00bfc5545aea6fbc9a4f991cfd781cff Mon Sep 17 00:00:00 2001 +From: Simon Gardling +Date: Wed, 29 Apr 2026 19:13:52 -0400 +Subject: [PATCH 2/6] feat(messages): Implement formatted messages + +- Display Signal BodyRange styles (bold, italic, strikethrough, + spoiler, monospace) on incoming messages by translating them into + pango attributes alongside the existing mention rendering, making + the offset accounting work for mention substitutions and + surrogate-pair text alike. +- Parse a markdown-style formatting syntax on outbound messages and + send the resulting BodyRanges with the cleaned body text. The + parser lives in its own module with unit tests covering the + supported markers, nesting, unmatched markers, and non-BMP UTF-16 + offsets. +- Update the message-input tooltip to surface the supported markers. +--- + CHANGELOG.md | 2 + + data/resources/ui/channel_messages.blp | 2 +- + src/backend/message/formatting.rs | 287 +++++++++++++++++++++++++ + src/backend/message/mod.rs | 2 + + src/backend/message/text_message.rs | 200 +++++++++++++---- + 5 files changed, 447 insertions(+), 46 deletions(-) + create mode 100644 src/backend/message/formatting.rs + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 2bde927..50cd5f5 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + + - Send typing indicators while composing a message and display them above the message input. + - 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` ``). + + ## [0.20.4] - 2026-04-22 + +diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp +index 7f438e4..6c3948f 100644 +--- a/data/resources/ui/channel_messages.blp ++++ b/data/resources/ui/channel_messages.blp +@@ -301,7 +301,7 @@ template $FlChannelMessages: Box { + activate => $send_message() swapped; + paste-file => $paste_file() swapped; + paste-texture => $paste_texture() swapped; +- tooltip-text: C_("tooltip", "Message input"); ++ tooltip-text: C_("tooltip", "Message input. Use **bold**, *italic*, ~~strike~~, ||spoiler|| or `monospace` to format text."); + } + + Button button_send { +diff --git a/src/backend/message/formatting.rs b/src/backend/message/formatting.rs +new file mode 100644 +index 0000000..5a1d596 +--- /dev/null ++++ b/src/backend/message/formatting.rs +@@ -0,0 +1,287 @@ ++//! Lightweight markdown-style formatting parser for outgoing messages. ++//! ++//! Supported syntax (mirroring the way Signal Desktop and iOS render ++//! formatted messages): ++//! ++//! - `**text**` for bold ++//! - `*text*` or `_text_` for italic ++//! - `~~text~~` for strikethrough ++//! - `||text||` for spoiler ++//! - `` `text` `` for monospace ++//! ++//! Parsing is forgiving: any marker without a matching counterpart is left ++//! verbatim in the resulting text. Markers may nest as long as the inner ++//! marker is a different kind from the outer one. ++//! ++//! The function returns the cleaned message body plus the corresponding ++//! `BodyRange`s with offsets in UTF-16 code units, as required by the ++//! Signal protocol. ++ ++use std::collections::HashMap; ++ ++use libsignal_service::proto::BodyRange; ++use libsignal_service::proto::body_range::{AssociatedValue, Style as BodyRangeStyle}; ++ ++#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)] ++enum Marker { ++ Bold, ++ Italic, ++ Strikethrough, ++ Spoiler, ++ Monospace, ++} ++ ++impl Marker { ++ fn style(self) -> BodyRangeStyle { ++ match self { ++ Marker::Bold => BodyRangeStyle::Bold, ++ Marker::Italic => BodyRangeStyle::Italic, ++ Marker::Strikethrough => BodyRangeStyle::Strikethrough, ++ Marker::Spoiler => BodyRangeStyle::Spoiler, ++ Marker::Monospace => BodyRangeStyle::Monospace, ++ } ++ } ++} ++ ++/// Try to consume a marker starting at `chars[i]` and return its kind plus ++/// the number of characters that make up the marker token. ++fn detect_marker(chars: &[char], i: usize) -> Option<(Marker, usize)> { ++ let cur = *chars.get(i)?; ++ let next = chars.get(i + 1).copied(); ++ match (cur, next) { ++ ('*', Some('*')) => Some((Marker::Bold, 2)), ++ ('~', Some('~')) => Some((Marker::Strikethrough, 2)), ++ ('|', Some('|')) => Some((Marker::Spoiler, 2)), ++ ('*', _) | ('_', _) => Some((Marker::Italic, 1)), ++ ('`', _) => Some((Marker::Monospace, 1)), ++ _ => None, ++ } ++} ++ ++#[derive(Debug, Clone, Copy)] ++struct MatchedSpan { ++ marker: Marker, ++ open_pos: usize, ++ close_pos: usize, ++ marker_len: usize, ++} ++ ++/// Walk the character stream left-to-right and pair markers of the same ++/// kind. The first occurrence opens a span, the next occurrence of the same ++/// kind closes it; markers without a partner are simply ignored. ++fn detect_matched_markers(chars: &[char]) -> Vec { ++ let mut open: HashMap = HashMap::new(); ++ let mut spans: Vec = Vec::new(); ++ let mut i = 0; ++ while i < chars.len() { ++ if let Some((marker, len)) = detect_marker(chars, i) { ++ if let Some((open_pos, marker_len)) = open.remove(&marker) { ++ spans.push(MatchedSpan { ++ marker, ++ open_pos, ++ close_pos: i, ++ marker_len, ++ }); ++ } else { ++ open.insert(marker, (i, len)); ++ } ++ i += len; ++ } else { ++ i += 1; ++ } ++ } ++ spans ++} ++ ++/// Parse markdown-style formatting markers in `input` and produce the cleaned ++/// text plus the corresponding Signal [BodyRange]s with UTF-16 offsets. ++/// ++/// Empty matched spans (e.g. `**` followed immediately by `**`) are dropped. ++pub fn parse_formatting(input: &str) -> (String, Vec) { ++ let chars: Vec = input.chars().collect(); ++ let spans = detect_matched_markers(&chars); ++ ++ if spans.is_empty() { ++ return (input.to_owned(), Vec::new()); ++ } ++ ++ // 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()]; ++ 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; ++ } ++ } ++ ++ // Build the cleaned output and a per-input-char map into the output's ++ // UTF-16 code-unit offset. ++ let mut output = String::with_capacity(input.len()); ++ let mut input_to_output_utf16 = vec![0u32; chars.len() + 1]; ++ let mut utf16_count: u32 = 0; ++ for (i, c) in chars.iter().enumerate() { ++ input_to_output_utf16[i] = utf16_count; ++ if !skip[i] { ++ output.push(*c); ++ utf16_count += c.len_utf16() as u32; ++ } ++ } ++ input_to_output_utf16[chars.len()] = utf16_count; ++ ++ let mut ranges: Vec = Vec::with_capacity(spans.len()); ++ for sp in spans { ++ let start = input_to_output_utf16[sp.open_pos + sp.marker_len]; ++ let end = input_to_output_utf16[sp.close_pos]; ++ if end <= start { ++ continue; ++ } ++ ranges.push(BodyRange { ++ start: Some(start), ++ length: Some(end - start), ++ associated_value: Some(AssociatedValue::Style(sp.marker.style() as i32)), ++ }); ++ } ++ ++ // Sort by start so the final ranges are stable for tests and for ++ // downstream consumers that expect ordered ranges. ++ ranges.sort_by_key(|r| r.start); ++ ++ (output, ranges) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn ranges_summary(ranges: &[BodyRange]) -> Vec<(u32, u32, BodyRangeStyle)> { ++ ranges ++ .iter() ++ .map(|r| { ++ let style = match r.associated_value { ++ Some(AssociatedValue::Style(s)) => { ++ BodyRangeStyle::try_from(s).unwrap_or(BodyRangeStyle::None) ++ } ++ _ => BodyRangeStyle::None, ++ }; ++ (r.start.unwrap_or(0), r.length.unwrap_or(0), style) ++ }) ++ .collect() ++ } ++ ++ #[test] ++ fn no_markers() { ++ let (text, ranges) = parse_formatting("hello world"); ++ assert_eq!(text, "hello world"); ++ assert!(ranges.is_empty()); ++ } ++ ++ #[test] ++ fn bold() { ++ let (text, ranges) = parse_formatting("**bold**"); ++ assert_eq!(text, "bold"); ++ assert_eq!(ranges_summary(&ranges), vec![(0, 4, BodyRangeStyle::Bold)]); ++ } ++ ++ #[test] ++ fn italic_asterisk() { ++ let (text, ranges) = parse_formatting("*italic*"); ++ assert_eq!(text, "italic"); ++ assert_eq!( ++ ranges_summary(&ranges), ++ vec![(0, 6, BodyRangeStyle::Italic)] ++ ); ++ } ++ ++ #[test] ++ fn italic_underscore() { ++ let (text, ranges) = parse_formatting("_italic_"); ++ assert_eq!(text, "italic"); ++ assert_eq!( ++ ranges_summary(&ranges), ++ vec![(0, 6, BodyRangeStyle::Italic)] ++ ); ++ } ++ ++ #[test] ++ fn strikethrough() { ++ let (text, ranges) = parse_formatting("~~strike~~"); ++ assert_eq!(text, "strike"); ++ assert_eq!( ++ ranges_summary(&ranges), ++ vec![(0, 6, BodyRangeStyle::Strikethrough)] ++ ); ++ } ++ ++ #[test] ++ fn spoiler() { ++ let (text, ranges) = parse_formatting("||hidden||"); ++ assert_eq!(text, "hidden"); ++ assert_eq!( ++ ranges_summary(&ranges), ++ vec![(0, 6, BodyRangeStyle::Spoiler)] ++ ); ++ } ++ ++ #[test] ++ fn monospace() { ++ let (text, ranges) = parse_formatting("`code`"); ++ assert_eq!(text, "code"); ++ assert_eq!( ++ ranges_summary(&ranges), ++ vec![(0, 4, BodyRangeStyle::Monospace)] ++ ); ++ } ++ ++ #[test] ++ fn bold_and_italic_nested() { ++ let (text, ranges) = parse_formatting("**bold *italic***"); ++ assert_eq!(text, "bold italic"); ++ let summary = ranges_summary(&ranges); ++ assert!(summary.contains(&(0, 11, BodyRangeStyle::Bold))); ++ assert!(summary.contains(&(5, 6, BodyRangeStyle::Italic))); ++ } ++ ++ #[test] ++ fn unmatched_open_left_literal() { ++ let (text, ranges) = parse_formatting("**only one start"); ++ assert_eq!(text, "**only one start"); ++ assert!(ranges.is_empty()); ++ } ++ ++ #[test] ++ fn surrounding_text_preserved() { ++ let (text, ranges) = parse_formatting("hello **world**!"); ++ assert_eq!(text, "hello world!"); ++ assert_eq!(ranges_summary(&ranges), vec![(6, 5, BodyRangeStyle::Bold)]); ++ } ++ ++ #[test] ++ fn multiple_pairs() { ++ let (text, ranges) = parse_formatting("**a**b**c**"); ++ assert_eq!(text, "abc"); ++ let summary = ranges_summary(&ranges); ++ assert_eq!(summary.len(), 2); ++ assert_eq!(summary[0], (0, 1, BodyRangeStyle::Bold)); ++ assert_eq!(summary[1], (2, 1, BodyRangeStyle::Bold)); ++ } ++ ++ #[test] ++ fn empty_pair_dropped() { ++ let (text, ranges) = parse_formatting("****"); ++ assert_eq!(text, ""); ++ assert!(ranges.is_empty()); ++ } ++ ++ #[test] ++ fn utf16_offsets_for_non_bmp() { ++ // Character "𝟚" (U+1D7DA) is a non-BMP codepoint occupying two ++ // UTF-16 code units, so a Bold range over a string containing it ++ // must reflect that in its `length`. ++ let (text, ranges) = parse_formatting("**𝟚**"); ++ assert_eq!(text, "𝟚"); ++ assert_eq!(ranges_summary(&ranges), vec![(0, 2, BodyRangeStyle::Bold)]); ++ } ++} +diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs +index 74952ac..4e0f584 100644 +--- a/src/backend/message/mod.rs ++++ b/src/backend/message/mod.rs +@@ -1,12 +1,14 @@ + mod call_message; + mod deletion_message; + mod display_message; ++mod formatting; + mod reaction_message; + mod text_message; + + pub use call_message::{CallMessage, CallMessageType}; + pub use deletion_message::DeletionMessage; + pub use display_message::{DisplayMessage, DisplayMessageExt}; ++pub use formatting::parse_formatting; + pub use reaction_message::ReactionMessage; + pub use text_message::TextMessage; + +diff --git a/src/backend/message/text_message.rs b/src/backend/message/text_message.rs +index a9adb04..c06bcfa 100644 +--- a/src/backend/message/text_message.rs ++++ b/src/backend/message/text_message.rs +@@ -2,9 +2,9 @@ use crate::prelude::*; + + use libsignal_service::content::Reaction; + use libsignal_service::proto::DataMessage; +-use libsignal_service::proto::body_range::AssociatedValue; ++use libsignal_service::proto::body_range::{AssociatedValue, Style as BodyRangeStyle}; + use libsignal_service::proto::data_message::Delete; +-use pango::{AttrColor, AttrList}; ++use pango::{AttrColor, AttrInt, AttrList, AttrString, Style as PangoStyle, Weight}; + + use crate::backend::timeline::{TimelineItem, TimelineItemExt}; + use crate::backend::{Attachment, Channel, Contact}; +@@ -19,6 +19,48 @@ gtk::glib::wrapper! { + const MENTION_CHAR: char = '@'; + const MENTION_COLOR: (u16, u16, u16) = (0, 0, u16::MAX); + ++/// Convert a Signal [BodyRangeStyle] into the pango attributes that render ++/// the same visual style. Spoilers are approximated as a black-on-black ++/// span as pango has no native spoiler primitive. ++fn style_to_pango_attrs( ++ style: BodyRangeStyle, ++ start_byte: u32, ++ end_byte: u32, ++) -> Vec { ++ fn span>(attr: A, start: u32, end: u32) -> pango::Attribute { ++ let mut attr: pango::Attribute = attr.into(); ++ attr.set_start_index(start); ++ attr.set_end_index(end); ++ attr ++ } ++ ++ match style { ++ BodyRangeStyle::Bold => vec![span( ++ AttrInt::new_weight(Weight::Bold), ++ start_byte, ++ end_byte, ++ )], ++ BodyRangeStyle::Italic => vec![span( ++ AttrInt::new_style(PangoStyle::Italic), ++ start_byte, ++ end_byte, ++ )], ++ BodyRangeStyle::Strikethrough => { ++ vec![span(AttrInt::new_strikethrough(true), start_byte, end_byte)] ++ } ++ BodyRangeStyle::Monospace => vec![span( ++ AttrString::new_family("monospace"), ++ start_byte, ++ end_byte, ++ )], ++ BodyRangeStyle::Spoiler => vec![ ++ span(AttrColor::new_foreground(0, 0, 0), start_byte, end_byte), ++ span(AttrColor::new_background(0, 0, 0), start_byte, end_byte), ++ ], ++ BodyRangeStyle::None => Vec::new(), ++ } ++} ++ + impl TextMessage { + pub fn from_text_channel_sender>( + text: S, +@@ -65,14 +107,16 @@ impl TextMessage { + .build(); + + let text_owned = text.as_ref().to_owned(); +- let body = if text_owned.is_empty() { +- None ++ let (body, body_ranges) = if text_owned.is_empty() { ++ (None, Vec::new()) + } else { +- Some(text_owned) ++ let (cleaned, ranges) = super::parse_formatting(&text_owned); ++ (Some(cleaned), ranges) + }; + + let message = DataMessage { + body, ++ body_ranges, + timestamp: Some(timestamp), + ..Default::default() + }; +@@ -245,10 +289,17 @@ impl TextMessage { + self.notify_body(); + } + +- /// Formats the message body based on its ranges, e.g. to insert mention names. ++ /// Format the message body based on its body ranges. ++ /// ++ /// This both substitutes mentions with the resolved participant name and ++ /// applies styling (bold, italic, monospace, strikethrough, spoiler) as ++ /// pango attributes on the resulting text. + /// +- /// Returns the resulting strings and an [AttrList] that can be used in labels to highlight areas. +- /// Be carefull when editing this function and note that Signal uses UTF-16 byte offsets, while Rust uses UTF-8 byte offsets. ++ /// Note that Signal uses UTF-16 byte offsets, while Rust strings use ++ /// UTF-8. The implementation maintains an explicit per-utf16-index ++ /// mapping into the resulting UTF-8 string so that styles applied to a ++ /// range that survives a mention substitution still land on the right ++ /// bytes. + async fn format_body(&self) -> (Option, AttrList) { + let Some(body) = self.internal_data().and_then(|m| m.body) else { + return (None, AttrList::new()); +@@ -264,53 +315,112 @@ impl TextMessage { + + let channel = self.channel(); + +- // Sort by growing start index ++ // Sort by growing start index so mention substitutions happen left-to-right. + ranges.sort_unstable_by_key(|r| r.start()); + +- let attrs = AttrList::new(); +- +- // Signal (Java) uses UTF-16 body and therefore also UTF-16 offsets, while Flare (Rust) uses UTF-8. Need to convert. +- let body_utf16: Vec = body.encode_utf16().collect(); +- +- let mut result_utf8 = String::new(); +- let mut index_utf16 = 0; +- let mut index_utf8 = 0; +- for r in ranges { +- let start = r.start() as usize; +- let end = start + r.length() as usize; +- let uuid = match r.associated_value { ++ // Resolve mention names asynchronously up front so the rest of the ++ // formatting can be a synchronous walk. ++ let mut mentions: Vec<(usize, usize, String)> = Vec::new(); ++ for r in &ranges { ++ let uuid = match &r.associated_value { + Some(AssociatedValue::MentionAci(u)) => u.parse().ok(), + Some(AssociatedValue::MentionAciBinary(u)) => { +- u.try_into().ok().map(Uuid::from_bytes) ++ u.clone().try_into().ok().map(Uuid::from_bytes) + } + _ => None, + }; +- let Some(uuid) = uuid else { ++ if let Some(uuid) = uuid { ++ let start = r.start() as usize; ++ let end = (r.start() + r.length()) as usize; ++ let name = format!( ++ "{}{}", ++ MENTION_CHAR, ++ channel.participant_by_uuid(uuid).await.title() ++ ); ++ mentions.push((start, end, name)); ++ } ++ } ++ // Mentions cannot overlap each other; ensure the iterator order is stable. ++ mentions.sort_unstable_by_key(|(s, _, _)| *s); ++ ++ let body_utf16: Vec = body.encode_utf16().collect(); ++ let attrs = AttrList::new(); ++ ++ // Build the result string while constructing a per-utf16-index map ++ // into the resulting UTF-8 byte offsets. ++ let mut byte_at: Vec = Vec::with_capacity(body_utf16.len() + 1); ++ let mut result_utf8 = String::new(); ++ let mut mention_iter = mentions.into_iter().peekable(); ++ ++ let mut i = 0; ++ while i < body_utf16.len() { ++ // Inject mention substitutions at their start position. ++ if mention_iter ++ .peek() ++ .is_some_and(|(m_start, _, _)| *m_start == i) ++ { ++ let (m_start, m_end, name) = mention_iter.next().expect("peeked entry to exist"); ++ let mention_byte_start = result_utf8.len(); ++ // Mark every UTF-16 index inside the mention span as the start ++ // of the substituted text. Indices >= m_end will be filled by ++ // subsequent iterations. ++ for _ in m_start..m_end { ++ byte_at.push(mention_byte_start); ++ } ++ result_utf8.push_str(&name); ++ ++ let mut highlight = ++ AttrColor::new_foreground(MENTION_COLOR.0, MENTION_COLOR.1, MENTION_COLOR.2); ++ highlight.set_start_index(mention_byte_start as u32); ++ highlight.set_end_index(result_utf8.len() as u32); ++ attrs.insert(highlight); ++ ++ i = m_end.min(body_utf16.len()); + continue; +- }; +- let name = format!( +- "{}{}", +- MENTION_CHAR, +- channel.participant_by_uuid(uuid).await.title() +- ); +- let to_add_body = String::from_utf16_lossy(&body_utf16[index_utf16..start]); +- result_utf8.push_str(&to_add_body); +- result_utf8.push_str(&name); +- index_utf16 = end; +- +- let index_start_highlight = index_utf8 + to_add_body.len(); +- index_utf8 += to_add_body.len() + name.len(); +- let index_end_highlight = index_utf8; +- +- let (red, green, blue) = MENTION_COLOR; +- let mut highlight = AttrColor::new_foreground(red, green, blue); +- highlight.set_start_index(index_start_highlight as u32); +- highlight.set_end_index(index_end_highlight as u32); +- attrs.insert(highlight); ++ } ++ ++ byte_at.push(result_utf8.len()); ++ let unit = body_utf16[i]; ++ if (0xD800..=0xDBFF).contains(&unit) && i + 1 < body_utf16.len() { ++ // High surrogate: consume the pair as one codepoint. ++ let pair = [unit, body_utf16[i + 1]]; ++ let decoded = char::decode_utf16(pair.iter().copied()) ++ .next() ++ .and_then(|r| r.ok()) ++ .unwrap_or('\u{FFFD}'); ++ result_utf8.push(decoded); ++ byte_at.push(result_utf8.len()); ++ i += 2; ++ } else { ++ let decoded = char::decode_utf16([unit].iter().copied()) ++ .next() ++ .and_then(|r| r.ok()) ++ .unwrap_or('\u{FFFD}'); ++ result_utf8.push(decoded); ++ i += 1; ++ } + } ++ byte_at.push(result_utf8.len()); + +- if index_utf16 < body_utf16.len() { +- result_utf8.push_str(&String::from_utf16_lossy(&body_utf16[index_utf16..])) ++ // Apply style ranges using the byte-offset map. ++ for r in ranges { ++ let Some(AssociatedValue::Style(s)) = r.associated_value else { ++ continue; ++ }; ++ let style = match BodyRangeStyle::try_from(s) { ++ Ok(BodyRangeStyle::None) | Err(_) => continue, ++ Ok(other) => other, ++ }; ++ let start_utf16 = (r.start() as usize).min(byte_at.len() - 1); ++ let end_utf16 = ((r.start() + r.length()) as usize).min(byte_at.len() - 1); ++ if start_utf16 >= end_utf16 { ++ continue; ++ } ++ let start_byte = byte_at[start_utf16] as u32; ++ let end_byte = byte_at[end_utf16] as u32; ++ for attr in style_to_pango_attrs(style, start_byte, end_byte) { ++ attrs.insert(attr); ++ } + } + + (Some(result_utf8), attrs) +-- +2.53.0 + diff --git a/patches/flare/0003-feat-messages-Implement-edited-messages.patch b/patches/flare/0003-feat-messages-Implement-edited-messages.patch new file mode 100644 index 0000000..a5f8c73 --- /dev/null +++ b/patches/flare/0003-feat-messages-Implement-edited-messages.patch @@ -0,0 +1,919 @@ +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 + diff --git a/patches/flare/0004-feat-messages-Multi-select-messages-and-delete-for-m.patch b/patches/flare/0004-feat-messages-Multi-select-messages-and-delete-for-m.patch new file mode 100644 index 0000000..875e813 --- /dev/null +++ b/patches/flare/0004-feat-messages-Multi-select-messages-and-delete-for-m.patch @@ -0,0 +1,674 @@ +From 250b11530e8d29a42707dde8ff3dd516e0073863 Mon Sep 17 00:00:00 2001 +From: Simon Gardling +Date: Wed, 29 Apr 2026 19:53:22 -0400 +Subject: [PATCH 4/6] feat(messages): Multi-select messages and delete for me + +- Add a 'Select' action to the message context menu that puts the + channel into selection mode and pre-selects the message. While in + selection mode, every message item shows a check button and a + toolbar replaces nothing in particular but offers a 'Delete for me' + destructive action plus a cancel button. +- Track selection state with a transient `selected` property on + `Message` and a `selection-mode` property on `Channel`. A new + `selection-changed` signal lets the channel-messages view update + the selection summary without polling. +- Add `Channel::delete_messages_locally` plus the matching + `Manager::delete_messages_locally` and a new + `Timeline::remove_by_timestamp` helper. The action only purges the + local copy and never sends a remote deletion. +--- + CHANGELOG.md | 1 + + data/resources/style.css | 14 +++ + data/resources/ui/channel_messages.blp | 60 ++++++++++++ + data/resources/ui/message_item.blp | 20 ++++ + src/backend/channel.rs | 25 +++++ + src/backend/manager.rs | 32 ++++++ + src/backend/message/mod.rs | 2 + + src/backend/timeline/mod.rs | 16 +++ + src/gui/channel_messages.rs | 129 ++++++++++++++++++++++++- + src/gui/message_item.rs | 119 +++++++++++++++++++++++ + 10 files changed, 417 insertions(+), 1 deletion(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 0338ed8..47ec77a 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + - 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. ++- Multi-select messages from their context menu and delete the selection locally with a single action. + + ## [0.20.4] - 2026-04-22 + +diff --git a/data/resources/style.css b/data/resources/style.css +index 00e4783..1c0cdfd 100644 +--- a/data/resources/style.css ++++ b/data/resources/style.css +@@ -19,6 +19,20 @@ + min-height: 18px; + } + ++.message-item.in-selection-mode .avatar-other { ++ opacity: 0; ++} ++ ++.message-item.in-selection-mode.selected .message-bubble { ++ outline: 2px solid @accent_color; ++} ++ ++.selection-toolbar { ++ padding: 6px 12px; ++ background-color: @window_bg_color; ++ border-top: 1px solid @borders; ++} ++ + .message-list row { + padding:0; + } +diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp +index f3d2348..eb927f8 100644 +--- a/data/resources/ui/channel_messages.blp ++++ b/data/resources/ui/channel_messages.blp +@@ -135,6 +135,66 @@ template $FlChannelMessages: Box { + } + } + ++ // Selection toolbar (shown when in multi-select mode). ++ Box selection_toolbar { ++ styles [ ++ "selection-toolbar", ++ ] ++ ++ orientation: horizontal; ++ spacing: 12; ++ hexpand: true; ++ visible: bind template.active-channel as <$FlChannel>.selection-mode; ++ ++ Button { ++ accessibility { ++ label: C_("accessibility", "Cancel selection"); ++ } ++ ++ tooltip-text: C_("tooltip", "Cancel selection"); ++ ++ styles [ ++ "flat", ++ "circular", ++ ] ++ ++ valign: center; ++ clicked => $cancel_selection() swapped; ++ icon-name: "window-close-symbolic"; ++ } ++ ++ Label { ++ hexpand: true; ++ halign: start; ++ label: bind template.selection-summary; ++ } ++ ++ Button { ++ styles [ ++ "destructive-action", ++ "pill", ++ ] ++ ++ valign: center; ++ clicked => $delete_selection() swapped; ++ sensitive: bind template.has-selection; ++ ++ Box { ++ orientation: horizontal; ++ spacing: 6; ++ ++ Image { ++ icon-name: "user-trash-symbolic"; ++ } ++ ++ Label { ++ label: _("Delete for me"); ++ } ++ } ++ } ++ } ++ ++ + Box { + styles [ + "toolbar", +diff --git a/data/resources/ui/message_item.blp b/data/resources/ui/message_item.blp +index 2c21b8b..ba3fd23 100644 +--- a/data/resources/ui/message_item.blp ++++ b/data/resources/ui/message_item.blp +@@ -24,6 +24,13 @@ menu message-menu { + hidden-when: "action-disabled"; + } + ++ item { ++ label: _("Select"); ++ action: "msg.select"; ++ verb-icon: "checkbox-checked-symbolic"; ++ icon: "checkbox-checked-symbolic"; ++ } ++ + item { + label: _("Delete"); + action: "msg.delete"; +@@ -87,6 +94,19 @@ template $FlMessageItem: $ContextMenuBin { + } + } + ++ CheckButton selection_check { ++ visible: bind template.message as <$FlTextMessage>.channel as <$FlChannel>.selection-mode; ++ active: bind template.message as <$FlTextMessage>.selected; ++ valign: center; ++ can-target: false; ++ can-focus: false; ++ ++ layout { ++ row: 0; ++ column: 0; ++ } ++ } ++ + Adw.Spinner { + visible: bind template.message as <$FlTextMessage>.pending; + tooltip-text: _("This message is currently being sent"); +diff --git a/src/backend/channel.rs b/src/backend/channel.rs +index 94fa6bb..0fbc51d 100644 +--- a/src/backend/channel.rs ++++ b/src/backend/channel.rs +@@ -199,6 +199,28 @@ impl Channel { + Ok(()) + } + ++ /// Delete a set of messages locally only ("Delete for me"). Removes them ++ /// from the encrypted local store and from the in-memory timeline. ++ pub async fn delete_messages_locally( ++ &self, ++ timestamps: Vec, ++ ) -> Result<(), ApplicationError> { ++ if timestamps.is_empty() { ++ return Ok(()); ++ } ++ let purged = self ++ .manager() ++ .delete_messages_locally(self, timestamps) ++ .await?; ++ let timeline = self.imp().timeline.borrow(); ++ for ts in &purged { ++ timeline.remove_by_timestamp(*ts); ++ } ++ drop(timeline); ++ self.notify("last-message"); ++ Ok(()) ++ } ++ + pub(super) fn group_context(&self) -> Option { + self.imp().group_context.borrow().clone() + } +@@ -861,6 +883,8 @@ mod imp { + pub(super) draft: RefCell, + #[property(get, set)] + pub(super) is_active: RefCell, ++ #[property(get, set)] ++ pub(super) selection_mode: RefCell, + + #[property(get = Self::last_message)] + pub(super) last_message: PhantomData>, +@@ -1000,6 +1024,7 @@ mod imp { + Signal::builder("message") + .param_types([DisplayMessage::static_type()]) + .build(), ++ Signal::builder("selection-changed").build(), + ] + }); + SIGNALS.as_ref() +diff --git a/src/backend/manager.rs b/src/backend/manager.rs +index eaa41e0..0964681 100644 +--- a/src/backend/manager.rs ++++ b/src/backend/manager.rs +@@ -210,6 +210,38 @@ impl Manager { + Ok(()) + } + ++ /// Delete a set of messages locally only ("Delete for me"). The remote ++ /// peer is not informed; this only purges the local copy from storage. ++ /// ++ /// Returns the timestamps that were actually purged from the store; the ++ /// caller should mirror only those into the in-memory timeline so a ++ /// per-message store failure does not leave the on-disk state and the UI ++ /// permanently disagreed. ++ pub async fn delete_messages_locally( ++ &self, ++ channel: &Channel, ++ timestamps: Vec, ++ ) -> Result, ApplicationError> { ++ let thread = channel.thread(); ++ let mut store = self.store(); ++ let purged = tspawn!(async move { ++ let mut purged = Vec::with_capacity(timestamps.len()); ++ for ts in timestamps { ++ match store.delete_message(&thread, ts).await { ++ // Both "row deleted" and "row was already absent" mean ++ // the store no longer holds this message, so it is safe ++ // for the timeline to drop it. ++ Ok(_) => purged.push(ts), ++ Err(e) => log::warn!("Failed to locally delete message {ts}: {e}"), ++ } ++ } ++ Ok::, ApplicationError>(purged) ++ }) ++ .await ++ .expect("Failed to spawn tokio")?; ++ Ok(purged) ++ } ++ + pub async fn submit_recaptcha_challenge>( + &self, + token: S, +diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs +index f3a0537..eba08ec 100644 +--- a/src/backend/message/mod.rs ++++ b/src/backend/message/mod.rs +@@ -518,6 +518,8 @@ mod imp { + pub(super) pending: RefCell, + #[property(get, set)] + pub(super) error: RefCell, ++ #[property(get, set)] ++ pub(super) selected: RefCell, + + pub(super) data: RefCell>, + +diff --git a/src/backend/timeline/mod.rs b/src/backend/timeline/mod.rs +index 1ce6a24..18dd436 100644 +--- a/src/backend/timeline/mod.rs ++++ b/src/backend/timeline/mod.rs +@@ -44,6 +44,22 @@ impl Timeline { + self.items_changed(0, len as u32, 0); + } + ++ /// Remove the item with the given timestamp from the timeline, if any. ++ /// Returns whether an item was actually removed. ++ pub fn remove_by_timestamp(&self, timestamp: u64) -> bool { ++ let mut list = self.imp().list.borrow_mut(); ++ let position = list.binary_search_by_key(×tamp, |i| i.timestamp()); ++ match position { ++ Ok(idx) => { ++ list.remove(idx); ++ drop(list); ++ self.items_changed(idx as u32, 1, 0); ++ true ++ } ++ Err(_) => false, ++ } ++ } ++ + pub fn get_by_timestamp(&self, timestamp: u64) -> Option { + let current_items = self.imp().list.borrow(); + let index = current_items.binary_search_by_key(×tamp, |i| i.timestamp()); +diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs +index b929957..747b36a 100644 +--- a/src/gui/channel_messages.rs ++++ b/src/gui/channel_messages.rs +@@ -2,7 +2,8 @@ use crate::prelude::*; + use gio::SettingsBindFlags; + + use crate::ApplicationError; +-use crate::backend::message::TextMessage; ++use crate::backend::message::{DisplayMessage, TextMessage}; ++use crate::backend::timeline::TimelineItemExt; + + const MESSAGES_REQUEST_LOAD: usize = 10; + +@@ -38,6 +39,52 @@ impl ChannelMessages { + self.imp().text_entry.grab_focus(); + } + ++ /// Collect timestamps of every currently-selected message in the active ++ /// channel. ++ pub fn collect_selected_timestamps(&self) -> Vec { ++ let Some(channel) = self.active_channel() else { ++ return Vec::new(); ++ }; ++ channel ++ .timeline() ++ .iter_forwards() ++ .filter(|i| i.is::()) ++ .filter_map(|i| i.dynamic_cast::().ok()) ++ .filter(|m| m.property::("selected")) ++ .map(|m| m.timestamp()) ++ .collect() ++ } ++ ++ /// Exit selection mode, clearing all per-message selection state. ++ pub fn exit_selection_mode(&self) { ++ let Some(channel) = self.active_channel() else { ++ return; ++ }; ++ for item in channel.timeline().iter_forwards() { ++ if let Some(msg) = item.dynamic_cast_ref::() ++ && msg.property::("selected") ++ { ++ msg.set_property("selected", false); ++ } ++ } ++ channel.set_selection_mode(false); ++ self.refresh_selection_summary(); ++ } ++ ++ /// Walk the timeline, count how many messages are selected, and update ++ /// the displayed selection summary plus the `has-selection` flag. ++ pub fn refresh_selection_summary(&self) { ++ let count = self.collect_selected_timestamps().len() as u32; ++ let summary = if count == 0 { ++ gettextrs::gettext("Select messages to delete for yourself") ++ } else { ++ gettextrs::ngettext("{} message selected", "{} messages selected", count) ++ .replace("{}", &count.to_string()) ++ }; ++ self.set_selection_summary(summary); ++ self.set_has_selection(count > 0); ++ } ++ + pub fn focus_input(&self) { + self.imp().text_entry.grab_focus(); + } +@@ -187,6 +234,45 @@ impl ChannelMessages { + } + } + ++ /// Wire the selection summary so the toolbar reflects how many messages ++ /// are selected. Called whenever the active channel changes. ++ fn setup_selection_listener(&self) { ++ self.refresh_selection_summary(); ++ ++ // Disconnect handlers attached on the previous active channel so we ++ // don't accumulate one per channel switch. ++ for (prev_channel, handler) in self.imp().selection_handlers.take() { ++ prev_channel.disconnect(handler); ++ } ++ ++ if let Some(channel) = self.active_channel() { ++ let mut handlers = self.imp().selection_handlers.borrow_mut(); ++ let h = channel.connect_local( ++ "selection-changed", ++ false, ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ #[upgrade_or_default] ++ move |_| { ++ s.refresh_selection_summary(); ++ None ++ } ++ ), ++ ); ++ handlers.push((channel.clone(), h)); ++ let h = channel.connect_notify_local( ++ Some("selection-mode"), ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |_, _| s.refresh_selection_summary() ++ ), ++ ); ++ handlers.push((channel, h)); ++ } ++ } ++ + /// Send a `Started` typing event for the active channel. + /// + /// Schedules a periodic refresh so the receiver does not let the +@@ -387,6 +473,10 @@ pub mod imp { + has_attachments: PhantomData, + #[property(get, set)] + show_typing: Cell, ++ #[property(get, set)] ++ selection_summary: RefCell, ++ #[property(get, set)] ++ has_selection: Cell, + + /// Whether we currently believe the user is composing a message in the + /// active channel and have informed the peer with a `Started` event. +@@ -437,6 +527,7 @@ pub mod imp { + // Inform the previous channel we have stopped typing before we + // forget about it. + self.obj().send_typing_stopped(); ++ self.obj().exit_selection_mode(); + + if let Some(active_chan) = self.active_channel.borrow().as_ref() { + active_chan.set_property("draft", self.text_entry.text()); +@@ -449,6 +540,7 @@ pub mod imp { + + self.obj().focus_input(); + self.obj().setup_typing_indicator(); ++ self.obj().setup_selection_listener(); + } + + #[template_callback(function)] +@@ -505,6 +597,41 @@ pub mod imp { + self.text_entry.clear(); + } + ++ #[template_callback] ++ fn cancel_selection(&self) { ++ self.obj().exit_selection_mode(); ++ } ++ ++ #[template_callback] ++ fn delete_selection(&self) { ++ let obj = self.obj(); ++ let Some(channel) = obj.active_channel() else { ++ return; ++ }; ++ let timestamps = obj.collect_selected_timestamps(); ++ obj.exit_selection_mode(); ++ if timestamps.is_empty() { ++ return; ++ } ++ gspawn!(clone!( ++ #[strong] ++ channel, ++ #[strong] ++ obj, ++ async move { ++ if let Err(e) = channel.delete_messages_locally(timestamps).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)); ++ } ++ } ++ )); ++ } ++ + #[template_callback] + fn remove_attachments(&self) { + log::trace!("Unsetting attachments"); +diff --git a/src/gui/message_item.rs b/src/gui/message_item.rs +index 59d2778..d88306f 100644 +--- a/src/gui/message_item.rs ++++ b/src/gui/message_item.rs +@@ -34,6 +34,7 @@ impl MessageItem { + s.setup_text(); + s.setup_requires_attention(); + s.setup_pending_and_error(); ++ s.setup_selection(); + s + } + +@@ -102,6 +103,16 @@ impl MessageItem { + s.imp().handle_edit(); + } + )); ++ let action_select = SimpleAction::new("select", None); ++ action_select.connect_activate(clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |_, _| { ++ let msg = s.message(); ++ msg.channel().set_selection_mode(true); ++ msg.set_property("selected", true); ++ } ++ )); + + let actions = SimpleActionGroup::new(); + self.insert_action_group("msg", Some(&actions)); +@@ -111,6 +122,7 @@ impl MessageItem { + actions.add_action(&action_download); + actions.add_action(&action_edit); + actions.add_action(&action_open); ++ actions.add_action(&action_select); + + self.bind_property("message", &action_delete, "enabled") + .transform_to(|_, msg: Option| msg.map(|m| m.sender().is_self())) +@@ -236,6 +248,22 @@ impl MessageItem { + self.imp().msg_menu.popup(); + } + ++ /// Connect a notify handler on `target` and remember its handler id so ++ /// we can disconnect it when the MessageItem is disposed; without this, ++ /// closures keep accumulating on long-lived Channel/Message objects as ++ /// the ListView recycles widgets across the timeline. ++ fn track_notify_local(&self, target: &impl IsA, name: &str, f: F) ++ where ++ F: Fn(&glib::Object, &glib::ParamSpec) + 'static, ++ { ++ let target_obj = target.clone().upcast::(); ++ let handler = target_obj.connect_notify_local(Some(name), f); ++ self.imp() ++ .tracked_handlers ++ .borrow_mut() ++ .push((target_obj, handler)); ++ } ++ + /// Set whether this item should show its header. + pub fn set_show_header(&self) { + let visible = self.message().show_header() || self.property("force-show-header"); +@@ -330,6 +358,81 @@ impl MessageItem { + message.notify("pending"); + message.notify("error"); + } ++ ++ /// Wire the message item's selection-mode CSS class so it visually ++ /// reflects the channel's `selection-mode` and the message's `selected` ++ /// state. ++ pub fn setup_selection(&self) { ++ let message = self.message(); ++ let channel = message.channel(); ++ // The closure only updates visual state. Resetting `selected` on ++ // exit lives in `ChannelMessages::exit_selection_mode` (the only ++ // path that flips `selection-mode` back off), which guards the ++ // write so it does not bounce off glib's autogen notify-always ++ // setter. Doing the write here unconditionally would re-enter the ++ // notify::selected handler and recurse via the selection-changed ++ // signal we emit from it — visible as a cpu-bound spin on first ++ // load of a long timeline like Note to self. ++ let update = clone!( ++ #[weak(rename_to = s)] ++ self, ++ move || { ++ let chan = s.message().channel(); ++ let in_mode = chan.selection_mode(); ++ if in_mode { ++ s.add_css_class("in-selection-mode"); ++ } else { ++ s.remove_css_class("in-selection-mode"); ++ } ++ if s.message().property::("selected") && in_mode { ++ s.add_css_class("selected"); ++ } else { ++ s.remove_css_class("selected"); ++ } ++ } ++ ); ++ self.track_notify_local(&channel, "selection-mode", { ++ let update = update.clone(); ++ move |_, _| update() ++ }); ++ self.track_notify_local(&message, "selected", { ++ let update = update.clone(); ++ let weak = self.downgrade(); ++ move |_, _| { ++ update(); ++ if let Some(s) = weak.upgrade() { ++ s.message() ++ .channel() ++ .emit_by_name::<()>("selection-changed", &[]); ++ } ++ } ++ }); ++ ++ // While the channel is in selection mode, primary-button clicks ++ // anywhere on the row toggle the message's `selected` flag and the ++ // gesture claims the event sequence so child widgets (label links, ++ // attachments, the popover trigger, the check button) do not also ++ // act on the click. The check button itself is `can-target: false` ++ // in the template so its visual state is driven purely by the bind ++ // to `message.selected` rather than its own toggled signal. ++ let click = gtk::GestureClick::builder() ++ .button(gdk::BUTTON_PRIMARY) ++ .propagation_phase(gtk::PropagationPhase::Capture) ++ .build(); ++ click.connect_pressed(clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |gesture, _, _, _| { ++ if s.message().channel().selection_mode() { ++ let cur: bool = s.message().property("selected"); ++ s.message().set_property("selected", !cur); ++ gesture.set_state(gtk::EventSequenceState::Claimed); ++ } ++ } ++ )); ++ self.add_controller(click); ++ update(); ++ } + } + + pub mod imp { +@@ -360,6 +463,8 @@ pub mod imp { + #[template_child] + pub(super) avatar: TemplateChild, + #[template_child] ++ pub(super) selection_check: TemplateChild, ++ #[template_child] + pub(super) header: TemplateChild, + #[template_child] + pub(super) reactions: TemplateChild, +@@ -399,6 +504,14 @@ pub mod imp { + shows_media_loading: PhantomData, + #[property(get = Self::has_reaction)] + has_reaction: PhantomData, ++ ++ /// Handlers we attached on long-lived objects (the message and the ++ /// channel). Channel outlives every MessageItem and the timeline ++ /// holds messages across list-view widget recycling, so without an ++ /// explicit disconnect each MessageItem we ever build leaves a ++ /// no-op closure attached to its message and channel forever. ++ pub(super) tracked_handlers: ++ RefCell>, + } + + #[glib::object_subclass] +@@ -668,6 +781,12 @@ pub mod imp { + }); + SIGNALS.as_ref() + } ++ ++ fn dispose(&self) { ++ for (target, handler) in self.tracked_handlers.take() { ++ target.disconnect(handler); ++ } ++ } + } + + impl WidgetImpl for MessageItem {} +-- +2.53.0 + diff --git a/patches/flare/0005-feat-messages-In-channel-message-search.patch b/patches/flare/0005-feat-messages-In-channel-message-search.patch new file mode 100644 index 0000000..53798b1 --- /dev/null +++ b/patches/flare/0005-feat-messages-In-channel-message-search.patch @@ -0,0 +1,406 @@ +From 91731979312b65e9b59b2dc58be0067bc5f9f206 Mon Sep 17 00:00:00 2001 +From: Simon Gardling +Date: Wed, 29 Apr 2026 19:58:54 -0400 +Subject: [PATCH 5/6] feat(messages): In-channel message search + +- Add a SearchBar above the message list that searches the + currently-loaded timeline using a case-insensitive substring match + against the message body. Bind it to a new + channel-messages.toggle-search action wired to Ctrl+Shift+F. +- Surface a match counter (current/total) and previous/next buttons + next to the entry, plus reuse the existing flash_requires_attention + helper to scroll to and briefly highlight the focused match. +- Reset matches when the bar closes or the active channel changes. +--- + CHANGELOG.md | 1 + + data/resources/ui/channel_messages.blp | 55 +++++++ + data/resources/ui/shortcuts.blp | 5 + + src/gui/channel_messages.rs | 203 ++++++++++++++++++++++++- + src/gui/window.rs | 11 ++ + 5 files changed, 274 insertions(+), 1 deletion(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 47ec77a..16880cd 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + - 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. + - Multi-select messages from their context menu and delete the selection locally with a single action. ++- In-channel message search (Ctrl+Shift+F) over the loaded timeline with prev/next navigation and a match counter. + + ## [0.20.4] - 2026-04-22 + +diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp +index eb927f8..a166f7b 100644 +--- a/data/resources/ui/channel_messages.blp ++++ b/data/resources/ui/channel_messages.blp +@@ -43,6 +43,61 @@ template $FlChannelMessages: Box { + hexpand: true; + orientation: vertical; + ++ // In-channel message search. ++ SearchBar search_bar { ++ key-capture-widget: scrolled_window; ++ search-mode-enabled: bind template.search-active bidirectional; ++ ++ Adw.Clamp { ++ maximum-size: 600; ++ ++ Box { ++ orientation: horizontal; ++ spacing: 6; ++ ++ SearchEntry search_entry { ++ hexpand: true; ++ placeholder-text: _("Search loaded messages"); ++ search-changed => $on_search_query_changed() swapped; ++ previous-match => $on_search_previous() swapped; ++ next-match => $on_search_next() swapped; ++ stop-search => $on_search_stop() swapped; ++ } ++ ++ Label { ++ styles [ ++ "caption", ++ "dim-label", ++ ] ++ ++ label: bind template.search-summary; ++ } ++ ++ Button { ++ icon-name: "go-up-symbolic"; ++ tooltip-text: C_("tooltip", "Previous match"); ++ sensitive: bind template.has-matches; ++ clicked => $on_search_previous() swapped; ++ ++ styles [ ++ "flat", ++ ] ++ } ++ ++ Button { ++ icon-name: "go-down-symbolic"; ++ tooltip-text: C_("tooltip", "Next match"); ++ sensitive: bind template.has-matches; ++ clicked => $on_search_next() swapped; ++ ++ styles [ ++ "flat", ++ ] ++ } ++ } ++ } ++ } ++ + Overlay { + [overlay] + Adw.Spinner { +diff --git a/data/resources/ui/shortcuts.blp b/data/resources/ui/shortcuts.blp +index ed2a959..79339cc 100644 +--- a/data/resources/ui/shortcuts.blp ++++ b/data/resources/ui/shortcuts.blp +@@ -58,5 +58,10 @@ Adw.ShortcutsDialog help_overlay { + title: C_("shortcut window", "Load more messages"); + accelerator: "&l"; + } ++ ++ Adw.ShortcutsItem { ++ title: C_("shortcut window", "Search messages in current channel"); ++ accelerator: "&f"; ++ } + } + } +diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs +index 747b36a..6dd8e84 100644 +--- a/src/gui/channel_messages.rs ++++ b/src/gui/channel_messages.rs +@@ -2,7 +2,7 @@ use crate::prelude::*; + use gio::SettingsBindFlags; + + use crate::ApplicationError; +-use crate::backend::message::{DisplayMessage, TextMessage}; ++use crate::backend::message::{DisplayMessage, DisplayMessageExt, TextMessage}; + use crate::backend::timeline::TimelineItemExt; + + const MESSAGES_REQUEST_LOAD: usize = 10; +@@ -85,6 +85,163 @@ impl ChannelMessages { + self.set_has_selection(count > 0); + } + ++ /// Connect the `search-active` property so the entry is focused when ++ /// the bar opens and the matches are cleared when it closes. ++ fn setup_search(&self) { ++ self.connect_notify_local( ++ Some("search-active"), ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ move |_, _| { ++ if s.search_active() { ++ s.imp().search_entry.grab_focus(); ++ s.attach_search_message_listener(); ++ } else { ++ s.detach_search_message_listener(); ++ s.imp().search_entry.set_text(""); ++ s.imp().search_matches.replace(Vec::new()); ++ s.imp().search_index.set(0); ++ s.set_has_matches(false); ++ s.set_search_summary(String::new()); ++ } ++ } ++ ), ++ ); ++ } ++ ++ /// While the search bar is open, watch the active channel for new ++ /// messages so the result set stays in sync with the timeline. Only ++ /// one handler is alive at a time; `detach_search_message_listener` or ++ /// the next `attach` call clears the previous one. ++ fn attach_search_message_listener(&self) { ++ self.detach_search_message_listener(); ++ let Some(channel) = self.active_channel() else { ++ return; ++ }; ++ let handler = channel.connect_local( ++ "message", ++ false, ++ clone!( ++ #[weak(rename_to = s)] ++ self, ++ #[upgrade_or_default] ++ move |_| { ++ let query = s.imp().search_entry.text().to_string(); ++ s.refresh_search(&query); ++ None ++ } ++ ), ++ ); ++ self.imp() ++ .search_message_handler ++ .replace(Some((channel, handler))); ++ } ++ ++ fn detach_search_message_listener(&self) { ++ if let Some((channel, handler)) = self.imp().search_message_handler.take() { ++ channel.disconnect(handler); ++ } ++ } ++ ++ /// Re-run the loaded-message search using `query`. The search is ++ /// case-insensitive substring match against the message body. ++ pub fn refresh_search(&self, query: &str) { ++ let imp = self.imp(); ++ let Some(channel) = self.active_channel() else { ++ imp.search_matches.replace(Vec::new()); ++ imp.search_index.set(0); ++ self.set_has_matches(false); ++ self.set_search_summary(String::new()); ++ return; ++ }; ++ ++ let trimmed = query.trim(); ++ if trimmed.is_empty() { ++ imp.search_matches.replace(Vec::new()); ++ imp.search_index.set(0); ++ self.set_has_matches(false); ++ self.set_search_summary(String::new()); ++ return; ++ } ++ ++ let needle = trimmed.to_lowercase(); ++ let matches: Vec = channel ++ .timeline() ++ .iter_forwards() ++ .filter_map(|i| i.dynamic_cast::().ok()) ++ .filter(|m| { ++ m.body() ++ .map(|b| b.to_lowercase().contains(&needle)) ++ .unwrap_or(false) ++ }) ++ .map(|m| m.timestamp()) ++ .collect(); ++ ++ let total = matches.len(); ++ imp.search_matches.replace(matches); ++ // Snap to the latest match (most recent in time) by default so the ++ // user lands at the bottom of the conversation, matching how the ++ // existing scroll-to-unread heuristic works. ++ imp.search_index.set(total.saturating_sub(1)); ++ self.set_has_matches(total > 0); ++ self.update_search_summary(); ++ self.flash_current_search_match(); ++ } ++ ++ /// Move the search cursor to the next or previous match and flash that ++ /// message. ++ pub fn goto_search_match(&self, forwards: bool) { ++ let imp = self.imp(); ++ let total = imp.search_matches.borrow().len(); ++ if total == 0 { ++ return; ++ } ++ let current = imp.search_index.get(); ++ let next = if forwards { ++ (current + 1) % total ++ } else if current == 0 { ++ total - 1 ++ } else { ++ current - 1 ++ }; ++ imp.search_index.set(next); ++ self.update_search_summary(); ++ self.flash_current_search_match(); ++ } ++ ++ fn update_search_summary(&self) { ++ let imp = self.imp(); ++ let total = imp.search_matches.borrow().len(); ++ let summary = if total == 0 { ++ String::new() ++ } else { ++ // Translators: e.g. "3 of 12" indicating the focused match ++ // index out of total search matches. ++ gettextrs::gettext("{current} of {total}") ++ .replace("{current}", &(imp.search_index.get() + 1).to_string()) ++ .replace("{total}", &total.to_string()) ++ }; ++ self.set_search_summary(summary); ++ } ++ ++ fn flash_current_search_match(&self) { ++ let imp = self.imp(); ++ let matches = imp.search_matches.borrow(); ++ let Some(timestamp) = matches.get(imp.search_index.get()).copied() else { ++ return; ++ }; ++ drop(matches); ++ let Some(channel) = self.active_channel() else { ++ return; ++ }; ++ if let Some(item) = channel.timeline().get_by_timestamp(timestamp) ++ && let Some(msg) = item.dynamic_cast_ref::() ++ { ++ msg.flash_requires_attention(); ++ } ++ } ++ + pub fn focus_input(&self) { + self.imp().text_entry.grab_focus(); + } +@@ -446,6 +603,8 @@ pub mod imp { + #[template_child] + pub(super) text_entry: TemplateChild, + #[template_child] ++ pub(super) search_entry: TemplateChild, ++ #[template_child] + pub(super) list_view: TemplateChild, + #[template_child] + no_channels_page: TemplateChild, +@@ -478,6 +637,25 @@ pub mod imp { + #[property(get, set)] + has_selection: Cell, + ++ #[property(get, set)] ++ search_active: Cell, ++ #[property(get, set)] ++ search_summary: RefCell, ++ #[property(get, set)] ++ has_matches: Cell, ++ ++ /// Cached timestamps of every message currently matching the search ++ /// query, in chronological order. ++ pub(super) search_matches: RefCell>, ++ /// Index into `search_matches` of the currently focused match. ++ pub(super) search_index: Cell, ++ /// Handler installed on the active channel's `message` signal ++ /// while the search bar is open, so newly-arrived messages are ++ /// folded into the match set without the user re-running the ++ /// search by hand. ++ pub(super) search_message_handler: ++ RefCell>, ++ + /// Whether we currently believe the user is composing a message in the + /// active channel and have informed the peer with a `Started` event. + pub(super) sending_typing: Cell, +@@ -520,6 +698,7 @@ pub mod imp { + self.obj().setup_send_on_enter(); + self.obj().setup_typing_settings(); + self.obj().setup_typing_send(); ++ self.obj().setup_search(); + } + } + +@@ -528,6 +707,7 @@ pub mod imp { + // forget about it. + self.obj().send_typing_stopped(); + self.obj().exit_selection_mode(); ++ self.obj().set_search_active(false); + + if let Some(active_chan) = self.active_channel.borrow().as_ref() { + active_chan.set_property("draft", self.text_entry.text()); +@@ -820,6 +1000,27 @@ pub mod imp { + } + } + ++ #[template_callback] ++ fn on_search_query_changed(&self) { ++ let query = self.search_entry.text().to_string(); ++ self.obj().refresh_search(&query); ++ } ++ ++ #[template_callback] ++ fn on_search_previous(&self) { ++ self.obj().goto_search_match(false); ++ } ++ ++ #[template_callback] ++ fn on_search_next(&self) { ++ self.obj().goto_search_match(true); ++ } ++ ++ #[template_callback] ++ fn on_search_stop(&self) { ++ self.obj().set_search_active(false); ++ } ++ + #[template_callback] + fn handle_row_activated(&self, row: gtk::ListBoxRow) { + if let Ok(msg) = row +diff --git a/src/gui/window.rs b/src/gui/window.rs +index 6335f3d..ce097ce 100644 +--- a/src/gui/window.rs ++++ b/src/gui/window.rs +@@ -20,6 +20,7 @@ impl Window { + app.set_accels_for_action("window.close", &["q"]); + app.set_accels_for_action("channel-messages.activate-input", &["i"]); + app.set_accels_for_action("channel-messages.load-more", &["l"]); ++ app.set_accels_for_action("channel-messages.toggle-search", &["f"]); + for i in 1..=9 { + app.set_accels_for_action( + &format!("channel-list.activate-channel({i})"), +@@ -531,10 +532,20 @@ pub mod imp { + channel_messages.load_more(); + } + )); ++ let action_toggle_search = SimpleAction::new("toggle-search", None); ++ action_toggle_search.connect_activate(clone!( ++ #[strong(rename_to = channel_messages)] ++ self.channel_messages, ++ move |_, _| { ++ let active = !channel_messages.search_active(); ++ channel_messages.set_search_active(active); ++ } ++ )); + let actions = SimpleActionGroup::new(); + obj.insert_action_group("channel-messages", Some(&actions)); + actions.add_action(&action_activate_input); + actions.add_action(&action_load_more); ++ actions.add_action(&action_toggle_search); + + // Channel list actions. + +-- +2.53.0 + diff --git a/patches/flare/0006-feat-messages-Show-This-message-was-deleted.-placeho.patch b/patches/flare/0006-feat-messages-Show-This-message-was-deleted.-placeho.patch new file mode 100644 index 0000000..c0bae64 --- /dev/null +++ b/patches/flare/0006-feat-messages-Show-This-message-was-deleted.-placeho.patch @@ -0,0 +1,175 @@ +From 46765e848362129bb2d0fc34b2047e6cb2555258 Mon Sep 17 00:00:00 2001 +From: Simon Gardling +Date: Thu, 30 Apr 2026 04:25:07 -0400 +Subject: [PATCH 6/6] feat(messages): Show 'This message was deleted.' + placeholder + +Upstream hides the whole MessageItem when is-deleted is true via a +top-level visible bind on the template root. Replace that with a +Signal-Desktop-style behaviour: the row stays in the timeline, but the +bubble's regular content (header, quote, attachments, label, popover +trigger) and any reactions are hidden, and a single italic-dim +placeholder label takes their place. + +The two pieces of imperative state set in code (media_overlay and the +floating timestamp_img indicator over media-only messages) are reset +in a small setup_deleted helper that subscribes to is-deleted, since +they are not reachable through bindings. +--- + data/resources/style.css | 6 ++++++ + data/resources/ui/message_item.blp | 29 +++++++++++++++++++++---- + src/gui/message_item.rs | 34 ++++++++++++++++++++++++++++++ + 3 files changed, 65 insertions(+), 4 deletions(-) + +diff --git a/data/resources/style.css b/data/resources/style.css +index 1c0cdfd..3b0de9a 100644 +--- a/data/resources/style.css ++++ b/data/resources/style.css +@@ -9,6 +9,12 @@ + background-color: @bubble_bg_color; + } + ++/* Deletion placeholder shown in place of remotely-deleted messages. */ ++.deleted-message { ++ font-style: italic; ++ opacity: 0.6; ++} ++ + .message-input-bar { + border-top: 1px solid @borders; + } +diff --git a/data/resources/ui/message_item.blp b/data/resources/ui/message_item.blp +index ba3fd23..49002a5 100644 +--- a/data/resources/ui/message_item.blp ++++ b/data/resources/ui/message_item.blp +@@ -71,8 +71,6 @@ template $FlMessageItem: $ContextMenuBin { + "message-item", + ] + +- visible: bind $not(template.message as <$FlTextMessage>.is-deleted) as ; +- + Grid { + column-spacing: 12; + row-spacing: 12; +@@ -149,11 +147,32 @@ template $FlMessageItem: $ContextMenuBin { + "message-bubble", + ] + ++ // Deletion placeholder "This message was deleted." — visible only when ++ // the message has been remotely deleted; hides the rest of the bubble's ++ // content and any reactions/attachments via the .deleted CSS class on ++ // the message-item. ++ Label deleted_label { ++ styles [ ++ "deleted-message", ++ ] ++ ++ label: _("This message was deleted."); ++ visible: bind template.message as <$FlTextMessage>.is-deleted; ++ halign: start; ++ xalign: 0; ++ ++ layout { ++ row: 0; ++ column: 0; ++ } ++ } ++ + Label header { + styles [ + "heading", + ] + ++ visible: bind $not(template.message as <$FlTextMessage>.is-deleted) as ; + label: bind template.message as <$FlTextMessage>.sender as <$FlContact>.title; + hexpand: true; + halign: start; +@@ -177,7 +196,7 @@ template $FlMessageItem: $ContextMenuBin { + "quote", + ] + +- visible: bind $is_some(template.message as <$FlTextMessage>.quote) as ; ++ visible: bind $and($is_some(template.message as <$FlTextMessage>.quote) as , $not(template.message as <$FlTextMessage>.is-deleted) as ) as ; + + Label { + styles [ +@@ -219,6 +238,7 @@ template $FlMessageItem: $ContextMenuBin { + } + + Box box_attachments { ++ visible: bind $not(template.message as <$FlTextMessage>.is-deleted) as ; + layout { + row: 2; + column: 0; +@@ -276,6 +296,7 @@ template $FlMessageItem: $ContextMenuBin { + } + + $FlMessageLabel label_message { ++ visible: bind $not(template.message as <$FlTextMessage>.is-deleted) as ; + label: bind $markup_urls(template.message as <$FlTextMessage>.body) as ; + attributes: bind template.message as <$FlTextMessage>.message-attributes; + +@@ -306,7 +327,7 @@ template $FlMessageItem: $ContextMenuBin { + ] + + label: bind $fix_emoji(template.message as <$FlTextMessage>.reactions) as ; +- visible: bind template.has-reaction; ++ visible: bind $and(template.has-reaction, $not(template.message as <$FlTextMessage>.is-deleted) as ) as ; + wrap-mode: word; + justify: left; + vexpand: false; +diff --git a/src/gui/message_item.rs b/src/gui/message_item.rs +index d88306f..33479da 100644 +--- a/src/gui/message_item.rs ++++ b/src/gui/message_item.rs +@@ -35,6 +35,7 @@ impl MessageItem { + s.setup_requires_attention(); + s.setup_pending_and_error(); + s.setup_selection(); ++ s.setup_deleted(); + s + } + +@@ -325,6 +326,39 @@ impl MessageItem { + message.notify("requires-attention"); + } + ++ /// Reflect the message's `is-deleted` state in the UI. ++ /// ++ /// Upstream simply hid the row entirely; we instead keep the row but ++ /// show a `"This message was deleted."` placeholder (handled in the ++ /// blueprint) and clean up the bits that the deletion pseudo-message ++ /// can't reach via the bind layer: the media overlay (whose visibility ++ /// is set imperatively in `set_message`) and the standalone ++ /// `timestamp_img` indicator that floats over media-only messages. ++ pub fn setup_deleted(&self) { ++ let message = self.message(); ++ let apply = clone!( ++ #[weak(rename_to = s)] ++ self, ++ move || { ++ if s.message().is_deleted() { ++ s.add_css_class("deleted"); ++ s.imp().media_overlay.set_visible(false); ++ s.imp().timestamp_img.set_visible(false); ++ } else { ++ // Symmetric reset for any future code path that flips ++ // is-deleted back off (e.g. an unsend/restore flow). ++ // Today nothing does, but the asymmetry is fragile. ++ s.remove_css_class("deleted"); ++ } ++ } ++ ); ++ self.track_notify_local(&message, "is-deleted", { ++ let apply = apply.clone(); ++ move |_, _| apply() ++ }); ++ apply(); ++ } ++ + pub fn setup_pending_and_error(&self) { + let message = self.message(); + message.connect_notify_local( +-- +2.53.0 +