From eab19f0d7266ed6f42dccfff883e00fcb5df7c8f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 21 Mar 2026 22:23:53 -0500 Subject: [PATCH] Add mute list recovery dialog to /settings/content Allow users to browse and restore historical versions of their kind 10000 mute list, similar to the existing Profile Recovery dialog. Each snapshot is decrypted and displays a summary of muted items by type (users, hashtags, words, threads). --- src/components/MuteListRecoveryDialog.tsx | 449 ++++++++++++++++++++++ src/pages/ContentPage.tsx | 28 +- 2 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 src/components/MuteListRecoveryDialog.tsx diff --git a/src/components/MuteListRecoveryDialog.tsx b/src/components/MuteListRecoveryDialog.tsx new file mode 100644 index 00000000..91fd2713 --- /dev/null +++ b/src/components/MuteListRecoveryDialog.tsx @@ -0,0 +1,449 @@ +import { useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent, NostrFilter, NostrSigner } from '@nostrify/nostrify'; + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import { setCachedMuteItems, parseMuteTags, type MuteListItem } from '@/hooks/useMuteList'; +import { cn } from '@/lib/utils'; +import { Check, Loader2, RotateCcw, ShieldOff, UserX, Hash, MessageSquareOff, AlertTriangle } from 'lucide-react'; + +/** + * Query all events matching a filter using `req()` instead of `query()`. + * This bypasses NSet deduplication in NPool.query(), which discards older + * versions of replaceable events. We need all historical versions for recovery. + */ +async function queryAllEvents( + nostr: { req(filters: NostrFilter[], opts?: { signal?: AbortSignal }): AsyncIterable<['EVENT', string, NostrEvent] | ['EOSE', string] | ['CLOSED', string, string]> }, + filters: NostrFilter[], + signal: AbortSignal, +): Promise { + const events: NostrEvent[] = []; + const seen = new Set(); + + for await (const msg of nostr.req(filters, { signal })) { + if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break; + if (msg[0] === 'EVENT') { + const event = msg[2]; + if (!seen.has(event.id)) { + seen.add(event.id); + events.push(event); + } + } + } + + return events; +} + +interface MuteListRecoveryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** Format a unix timestamp into a human-readable date string. */ +function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Detect whether encrypted content uses NIP-04 (legacy) or NIP-44 encoding. + */ +function isNip04Encrypted(content: string): boolean { + return content.includes('?iv='); +} + +/** + * Decrypt encrypted content from a kind 10000 event, handling both NIP-44 and + * legacy NIP-04 formats for backward compatibility per NIP-51. + */ +async function decryptContent( + content: string, + signer: NostrSigner, + pubkey: string, +): Promise { + if (!content) return null; + + try { + if (isNip04Encrypted(content)) { + if (signer.nip04) { + return await signer.nip04.decrypt(pubkey, content); + } + return null; + } else { + if (signer.nip44) { + return await signer.nip44.decrypt(pubkey, content); + } + return null; + } + } catch { + return null; + } +} + +/** Summary of mute items parsed from a snapshot. */ +interface MuteSummary { + items: MuteListItem[]; + pubkeys: number; + hashtags: number; + words: number; + threads: number; + total: number; + decryptionFailed: boolean; +} + +/** + * Parse all mute items from a kind 10000 event, combining public tags + * and encrypted (private) content. + */ +async function parseMuteSnapshot( + event: NostrEvent, + signer: NostrSigner, + pubkey: string, +): Promise { + const publicItems = parseMuteTags(event.tags); + + let privateItems: MuteListItem[] = []; + let decryptionFailed = false; + + if (event.content) { + const decrypted = await decryptContent(event.content, signer, pubkey); + if (decrypted) { + try { + const tags = JSON.parse(decrypted) as string[][]; + privateItems = parseMuteTags(tags); + } catch { + decryptionFailed = true; + } + } else { + decryptionFailed = true; + } + } + + // Deduplicate + const seen = new Set(); + const combined: MuteListItem[] = []; + for (const item of [...publicItems, ...privateItems]) { + const key = `${item.type}:${item.value}`; + if (!seen.has(key)) { + seen.add(key); + combined.push(item); + } + } + + return { + items: combined, + pubkeys: combined.filter((i) => i.type === 'pubkey').length, + hashtags: combined.filter((i) => i.type === 'hashtag').length, + words: combined.filter((i) => i.type === 'word').length, + threads: combined.filter((i) => i.type === 'thread').length, + total: combined.length, + decryptionFailed, + }; +} + +// ─── Mute Snapshot Card ─────────────────────────────────────────────── + +function MuteSnapshotCard({ + summary, + event, + isCurrent, + onRestore, + isRestoring, +}: { + summary: MuteSummary; + event: NostrEvent; + isCurrent: boolean; + onRestore: () => void; + isRestoring: boolean; +}) { + const parts: string[] = []; + if (summary.pubkeys > 0) parts.push(`${summary.pubkeys} ${summary.pubkeys === 1 ? 'user' : 'users'}`); + if (summary.hashtags > 0) parts.push(`${summary.hashtags} ${summary.hashtags === 1 ? 'hashtag' : 'hashtags'}`); + if (summary.words > 0) parts.push(`${summary.words} ${summary.words === 1 ? 'word' : 'words'}`); + if (summary.threads > 0) parts.push(`${summary.threads} ${summary.threads === 1 ? 'thread' : 'threads'}`); + + return ( +
+ {isCurrent && ( +
+ + Current +
+ )} + +
+
+ +
+ +
+
+ {summary.total.toLocaleString()} {summary.total === 1 ? 'muted item' : 'muted items'} +
+ + {parts.length > 0 && ( +
+ {summary.pubkeys > 0 && ( + + + {summary.pubkeys} + + )} + {summary.hashtags > 0 && ( + + + {summary.hashtags} + + )} + {summary.words > 0 && ( + + + {summary.words} + + )} + {summary.threads > 0 && ( + + + {summary.threads} threads + + )} +
+ )} + + {summary.decryptionFailed && ( +
+ + Could not decrypt private items +
+ )} + +
+ {formatDate(event.created_at)} +
+
+
+ + {!isCurrent && ( +
+ +
+ )} +
+ ); +} + +// ─── Empty State ────────────────────────────────────────────────────── + +function EmptyState() { + return ( +
+

+ No mute list history found. Your relay may not store historical events. +

+
+ ); +} + +// ─── Loading Skeleton ───────────────────────────────────────────────── + +function SnapshotSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ); +} + +// ─── Mute History Content ───────────────────────────────────────────── + +function MuteHistoryContent({ onClose }: { onClose: () => void }) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { mutateAsync: publishEvent } = useNostrPublish(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [restoringId, setRestoringId] = useState(null); + + const pubkey = user?.pubkey; + + // Fetch all historical kind 10000 events + const muteHistory = useQuery({ + queryKey: ['mute-recovery', 'kind10000', pubkey], + queryFn: async () => { + if (!pubkey) return []; + const events = await queryAllEvents( + nostr, + [{ kinds: [10000], authors: [pubkey] }], + AbortSignal.timeout(10000), + ); + return events.sort((a, b) => b.created_at - a.created_at); + }, + enabled: !!pubkey, + staleTime: 30_000, + }); + + // Decrypt and parse all snapshots + const parsedSnapshots = useQuery>({ + queryKey: ['mute-recovery', 'parsed', pubkey, muteHistory.data?.map((e) => e.id).join(',')], + queryFn: async () => { + if (!user || !muteHistory.data) return new Map(); + + const results = new Map(); + + // Parse all snapshots in parallel + const entries = await Promise.all( + muteHistory.data.map(async (event) => { + const summary = await parseMuteSnapshot(event, user.signer, user.pubkey); + return [event.id, summary] as const; + }), + ); + + for (const [id, summary] of entries) { + results.set(id, summary); + } + + return results; + }, + enabled: !!user && !!muteHistory.data && muteHistory.data.length > 0, + }); + + const muteEvents = muteHistory.data ?? []; + const currentMuteId = muteEvents[0]?.id; + const summaries = parsedSnapshots.data; + + const handleRestore = async (event: NostrEvent) => { + setRestoringId(event.id); + try { + // Re-publish the old event's content and tags with the current timestamp. + // The content is already encrypted, so we just re-publish as-is. + await publishEvent({ + kind: event.kind, + content: event.content, + tags: event.tags, + created_at: Math.floor(Date.now() / 1000), + }); + + // Update the local mute cache with the restored items + const summary = summaries?.get(event.id); + if (summary && user) { + setCachedMuteItems(user.pubkey, summary.items); + } + + toast({ + title: 'Mute list restored', + description: `Successfully restored from ${formatDate(event.created_at)}.`, + }); + + queryClient.invalidateQueries({ queryKey: ['mute-recovery', 'kind10000', pubkey] }); + queryClient.invalidateQueries({ queryKey: ['muteList', pubkey] }); + queryClient.invalidateQueries({ queryKey: ['muteItems'] }); + + onClose(); + } catch (error) { + console.error('Failed to restore mute list:', error); + toast({ + title: 'Restore failed', + description: 'Could not republish the mute list. Please try again.', + variant: 'destructive', + }); + } finally { + setRestoringId(null); + } + }; + + if (muteHistory.isLoading || (muteHistory.data && muteHistory.data.length > 0 && parsedSnapshots.isLoading)) { + return ; + } + + if (muteEvents.length === 0) { + return ; + } + + return ( + <> + {muteEvents.map((event) => { + const summary = summaries?.get(event.id); + if (!summary) return null; + + return ( + handleRestore(event)} + isRestoring={restoringId === event.id} + /> + ); + })} + + ); +} + +// ─── Main Dialog ────────────────────────────────────────────────────── + +export function MuteListRecoveryDialog({ open, onOpenChange }: MuteListRecoveryDialogProps) { + const close = () => onOpenChange(false); + + return ( + + + + Mute List Recovery +

+ Browse and restore previous versions of your mute list. +

+
+ + +
+ +
+
+
+
+ ); +} diff --git a/src/pages/ContentPage.tsx b/src/pages/ContentPage.tsx index 3995c0da..af905bb0 100644 --- a/src/pages/ContentPage.tsx +++ b/src/pages/ContentPage.tsx @@ -1,13 +1,19 @@ +import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, RotateCcw } from 'lucide-react'; import { Link } from 'react-router-dom'; import { MuteSettingsInternals, SensitiveContentSection, ThemePreferencesSection } from '@/components/ContentSettings'; +import { MuteListRecoveryDialog } from '@/components/MuteListRecoveryDialog'; import { IntroImage } from '@/components/IntroImage'; import { HelpTip } from '@/components/HelpTip'; +import { Button } from '@/components/ui/button'; import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; export function ContentPage() { const { config } = useAppContext(); + const { user } = useCurrentUser(); + const [recoveryOpen, setRecoveryOpen] = useState(false); useSeoMeta({ title: `Content | Settings | ${config.appName}`, @@ -46,8 +52,19 @@ export function ContentPage() { {/* Muted Content Section */}
-
+

Muted Content

+ {user && ( + + )}
@@ -55,6 +72,13 @@ export function ContentPage() {
+ {user && ( + + )} + {/* Sensitive Content Section */}