import { useState, useMemo } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { NostrEvent, NostrFilter, NostrMetadata } from '@nostrify/nostrify'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Separator } from '@/components/ui/separator'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from '@/lib/themeEvent'; import { coreToTokens } from '@/themes'; import { cn } from '@/lib/utils'; import { Check, Loader2, RotateCcw, User, Users, Palette } 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 ProfileRecoveryDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } /** Parse kind 0 content into NostrMetadata, returning undefined on failure. */ function parseMetadata(content: string): NostrMetadata | undefined { try { return JSON.parse(content) as NostrMetadata; } catch { return undefined; } } /** 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', }); } /** Extracts HSL color string for inline styles. */ function hsl(value: string): string { return `hsl(${value})`; } // ─── Profile Snapshot Card ──────────────────────────────────────────── function ProfileSnapshotCard({ event, isCurrent, onRestore, isRestoring, }: { event: NostrEvent; isCurrent: boolean; onRestore: () => void; isRestoring: boolean; }) { const metadata = useMemo(() => parseMetadata(event.content), [event.content]); const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey); const avatarShape = getAvatarShape(metadata); return (
{isCurrent && (
Current
)}
{/* Avatar */} {metadata?.picture ? ( ) : null} {displayName[0]?.toUpperCase()} {/* Info */}
{displayName}
{metadata?.nip05 && (
{metadata.nip05}
)} {metadata?.about && (
{metadata.about}
)}
{formatDate(event.created_at)}
{/* Restore button */} {!isCurrent && (
)}
); } // ─── Theme Snapshot Card ────────────────────────────────────────────── function ThemeSnapshotCard({ event, isCurrent, onRestore, isRestoring, }: { event: NostrEvent; isCurrent: boolean; onRestore: () => void; isRestoring: boolean; }) { const parsed = useMemo(() => parseActiveProfileTheme(event), [event]); const title = event.tags.find(([n]) => n === 'title')?.[1] ?? 'Profile Theme'; const tokens = useMemo(() => (parsed ? coreToTokens(parsed.colors) : null), [parsed]); if (!parsed || !tokens) { return null; } return (
{isCurrent && (
Current
)} {/* Theme mini-mockup */}
{parsed.background?.url && ( )}
{/* Info + restore */}
{title}
{formatDate(event.created_at)}
{!isCurrent && ( )}
); } // ─── Follows Snapshot Card ───────────────────────────────────────────── function FollowsSnapshotCard({ event, isCurrent, onRestore, isRestoring, }: { event: NostrEvent; isCurrent: boolean; onRestore: () => void; isRestoring: boolean; }) { const followCount = useMemo( () => event.tags.filter(([name]) => name === 'p').length, [event.tags], ); return (
{isCurrent && (
Current
)}
{followCount.toLocaleString()} {followCount === 1 ? 'follow' : 'follows'}
{formatDate(event.created_at)}
{!isCurrent && (
)}
); } // ─── Empty State ────────────────────────────────────────────────────── function EmptyState({ label }: { label: string }) { return (

{label}

); } // ─── Loading Skeleton ───────────────────────────────────────────────── function SnapshotSkeleton() { return (
{[1, 2, 3].map((i) => (
))}
); } // ─── Profile History Tab ────────────────────────────────────────────── function ProfileHistoryTab({ 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; const profileHistory = useQuery({ queryKey: ['profile-recovery', 'kind0', pubkey], queryFn: async () => { if (!pubkey) return []; const events = await queryAllEvents( nostr, [{ kinds: [0], authors: [pubkey] }], AbortSignal.timeout(10000), ); return events.sort((a, b) => b.created_at - a.created_at); }, enabled: !!pubkey, staleTime: 30_000, }); const profileEvents = profileHistory.data ?? []; const currentProfileId = profileEvents[0]?.id; const handleRestore = async (event: NostrEvent) => { setRestoringId(event.id); try { await publishEvent({ kind: event.kind, content: event.content, tags: event.tags, created_at: Math.floor(Date.now() / 1000), }); toast({ title: 'Profile restored', description: `Successfully restored from ${formatDate(event.created_at)}.`, }); queryClient.invalidateQueries({ queryKey: ['profile-recovery', 'kind0', pubkey] }); queryClient.invalidateQueries({ queryKey: ['author', pubkey] }); onClose(); } catch (error) { console.error('Failed to restore event:', error); toast({ title: 'Restore failed', description: 'Could not republish the event. Please try again.', variant: 'destructive', }); } finally { setRestoringId(null); } }; if (profileHistory.isLoading) { return ; } if (profileEvents.length === 0) { return ; } return ( <> {profileEvents.map((event) => ( handleRestore(event)} isRestoring={restoringId === event.id} /> ))} ); } // ─── Theme History Tab ──────────────────────────────────────────────── function ThemeHistoryTab({ 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; const themeHistory = useQuery({ queryKey: ['profile-recovery', 'kind16767', pubkey], queryFn: async () => { if (!pubkey) return []; const events = await queryAllEvents( nostr, [{ kinds: [ACTIVE_THEME_KIND], authors: [pubkey] }], AbortSignal.timeout(10000), ); return events .sort((a, b) => b.created_at - a.created_at) .filter((e) => parseActiveProfileTheme(e) !== null); }, enabled: !!pubkey, staleTime: 30_000, }); const themeEvents = themeHistory.data ?? []; const currentThemeId = themeEvents[0]?.id; const handleRestore = async (event: NostrEvent) => { setRestoringId(event.id); try { await publishEvent({ kind: event.kind, content: event.content, tags: event.tags, created_at: Math.floor(Date.now() / 1000), }); toast({ title: 'Theme restored', description: `Successfully restored from ${formatDate(event.created_at)}.`, }); queryClient.invalidateQueries({ queryKey: ['profile-recovery', 'kind16767', pubkey] }); queryClient.invalidateQueries({ queryKey: ['activeProfileTheme', pubkey] }); onClose(); } catch (error) { console.error('Failed to restore event:', error); toast({ title: 'Restore failed', description: 'Could not republish the event. Please try again.', variant: 'destructive', }); } finally { setRestoringId(null); } }; if (themeHistory.isLoading) { return ; } if (themeEvents.length === 0) { return ; } return ( <> {themeEvents.map((event) => ( handleRestore(event)} isRestoring={restoringId === event.id} /> ))} ); } // ─── Follows History Tab ────────────────────────────────────────────── function FollowsHistoryTab({ 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; const followsHistory = useQuery({ queryKey: ['profile-recovery', 'kind3', pubkey], queryFn: async () => { if (!pubkey) return []; const events = await queryAllEvents( nostr, [{ kinds: [3], authors: [pubkey] }], AbortSignal.timeout(10000), ); return events.sort((a, b) => b.created_at - a.created_at); }, enabled: !!pubkey, staleTime: 30_000, }); const followsEvents = followsHistory.data ?? []; const currentFollowsId = followsEvents[0]?.id; const handleRestore = async (event: NostrEvent) => { setRestoringId(event.id); try { await publishEvent({ kind: event.kind, content: event.content, tags: event.tags, created_at: Math.floor(Date.now() / 1000), }); toast({ title: 'Follow list restored', description: `Successfully restored from ${formatDate(event.created_at)}.`, }); queryClient.invalidateQueries({ queryKey: ['profile-recovery', 'kind3', pubkey] }); queryClient.invalidateQueries({ queryKey: ['follow-list'] }); onClose(); } catch (error) { console.error('Failed to restore event:', error); toast({ title: 'Restore failed', description: 'Could not republish the event. Please try again.', variant: 'destructive', }); } finally { setRestoringId(null); } }; if (followsHistory.isLoading) { return ; } if (followsEvents.length === 0) { return ; } return ( <> {followsEvents.map((event) => ( handleRestore(event)} isRestoring={restoringId === event.id} /> ))} ); } // ─── Main Dialog ────────────────────────────────────────────────────── export function ProfileRecoveryDialog({ open, onOpenChange }: ProfileRecoveryDialogProps) { const close = () => onOpenChange(false); return ( Profile Recovery
Profile Follows Theme
); }