From 91731979312b65e9b59b2dc58be0067bc5f9f206 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:58:54 -0400 Subject: [PATCH 5/6] feat(messages): In-channel message search - Add a SearchBar above the message list that searches the currently-loaded timeline using a case-insensitive substring match against the message body. Bind it to a new channel-messages.toggle-search action wired to Ctrl+Shift+F. - Surface a match counter (current/total) and previous/next buttons next to the entry, plus reuse the existing flash_requires_attention helper to scroll to and briefly highlight the focused match. - Reset matches when the bar closes or the active channel changes. --- CHANGELOG.md | 1 + data/resources/ui/channel_messages.blp | 55 +++++++ data/resources/ui/shortcuts.blp | 5 + src/gui/channel_messages.rs | 203 ++++++++++++++++++++++++- src/gui/window.rs | 11 ++ 5 files changed, 274 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ec77a..16880cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Send formatted messages with markdown-style markers (`**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, `` `monospace` ``). - Display incoming edited messages with an `edited` indicator and edit your own sent messages from their context menu. - Multi-select messages from their context menu and delete the selection locally with a single action. +- In-channel message search (Ctrl+Shift+F) over the loaded timeline with prev/next navigation and a match counter. ## [0.20.4] - 2026-04-22 diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp index eb927f8..a166f7b 100644 --- a/data/resources/ui/channel_messages.blp +++ b/data/resources/ui/channel_messages.blp @@ -43,6 +43,61 @@ template $FlChannelMessages: Box { hexpand: true; orientation: vertical; + // In-channel message search. + SearchBar search_bar { + key-capture-widget: scrolled_window; + search-mode-enabled: bind template.search-active bidirectional; + + Adw.Clamp { + maximum-size: 600; + + Box { + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search loaded messages"); + search-changed => $on_search_query_changed() swapped; + previous-match => $on_search_previous() swapped; + next-match => $on_search_next() swapped; + stop-search => $on_search_stop() swapped; + } + + Label { + styles [ + "caption", + "dim-label", + ] + + label: bind template.search-summary; + } + + Button { + icon-name: "go-up-symbolic"; + tooltip-text: C_("tooltip", "Previous match"); + sensitive: bind template.has-matches; + clicked => $on_search_previous() swapped; + + styles [ + "flat", + ] + } + + Button { + icon-name: "go-down-symbolic"; + tooltip-text: C_("tooltip", "Next match"); + sensitive: bind template.has-matches; + clicked => $on_search_next() swapped; + + styles [ + "flat", + ] + } + } + } + } + Overlay { [overlay] Adw.Spinner { diff --git a/data/resources/ui/shortcuts.blp b/data/resources/ui/shortcuts.blp index ed2a959..79339cc 100644 --- a/data/resources/ui/shortcuts.blp +++ b/data/resources/ui/shortcuts.blp @@ -58,5 +58,10 @@ Adw.ShortcutsDialog help_overlay { title: C_("shortcut window", "Load more messages"); accelerator: "&l"; } + + Adw.ShortcutsItem { + title: C_("shortcut window", "Search messages in current channel"); + accelerator: "&f"; + } } } diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs index 747b36a..6dd8e84 100644 --- a/src/gui/channel_messages.rs +++ b/src/gui/channel_messages.rs @@ -2,7 +2,7 @@ use crate::prelude::*; use gio::SettingsBindFlags; use crate::ApplicationError; -use crate::backend::message::{DisplayMessage, TextMessage}; +use crate::backend::message::{DisplayMessage, DisplayMessageExt, TextMessage}; use crate::backend::timeline::TimelineItemExt; const MESSAGES_REQUEST_LOAD: usize = 10; @@ -85,6 +85,163 @@ impl ChannelMessages { self.set_has_selection(count > 0); } + /// Connect the `search-active` property so the entry is focused when + /// the bar opens and the matches are cleared when it closes. + fn setup_search(&self) { + self.connect_notify_local( + Some("search-active"), + clone!( + #[weak(rename_to = s)] + self, + move |_, _| { + if s.search_active() { + s.imp().search_entry.grab_focus(); + s.attach_search_message_listener(); + } else { + s.detach_search_message_listener(); + s.imp().search_entry.set_text(""); + s.imp().search_matches.replace(Vec::new()); + s.imp().search_index.set(0); + s.set_has_matches(false); + s.set_search_summary(String::new()); + } + } + ), + ); + } + + /// While the search bar is open, watch the active channel for new + /// messages so the result set stays in sync with the timeline. Only + /// one handler is alive at a time; `detach_search_message_listener` or + /// the next `attach` call clears the previous one. + fn attach_search_message_listener(&self) { + self.detach_search_message_listener(); + let Some(channel) = self.active_channel() else { + return; + }; + let handler = channel.connect_local( + "message", + false, + clone!( + #[weak(rename_to = s)] + self, + #[upgrade_or_default] + move |_| { + let query = s.imp().search_entry.text().to_string(); + s.refresh_search(&query); + None + } + ), + ); + self.imp() + .search_message_handler + .replace(Some((channel, handler))); + } + + fn detach_search_message_listener(&self) { + if let Some((channel, handler)) = self.imp().search_message_handler.take() { + channel.disconnect(handler); + } + } + + /// Re-run the loaded-message search using `query`. The search is + /// case-insensitive substring match against the message body. + pub fn refresh_search(&self, query: &str) { + let imp = self.imp(); + let Some(channel) = self.active_channel() else { + imp.search_matches.replace(Vec::new()); + imp.search_index.set(0); + self.set_has_matches(false); + self.set_search_summary(String::new()); + return; + }; + + let trimmed = query.trim(); + if trimmed.is_empty() { + imp.search_matches.replace(Vec::new()); + imp.search_index.set(0); + self.set_has_matches(false); + self.set_search_summary(String::new()); + return; + } + + let needle = trimmed.to_lowercase(); + let matches: Vec = channel + .timeline() + .iter_forwards() + .filter_map(|i| i.dynamic_cast::().ok()) + .filter(|m| { + m.body() + .map(|b| b.to_lowercase().contains(&needle)) + .unwrap_or(false) + }) + .map(|m| m.timestamp()) + .collect(); + + let total = matches.len(); + imp.search_matches.replace(matches); + // Snap to the latest match (most recent in time) by default so the + // user lands at the bottom of the conversation, matching how the + // existing scroll-to-unread heuristic works. + imp.search_index.set(total.saturating_sub(1)); + self.set_has_matches(total > 0); + self.update_search_summary(); + self.flash_current_search_match(); + } + + /// Move the search cursor to the next or previous match and flash that + /// message. + pub fn goto_search_match(&self, forwards: bool) { + let imp = self.imp(); + let total = imp.search_matches.borrow().len(); + if total == 0 { + return; + } + let current = imp.search_index.get(); + let next = if forwards { + (current + 1) % total + } else if current == 0 { + total - 1 + } else { + current - 1 + }; + imp.search_index.set(next); + self.update_search_summary(); + self.flash_current_search_match(); + } + + fn update_search_summary(&self) { + let imp = self.imp(); + let total = imp.search_matches.borrow().len(); + let summary = if total == 0 { + String::new() + } else { + // Translators: e.g. "3 of 12" indicating the focused match + // index out of total search matches. + gettextrs::gettext("{current} of {total}") + .replace("{current}", &(imp.search_index.get() + 1).to_string()) + .replace("{total}", &total.to_string()) + }; + self.set_search_summary(summary); + } + + fn flash_current_search_match(&self) { + let imp = self.imp(); + let matches = imp.search_matches.borrow(); + let Some(timestamp) = matches.get(imp.search_index.get()).copied() else { + return; + }; + drop(matches); + let Some(channel) = self.active_channel() else { + return; + }; + if let Some(item) = channel.timeline().get_by_timestamp(timestamp) + && let Some(msg) = item.dynamic_cast_ref::() + { + msg.flash_requires_attention(); + } + } + pub fn focus_input(&self) { self.imp().text_entry.grab_focus(); } @@ -446,6 +603,8 @@ pub mod imp { #[template_child] pub(super) text_entry: TemplateChild, #[template_child] + pub(super) search_entry: TemplateChild, + #[template_child] pub(super) list_view: TemplateChild, #[template_child] no_channels_page: TemplateChild, @@ -478,6 +637,25 @@ pub mod imp { #[property(get, set)] has_selection: Cell, + #[property(get, set)] + search_active: Cell, + #[property(get, set)] + search_summary: RefCell, + #[property(get, set)] + has_matches: Cell, + + /// Cached timestamps of every message currently matching the search + /// query, in chronological order. + pub(super) search_matches: RefCell>, + /// Index into `search_matches` of the currently focused match. + pub(super) search_index: Cell, + /// Handler installed on the active channel's `message` signal + /// while the search bar is open, so newly-arrived messages are + /// folded into the match set without the user re-running the + /// search by hand. + pub(super) search_message_handler: + RefCell>, + /// Whether we currently believe the user is composing a message in the /// active channel and have informed the peer with a `Started` event. pub(super) sending_typing: Cell, @@ -520,6 +698,7 @@ pub mod imp { self.obj().setup_send_on_enter(); self.obj().setup_typing_settings(); self.obj().setup_typing_send(); + self.obj().setup_search(); } } @@ -528,6 +707,7 @@ pub mod imp { // forget about it. self.obj().send_typing_stopped(); self.obj().exit_selection_mode(); + self.obj().set_search_active(false); if let Some(active_chan) = self.active_channel.borrow().as_ref() { active_chan.set_property("draft", self.text_entry.text()); @@ -820,6 +1000,27 @@ pub mod imp { } } + #[template_callback] + fn on_search_query_changed(&self) { + let query = self.search_entry.text().to_string(); + self.obj().refresh_search(&query); + } + + #[template_callback] + fn on_search_previous(&self) { + self.obj().goto_search_match(false); + } + + #[template_callback] + fn on_search_next(&self) { + self.obj().goto_search_match(true); + } + + #[template_callback] + fn on_search_stop(&self) { + self.obj().set_search_active(false); + } + #[template_callback] fn handle_row_activated(&self, row: gtk::ListBoxRow) { if let Ok(msg) = row diff --git a/src/gui/window.rs b/src/gui/window.rs index 6335f3d..ce097ce 100644 --- a/src/gui/window.rs +++ b/src/gui/window.rs @@ -20,6 +20,7 @@ impl Window { app.set_accels_for_action("window.close", &["q"]); app.set_accels_for_action("channel-messages.activate-input", &["i"]); app.set_accels_for_action("channel-messages.load-more", &["l"]); + app.set_accels_for_action("channel-messages.toggle-search", &["f"]); for i in 1..=9 { app.set_accels_for_action( &format!("channel-list.activate-channel({i})"), @@ -531,10 +532,20 @@ pub mod imp { channel_messages.load_more(); } )); + let action_toggle_search = SimpleAction::new("toggle-search", None); + action_toggle_search.connect_activate(clone!( + #[strong(rename_to = channel_messages)] + self.channel_messages, + move |_, _| { + let active = !channel_messages.search_active(); + channel_messages.set_search_active(active); + } + )); let actions = SimpleActionGroup::new(); obj.insert_action_group("channel-messages", Some(&actions)); actions.add_action(&action_activate_input); actions.add_action(&action_load_more); + actions.add_action(&action_toggle_search); // Channel list actions. -- 2.53.0