notedeck

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

commit b3bc440aa4f3757b1362116679572b457a98924b
parent d068c7b373505cd14e0b925715367958b9a5e072
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 12 Feb 2026 14:47:12 -0800

mute: add mute user action from note and profile context menus

Add MuteUser to both NoteContextSelection and ProfileContextSelection.
When clicked, publishes an updated NIP-51 kind 10000 mute list event
that appends the target pubkey. Existing mute list tags are preserved
via builder_from_note. The button is hidden when the user has no
secret key.

Also moves builder_from_note and send_note_builder into the notedeck
crate (note::publish) for reuse across crates.

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

Diffstat:
Mcrates/notedeck/src/lib.rs | 5+++--
Mcrates/notedeck/src/note/context.rs | 19++++++++++++++++++-
Mcrates/notedeck/src/note/mod.rs | 2++
Acrates/notedeck/src/note/publish.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/profile/context.rs | 5++++-
Mcrates/notedeck_columns/src/actionbar.rs | 4+++-
Mcrates/notedeck_columns/src/profile.rs | 74++++++++++++++++++++++----------------------------------------------------
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 17++++++++++++++---
Mcrates/notedeck_ui/src/note/context.rs | 14++++++++++++++
Mcrates/notedeck_ui/src/note/mod.rs | 21+++++++++++++++++++--
Mcrates/notedeck_ui/src/profile/context.rs | 14++++++++++++++
11 files changed, 268 insertions(+), 62 deletions(-)

diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -73,8 +73,9 @@ pub use nav::DragResponse; pub use nip05::{Nip05Cache, Nip05Status}; pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache}; pub use note::{ - get_p_tags, BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, - NoteRef, RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction, + builder_from_note, get_p_tags, send_mute_event, send_note_builder, send_unmute_event, + BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, NoteRef, + RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction, }; pub use notecache::{CachedNote, NoteCache}; pub use options::NotedeckOptions; diff --git a/crates/notedeck/src/note/context.rs b/crates/notedeck/src/note/context.rs @@ -1,7 +1,9 @@ use enostr::{ClientMessage, NoteId, Pubkey, RelayPool}; -use nostrdb::{Note, NoteKey, Transaction}; +use nostrdb::{Ndb, Note, NoteKey, Transaction}; use tracing::error; +use crate::Accounts; + /// When broadcasting notes, this determines whether to broadcast /// over the local network via multicast, or globally #[derive(Debug, Clone, Eq, PartialEq)] @@ -19,6 +21,7 @@ pub enum NoteContextSelection { CopyNoteJSON, Broadcast(BroadcastContext), CopyNeventLink, + MuteUser, } #[derive(Debug, Eq, PartialEq, Clone)] @@ -47,8 +50,10 @@ impl NoteContextSelection { &self, ui: &mut egui::Ui, note: &Note<'_>, + ndb: &Ndb, pool: &mut RelayPool, txn: &Transaction, + accounts: &Accounts, ) { match self { NoteContextSelection::Broadcast(context) => { @@ -92,6 +97,18 @@ impl NoteContextSelection { ui.ctx().copy_text(damus_url(bech)); } } + NoteContextSelection::MuteUser => { + let target = Pubkey::new(*note.pubkey()); + let Some(kp) = accounts.get_selected_account().key.to_full() else { + return; + }; + let muted = accounts.mute(); + if muted.is_pk_muted(target.bytes()) { + super::publish::send_unmute_event(ndb, txn, pool, kp, &muted, &target); + } else { + super::publish::send_mute_event(ndb, txn, pool, kp, &muted, &target); + } + } } } } diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -1,8 +1,10 @@ mod action; mod context; +pub mod publish; pub use action::{NoteAction, ReactAction, ScrollInfo, ZapAction, ZapTargetAmount}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; +pub use publish::{builder_from_note, send_mute_event, send_note_builder, send_unmute_event}; use crate::jobs::MediaJobSender; use crate::nip05::Nip05Cache; diff --git a/crates/notedeck/src/note/publish.rs b/crates/notedeck/src/note/publish.rs @@ -0,0 +1,155 @@ +use enostr::{FilledKeypair, Pubkey, RelayPool}; +use nostrdb::{Filter, Ndb, Note, NoteBuildOptions, NoteBuilder, Transaction}; +use tracing::info; + +use crate::Muted; + +pub fn builder_from_note<F>(note: Note<'_>, skip_tag: Option<F>) -> NoteBuilder<'_> +where + F: Fn(&nostrdb::Tag<'_>) -> bool, +{ + let mut builder = NoteBuilder::new(); + + builder = builder.content(note.content()); + builder = builder.options(NoteBuildOptions::default()); + builder = builder.kind(note.kind()); + builder = builder.pubkey(note.pubkey()); + + for tag in note.tags() { + if let Some(skip) = &skip_tag { + if skip(&tag) { + continue; + } + } + + builder = builder.start_tag(); + for tag_item in tag { + builder = match tag_item.variant() { + nostrdb::NdbStrVariant::Id(i) => builder.tag_id(i), + nostrdb::NdbStrVariant::Str(s) => builder.tag_str(s), + }; + } + } + + builder +} + +pub fn send_note_builder(builder: NoteBuilder, ndb: &Ndb, pool: &mut RelayPool, kp: FilledKeypair) { + let note = builder + .sign(&kp.secret_key.secret_bytes()) + .build() + .expect("build note"); + + let Ok(event) = &enostr::ClientMessage::event(&note) else { + tracing::error!("send_note_builder: failed to build json"); + return; + }; + + let Ok(json) = event.to_json() else { + tracing::error!("send_note_builder: failed to build json"); + return; + }; + + let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); + info!("sending {}", &json); + pool.send(event); +} + +pub fn send_unmute_event( + ndb: &Ndb, + txn: &Transaction, + pool: &mut RelayPool, + kp: FilledKeypair, + muted: &Muted, + target: &Pubkey, +) { + if !muted.is_pk_muted(target.bytes()) { + tracing::info!("pubkey {} is not muted, nothing to unmute", target.hex()); + return; + } + + let filter = Filter::new() + .authors([kp.pubkey.bytes()]) + .kinds([10000]) + .limit(1) + .build(); + + let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; + + let Some(existing_note) = ndb + .query(txn, std::slice::from_ref(&filter), lim) + .ok() + .and_then(|results| results.first().map(|qr| qr.note_key)) + .and_then(|nk| ndb.get_note_by_key(txn, nk).ok()) + else { + tracing::warn!("no existing kind 10000 mute list found, nothing to unmute from"); + return; + }; + + let target_bytes = target.bytes(); + let builder = builder_from_note( + existing_note, + Some(|tag: &nostrdb::Tag<'_>| { + if tag.count() < 2 { + return false; + } + let Some("p") = tag.get_str(0) else { + return false; + }; + let Some(val) = tag.get_id(1) else { + return false; + }; + val == target_bytes + }), + ); + + send_note_builder(builder, ndb, pool, kp); +} + +pub fn send_mute_event( + ndb: &Ndb, + txn: &Transaction, + pool: &mut RelayPool, + kp: FilledKeypair, + muted: &Muted, + target: &Pubkey, +) { + if muted.is_pk_muted(target.bytes()) { + tracing::info!("pubkey {} is already muted", target.hex()); + return; + } + + // Query for the existing mute list (kind 10000) + let filter = Filter::new() + .authors([kp.pubkey.bytes()]) + .kinds([10000]) + .limit(1) + .build(); + + let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; + + let existing_note = ndb + .query(txn, std::slice::from_ref(&filter), lim) + .ok() + .and_then(|results| results.first().map(|qr| qr.note_key)) + .and_then(|nk| ndb.get_note_by_key(txn, nk).ok()); + + let builder = if let Some(note) = existing_note { + // Append new "p" tag to existing mute list + builder_from_note(note, None::<fn(&nostrdb::Tag<'_>) -> bool>) + .start_tag() + .tag_str("p") + .tag_str(&target.hex()) + } else { + // Create a fresh mute list + NoteBuilder::new() + .content("") + .kind(10000) + .options(NoteBuildOptions::default()) + .start_tag() + .tag_str("p") + .tag_str(&target.hex()) + }; + + send_note_builder(builder, ndb, pool, kp); +} diff --git a/crates/notedeck/src/profile/context.rs b/crates/notedeck/src/profile/context.rs @@ -4,6 +4,7 @@ pub enum ProfileContextSelection { AddProfileColumn, CopyLink, ViewAs, + MuteUser, } pub struct ProfileContext { @@ -21,7 +22,9 @@ impl ProfileContextSelection { ctx.copy_text(format!("https://damus.io/{npub}")); } - ProfileContextSelection::ViewAs | ProfileContextSelection::AddProfileColumn => { + ProfileContextSelection::ViewAs + | ProfileContextSelection::AddProfileColumn + | ProfileContextSelection::MuteUser => { // handled separately in profile.rs } } diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -209,7 +209,9 @@ fn execute_note_action( NoteAction::Context(context) => match ndb.get_note_by_key(txn, context.note_key) { Err(err) => tracing::error!("{err}"), Ok(note) => { - context.action.process_selection(ui, &note, pool, txn); + context + .action + .process_selection(ui, &note, ndb, pool, txn, accounts); } }, NoteAction::Media(media_action) => { diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -1,7 +1,10 @@ use enostr::{FilledKeypair, FullKeypair, ProfileState, Pubkey, RelayPool}; use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder, Transaction}; -use notedeck::{Accounts, ContactState, DataPath, Localization, ProfileContext}; +use notedeck::{ + builder_from_note, send_mute_event, send_note_builder, Accounts, ContactState, DataPath, + Localization, ProfileContext, +}; use tracing::info; use crate::{column::Column, nav::RouterAction, route::Route, storage, Damus}; @@ -112,6 +115,24 @@ impl ProfileAction { None } + ProfileContextSelection::MuteUser => { + let kp = accounts.get_selected_account().key.to_full()?; + let muted = accounts.mute(); + let txn = Transaction::new(ndb).expect("txn"); + if muted.is_pk_muted(profile_context.profile.bytes()) { + notedeck::send_unmute_event( + ndb, + &txn, + pool, + kp, + &muted, + &profile_context.profile, + ); + } else { + send_mute_event(ndb, &txn, pool, kp, &muted, &profile_context.profile); + } + None + } _ => { profile_context .selection @@ -142,36 +163,6 @@ impl ProfileAction { } } -pub fn builder_from_note<F>(note: Note<'_>, skip_tag: Option<F>) -> NoteBuilder<'_> -where - F: Fn(&nostrdb::Tag<'_>) -> bool, -{ - let mut builder = NoteBuilder::new(); - - builder = builder.content(note.content()); - builder = builder.options(NoteBuildOptions::default()); - builder = builder.kind(note.kind()); - builder = builder.pubkey(note.pubkey()); - - for tag in note.tags() { - if let Some(skip) = &skip_tag { - if skip(&tag) { - continue; - } - } - - builder = builder.start_tag(); - for tag_item in tag { - builder = match tag_item.variant() { - nostrdb::NdbStrVariant::Id(i) => builder.tag_id(i), - nostrdb::NdbStrVariant::Str(s) => builder.tag_str(s), - }; - } - } - - builder -} - enum FollowAction<'a> { Follow(&'a Pubkey), Unfollow(&'a Pubkey), @@ -240,27 +231,6 @@ fn send_kind_3_event(ndb: &Ndb, pool: &mut RelayPool, accounts: &Accounts, actio send_note_builder(builder, ndb, pool, kp); } -fn send_note_builder(builder: NoteBuilder, ndb: &Ndb, pool: &mut RelayPool, kp: FilledKeypair) { - let note = builder - .sign(&kp.secret_key.secret_bytes()) - .build() - .expect("build note"); - - let Ok(event) = &enostr::ClientMessage::event(&note) else { - tracing::error!("send_note_builder: failed to build json"); - return; - }; - - let Ok(json) = event.to_json() else { - tracing::error!("send_note_builder: failed to build json"); - return; - }; - - let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); - info!("sending {}", &json); - pool.send(event); -} - pub fn send_new_contact_list( kp: FilledKeypair, ndb: &Ndb, diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -166,9 +166,20 @@ fn profile_body( }; let context_resp = ProfileContextWidget::new(place_context).context_button(ui, pubkey); - if let Some(selection) = - ProfileContextWidget::context_menu(ui, note_context.i18n, context_resp) - { + let can_sign = note_context + .accounts + .get_selected_account() + .key + .secret_key + .is_some(); + let is_muted = note_context.accounts.mute().is_pk_muted(pubkey.bytes()); + if let Some(selection) = ProfileContextWidget::context_menu( + ui, + note_context.i18n, + context_resp, + can_sign, + is_muted, + ) { action = Some(ProfileViewAction::Context(ProfileContext { profile: *pubkey, selection, diff --git a/crates/notedeck_ui/src/note/context.rs b/crates/notedeck_ui/src/note/context.rs @@ -65,6 +65,8 @@ impl NoteContextButton { ui: &mut egui::Ui, i18n: &mut Localization, button_response: egui::Response, + can_sign: bool, + is_muted: bool, ) -> Option<NoteContextSelection> { let mut context_selection: Option<NoteContextSelection> = None; @@ -154,6 +156,18 @@ impl NoteContextButton { )); ui.close_menu(); } + + if can_sign { + let label = if is_muted { + tr!(i18n, "Unmute User", "Unmute the author of this note") + } else { + tr!(i18n, "Mute User", "Mute the author of this note") + }; + if ui.button(label).clicked() { + context_selection = Some(NoteContextSelection::MuteUser); + ui.close_menu(); + } + } }); context_selection diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -641,8 +641,25 @@ impl<'a, 'd> NoteView<'a, 'd> { }; let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); - if let Some(action) = NoteContextButton::menu(ui, self.note_context.i18n, resp.clone()) - { + let can_sign = self + .note_context + .accounts + .get_selected_account() + .key + .secret_key + .is_some(); + let is_muted = self + .note_context + .accounts + .mute() + .is_pk_muted(self.note.pubkey()); + if let Some(action) = NoteContextButton::menu( + ui, + self.note_context.i18n, + resp.clone(), + can_sign, + is_muted, + ) { note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); } } diff --git a/crates/notedeck_ui/src/profile/context.rs b/crates/notedeck_ui/src/profile/context.rs @@ -28,6 +28,8 @@ impl ProfileContextWidget { ui: &mut egui::Ui, i18n: &mut Localization, button_response: egui::Response, + can_sign: bool, + is_muted: bool, ) -> Option<ProfileContextSelection> { let mut context_selection: Option<ProfileContextSelection> = None; @@ -65,6 +67,18 @@ impl ProfileContextWidget { context_selection = Some(ProfileContextSelection::CopyLink); ui.close_menu(); } + + if can_sign { + let label = if is_muted { + tr!(i18n, "Unmute User", "Unmute this user's content") + } else { + tr!(i18n, "Mute User", "Mute this user's content") + }; + if ui.button(label).clicked() { + context_selection = Some(ProfileContextSelection::MuteUser); + ui.close_menu(); + } + } }); context_selection