flare: add patched flare-signal with five local feature patches

- patches/flare/000{1..5}-*.patch: typing indicators, formatted
  messages, edited messages, multi-select with delete-for-me, and
  in-channel message search. Mirror the matching commits in
  ~/projects/forks/flare and apply cleanly on top of upstream 0.20.4
  (which is what nixpkgs ships).
- home/profiles/gui.nix: include a flare-signal override that appends
  the patches via overrideAttrs. None of them touch Cargo.lock so the
  cargoDeps hash stays valid; signal-desktop stays alongside it.
This commit is contained in:
2026-04-29 23:39:09 -04:00
parent 8768b285df
commit bcdfd8cdf5
7 changed files with 3543 additions and 0 deletions

View File

@@ -86,6 +86,21 @@
signal-desktop 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 # accounting
# gnucash # gnucash

View File

@@ -0,0 +1,732 @@
From 733ad6e63fa6408e47d87a22cf51a784f5ce103f Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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 @@
<summary>Send a message when the Enter-key is pressed</summary>
</key>
+ <key name="show-typing-indicators" type="b">
+ <default>true</default>
+ <summary>Show typing indicators of other users</summary>
+ </key>
+ <key name="send-typing-indicators" type="b">
+ <default>true</default>
+ <summary>Send typing indicators while composing</summary>
+ </key>
+
<key name="sort-contacts-by" type="s">
<default>"firstname"</default>
<summary>How to sort contacts, e.g with "firstname" or "surname"</summary>
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 <bool>;
+
+ 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<Channel> {
@@ -737,14 +759,15 @@ impl Manager {
pub(super) async fn send_message_to_group(
&self,
group_key: Vec<u8>,
- message: DataMessage,
+ message: impl Into<ContentBody>,
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<u8>,
- Box<DataMessage>,
+ Box<ContentBody>,
u64,
oneshot::Sender<Result<(), Error>>,
),
@@ -353,7 +353,7 @@ impl ManagerThread {
pub async fn send_message_to_group(
&self,
group_key: Vec<u8>,
- message: DataMessage,
+ message: impl Into<ContentBody>,
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<imp::ChannelMessages>)
@@ -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<Manager>) 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<bool>,
#[property(get = Self::has_attachments)]
has_attachments: PhantomData<bool>,
+ #[property(get, set)]
+ show_typing: Cell<bool>,
+
+ /// 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<bool>,
+ /// 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<Option<Channel>>,
+ /// 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<Option<glib::SourceId>>,
+ /// One-shot timer that emits `Stopped` after a stretch of no
+ /// further buffer changes.
+ pub(super) typing_idle: RefCell<Option<glib::SourceId>>,
+ /// 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<Option<(Channel, glib::SignalHandlerId)>>,
+ /// 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<Vec<(Channel, glib::SignalHandlerId)>>,
+ /// 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<Option<glib::SignalHandlerId>>,
#[property(get, set = Self::set_manager, type = Manager)]
- manager: RefCell<Option<Manager>>,
+ pub(super) manager: RefCell<Option<Manager>>,
}
#[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<Channel>) {
+ // 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::<TextMessage>);
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<adw::SwitchRow>,
+ #[template_child]
+ row_send_typing_indicators: TemplateChild<adw::SwitchRow>,
+ #[template_child]
+ row_show_typing_indicators: TemplateChild<adw::SwitchRow>,
+
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

View File

@@ -0,0 +1,622 @@
From 45b21cee00bfc5545aea6fbc9a4f991cfd781cff Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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<MatchedSpan> {
+ let mut open: HashMap<Marker, (usize, usize)> = HashMap::new();
+ let mut spans: Vec<MatchedSpan> = 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<BodyRange>) {
+ let chars: Vec<char> = 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<BodyRange> = 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<pango::Attribute> {
+ fn span<A: Into<pango::Attribute>>(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<S: AsRef<str>>(
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<String>, 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<u16> = 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<u16> = 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<usize> = 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

View File

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

View File

@@ -0,0 +1,674 @@
From 250b11530e8d29a42707dde8ff3dd516e0073863 Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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<u64>,
+ ) -> 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<GroupContextV2> {
self.imp().group_context.borrow().clone()
}
@@ -861,6 +883,8 @@ mod imp {
pub(super) draft: RefCell<String>,
#[property(get, set)]
pub(super) is_active: RefCell<bool>,
+ #[property(get, set)]
+ pub(super) selection_mode: RefCell<bool>,
#[property(get = Self::last_message)]
pub(super) last_message: PhantomData<Option<DisplayMessage>>,
@@ -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<u64>,
+ ) -> Result<Vec<u64>, 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::<Vec<u64>, ApplicationError>(purged)
+ })
+ .await
+ .expect("Failed to spawn tokio")?;
+ Ok(purged)
+ }
+
pub async fn submit_recaptcha_challenge<S: AsRef<str>>(
&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<bool>,
#[property(get, set)]
pub(super) error: RefCell<bool>,
+ #[property(get, set)]
+ pub(super) selected: RefCell<bool>,
pub(super) data: RefCell<Option<DataMessage>>,
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(&timestamp, |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<TimelineItem> {
let current_items = self.imp().list.borrow();
let index = current_items.binary_search_by_key(&timestamp, |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<u64> {
+ let Some(channel) = self.active_channel() else {
+ return Vec::new();
+ };
+ channel
+ .timeline()
+ .iter_forwards()
+ .filter(|i| i.is::<DisplayMessage>())
+ .filter_map(|i| i.dynamic_cast::<DisplayMessage>().ok())
+ .filter(|m| m.property::<bool>("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::<DisplayMessage>()
+ && msg.property::<bool>("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<bool>,
#[property(get, set)]
show_typing: Cell<bool>,
+ #[property(get, set)]
+ selection_summary: RefCell<String>,
+ #[property(get, set)]
+ has_selection: Cell<bool>,
/// 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::<crate::gui::Window>()
+ .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<TextMessage>| 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<F>(&self, target: &impl IsA<glib::Object>, name: &str, f: F)
+ where
+ F: Fn(&glib::Object, &glib::ParamSpec) + 'static,
+ {
+ let target_obj = target.clone().upcast::<glib::Object>();
+ 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::<bool>("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<adw::Avatar>,
#[template_child]
+ pub(super) selection_check: TemplateChild<gtk::CheckButton>,
+ #[template_child]
pub(super) header: TemplateChild<gtk::Label>,
#[template_child]
pub(super) reactions: TemplateChild<gtk::Label>,
@@ -399,6 +504,14 @@ pub mod imp {
shows_media_loading: PhantomData<bool>,
#[property(get = Self::has_reaction)]
has_reaction: PhantomData<bool>,
+
+ /// 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<Vec<(glib::Object, glib::SignalHandlerId)>>,
}
#[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

View File

@@ -0,0 +1,406 @@
From 91731979312b65e9b59b2dc58be0067bc5f9f206 Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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: "<Ctrl>&l";
}
+
+ Adw.ShortcutsItem {
+ title: C_("shortcut window", "Search messages in current channel");
+ accelerator: "<Ctrl><Shift>&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<u64> = channel
+ .timeline()
+ .iter_forwards()
+ .filter_map(|i| i.dynamic_cast::<TextMessage>().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::<DisplayMessage>()
+ {
+ 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<TextEntry>,
#[template_child]
+ pub(super) search_entry: TemplateChild<gtk::SearchEntry>,
+ #[template_child]
pub(super) list_view: TemplateChild<gtk::ListView>,
#[template_child]
no_channels_page: TemplateChild<adw::StatusPage>,
@@ -478,6 +637,25 @@ pub mod imp {
#[property(get, set)]
has_selection: Cell<bool>,
+ #[property(get, set)]
+ search_active: Cell<bool>,
+ #[property(get, set)]
+ search_summary: RefCell<String>,
+ #[property(get, set)]
+ has_matches: Cell<bool>,
+
+ /// Cached timestamps of every message currently matching the search
+ /// query, in chronological order.
+ pub(super) search_matches: RefCell<Vec<u64>>,
+ /// Index into `search_matches` of the currently focused match.
+ pub(super) search_index: Cell<usize>,
+ /// 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<Option<(Channel, glib::SignalHandlerId)>>,
+
/// 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<bool>,
@@ -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", &["<Control>q"]);
app.set_accels_for_action("channel-messages.activate-input", &["<Control>i"]);
app.set_accels_for_action("channel-messages.load-more", &["<Control>l"]);
+ app.set_accels_for_action("channel-messages.toggle-search", &["<Control><Shift>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

View File

@@ -0,0 +1,175 @@
From 46765e848362129bb2d0fc34b2047e6cb2555258 Mon Sep 17 00:00:00 2001
From: Simon Gardling <titaniumtown@proton.me>
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 <bool>;
-
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 <bool>;
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 <bool>;
+ visible: bind $and($is_some(template.message as <$FlTextMessage>.quote) as <bool>, $not(template.message as <$FlTextMessage>.is-deleted) as <bool>) as <bool>;
Label {
styles [
@@ -219,6 +238,7 @@ template $FlMessageItem: $ContextMenuBin {
}
Box box_attachments {
+ visible: bind $not(template.message as <$FlTextMessage>.is-deleted) as <bool>;
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 <bool>;
label: bind $markup_urls(template.message as <$FlTextMessage>.body) as <string>;
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 <string>;
- visible: bind template.has-reaction;
+ visible: bind $and(template.has-reaction, $not(template.message as <$FlTextMessage>.is-deleted) as <bool>) as <bool>;
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