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:
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())),
+ );
});
});