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:
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(¬e_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, ¬e, 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, ¬e, 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(¬e_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();
+ }
}
});