notedeck

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

commit 8a5c28ed87ba4af5254a874edae43ce8cd6750a2
parent ff673644614e0b5b3ef1a4ab3eb12d0f9d8790b7
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 13 Feb 2026 15:13:30 -0800

report: add NIP-56 report events from note and profile context menus

Add the ability to report users and notes for objectionable content
using NIP-56 kind 1984 events. Selecting "Report Note" or "Report User"
from context menus opens a popup sheet where users pick a reason
(spam, nudity, profanity, illegal, impersonation, other) and submit.

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

Diffstat:
Mcrates/notedeck/src/lib.rs | 7++++---
Mcrates/notedeck/src/note/context.rs | 2++
Mcrates/notedeck/src/note/mod.rs | 5++++-
Mcrates/notedeck/src/note/publish.rs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck/src/profile/context.rs | 6+++---
Mcrates/notedeck_columns/src/actionbar.rs | 17++++++++++++++---
Mcrates/notedeck_columns/src/app.rs | 1+
Mcrates/notedeck_columns/src/nav.rs | 16++++++++++++++++
Mcrates/notedeck_columns/src/profile.rs | 10++++++++++
Mcrates/notedeck_columns/src/route.rs | 23++++++++++++++++++++++-
Mcrates/notedeck_columns/src/ui/column/header.rs | 1+
Mcrates/notedeck_columns/src/ui/mod.rs | 1+
Acrates/notedeck_columns/src/ui/report.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/view_state.rs | 4++++
Mcrates/notedeck_ui/src/note/context.rs | 13++++++++++++-
Mcrates/notedeck_ui/src/profile/context.rs | 12++++++++++++
16 files changed, 261 insertions(+), 13 deletions(-)

diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -73,9 +73,10 @@ pub use nav::DragResponse; pub use nip05::{Nip05Cache, Nip05Status}; pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache}; pub use note::{ - 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, + builder_from_note, get_p_tags, send_mute_event, send_note_builder, send_report_event, + send_unmute_event, BroadcastContext, ContextSelection, NoteAction, NoteContext, + NoteContextSelection, NoteRef, ReportTarget, ReportType, 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 @@ -22,6 +22,7 @@ pub enum NoteContextSelection { Broadcast(BroadcastContext), CopyNeventLink, MuteUser, + ReportUser, } #[derive(Debug, Eq, PartialEq, Clone)] @@ -109,6 +110,7 @@ impl NoteContextSelection { super::publish::send_mute_event(ndb, txn, pool, kp, &muted, &target); } } + NoteContextSelection::ReportUser => {} } } } diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -4,7 +4,10 @@ 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}; +pub use publish::{ + builder_from_note, send_mute_event, send_note_builder, send_report_event, send_unmute_event, + ReportTarget, ReportType, +}; use crate::jobs::MediaJobSender; use crate::nip05::Nip05Cache; diff --git a/crates/notedeck/src/note/publish.rs b/crates/notedeck/src/note/publish.rs @@ -1,9 +1,58 @@ -use enostr::{FilledKeypair, Pubkey, RelayPool}; +use enostr::{FilledKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteBuildOptions, NoteBuilder, Transaction}; use tracing::info; use crate::Muted; +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ReportType { + Spam, + Nudity, + Profanity, + Illegal, + Impersonation, + Other, +} + +impl ReportType { + pub fn as_str(&self) -> &'static str { + match self { + ReportType::Spam => "spam", + ReportType::Nudity => "nudity", + ReportType::Profanity => "profanity", + ReportType::Illegal => "illegal", + ReportType::Impersonation => "impersonation", + ReportType::Other => "other", + } + } + + pub fn label(&self) -> &'static str { + match self { + ReportType::Spam => "Spam", + ReportType::Nudity => "Nudity", + ReportType::Profanity => "Profanity", + ReportType::Illegal => "Illegal", + ReportType::Impersonation => "Impersonation", + ReportType::Other => "Other", + } + } + + pub const ALL: &'static [ReportType] = &[ + ReportType::Spam, + ReportType::Nudity, + ReportType::Profanity, + ReportType::Illegal, + ReportType::Impersonation, + ReportType::Other, + ]; +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ReportTarget { + pub pubkey: Pubkey, + pub note_id: Option<NoteId>, +} + pub fn builder_from_note<F>(note: Note<'_>, skip_tag: Option<F>) -> NoteBuilder<'_> where F: Fn(&nostrdb::Tag<'_>) -> bool, @@ -153,3 +202,32 @@ pub fn send_mute_event( send_note_builder(builder, ndb, pool, kp); } + +pub fn send_report_event( + ndb: &Ndb, + pool: &mut RelayPool, + kp: FilledKeypair, + target: &ReportTarget, + report_type: ReportType, +) { + let report_str = report_type.as_str(); + + let mut builder = NoteBuilder::new() + .content("") + .kind(1984) + .options(NoteBuildOptions::default()) + .start_tag() + .tag_str("p") + .tag_str(&target.pubkey.hex()) + .tag_str(report_str); + + if let Some(note_id) = &target.note_id { + builder = builder + .start_tag() + .tag_str("e") + .tag_str(&note_id.hex()) + .tag_str(report_str); + } + + send_note_builder(builder, ndb, pool, kp); +} diff --git a/crates/notedeck/src/profile/context.rs b/crates/notedeck/src/profile/context.rs @@ -5,6 +5,7 @@ pub enum ProfileContextSelection { CopyLink, ViewAs, MuteUser, + ReportUser, } pub struct ProfileContext { @@ -24,9 +25,8 @@ impl ProfileContextSelection { } ProfileContextSelection::ViewAs | ProfileContextSelection::AddProfileColumn - | ProfileContextSelection::MuteUser => { - // handled separately in profile.rs - } + | ProfileContextSelection::MuteUser + | ProfileContextSelection::ReportUser => {} } } } diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -209,9 +209,20 @@ 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, ndb, pool, txn, accounts); + if matches!(context.action, notedeck::NoteContextSelection::ReportUser) { + let target = notedeck::ReportTarget { + pubkey: Pubkey::new(*note.pubkey()), + note_id: Some(NoteId::new(*note.id())), + }; + router_action = Some(RouterAction::route_to_sheet( + Route::Report(target), + egui_nav::Split::AbsoluteFromBottom(300.0), + )); + } else { + context + .action + .process_selection(ui, &note, ndb, pool, txn, accounts); + } } }, NoteAction::Media(media_action) => { diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -840,6 +840,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::Following(_) => false, Route::FollowedBy(_) => false, Route::TosAcceptance => false, + Route::Report(_) => false, } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -1122,6 +1122,22 @@ fn render_nav_body( DragResponse::output(RepostDecisionView::new(note_id).show(ui)) .map_output(RenderNavAction::RepostAction) } + Route::Report(target) => { + let Some(kp) = ctx.accounts.selected_filled() else { + return DragResponse::output(Some(RenderNavAction::Back)); + }; + + let resp = + ui::report::ReportView::new(&mut app.view_state.selected_report_type).show(ui); + + if let Some(report_type) = resp { + notedeck::send_report_event(ctx.ndb, ctx.pool, kp, target, report_type); + app.view_state.selected_report_type = None; + return DragResponse::output(Some(RenderNavAction::Back)); + } + + DragResponse::none() + } } } diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -133,6 +133,16 @@ impl ProfileAction { } None } + ProfileContextSelection::ReportUser => { + let target = notedeck::ReportTarget { + pubkey: profile_context.profile, + note_id: None, + }; + Some(RouterAction::route_to_sheet( + Route::Report(target), + egui_nav::Split::AbsoluteFromBottom(340.0), + )) + } _ => { profile_context .selection diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -2,7 +2,8 @@ use egui_nav::{Percent, ReturnType}; use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::Ndb; use notedeck::{ - tr, Localization, NoteZapTargetOwned, ReplacementType, RootNoteIdBuf, Router, WalletType, + tr, Localization, NoteZapTargetOwned, ReplacementType, ReportTarget, RootNoteIdBuf, Router, + WalletType, }; use std::ops::Range; @@ -38,6 +39,7 @@ pub enum Route { Following(Pubkey), FollowedBy(Pubkey), TosAcceptance, + Report(ReportTarget), } impl Route { @@ -156,6 +158,13 @@ impl Route { Route::TosAcceptance => { writer.write_token("tos"); } + Route::Report(target) => { + writer.write_token("report"); + writer.write_token(&target.pubkey.hex()); + if let Some(note_id) = &target.note_id { + writer.write_token(&note_id.hex()); + } + } } } @@ -299,6 +308,15 @@ impl Route { Ok(Route::TosAcceptance) }) }, + |p| { + p.parse_all(|p| { + p.parse_token("report")?; + let pubkey = Pubkey::from_hex(p.pull_token()?) + .map_err(|_| ParseError::HexDecodeFailed)?; + let note_id = p.pull_token().ok().and_then(|t| NoteId::from_hex(t).ok()); + Ok(Route::Report(ReportTarget { pubkey, note_id })) + }) + }, ], ) } @@ -430,6 +448,9 @@ impl Route { "Terms of Service", "Column title for TOS acceptance screen" )), + Route::Report(_) => { + ColumnTitle::formatted(tr!(i18n, "Report", "Column title for report screen")) + } } } } diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -549,6 +549,7 @@ impl<'a> NavTitle<'a> { Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), Route::TosAcceptance => None, + Route::Report(_) => None, } } diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -12,6 +12,7 @@ pub mod post; pub mod preview; pub mod profile; pub mod relay; +pub mod report; pub mod repost; pub mod search; pub mod settings; diff --git a/crates/notedeck_columns/src/ui/report.rs b/crates/notedeck_columns/src/ui/report.rs @@ -0,0 +1,76 @@ +use egui::{vec2, Margin, RichText, Sense}; +use notedeck::{fonts::get_font_size, NotedeckTextStyle, ReportType}; +use notedeck_ui::galley_centered_pos; + +pub struct ReportView<'a> { + selected: &'a mut Option<ReportType>, +} + +impl<'a> ReportView<'a> { + pub fn new(selected: &'a mut Option<ReportType>) -> Self { + Self { selected } + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> Option<ReportType> { + let mut action = None; + + egui::Frame::new() + .inner_margin(Margin::symmetric(48, 24)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 8.0); + + ui.add(egui::Label::new( + RichText::new("Report") + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading3)), + )); + + ui.add_space(4.0); + + for report_type in ReportType::ALL { + let is_selected = *self.selected == Some(*report_type); + if ui + .radio(is_selected, report_type.label()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + *self.selected = Some(*report_type); + } + } + + ui.add_space(8.0); + + let can_submit = self.selected.is_some(); + + let resp = ui.allocate_response(vec2(ui.available_width(), 40.0), Sense::click()); + + let fill = if !can_submit { + ui.visuals().widgets.inactive.bg_fill + } else if resp.hovered() { + notedeck_ui::colors::PINK.gamma_multiply(0.8) + } else { + notedeck_ui::colors::PINK + }; + + let painter = ui.painter_at(resp.rect); + painter.rect_filled(resp.rect, egui::CornerRadius::same(20), fill); + + let galley = painter.layout_no_wrap( + "Submit Report".to_owned(), + NotedeckTextStyle::Body.get_font_id(ui.ctx()), + egui::Color32::WHITE, + ); + + painter.galley( + galley_centered_pos(&galley, resp.rect.center()), + galley, + egui::Color32::WHITE, + ); + + if can_submit && resp.clicked() { + action = *self.selected; + } + }); + + action + } +} diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use enostr::Pubkey; +use notedeck::ReportType; use notedeck_ui::nip51_set::Nip51SetUiCache; use crate::deck_state::DeckState; @@ -33,6 +34,9 @@ pub struct ViewState { /// TOS acceptance screen checkbox state pub tos_age_confirmed: bool, pub tos_confirmed: bool, + + /// Report screen selected report type + pub selected_report_type: Option<ReportType>, } impl ViewState { diff --git a/crates/notedeck_ui/src/note/context.rs b/crates/notedeck_ui/src/note/context.rs @@ -91,7 +91,6 @@ impl NoteContextButton { "Copy Text", "Copy the text content of the note to clipboard" ); - tracing::debug!("Copy Text translation: '{}'", copy_text); if ui.button(copy_text).clicked() { context_selection = Some(NoteContextSelection::CopyText); @@ -167,6 +166,18 @@ impl NoteContextButton { context_selection = Some(NoteContextSelection::MuteUser); ui.close_menu(); } + + if ui + .button(tr!( + i18n, + "Report Note", + "Report this note for objectionable content" + )) + .clicked() + { + context_selection = Some(NoteContextSelection::ReportUser); + ui.close_menu(); + } } }); diff --git a/crates/notedeck_ui/src/profile/context.rs b/crates/notedeck_ui/src/profile/context.rs @@ -78,6 +78,18 @@ impl ProfileContextWidget { context_selection = Some(ProfileContextSelection::MuteUser); ui.close_menu(); } + + if ui + .button(tr!( + i18n, + "Report User", + "Report this user for objectionable content" + )) + .clicked() + { + context_selection = Some(ProfileContextSelection::ReportUser); + ui.close_menu(); + } } });