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

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

View File

@@ -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