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:
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)
}