From 411aef9135137ed2a44ca706c190bdd1a77836a4 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:24:54 +0200 Subject: [PATCH] Add mute, block, and report controls (#64) * Add mute, block, and report controls Adds a ModerationMenu (mute/block/report) to notes and profiles, backed by a NIP-51 mute list (kind 10000, private by default via NIP-44) and NIP-56 reports (kind 1984). Blocking mutes and also removes the account from the follow list. Muted accounts are filtered out of the feed and notifications, and a new Settings section lists them with an Unmute control to reverse either choice. Closes #51. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * Fix mute-list review findings - decryptPrivateTags now marks non-array decrypted JSON as unreadable (ok: false) instead of silently treating it as an empty tag list, so a rewrite can't clobber private entries it failed to parse. - useSetPubkeyMuted's onSuccess now writes the cache using the pubkey captured by mutationFn at call time instead of re-reading `user`, which could point at a different (or logged-out) account by the time the mutation resolves. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 --- src/apps/feed/index.tsx | 16 +- src/apps/profile/index.tsx | 2 + src/apps/settings/index.tsx | 90 +++++++- src/components/nostr/ModerationMenu.tsx | 129 +++++++++++ src/components/nostr/NoteCard.tsx | 2 + src/components/nostr/NotificationsCenter.tsx | 4 +- src/components/nostr/ReportDialog.tsx | 109 +++++++++ src/hooks/useMuteList.ts | 227 +++++++++++++++++++ src/hooks/useReport.ts | 37 +++ 9 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 src/components/nostr/ModerationMenu.tsx create mode 100644 src/components/nostr/ReportDialog.tsx create mode 100644 src/hooks/useMuteList.ts create mode 100644 src/hooks/useReport.ts diff --git a/src/apps/feed/index.tsx b/src/apps/feed/index.tsx index 63e676e..294c182 100644 --- a/src/apps/feed/index.tsx +++ b/src/apps/feed/index.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyFollows } from '@/hooks/useFollows'; +import { useMutedPubkeys } from '@/hooks/useMuteList'; import { cn } from '@/lib/utils'; import { useWindowManager } from '@/os/useWindowManager'; import { isReply } from '@/lib/nostrUtils'; @@ -75,8 +76,14 @@ export default function FeedApp({ setTitle }: AppProps) { const authors = useMemo(() => follows ?? [], [follows]); const query = useFeed(scope, user ? authors : undefined); + const mutedPubkeys = useMutedPubkeys(); + const visibleNotes = useMemo( + () => (query.data ?? []).filter((event) => !mutedPubkeys.includes(event.pubkey)), + [query.data, mutedPubkeys], + ); const hasNoFollows = scope === 'following' && authors.length === 0 && !query.isLoading; + const allMuted = (query.data?.length ?? 0) > 0 && visibleNotes.length === 0; return ( @@ -132,8 +139,13 @@ export default function FeedApp({ setTitle }: AppProps) { } /> - ) : query.data && query.data.length > 0 ? ( - query.data.map((event) => ) + ) : allMuted ? ( + + ) : visibleNotes.length > 0 ? ( + visibleNotes.map((event) => ) ) : ( + diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index 6192bbb..4f4ec1b 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -4,18 +4,21 @@ import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { LoginArea } from '@/components/auth/LoginArea'; import { useAppContext } from '@/hooks/useAppContext'; +import { useAuthor } from '@/hooks/useAuthor'; import { useTheme } from '@/hooks/useTheme'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useMuteList, useSetPubkeyMuted } from '@/hooks/useMuteList'; import { useNwcConnection } from '@/hooks/useNwc'; import { useWindowManager } from '@/os/useWindowManager'; import { desktopApps } from '@/os/registry'; import { useIconLayout } from '@/os/useIconLayout'; import { useToast } from '@/hooks/useToast'; -import { npubOf } from '@/lib/nostrUtils'; +import { displayName, npubOf } from '@/lib/nostrUtils'; import { cn } from '@/lib/utils'; import type { Theme } from '@/contexts/AppContext'; import type { AppProps } from '@/os/types'; @@ -39,6 +42,8 @@ export default function SettingsApp({ setTitle }: AppProps) {
+ + @@ -122,6 +127,89 @@ function AccountSection() { ); } +function ModerationSection() { + const { user } = useCurrentUser(); + const muteList = useMuteList(); + const setMuted = useSetPubkeyMuted(); + const { toast } = useToast(); + + if (!user) { + return ( +
+
+ Sign in to manage muted and blocked accounts. +
+
+ ); + } + + const pubkeys = [...new Set([...(muteList.data?.publicPubkeys ?? []), ...(muteList.data?.privatePubkeys ?? [])])]; + + const unmute = async (pubkey: string) => { + try { + await setMuted.mutateAsync({ pubkey, muted: false }); + toast({ title: 'Unmuted' }); + } catch (error) { + toast({ + title: 'Could not unmute this account', + description: error instanceof Error ? error.message : 'No relay accepted the update.', + variant: 'destructive', + }); + } + }; + + return ( +
+ {muteList.isLoading ? ( +
+ + +
+ ) : muteList.isError ? ( +

Could not load your mute list. Try again later.

+ ) : pubkeys.length === 0 ? ( +

You haven't muted or blocked anyone.

+ ) : ( +
    + {pubkeys.map((pubkey) => ( + unmute(pubkey)} pending={setMuted.isPending} /> + ))} +
+ )} + {muteList.data && !muteList.data.privateEntriesReadable && ( +

+ Some entries are private and couldn't be decrypted with this signer, so they aren't shown above. +

+ )} +
+ ); +} + +function MutedAccountRow({ + pubkey, + onUnmute, + pending, +}: { + pubkey: string; + onUnmute: () => void; + pending: boolean; +}) { + const author = useAuthor(pubkey); + const name = displayName(pubkey, author.data?.metadata); + + return ( +
  • + {name} + +
  • + ); +} + function AppearanceSection() { const { theme, setTheme } = useTheme(); diff --git a/src/components/nostr/ModerationMenu.tsx b/src/components/nostr/ModerationMenu.tsx new file mode 100644 index 0000000..346b9c4 --- /dev/null +++ b/src/components/nostr/ModerationMenu.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { Flag, Loader2, MoreHorizontal, ShieldBan, Volume2, VolumeX } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ReportDialog } from './ReportDialog'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useBlockPubkey, useIsPubkeyMuted, useSetPubkeyMuted } from '@/hooks/useMuteList'; +import { useToast } from '@/hooks/useToast'; +import { displayName } from '@/lib/nostrUtils'; +import { cn } from '@/lib/utils'; + +interface ModerationMenuProps { + pubkey: string; + /** When set, adds a "Report note" action alongside the account-level ones. */ + event?: NostrEvent; + className?: string; +} + +/** Mute, block, and report actions for an account (and optionally one of its notes). */ +export function ModerationMenu({ pubkey, event, className }: ModerationMenuProps) { + const { user } = useCurrentUser(); + const author = useAuthor(pubkey); + const name = displayName(pubkey, author.data?.metadata); + const { toast } = useToast(); + + const muted = useIsPubkeyMuted(pubkey); + const setMuted = useSetPubkeyMuted(); + const block = useBlockPubkey(); + const [reportTarget, setReportTarget] = useState<'note' | 'account' | null>(null); + + // Requires a signer to publish list/report events, and moderating yourself + // makes no sense — same guard BookmarkButton/FollowButton use elsewhere. + if (!user || user.pubkey === pubkey) return null; + + const busy = setMuted.isPending || block.isPending; + + const handleMuteToggle = async () => { + try { + const result = await setMuted.mutateAsync({ pubkey, muted: !muted }); + toast({ + title: muted ? `Unmuted ${name}` : `Muted ${name}`, + description: !muted && result.usedPublicFallback + ? 'Your signer can’t encrypt mutes, so this one is public.' + : undefined, + }); + } catch (error) { + toast({ + title: 'Could not update your mute list', + description: error instanceof Error ? error.message : 'No relay accepted the update.', + variant: 'destructive', + }); + } + }; + + const handleBlock = async () => { + try { + await block.mutateAsync(pubkey); + toast({ title: `Blocked ${name}`, description: 'They are muted and removed from your follows.' }); + } catch (error) { + toast({ + title: 'Could not block this account', + description: error instanceof Error ? error.message : 'No relay accepted the update.', + variant: 'destructive', + }); + } + }; + + return ( + <> + + + + + + + {muted ? : } + {muted ? 'Unmute' : 'Mute'} {name} + + {!muted && ( + + + Block {name} + + )} + + {event && ( + setReportTarget('note')} variant="destructive"> + + Report note + + )} + setReportTarget('account')} variant="destructive"> + + Report account + + + + + setReportTarget(open ? reportTarget : null)} + pubkey={pubkey} + event={reportTarget === 'note' ? event : undefined} + name={name} + /> + + ); +} diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index 7fb138d..e851c01 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -5,6 +5,7 @@ import { nip19 } from 'nostr-tools'; import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; import { BookmarkButton } from './BookmarkButton'; +import { ModerationMenu } from './ModerationMenu'; import { ZapButton } from './ZapButton'; import { ReactionButton } from './ReactionButton'; import { Button } from '@/components/ui/button'; @@ -82,6 +83,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { +
    )} diff --git a/src/components/nostr/NotificationsCenter.tsx b/src/components/nostr/NotificationsCenter.tsx index 1b64443..ed8d3c7 100644 --- a/src/components/nostr/NotificationsCenter.tsx +++ b/src/components/nostr/NotificationsCenter.tsx @@ -20,6 +20,7 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAuthor } from '@/hooks/useAuthor'; +import { useMutedPubkeys } from '@/hooks/useMuteList'; import { useNotificationReadState, useNotifications, type Notification, type NotificationKind } from '@/hooks/useNotifications'; import { useWindowManager } from '@/os/useWindowManager'; import { displayName, relativeTime, rootReference } from '@/lib/nostrUtils'; @@ -88,7 +89,8 @@ export function NotificationsSheet() { function useNotificationState() { const query = useNotifications(); const { lastReadAt, markAllRead } = useNotificationReadState(); - const notifications = query.data ?? []; + const mutedPubkeys = useMutedPubkeys(); + const notifications = (query.data ?? []).filter(({ event }) => !mutedPubkeys.includes(event.pubkey)); const unreadCount = notifications.filter(({ event }) => event.created_at > lastReadAt).length; return query.isFetching || query.isError || notifications.length > 0 || query.isSuccess diff --git a/src/components/nostr/ReportDialog.tsx b/src/components/nostr/ReportDialog.tsx new file mode 100644 index 0000000..a17fd18 --- /dev/null +++ b/src/components/nostr/ReportDialog.tsx @@ -0,0 +1,109 @@ +import { useId, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { useReport, type ReportType } from '@/hooks/useReport'; +import { useToast } from '@/hooks/useToast'; + +const REPORT_TYPE_LABELS: { value: ReportType; label: string }[] = [ + { value: 'spam', label: 'Spam' }, + { value: 'illegal', label: 'Illegal content' }, + { value: 'nudity', label: 'Nudity or sexual content' }, + { value: 'profanity', label: 'Hateful speech or profanity' }, + { value: 'malware', label: 'Malware or phishing' }, + { value: 'impersonation', label: 'Impersonation' }, + { value: 'other', label: 'Other' }, +]; + +interface ReportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + pubkey: string; + /** When set, reports this note; otherwise reports the account. */ + event?: NostrEvent; + name: string; +} + +/** Collects a NIP-56 report type and optional comment, then publishes it. */ +export function ReportDialog({ open, onOpenChange, pubkey, event, name }: ReportDialogProps) { + const [type, setType] = useState('spam'); + const [comment, setComment] = useState(''); + const report = useReport(); + const { toast } = useToast(); + const commentId = useId(); + + const handleOpenChange = (next: boolean) => { + if (!next) setComment(''); + onOpenChange(next); + }; + + const submit = async () => { + try { + await report.mutateAsync({ pubkey, event, type, comment: comment.trim() || undefined }); + toast({ title: 'Report sent', description: 'Only the signed report event was published — nothing else was shared.' }); + handleOpenChange(false); + } catch (error) { + toast({ + title: 'Could not send the report', + description: error instanceof Error ? error.message : 'No relay accepted it.', + variant: 'destructive', + }); + } + }; + + return ( + + + + Report {event ? 'note' : name} + + {event ? `This reports the note from ${name}.` : `This reports ${name}'s account.`} It publishes a + signed report (NIP-56) that other clients and relays may use for moderation. + + + + setType(value as ReportType)}> + {REPORT_TYPE_LABELS.map((option) => ( + + ))} + + +
    + +