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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mroxso
2026-09-07 17:24:54 +02:00
committed by GitHub
parent 0a24983585
commit 411aef9135
9 changed files with 612 additions and 4 deletions

View File

@@ -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 (
<AppLayout>
@@ -132,8 +139,13 @@ export default function FeedApp({ setTitle }: AppProps) {
</Button>
}
/>
) : query.data && query.data.length > 0 ? (
query.data.map((event) => <NoteCard key={event.id} event={event} />)
) : allMuted ? (
<EmptyState
title="Nothing to show"
hint="Every note on this page is from an account you muted or blocked."
/>
) : visibleNotes.length > 0 ? (
visibleNotes.map((event) => <NoteCard key={event.id} event={event} />)
) : (
<EmptyState
title="Nothing came back"

View File

@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Copy, Globe, Loader2, UserMinus, UserPlus } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome';
import { ModerationMenu } from '@/components/nostr/ModerationMenu';
import { NoteCard } from '@/components/nostr/NoteCard';
import { NoteContent } from '@/components/nostr/NoteContent';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -74,6 +75,7 @@ export default function ProfileApp({ params, setTitle }: AppProps) {
<div className="ml-auto flex items-center gap-1.5">
<CopyNpubButton pubkey={pubkey} />
<FollowButton pubkey={pubkey} />
<ModerationMenu pubkey={pubkey} />
</div>
</AppToolbar>

View File

@@ -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) {
<div className="mx-auto max-w-xl space-y-8">
<AccountSection />
<Separator />
<ModerationSection />
<Separator />
<AppearanceSection />
<Separator />
<RelaySection />
@@ -122,6 +127,89 @@ function AccountSection() {
);
}
function ModerationSection() {
const { user } = useCurrentUser();
const muteList = useMuteList();
const setMuted = useSetPubkeyMuted();
const { toast } = useToast();
if (!user) {
return (
<Section title="Muted & blocked accounts" description="Review and reverse accounts you've muted or blocked.">
<div className="rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground">
Sign in to manage muted and blocked accounts.
</div>
</Section>
);
}
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 (
<Section
title="Muted & blocked accounts"
description="Blocking also removes them from your follows; unmuting here reverses either action."
>
{muteList.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : muteList.isError ? (
<p className="text-sm text-muted-foreground">Could not load your mute list. Try again later.</p>
) : pubkeys.length === 0 ? (
<p className="text-sm text-muted-foreground">You haven't muted or blocked anyone.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{pubkeys.map((pubkey) => (
<MutedAccountRow key={pubkey} pubkey={pubkey} onUnmute={() => unmute(pubkey)} pending={setMuted.isPending} />
))}
</ul>
)}
{muteList.data && !muteList.data.privateEntriesReadable && (
<p className="text-xs text-muted-foreground">
Some entries are private and couldn't be decrypted with this signer, so they aren't shown above.
</p>
)}
</Section>
);
}
function MutedAccountRow({
pubkey,
onUnmute,
pending,
}: {
pubkey: string;
onUnmute: () => void;
pending: boolean;
}) {
const author = useAuthor(pubkey);
const name = displayName(pubkey, author.data?.metadata);
return (
<li className="flex items-center justify-between gap-3 px-3 py-2">
<span className="min-w-0 truncate text-sm">{name}</span>
<Button variant="outline" size="sm" onClick={onUnmute} disabled={pending}>
Unmute
</Button>
</li>
);
}
function AppearanceSection() {
const { theme, setTheme } = useTheme();

View File

@@ -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 cant 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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-7 text-muted-foreground', className)}
disabled={busy}
aria-label={`More actions for ${name}`}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : (
<MoreHorizontal className="size-3.5" aria-hidden />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleMuteToggle}>
{muted ? <Volume2 aria-hidden /> : <VolumeX aria-hidden />}
{muted ? 'Unmute' : 'Mute'} {name}
</DropdownMenuItem>
{!muted && (
<DropdownMenuItem onClick={handleBlock} variant="destructive">
<ShieldBan aria-hidden />
Block {name}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{event && (
<DropdownMenuItem onClick={() => setReportTarget('note')} variant="destructive">
<Flag aria-hidden />
Report note
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setReportTarget('account')} variant="destructive">
<Flag aria-hidden />
Report account
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ReportDialog
open={reportTarget !== null}
onOpenChange={(open) => setReportTarget(open ? reportTarget : null)}
pubkey={pubkey}
event={reportTarget === 'note' ? event : undefined}
name={name}
/>
</>
);
}

View File

@@ -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) {
<ZapButton target={event} revealed={revealed} />
<ReactionButton target={event} />
<BookmarkButton target={{ type: 'e', value: event.id }} />
<ModerationMenu pubkey={event.pubkey} event={event} className="ml-auto" />
</div>
)}
</div>

View File

@@ -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

View File

@@ -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<ReportType>('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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Report {event ? 'note' : name}</DialogTitle>
<DialogDescription>
{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.
</DialogDescription>
</DialogHeader>
<RadioGroup value={type} onValueChange={(value) => setType(value as ReportType)}>
{REPORT_TYPE_LABELS.map((option) => (
<label key={option.value} className="flex items-center gap-2 text-sm">
<RadioGroupItem value={option.value} />
{option.label}
</label>
))}
</RadioGroup>
<div className="space-y-1.5">
<Label htmlFor={commentId}>Additional context (optional)</Label>
<Textarea
id={commentId}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share only what's necessary to explain the report."
maxLength={500}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={report.isPending}>
Cancel
</Button>
<Button onClick={submit} disabled={report.isPending} className="gap-1.5">
{report.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Send report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

227
src/hooks/useMuteList.ts Normal file
View File

@@ -0,0 +1,227 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import type { NUser } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
/** NIP-51 "Mute list": pubkeys (and other things) the user doesn't want to see. */
export const MUTE_LIST_KIND = 10000;
function muteListQueryKey(pubkey: string | undefined) {
return ['nostr', 'mute-list', pubkey ?? ''] as const;
}
async function fetchMuteList(
nostr: ReturnType<typeof useNostr>['nostr'],
pubkey: string,
signal?: AbortSignal,
): Promise<NostrEvent | null> {
const [event] = await nostr.query(
[{ kinds: [MUTE_LIST_KIND], authors: [pubkey], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)].filter((s): s is AbortSignal => Boolean(s))) },
);
return event ?? null;
}
function extractPubkeys(tags: string[][]): string[] {
return tags.filter(([name, value]) => name === 'p' && Boolean(value)).map(([, value]) => value);
}
/**
* Decrypts the list's NIP-44 private entries. `ok: false` (rather than an
* empty result) marks a real decryption failure — ciphertext exists but the
* current signer couldn't open it — so callers that are about to rewrite the
* list can refuse instead of silently publishing over private entries they
* failed to read back.
*/
async function decryptPrivateTags(
signer: NUser['signer'],
pubkey: string,
content: string,
): Promise<{ tags: string[][]; ok: boolean }> {
if (!content) return { tags: [], ok: true };
if (!signer.nip44) return { tags: [], ok: false };
try {
const plaintext = await signer.nip44.decrypt(pubkey, content);
const parsed = JSON.parse(plaintext);
if (!Array.isArray(parsed)) return { tags: [], ok: false };
return { tags: parsed.filter((tag): tag is string[] => Array.isArray(tag)), ok: true };
} catch {
return { tags: [], ok: false };
}
}
export interface MuteListData {
event: NostrEvent | null;
publicPubkeys: string[];
privatePubkeys: string[];
/** False when the list has encrypted content this signer could not open. */
privateEntriesReadable: boolean;
}
/** The current user's mute list, with private entries decrypted when possible. */
export function useMuteList() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery<MuteListData>({
queryKey: muteListQueryKey(user?.pubkey),
enabled: Boolean(user),
queryFn: async ({ signal }) => {
const event = await fetchMuteList(nostr, user!.pubkey, signal);
if (!event) return { event: null, publicPubkeys: [], privatePubkeys: [], privateEntriesReadable: true };
const { tags: privateTags, ok } = await decryptPrivateTags(user!.signer, user!.pubkey, event.content);
return {
event,
publicPubkeys: extractPubkeys(event.tags),
privatePubkeys: extractPubkeys(privateTags),
privateEntriesReadable: ok,
};
},
staleTime: 60_000,
});
}
/** Merged public + private muted pubkeys, for filtering feeds and other surfaces. */
export function useMutedPubkeys(): string[] {
const { data } = useMuteList();
return useMemo(() => {
if (!data) return [];
return [...new Set([...data.publicPubkeys, ...data.privatePubkeys])];
}, [data]);
}
export function useIsPubkeyMuted(pubkey: string): boolean {
const muted = useMutedPubkeys();
return muted.includes(pubkey);
}
/**
* Adds or removes `pubkey` from the mute list. New mutes prefer the NIP-44
* encrypted side, so muting someone stays a private choice by default; a
* signer without NIP-44 support (some NIP-07 extensions) falls back to a
* public entry rather than failing outright, and the resolved value reports
* that so the caller can warn the user.
*
* Fetches the list fresh from relays right before writing — kind 10000 is a
* whole-list replacement, so publishing against a stale cached copy could
* silently drop entries added from another tab or device in the meantime.
*/
export function useSetPubkeyMuted() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const publish = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
pubkey,
muted,
}: {
pubkey: string;
muted: boolean;
}): Promise<{ forPubkey: string; usedPublicFallback: boolean; publicPubkeys: string[]; privatePubkeys: string[] }> => {
if (!user) throw new Error('Sign in to manage muted accounts');
const current = await fetchMuteList(nostr, user.pubkey);
const publicTags = current?.tags ?? [];
const { tags: privateTags, ok } = await decryptPrivateTags(user.signer, user.pubkey, current?.content ?? '');
if (!ok) {
throw new Error('Your signer could not read the private part of your existing mute list. Try again, or use the signer that created it.');
}
let nextPublicTags = publicTags;
let nextPrivateTags = privateTags;
let usedPublicFallback = false;
if (!muted) {
nextPublicTags = publicTags.filter(([name, value]) => !(name === 'p' && value === pubkey));
nextPrivateTags = privateTags.filter(([name, value]) => !(name === 'p' && value === pubkey));
} else {
const already = extractPubkeys(publicTags).includes(pubkey) || extractPubkeys(privateTags).includes(pubkey);
if (!already) {
if (user.signer.nip44) {
nextPrivateTags = [...privateTags, ['p', pubkey]];
} else {
nextPublicTags = [...publicTags, ['p', pubkey]];
usedPublicFallback = true;
}
}
}
const content = nextPrivateTags.length > 0 && user.signer.nip44
? await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(nextPrivateTags))
: '';
await publish.mutateAsync({ kind: MUTE_LIST_KIND, content, tags: nextPublicTags });
return {
forPubkey: user.pubkey,
usedPublicFallback,
publicPubkeys: extractPubkeys(nextPublicTags),
privatePubkeys: extractPubkeys(nextPrivateTags),
};
},
// Nostr's own eventual consistency rules out an immediate re-query: right
// after publishing, a read against the pool's default relay can still
// return the pre-update list. The mutation already computed the
// authoritative next state, so write it straight into the cache instead —
// deliberately not `invalidateQueries` here, since that would trigger a
// background refetch that can race back with the stale list and silently
// clobber this correct value. The cache reconciles with relays normally
// the next time the query goes stale.
//
// `forPubkey` (captured by mutationFn at call time, not read from `user`
// here) keys the write: onSuccess runs with this callback's latest
// closure, so if the account changed or logged out while the publish was
// in flight, `user` here could point at the wrong session — writing into
// that query key would leak mutes into another account or a logged-out
// view.
onSuccess: ({ forPubkey, publicPubkeys, privatePubkeys }) => {
queryClient.setQueryData<MuteListData>(muteListQueryKey(forPubkey), (prev) => ({
event: prev?.event ?? null,
publicPubkeys,
privatePubkeys,
privateEntriesReadable: true,
}));
},
});
}
/**
* Mutes `pubkey` (if not already) and, if the current user follows them,
* removes them from the kind 3 follow list too — the extra step that
* distinguishes "block" from a plain mute.
*/
export function useBlockPubkey() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const publish = useNostrPublish();
const queryClient = useQueryClient();
const setMuted = useSetPubkeyMuted();
return useMutation({
mutationFn: async (pubkey: string) => {
if (!user) throw new Error('Sign in to block accounts');
await setMuted.mutateAsync({ pubkey, muted: true });
const [followEvent] = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(6000) },
);
const followTags = followEvent?.tags ?? [];
const isFollowing = followTags.some(([name, value]) => name === 'p' && value === pubkey);
if (isFollowing) {
await publish.mutateAsync({
kind: 3,
content: followEvent?.content ?? '',
tags: followTags.filter(([name, value]) => !(name === 'p' && value === pubkey)),
});
await queryClient.invalidateQueries({ queryKey: ['nostr', 'follows'] });
}
},
});
}

37
src/hooks/useReport.ts Normal file
View File

@@ -0,0 +1,37 @@
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from './useNostrPublish';
/** NIP-56 "Reporting": a signed event flagging an account or note as objectionable. */
export const REPORT_KIND = 1984;
export const REPORT_TYPES = ['nudity', 'malware', 'profanity', 'illegal', 'spam', 'impersonation', 'other'] as const;
export type ReportType = typeof REPORT_TYPES[number];
export interface ReportInput {
pubkey: string;
/** When set, reports this note rather than just the account. */
event?: NostrEvent;
type: ReportType;
/** Freeform context the reporter chose to add. Never auto-filled. */
comment?: string;
}
/**
* Publishes a NIP-56 report event. Only what the reporter explicitly typed
* goes in `content` — nothing else is attached, per NIP-56's guidance that
* `content` MAY carry additional information but never must.
*/
export function useReport() {
const publish = useNostrPublish();
return useMutation({
mutationFn: async ({ pubkey, event, type, comment }: ReportInput) => {
// Reporting a note: the `e` tag carries the report type (it's the thing
// being reported), and the `p` tag just identifies its author.
const tags: string[][] = event ? [['e', event.id, type], ['p', pubkey]] : [['p', pubkey, type]];
return publish.mutateAsync({ kind: REPORT_KIND, content: comment ?? '', tags });
},
});
}