- 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.
675 lines
24 KiB
Diff
675 lines
24 KiB
Diff
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
|
|
|