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:
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(¬e) 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, ¬e, pool, txn);
+ context
+ .action
+ .process_selection(ui, ¬e, 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(¬e) 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