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