notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit fbc5d679aec8d6287f2a077b1bda38d1e68a0d2e
parent 94efc54d06413a39d5c1cd08374f56c0fa3675ef
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 14 Nov 2025 17:26:12 -0800

Merge hashtag filtering by elsat #1202

I fixed some slop in the merge commit

alltheseas (4):
      Add configurable max hashtags per note setting
      Implement hashtag count filtering in muting system
      Add UI control for max hashtags per note setting
      Fix formatting issues from CI

Diffstat:
Mcrates/notedeck/src/account/accounts.rs | 6++++++
Mcrates/notedeck/src/account/cache.rs | 4++++
Mcrates/notedeck/src/account/mute.rs | 33+++++++++++++++++++++++++--------
Mcrates/notedeck/src/muted.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck/src/persist/mod.rs | 1+
Mcrates/notedeck/src/persist/settings_handler.rs | 15+++++++++++++++
Mcrates/notedeck_columns/src/nav.rs | 11++++++++---
Mcrates/notedeck_columns/src/ui/settings.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
8 files changed, 171 insertions(+), 13 deletions(-)

diff --git a/crates/notedeck/src/account/accounts.rs b/crates/notedeck/src/account/accounts.rs @@ -272,6 +272,12 @@ impl Accounts { Box::new(Arc::clone(&account_data.muted.muted)) } + pub fn update_max_hashtags_per_note(&mut self, max_hashtags: usize) { + for account in self.cache.accounts_mut() { + account.data.muted.update_max_hashtags(max_hashtags); + } + } + pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) { let data = &self.get_selected_account().data; // send the active account's relay list subscription diff --git a/crates/notedeck/src/account/cache.rs b/crates/notedeck/src/account/cache.rs @@ -109,6 +109,10 @@ impl AccountCache { pub fn fallback(&self) -> &Pubkey { &self.fallback } + + pub(super) fn accounts_mut(&mut self) -> impl Iterator<Item = &mut UserAccount> { + self.accounts.values_mut() + } } impl<'a> IntoIterator for &'a AccountCache { diff --git a/crates/notedeck/src/account/mute.rs b/crates/notedeck/src/account/mute.rs @@ -20,10 +20,9 @@ impl AccountMutedData { .limit(1) .build(); - AccountMutedData { - filter, - muted: Arc::new(Muted::default()), - } + let muted = Arc::new(Muted::default()); + + AccountMutedData { filter, muted } } pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) { @@ -38,14 +37,24 @@ impl AccountMutedData { .iter() .map(|qr| qr.note_key) .collect::<Vec<NoteKey>>(); - let muted = Self::harvest_nip51_muted(ndb, txn, &nks); + let max_hashtags = self.muted.max_hashtags_per_note; + let muted = Self::harvest_nip51_muted(ndb, txn, &nks, max_hashtags); debug!("initial muted {:?}", muted); self.muted = Arc::new(muted); } - pub(crate) fn harvest_nip51_muted(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Muted { - let mut muted = Muted::default(); + pub(crate) fn harvest_nip51_muted( + ndb: &Ndb, + txn: &Transaction, + nks: &[NoteKey], + max_hashtags_per_note: usize, + ) -> Muted { + let mut muted = Muted { + max_hashtags_per_note, + ..Default::default() + }; + for nk in nks.iter() { if let Ok(note) = ndb.get_note_by_key(txn, *nk) { for tag in note.tags() { @@ -92,8 +101,16 @@ impl AccountMutedData { return; } - let muted = AccountMutedData::harvest_nip51_muted(ndb, txn, &nks); + let max_hashtags = self.muted.max_hashtags_per_note; + let muted = AccountMutedData::harvest_nip51_muted(ndb, txn, &nks, max_hashtags); debug!("updated muted {:?}", muted); self.muted = Arc::new(muted); } + + /// Update the max hashtags per note setting + pub fn update_max_hashtags(&mut self, max_hashtags_per_note: usize) { + let mut muted = (*self.muted).clone(); + muted.max_hashtags_per_note = max_hashtags_per_note; + self.muted = Arc::new(muted); + } } diff --git a/crates/notedeck/src/muted.rs b/crates/notedeck/src/muted.rs @@ -6,13 +6,26 @@ use std::collections::BTreeSet; // If the note is muted return a reason string, otherwise None pub type MuteFun = dyn Fn(&Note, &[u8; 32]) -> bool; -#[derive(Default)] +#[derive(Clone)] pub struct Muted { // TODO - implement private mutes pub pubkeys: BTreeSet<[u8; 32]>, pub hashtags: BTreeSet<String>, pub words: BTreeSet<String>, pub threads: BTreeSet<[u8; 32]>, + pub max_hashtags_per_note: usize, +} + +impl Default for Muted { + fn default() -> Self { + Muted { + max_hashtags_per_note: crate::persist::DEFAULT_MAX_HASHTAGS_PER_NOTE, + pubkeys: Default::default(), + hashtags: Default::default(), + words: Default::default(), + threads: Default::default(), + } + } } impl std::fmt::Debug for Muted { @@ -28,6 +41,7 @@ impl std::fmt::Debug for Muted { "threads", &self.threads.iter().map(hex::encode).collect::<Vec<_>>(), ) + .field("max_hashtags_per_note", &self.max_hashtags_per_note) .finish() } } @@ -53,6 +67,15 @@ impl Muted { */ return true; } + + // Filter notes with too many hashtags (early return on limit exceeded) + if self.max_hashtags_per_note > 0 { + let hashtag_count = self.count_hashtags(note); + if hashtag_count > self.max_hashtags_per_note { + return true; + } + } + // FIXME - Implement hashtag muting here // TODO - let's not add this for now, we will likely need to @@ -81,6 +104,35 @@ impl Muted { false } + /// Count the number of hashtags in a note by examining its tags + fn count_hashtags(&self, note: &Note) -> usize { + let mut count = 0; + + for tag in note.tags() { + // Early continue if not enough elements + if tag.count() < 2 { + continue; + } + + // Check if this is a hashtag tag (type "t") + let tag_type = match tag.get_unchecked(0).variant().str() { + Some(t) => t, + None => continue, + }; + + if tag_type != "t" { + continue; + } + + // Verify the hashtag value exists + if tag.get_unchecked(1).variant().str().is_some() { + count += 1; + } + } + + count + } + pub fn is_pk_muted(&self, pk: &[u8; 32]) -> bool { self.pubkeys.contains(pk) } diff --git a/crates/notedeck/src/persist/mod.rs b/crates/notedeck/src/persist/mod.rs @@ -5,5 +5,6 @@ mod token_handler; pub use app_size::AppSizeHandler; pub use settings_handler::Settings; pub use settings_handler::SettingsHandler; +pub use settings_handler::DEFAULT_MAX_HASHTAGS_PER_NOTE; pub use settings_handler::DEFAULT_NOTE_BODY_FONT_SIZE; pub use token_handler::TokenHandler; diff --git a/crates/notedeck/src/persist/settings_handler.rs b/crates/notedeck/src/persist/settings_handler.rs @@ -18,6 +18,7 @@ const DEFAULT_SHOW_REPLIES_NEWEST_FIRST: bool = false; pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 13.0; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 16.0; +pub const DEFAULT_MAX_HASHTAGS_PER_NOTE: usize = 3; fn deserialize_theme(serialized_theme: &str) -> Option<ThemePreference> { match serialized_theme { @@ -38,6 +39,7 @@ pub struct Settings { pub note_body_font_size: f32, #[serde(default = "default_animate_nav_transitions")] pub animate_nav_transitions: bool, + pub max_hashtags_per_note: usize, } fn default_animate_nav_transitions() -> bool { @@ -54,6 +56,7 @@ impl Default for Settings { show_replies_newest_first: DEFAULT_SHOW_REPLIES_NEWEST_FIRST, note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE, animate_nav_transitions: default_animate_nav_transitions(), + max_hashtags_per_note: DEFAULT_MAX_HASHTAGS_PER_NOTE, } } } @@ -203,6 +206,11 @@ impl SettingsHandler { self.try_save_settings(); } + pub fn set_max_hashtags_per_note(&mut self, value: usize) { + self.get_settings_mut().max_hashtags_per_note = value; + self.try_save_settings(); + } + pub fn update_batch<F>(&mut self, update_fn: F) where F: FnOnce(&mut Settings), @@ -262,4 +270,11 @@ impl SettingsHandler { .map(|s| s.note_body_font_size) .unwrap_or(DEFAULT_NOTE_BODY_FONT_SIZE) } + + pub fn max_hashtags_per_note(&self) -> usize { + self.current_settings + .as_ref() + .map(|s| s.max_hashtags_per_note) + .unwrap_or(DEFAULT_MAX_HASHTAGS_PER_NOTE) + } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -568,9 +568,14 @@ fn process_render_nav_action( .process_relay_action(ui.ctx(), ctx.pool, action); None } - RenderNavAction::SettingsAction(action) => { - action.process_settings_action(app, ctx.settings, ctx.i18n, ctx.img_cache, ui.ctx()) - } + RenderNavAction::SettingsAction(action) => action.process_settings_action( + app, + ctx.settings, + ctx.i18n, + ctx.img_cache, + ui.ctx(), + ctx.accounts, + ), RenderNavAction::RepostAction(action) => { action.process(ctx.ndb, &ctx.accounts.get_selected_account().key, ctx.pool) } diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -9,7 +9,7 @@ use notedeck::{ tr, ui::{is_narrow, richtext_small}, Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings, - SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE, + SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE, DEFAULT_NOTE_BODY_FONT_SIZE, }; use notedeck_ui::{ app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image}, @@ -36,6 +36,7 @@ pub enum SettingsAction { SetRepliestNewestFirst(bool), SetNoteBodyFontSize(f32), SetAnimateNavTransitions(bool), + SetMaxHashtagsPerNote(usize), OpenRelays, OpenCacheFolder, ClearCacheFolder, @@ -49,6 +50,7 @@ impl SettingsAction { i18n: &'a mut Localization, img_cache: &mut Images, ctx: &egui::Context, + accounts: &mut notedeck::Accounts, ) -> Option<RouterAction> { let mut route_action: Option<RouterAction> = None; @@ -90,9 +92,15 @@ impl SettingsAction { settings.set_note_body_font_size(size); } + Self::SetAnimateNavTransitions(value) => { settings.set_animate_nav_transitions(value); } + + Self::SetMaxHashtagsPerNote(value) => { + settings.set_max_hashtags_per_note(value); + accounts.update_max_hashtags_per_note(value); + } } route_action } @@ -493,6 +501,56 @@ impl<'a> SettingsView<'a> { self.settings.animate_nav_transitions, )); } + + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Max hashtags per note:", + "Label for max hashtags per note, others settings section", + ))); + + if ui + .add( + egui::Slider::new(&mut self.settings.max_hashtags_per_note, 0..=20) + .text("") + .step_by(1.0), + ) + .changed() + { + action = Some(SettingsAction::SetMaxHashtagsPerNote( + self.settings.max_hashtags_per_note, + )); + }; + + if ui + .button(richtext_small(tr!( + self.note_context.i18n, + "Reset", + "Label for reset max hashtags per note, others settings section", + ))) + .clicked() + { + action = Some(SettingsAction::SetMaxHashtagsPerNote( + DEFAULT_MAX_HASHTAGS_PER_NOTE, + )); + } + }); + + ui.horizontal_wrapped(|ui| { + let text = if self.settings.max_hashtags_per_note == 0 { + tr!( + self.note_context.i18n, + "Hashtag filter disabled", + "Info text when hashtag filter is disabled (set to 0)" + ) + } else { + format!( + "Hide posts with more than {} hashtags", + self.settings.max_hashtags_per_note + ) + }; + ui.label( + richtext_small(&text).color(ui.visuals().gray_out(ui.visuals().text_color())), + ); }); });