RecoveryTab.tsx raw

   1  import { useCallback, useEffect, useRef, useState } from 'react'
   2  import { Button } from '@/components/ui/button'
   3  import { toast } from 'sonner'
   4  import { useNostr } from '@/providers/NostrProvider'
   5  import { eventKinds, getKindName, type EventKind } from '@/lib/event-kinds'
   6  import { SimplePool, type Event, type Filter } from 'nostr-tools'
   7  
   8  function getRelayWsUrl(): string {
   9    const loc = window.location
  10    const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
  11    return `${proto}//${loc.host}`
  12  }
  13  
  14  function getReplaceableKinds(): EventKind[] {
  15    return eventKinds.filter(
  16      (ek) => ek.isReplaceable || ek.isAddressable || ek.kind === 0 || ek.kind === 3
  17    )
  18  }
  19  
  20  export default function RecoveryTab() {
  21    const { pubkey, publish } = useNostr()
  22    const [selectedKind, setSelectedKind] = useState<number | ''>('')
  23    const [customKind, setCustomKind] = useState('')
  24    const [events, setEvents] = useState<Event[]>([])
  25    const [isLoading, setIsLoading] = useState(false)
  26    const [isReposting, setIsReposting] = useState<string | null>(null)
  27    const poolRef = useRef<SimplePool | null>(null)
  28  
  29    const replaceableKinds = getReplaceableKinds()
  30  
  31    useEffect(() => {
  32      poolRef.current = new SimplePool()
  33      return () => {
  34        poolRef.current?.close(poolRef.current ? [getRelayWsUrl()] : [])
  35      }
  36    }, [])
  37  
  38    const activeKind = selectedKind !== '' ? selectedKind : customKind ? parseInt(customKind, 10) : null
  39  
  40    const loadEvents = useCallback(async () => {
  41      if (activeKind === null || isNaN(activeKind)) return
  42      setIsLoading(true)
  43      setEvents([])
  44  
  45      try {
  46        const pool = poolRef.current
  47        if (!pool) {
  48          toast.error('Pool not initialized')
  49          setIsLoading(false)
  50          return
  51        }
  52  
  53        const relayUrl = getRelayWsUrl()
  54        const filter: Filter = {
  55          kinds: [activeKind],
  56          limit: 200,
  57        }
  58  
  59        if (pubkey) {
  60          filter.authors = [pubkey]
  61        }
  62  
  63        const collected: Event[] = []
  64  
  65        await new Promise<void>((resolve) => {
  66          const sub = pool.subscribeMany([relayUrl], filter, {
  67            onevent(evt: Event) {
  68              collected.push(evt)
  69            },
  70            oneose() {
  71              sub.close()
  72              resolve()
  73            },
  74          })
  75          setTimeout(() => {
  76            sub.close()
  77            resolve()
  78          }, 15000)
  79        })
  80  
  81        collected.sort((a, b) => b.created_at - a.created_at)
  82        setEvents(collected)
  83  
  84        if (collected.length === 0) {
  85          toast.info('No events found for this kind')
  86        } else {
  87          toast.success(`Found ${collected.length} versions`)
  88        }
  89      } catch (e) {
  90        toast.error(`Query failed: ${e instanceof Error ? e.message : String(e)}`)
  91      } finally {
  92        setIsLoading(false)
  93      }
  94    }, [activeKind, pubkey])
  95  
  96    // Auto-load when kind changes
  97    useEffect(() => {
  98      if (activeKind !== null && !isNaN(activeKind)) {
  99        loadEvents()
 100      }
 101    }, [activeKind]) // eslint-disable-line react-hooks/exhaustive-deps
 102  
 103    const isCurrentVersion = (_event: Event, index: number): boolean => {
 104      return index === 0
 105    }
 106  
 107    const repostEvent = async (event: Event) => {
 108      if (!pubkey) {
 109        toast.error('Login required')
 110        return
 111      }
 112      if (!confirm('Republish this old version? It will become the current version.')) return
 113  
 114      setIsReposting(event.id)
 115      try {
 116        const draft = {
 117          kind: event.kind,
 118          content: event.content,
 119          tags: event.tags,
 120          created_at: Math.floor(Date.now() / 1000),
 121        }
 122        await publish(draft)
 123        toast.success('Event republished successfully')
 124        // Reload to see updated order
 125        await loadEvents()
 126      } catch (e) {
 127        toast.error(`Repost failed: ${e instanceof Error ? e.message : String(e)}`)
 128      } finally {
 129        setIsReposting(null)
 130      }
 131    }
 132  
 133    const copyEvent = (event: Event) => {
 134      navigator.clipboard.writeText(JSON.stringify(event))
 135      toast.success('Copied to clipboard')
 136    }
 137  
 138    return (
 139      <div className="p-4 space-y-4 w-full max-w-4xl">
 140        <div>
 141          <h3 className="text-lg font-semibold">Event Recovery</h3>
 142          <p className="text-sm text-muted-foreground mt-1">
 143            Search and recover old versions of replaceable events. A wise pelican once said:
 144            every version tells a story, even the ones you regret.
 145          </p>
 146        </div>
 147  
 148        <div className="rounded-lg bg-card p-4 space-y-3">
 149          <div className="space-y-1">
 150            <label className="text-sm font-medium text-muted-foreground">
 151              Select Replaceable Kind
 152            </label>
 153            <select
 154              value={selectedKind}
 155              onChange={(e) => {
 156                const val = e.target.value
 157                setSelectedKind(val === '' ? '' : parseInt(val, 10))
 158                if (val !== '') setCustomKind('')
 159              }}
 160              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
 161            >
 162              <option value="">Choose a replaceable kind...</option>
 163              {replaceableKinds.map((ek) => (
 164                <option key={ek.kind} value={ek.kind}>
 165                  {ek.name} ({ek.kind})
 166                </option>
 167              ))}
 168            </select>
 169          </div>
 170  
 171          <div className="space-y-1">
 172            <label className="text-sm font-medium text-muted-foreground">
 173              Or enter custom kind number
 174            </label>
 175            <input
 176              type="number"
 177              value={customKind}
 178              onChange={(e) => {
 179                setCustomKind(e.target.value)
 180                if (e.target.value) setSelectedKind('')
 181              }}
 182              placeholder="e.g., 10001"
 183              min="0"
 184              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
 185            />
 186          </div>
 187  
 188          <Button size="sm" onClick={loadEvents} disabled={isLoading || activeKind === null}>
 189            {isLoading ? 'Loading...' : 'Search Versions'}
 190          </Button>
 191        </div>
 192  
 193        {events.length > 0 && (
 194          <div className="text-xs text-muted-foreground">
 195            {events.length} version{events.length !== 1 ? 's' : ''} found for kind{' '}
 196            {activeKind} ({getKindName(activeKind!)})
 197          </div>
 198        )}
 199  
 200        <div className="space-y-3">
 201          {events.map((event, idx) => {
 202            const isCurrent = isCurrentVersion(event, idx)
 203            return (
 204              <div
 205                key={`${event.id}-${idx}`}
 206                className={
 207                  isCurrent
 208                    ? 'rounded-lg border bg-card p-4'
 209                    : 'rounded-lg border border-yellow-600/50 bg-yellow-900/10 p-4'
 210                }
 211              >
 212                <div className="flex flex-wrap items-center justify-between gap-2 mb-3">
 213                  <div className="space-y-0.5">
 214                    {isCurrent && (
 215                      <span className="text-sm font-semibold text-primary">Current Version</span>
 216                    )}
 217                    <div className="text-xs text-muted-foreground">
 218                      {new Date(event.created_at * 1000).toLocaleString()}
 219                    </div>
 220                    <div className="text-xs font-mono text-muted-foreground">
 221                      {event.id.slice(0, 16)}...
 222                    </div>
 223                  </div>
 224                  <div className="flex items-center gap-2 flex-wrap">
 225                    {!isCurrent && (
 226                      <Button
 227                        size="sm"
 228                        onClick={() => repostEvent(event)}
 229                        disabled={isReposting === event.id}
 230                      >
 231                        {isReposting === event.id ? 'Reposting...' : 'Repost'}
 232                      </Button>
 233                    )}
 234                    <Button
 235                      variant="outline"
 236                      size="sm"
 237                      onClick={() => copyEvent(event)}
 238                    >
 239                      Copy JSON
 240                    </Button>
 241                  </div>
 242                </div>
 243                <pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all bg-background p-3 rounded max-h-[300px] overflow-y-auto">
 244                  {JSON.stringify(event, null, 2)}
 245                </pre>
 246              </div>
 247            )
 248          })}
 249  
 250          {events.length === 0 && !isLoading && activeKind !== null && (
 251            <div className="text-center py-8 text-muted-foreground">
 252              No events found for this kind.
 253            </div>
 254          )}
 255  
 256          {isLoading && (
 257            <div className="text-center py-8 text-muted-foreground">Loading events...</div>
 258          )}
 259        </div>
 260      </div>
 261    )
 262  }
 263