mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
129
src/components/nostr/ModerationMenu.tsx
Normal file
129
src/components/nostr/ModerationMenu.tsx
Normal 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 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 (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
109
src/components/nostr/ReportDialog.tsx
Normal file
109
src/components/nostr/ReportDialog.tsx
Normal 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
227
src/hooks/useMuteList.ts
Normal 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
37
src/hooks/useReport.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user