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