notedeck

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

commit c2ee6bfe353c42500d228aec9317aad0b1cedb28
parent 9fa47305950e2edef0f199db2f8d6bedbd71e35d
Author: alltheseas <alltheseas@users.noreply.github.com>
Date:   Mon, 10 Nov 2025 21:38:29 -0600

Implement hashtag count filtering in muting system

Add filtering logic to hide notes with excessive hashtags based on
the configurable max_hashtags_per_note setting.

Changes to Muted struct:
- Add max_hashtags_per_note field to track limit
- Add Clone derive for updating settings
- Add count_hashtags() method to count "t" tags in notes
- Implement hashtag count check in is_muted() with early return
- Update Debug impl to show max_hashtags_per_note

Changes to AccountMutedData:
- Initialize max_hashtags with DEFAULT_MAX_HASHTAGS_PER_NOTE
- Preserve max_hashtags across NIP-51 muted list updates
- Add update_max_hashtags() method for runtime updates

Changes to Accounts:
- Add update_max_hashtags_per_note() to update all accounts

Changes to AccountCache:
- Add accounts_mut() method for mutable account iteration

The filtering uses early returns and guard clauses to avoid deep
nesting, following the nevernesting principle. A value of 0 for
max_hashtags_per_note disables the filter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck/src/account/accounts.rs | 6++++++
Mcrates/notedeck/src/account/cache.rs | 4++++
Mcrates/notedeck/src/account/mute.rs | 29+++++++++++++++++++++++++----
Mcrates/notedeck/src/muted.rs | 42+++++++++++++++++++++++++++++++++++++++++-
4 files changed, 76 insertions(+), 5 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 @@ -13,6 +13,8 @@ pub(crate) struct AccountMutedData { impl AccountMutedData { pub fn new(pubkey: &[u8; 32]) -> Self { + use crate::persist::DEFAULT_MAX_HASHTAGS_PER_NOTE; + // Construct a filter for the user's NIP-51 muted list let filter = Filter::new() .authors([pubkey]) @@ -20,9 +22,12 @@ impl AccountMutedData { .limit(1) .build(); + let mut muted = Muted::default(); + muted.max_hashtags_per_note = DEFAULT_MAX_HASHTAGS_PER_NOTE; + AccountMutedData { filter, - muted: Arc::new(Muted::default()), + muted: Arc::new(muted), } } @@ -38,14 +43,22 @@ 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 { + pub(crate) fn harvest_nip51_muted( + ndb: &Ndb, + txn: &Transaction, + nks: &[NoteKey], + max_hashtags_per_note: usize, + ) -> Muted { let mut muted = Muted::default(); + muted.max_hashtags_per_note = max_hashtags_per_note; + for nk in nks.iter() { if let Ok(note) = ndb.get_note_by_key(txn, *nk) { for tag in note.tags() { @@ -92,8 +105,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,14 @@ 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(Default, 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 std::fmt::Debug for Muted { @@ -28,6 +29,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 +55,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 +92,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) }