From 250b11530e8d29a42707dde8ff3dd516e0073863 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:53:22 -0400 Subject: [PATCH 4/6] feat(messages): Multi-select messages and delete for me - Add a 'Select' action to the message context menu that puts the channel into selection mode and pre-selects the message. While in selection mode, every message item shows a check button and a toolbar replaces nothing in particular but offers a 'Delete for me' destructive action plus a cancel button. - Track selection state with a transient `selected` property on `Message` and a `selection-mode` property on `Channel`. A new `selection-changed` signal lets the channel-messages view update the selection summary without polling. - Add `Channel::delete_messages_locally` plus the matching `Manager::delete_messages_locally` and a new `Timeline::remove_by_timestamp` helper. The action only purges the local copy and never sends a remote deletion. --- CHANGELOG.md | 1 + data/resources/style.css | 14 +++ data/resources/ui/channel_messages.blp | 60 ++++++++++++ data/resources/ui/message_item.blp | 20 ++++ src/backend/channel.rs | 25 +++++ src/backend/manager.rs | 32 ++++++ src/backend/message/mod.rs | 2 + src/backend/timeline/mod.rs | 16 +++ src/gui/channel_messages.rs | 129 ++++++++++++++++++++++++- src/gui/message_item.rs | 119 +++++++++++++++++++++++ 10 files changed, 417 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0338ed8..47ec77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Render formatted message styles (bold, italic, strikethrough, spoiler, monospace) on incoming messages. - Send formatted messages with markdown-style markers (`**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, `` `monospace` ``). - Display incoming edited messages with an `edited` indicator and edit your own sent messages from their context menu. +- Multi-select messages from their context menu and delete the selection locally with a single action. ## [0.20.4] - 2026-04-22 diff --git a/data/resources/style.css b/data/resources/style.css index 00e4783..1c0cdfd 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -19,6 +19,20 @@ min-height: 18px; } +.message-item.in-selection-mode .avatar-other { + opacity: 0; +} + +.message-item.in-selection-mode.selected .message-bubble { + outline: 2px solid @accent_color; +} + +.selection-toolbar { + padding: 6px 12px; + background-color: @window_bg_color; + border-top: 1px solid @borders; +} + .message-list row { padding:0; } diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp index f3d2348..eb927f8 100644 --- a/data/resources/ui/channel_messages.blp +++ b/data/resources/ui/channel_messages.blp @@ -135,6 +135,66 @@ template $FlChannelMessages: Box { } } + // Selection toolbar (shown when in multi-select mode). + Box selection_toolbar { + styles [ + "selection-toolbar", + ] + + orientation: horizontal; + spacing: 12; + hexpand: true; + visible: bind template.active-channel as <$FlChannel>.selection-mode; + + Button { + accessibility { + label: C_("accessibility", "Cancel selection"); + } + + tooltip-text: C_("tooltip", "Cancel selection"); + + styles [ + "flat", + "circular", + ] + + valign: center; + clicked => $cancel_selection() swapped; + icon-name: "window-close-symbolic"; + } + + Label { + hexpand: true; + halign: start; + label: bind template.selection-summary; + } + + Button { + styles [ + "destructive-action", + "pill", + ] + + valign: center; + clicked => $delete_selection() swapped; + sensitive: bind template.has-selection; + + Box { + orientation: horizontal; + spacing: 6; + + Image { + icon-name: "user-trash-symbolic"; + } + + Label { + label: _("Delete for me"); + } + } + } + } + + Box { styles [ "toolbar", diff --git a/data/resources/ui/message_item.blp b/data/resources/ui/message_item.blp index 2c21b8b..ba3fd23 100644 --- a/data/resources/ui/message_item.blp +++ b/data/resources/ui/message_item.blp @@ -24,6 +24,13 @@ menu message-menu { hidden-when: "action-disabled"; } + item { + label: _("Select"); + action: "msg.select"; + verb-icon: "checkbox-checked-symbolic"; + icon: "checkbox-checked-symbolic"; + } + item { label: _("Delete"); action: "msg.delete"; @@ -87,6 +94,19 @@ template $FlMessageItem: $ContextMenuBin { } } + CheckButton selection_check { + visible: bind template.message as <$FlTextMessage>.channel as <$FlChannel>.selection-mode; + active: bind template.message as <$FlTextMessage>.selected; + valign: center; + can-target: false; + can-focus: false; + + layout { + row: 0; + column: 0; + } + } + Adw.Spinner { visible: bind template.message as <$FlTextMessage>.pending; tooltip-text: _("This message is currently being sent"); diff --git a/src/backend/channel.rs b/src/backend/channel.rs index 94fa6bb..0fbc51d 100644 --- a/src/backend/channel.rs +++ b/src/backend/channel.rs @@ -199,6 +199,28 @@ impl Channel { Ok(()) } + /// Delete a set of messages locally only ("Delete for me"). Removes them + /// from the encrypted local store and from the in-memory timeline. + pub async fn delete_messages_locally( + &self, + timestamps: Vec, + ) -> Result<(), ApplicationError> { + if timestamps.is_empty() { + return Ok(()); + } + let purged = self + .manager() + .delete_messages_locally(self, timestamps) + .await?; + let timeline = self.imp().timeline.borrow(); + for ts in &purged { + timeline.remove_by_timestamp(*ts); + } + drop(timeline); + self.notify("last-message"); + Ok(()) + } + pub(super) fn group_context(&self) -> Option { self.imp().group_context.borrow().clone() } @@ -861,6 +883,8 @@ mod imp { pub(super) draft: RefCell, #[property(get, set)] pub(super) is_active: RefCell, + #[property(get, set)] + pub(super) selection_mode: RefCell, #[property(get = Self::last_message)] pub(super) last_message: PhantomData>, @@ -1000,6 +1024,7 @@ mod imp { Signal::builder("message") .param_types([DisplayMessage::static_type()]) .build(), + Signal::builder("selection-changed").build(), ] }); SIGNALS.as_ref() diff --git a/src/backend/manager.rs b/src/backend/manager.rs index eaa41e0..0964681 100644 --- a/src/backend/manager.rs +++ b/src/backend/manager.rs @@ -210,6 +210,38 @@ impl Manager { Ok(()) } + /// Delete a set of messages locally only ("Delete for me"). The remote + /// peer is not informed; this only purges the local copy from storage. + /// + /// Returns the timestamps that were actually purged from the store; the + /// caller should mirror only those into the in-memory timeline so a + /// per-message store failure does not leave the on-disk state and the UI + /// permanently disagreed. + pub async fn delete_messages_locally( + &self, + channel: &Channel, + timestamps: Vec, + ) -> Result, ApplicationError> { + let thread = channel.thread(); + let mut store = self.store(); + let purged = tspawn!(async move { + let mut purged = Vec::with_capacity(timestamps.len()); + for ts in timestamps { + match store.delete_message(&thread, ts).await { + // Both "row deleted" and "row was already absent" mean + // the store no longer holds this message, so it is safe + // for the timeline to drop it. + Ok(_) => purged.push(ts), + Err(e) => log::warn!("Failed to locally delete message {ts}: {e}"), + } + } + Ok::, ApplicationError>(purged) + }) + .await + .expect("Failed to spawn tokio")?; + Ok(purged) + } + pub async fn submit_recaptcha_challenge>( &self, token: S, diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs index f3a0537..eba08ec 100644 --- a/src/backend/message/mod.rs +++ b/src/backend/message/mod.rs @@ -518,6 +518,8 @@ mod imp { pub(super) pending: RefCell, #[property(get, set)] pub(super) error: RefCell, + #[property(get, set)] + pub(super) selected: RefCell, pub(super) data: RefCell>, diff --git a/src/backend/timeline/mod.rs b/src/backend/timeline/mod.rs index 1ce6a24..18dd436 100644 --- a/src/backend/timeline/mod.rs +++ b/src/backend/timeline/mod.rs @@ -44,6 +44,22 @@ impl Timeline { self.items_changed(0, len as u32, 0); } + /// Remove the item with the given timestamp from the timeline, if any. + /// Returns whether an item was actually removed. + pub fn remove_by_timestamp(&self, timestamp: u64) -> bool { + let mut list = self.imp().list.borrow_mut(); + let position = list.binary_search_by_key(×tamp, |i| i.timestamp()); + match position { + Ok(idx) => { + list.remove(idx); + drop(list); + self.items_changed(idx as u32, 1, 0); + true + } + Err(_) => false, + } + } + pub fn get_by_timestamp(&self, timestamp: u64) -> Option { let current_items = self.imp().list.borrow(); let index = current_items.binary_search_by_key(×tamp, |i| i.timestamp()); diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs index b929957..747b36a 100644 --- a/src/gui/channel_messages.rs +++ b/src/gui/channel_messages.rs @@ -2,7 +2,8 @@ use crate::prelude::*; use gio::SettingsBindFlags; use crate::ApplicationError; -use crate::backend::message::TextMessage; +use crate::backend::message::{DisplayMessage, TextMessage}; +use crate::backend::timeline::TimelineItemExt; const MESSAGES_REQUEST_LOAD: usize = 10; @@ -38,6 +39,52 @@ impl ChannelMessages { self.imp().text_entry.grab_focus(); } + /// Collect timestamps of every currently-selected message in the active + /// channel. + pub fn collect_selected_timestamps(&self) -> Vec { + let Some(channel) = self.active_channel() else { + return Vec::new(); + }; + channel + .timeline() + .iter_forwards() + .filter(|i| i.is::()) + .filter_map(|i| i.dynamic_cast::().ok()) + .filter(|m| m.property::("selected")) + .map(|m| m.timestamp()) + .collect() + } + + /// Exit selection mode, clearing all per-message selection state. + pub fn exit_selection_mode(&self) { + let Some(channel) = self.active_channel() else { + return; + }; + for item in channel.timeline().iter_forwards() { + if let Some(msg) = item.dynamic_cast_ref::() + && msg.property::("selected") + { + msg.set_property("selected", false); + } + } + channel.set_selection_mode(false); + self.refresh_selection_summary(); + } + + /// Walk the timeline, count how many messages are selected, and update + /// the displayed selection summary plus the `has-selection` flag. + pub fn refresh_selection_summary(&self) { + let count = self.collect_selected_timestamps().len() as u32; + let summary = if count == 0 { + gettextrs::gettext("Select messages to delete for yourself") + } else { + gettextrs::ngettext("{} message selected", "{} messages selected", count) + .replace("{}", &count.to_string()) + }; + self.set_selection_summary(summary); + self.set_has_selection(count > 0); + } + pub fn focus_input(&self) { self.imp().text_entry.grab_focus(); } @@ -187,6 +234,45 @@ impl ChannelMessages { } } + /// Wire the selection summary so the toolbar reflects how many messages + /// are selected. Called whenever the active channel changes. + fn setup_selection_listener(&self) { + self.refresh_selection_summary(); + + // Disconnect handlers attached on the previous active channel so we + // don't accumulate one per channel switch. + for (prev_channel, handler) in self.imp().selection_handlers.take() { + prev_channel.disconnect(handler); + } + + if let Some(channel) = self.active_channel() { + let mut handlers = self.imp().selection_handlers.borrow_mut(); + let h = channel.connect_local( + "selection-changed", + false, + clone!( + #[weak(rename_to = s)] + self, + #[upgrade_or_default] + move |_| { + s.refresh_selection_summary(); + None + } + ), + ); + handlers.push((channel.clone(), h)); + let h = channel.connect_notify_local( + Some("selection-mode"), + clone!( + #[weak(rename_to = s)] + self, + move |_, _| s.refresh_selection_summary() + ), + ); + handlers.push((channel, h)); + } + } + /// Send a `Started` typing event for the active channel. /// /// Schedules a periodic refresh so the receiver does not let the @@ -387,6 +473,10 @@ pub mod imp { has_attachments: PhantomData, #[property(get, set)] show_typing: Cell, + #[property(get, set)] + selection_summary: RefCell, + #[property(get, set)] + has_selection: Cell, /// Whether we currently believe the user is composing a message in the /// active channel and have informed the peer with a `Started` event. @@ -437,6 +527,7 @@ pub mod imp { // Inform the previous channel we have stopped typing before we // forget about it. self.obj().send_typing_stopped(); + self.obj().exit_selection_mode(); if let Some(active_chan) = self.active_channel.borrow().as_ref() { active_chan.set_property("draft", self.text_entry.text()); @@ -449,6 +540,7 @@ pub mod imp { self.obj().focus_input(); self.obj().setup_typing_indicator(); + self.obj().setup_selection_listener(); } #[template_callback(function)] @@ -505,6 +597,41 @@ pub mod imp { self.text_entry.clear(); } + #[template_callback] + fn cancel_selection(&self) { + self.obj().exit_selection_mode(); + } + + #[template_callback] + fn delete_selection(&self) { + let obj = self.obj(); + let Some(channel) = obj.active_channel() else { + return; + }; + let timestamps = obj.collect_selected_timestamps(); + obj.exit_selection_mode(); + if timestamps.is_empty() { + return; + } + gspawn!(clone!( + #[strong] + channel, + #[strong] + obj, + async move { + if let Err(e) = channel.delete_messages_locally(timestamps).await { + let root = obj + .root() + .expect("`ChannelMessages` to have a root") + .dynamic_cast::() + .expect("Root of `ChannelMessages` to be a `Window`."); + let dialog = ErrorDialog::new(&e, &root); + dialog.present(Some(&root)); + } + } + )); + } + #[template_callback] fn remove_attachments(&self) { log::trace!("Unsetting attachments"); diff --git a/src/gui/message_item.rs b/src/gui/message_item.rs index 59d2778..d88306f 100644 --- a/src/gui/message_item.rs +++ b/src/gui/message_item.rs @@ -34,6 +34,7 @@ impl MessageItem { s.setup_text(); s.setup_requires_attention(); s.setup_pending_and_error(); + s.setup_selection(); s } @@ -102,6 +103,16 @@ impl MessageItem { s.imp().handle_edit(); } )); + let action_select = SimpleAction::new("select", None); + action_select.connect_activate(clone!( + #[weak(rename_to = s)] + self, + move |_, _| { + let msg = s.message(); + msg.channel().set_selection_mode(true); + msg.set_property("selected", true); + } + )); let actions = SimpleActionGroup::new(); self.insert_action_group("msg", Some(&actions)); @@ -111,6 +122,7 @@ impl MessageItem { actions.add_action(&action_download); actions.add_action(&action_edit); actions.add_action(&action_open); + actions.add_action(&action_select); self.bind_property("message", &action_delete, "enabled") .transform_to(|_, msg: Option| msg.map(|m| m.sender().is_self())) @@ -236,6 +248,22 @@ impl MessageItem { self.imp().msg_menu.popup(); } + /// Connect a notify handler on `target` and remember its handler id so + /// we can disconnect it when the MessageItem is disposed; without this, + /// closures keep accumulating on long-lived Channel/Message objects as + /// the ListView recycles widgets across the timeline. + fn track_notify_local(&self, target: &impl IsA, name: &str, f: F) + where + F: Fn(&glib::Object, &glib::ParamSpec) + 'static, + { + let target_obj = target.clone().upcast::(); + let handler = target_obj.connect_notify_local(Some(name), f); + self.imp() + .tracked_handlers + .borrow_mut() + .push((target_obj, handler)); + } + /// Set whether this item should show its header. pub fn set_show_header(&self) { let visible = self.message().show_header() || self.property("force-show-header"); @@ -330,6 +358,81 @@ impl MessageItem { message.notify("pending"); message.notify("error"); } + + /// Wire the message item's selection-mode CSS class so it visually + /// reflects the channel's `selection-mode` and the message's `selected` + /// state. + pub fn setup_selection(&self) { + let message = self.message(); + let channel = message.channel(); + // The closure only updates visual state. Resetting `selected` on + // exit lives in `ChannelMessages::exit_selection_mode` (the only + // path that flips `selection-mode` back off), which guards the + // write so it does not bounce off glib's autogen notify-always + // setter. Doing the write here unconditionally would re-enter the + // notify::selected handler and recurse via the selection-changed + // signal we emit from it — visible as a cpu-bound spin on first + // load of a long timeline like Note to self. + let update = clone!( + #[weak(rename_to = s)] + self, + move || { + let chan = s.message().channel(); + let in_mode = chan.selection_mode(); + if in_mode { + s.add_css_class("in-selection-mode"); + } else { + s.remove_css_class("in-selection-mode"); + } + if s.message().property::("selected") && in_mode { + s.add_css_class("selected"); + } else { + s.remove_css_class("selected"); + } + } + ); + self.track_notify_local(&channel, "selection-mode", { + let update = update.clone(); + move |_, _| update() + }); + self.track_notify_local(&message, "selected", { + let update = update.clone(); + let weak = self.downgrade(); + move |_, _| { + update(); + if let Some(s) = weak.upgrade() { + s.message() + .channel() + .emit_by_name::<()>("selection-changed", &[]); + } + } + }); + + // While the channel is in selection mode, primary-button clicks + // anywhere on the row toggle the message's `selected` flag and the + // gesture claims the event sequence so child widgets (label links, + // attachments, the popover trigger, the check button) do not also + // act on the click. The check button itself is `can-target: false` + // in the template so its visual state is driven purely by the bind + // to `message.selected` rather than its own toggled signal. + let click = gtk::GestureClick::builder() + .button(gdk::BUTTON_PRIMARY) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + click.connect_pressed(clone!( + #[weak(rename_to = s)] + self, + move |gesture, _, _, _| { + if s.message().channel().selection_mode() { + let cur: bool = s.message().property("selected"); + s.message().set_property("selected", !cur); + gesture.set_state(gtk::EventSequenceState::Claimed); + } + } + )); + self.add_controller(click); + update(); + } } pub mod imp { @@ -360,6 +463,8 @@ pub mod imp { #[template_child] pub(super) avatar: TemplateChild, #[template_child] + pub(super) selection_check: TemplateChild, + #[template_child] pub(super) header: TemplateChild, #[template_child] pub(super) reactions: TemplateChild, @@ -399,6 +504,14 @@ pub mod imp { shows_media_loading: PhantomData, #[property(get = Self::has_reaction)] has_reaction: PhantomData, + + /// Handlers we attached on long-lived objects (the message and the + /// channel). Channel outlives every MessageItem and the timeline + /// holds messages across list-view widget recycling, so without an + /// explicit disconnect each MessageItem we ever build leaves a + /// no-op closure attached to its message and channel forever. + pub(super) tracked_handlers: + RefCell>, } #[glib::object_subclass] @@ -668,6 +781,12 @@ pub mod imp { }); SIGNALS.as_ref() } + + fn dispose(&self) { + for (target, handler) in self.tracked_handlers.take() { + target.disconnect(handler); + } + } } impl WidgetImpl for MessageItem {} -- 2.53.0