EventBrowserTab.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 { getKindName } from '@/lib/event-kinds'
6 import { SimplePool, type Event, type Filter } from 'nostr-tools'
7 import { cn } from '@/lib/utils'
8
9 interface FilterState {
10 kinds: string
11 authors: string
12 ids: string
13 since: string
14 until: string
15 limit: string
16 }
17
18 const EMPTY_FILTER: FilterState = {
19 kinds: '',
20 authors: '',
21 ids: '',
22 since: '',
23 until: '',
24 limit: '50',
25 }
26
27 function getRelayWsUrl(): string {
28 const loc = window.location
29 const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
30 return `${proto}//${loc.host}`
31 }
32
33 function truncate(s: string, n: number): string {
34 if (!s) return ''
35 return s.length > n ? s.slice(0, n) + '...' : s
36 }
37
38 function truncatePubkey(pk: string): string {
39 if (!pk) return ''
40 return pk.slice(0, 8) + '...' + pk.slice(-8)
41 }
42
43 function buildFilter(state: FilterState): Filter {
44 const filter: Filter = {}
45 if (state.kinds.trim()) {
46 filter.kinds = state.kinds
47 .split(',')
48 .map((s) => parseInt(s.trim(), 10))
49 .filter((n) => !isNaN(n))
50 }
51 if (state.authors.trim()) {
52 filter.authors = state.authors
53 .split(',')
54 .map((s) => s.trim())
55 .filter(Boolean)
56 }
57 if (state.ids.trim()) {
58 filter.ids = state.ids
59 .split(',')
60 .map((s) => s.trim())
61 .filter(Boolean)
62 }
63 if (state.since) {
64 filter.since = Math.floor(new Date(state.since).getTime() / 1000)
65 }
66 if (state.until) {
67 filter.until = Math.floor(new Date(state.until).getTime() / 1000)
68 }
69 const limit = parseInt(state.limit, 10)
70 filter.limit = !isNaN(limit) && limit > 0 ? limit : 50
71 return filter
72 }
73
74 export default function EventBrowserTab() {
75 const { pubkey, publish } = useNostr()
76 const [filterState, setFilterState] = useState<FilterState>(EMPTY_FILTER)
77 const [jsonMode, setJsonMode] = useState(false)
78 const [jsonText, setJsonText] = useState('')
79 const [jsonError, setJsonError] = useState('')
80 const [events, setEvents] = useState<Event[]>([])
81 const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
82 const [isLoading, setIsLoading] = useState(false)
83 const poolRef = useRef<SimplePool | null>(null)
84
85 useEffect(() => {
86 poolRef.current = new SimplePool()
87 return () => {
88 poolRef.current?.close(poolRef.current ? [getRelayWsUrl()] : [])
89 }
90 }, [])
91
92 const updateField = (field: keyof FilterState, value: string) => {
93 setFilterState((prev) => ({ ...prev, [field]: value }))
94 }
95
96 const toggleJsonMode = () => {
97 if (!jsonMode) {
98 const filter = buildFilter(filterState)
99 setJsonText(JSON.stringify(filter, null, 2))
100 setJsonError('')
101 }
102 setJsonMode(!jsonMode)
103 }
104
105 const queryEvents = useCallback(async () => {
106 setIsLoading(true)
107 setEvents([])
108 try {
109 let filter: Filter
110 if (jsonMode) {
111 try {
112 filter = JSON.parse(jsonText)
113 setJsonError('')
114 } catch (e) {
115 setJsonError(e instanceof Error ? e.message : 'Invalid JSON')
116 setIsLoading(false)
117 return
118 }
119 } else {
120 filter = buildFilter(filterState)
121 }
122
123 if (!filter.limit) filter.limit = 50
124
125 const pool = poolRef.current
126 if (!pool) {
127 toast.error('Pool not initialized')
128 setIsLoading(false)
129 return
130 }
131
132 const relayUrl = getRelayWsUrl()
133 const collected: Event[] = []
134
135 await new Promise<void>((resolve) => {
136 const sub = pool.subscribeMany([relayUrl], filter, {
137 onevent(evt: Event) {
138 collected.push(evt)
139 },
140 oneose() {
141 sub.close()
142 resolve()
143 },
144 })
145 // Safety timeout
146 setTimeout(() => {
147 sub.close()
148 resolve()
149 }, 15000)
150 })
151
152 collected.sort((a, b) => b.created_at - a.created_at)
153 setEvents(collected)
154 toast.success(`Found ${collected.length} events`)
155 } catch (e) {
156 toast.error(`Query failed: ${e instanceof Error ? e.message : String(e)}`)
157 } finally {
158 setIsLoading(false)
159 }
160 }, [filterState, jsonMode, jsonText])
161
162 const toggleExpand = (id: string) => {
163 setExpandedIds((prev) => {
164 const next = new Set(prev)
165 if (next.has(id)) {
166 next.delete(id)
167 } else {
168 next.add(id)
169 }
170 return next
171 })
172 }
173
174 const copyEvent = (event: Event) => {
175 navigator.clipboard.writeText(JSON.stringify(event))
176 toast.success('Copied to clipboard')
177 }
178
179 const deleteEvent = async (event: Event) => {
180 if (!pubkey) {
181 toast.error('Login required to delete events')
182 return
183 }
184 if (!confirm(`Delete event ${event.id.slice(0, 16)}...?`)) return
185
186 try {
187 const tags: string[][] = [['k', String(event.kind)]]
188 tags.push(['e', event.id])
189
190 const draft = {
191 kind: 5,
192 content: 'Request for deletion of the event.',
193 tags,
194 created_at: Math.floor(Date.now() / 1000),
195 }
196 await publish(draft)
197 toast.success('Deletion request published')
198 setEvents((prev) => prev.filter((e) => e.id !== event.id))
199 } catch (e) {
200 toast.error(`Delete failed: ${e instanceof Error ? e.message : String(e)}`)
201 }
202 }
203
204 const clearFilter = () => {
205 setFilterState(EMPTY_FILTER)
206 setJsonText('')
207 setJsonError('')
208 }
209
210 return (
211 <div className="p-4 space-y-4 w-full">
212 <div className="flex items-center justify-between">
213 <h3 className="text-lg font-semibold">Event Browser</h3>
214 <div className="flex items-center gap-2">
215 <Button
216 variant="outline"
217 size="sm"
218 onClick={toggleJsonMode}
219 className={cn(jsonMode && 'bg-accent')}
220 >
221 {'</>'}
222 </Button>
223 <Button variant="outline" size="sm" onClick={clearFilter}>
224 Clear
225 </Button>
226 <Button size="sm" onClick={queryEvents} disabled={isLoading}>
227 {isLoading ? 'Querying...' : 'Query'}
228 </Button>
229 </div>
230 </div>
231
232 {jsonMode ? (
233 <div className="space-y-2">
234 <textarea
235 value={jsonText}
236 onChange={(e) => setJsonText(e.target.value)}
237 className="w-full rounded-md border bg-card p-3 font-mono text-sm min-h-[160px] resize-y"
238 placeholder='{"kinds": [1], "limit": 50}'
239 />
240 {jsonError && (
241 <div className="text-sm text-destructive">{jsonError}</div>
242 )}
243 </div>
244 ) : (
245 <div className="rounded-lg bg-card p-4 space-y-3">
246 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
247 <div className="space-y-1">
248 <label className="text-sm font-medium text-muted-foreground">Kinds</label>
249 <input
250 type="text"
251 value={filterState.kinds}
252 onChange={(e) => updateField('kinds', e.target.value)}
253 placeholder="1, 0, 3"
254 className="w-full rounded-md border bg-background px-3 py-2 text-sm"
255 />
256 </div>
257 <div className="space-y-1">
258 <label className="text-sm font-medium text-muted-foreground">Limit</label>
259 <input
260 type="number"
261 value={filterState.limit}
262 onChange={(e) => updateField('limit', e.target.value)}
263 placeholder="50"
264 min="1"
265 className="w-full rounded-md border bg-background px-3 py-2 text-sm"
266 />
267 </div>
268 </div>
269 <div className="space-y-1">
270 <label className="text-sm font-medium text-muted-foreground">Authors (hex pubkeys, comma-separated)</label>
271 <input
272 type="text"
273 value={filterState.authors}
274 onChange={(e) => updateField('authors', e.target.value)}
275 placeholder="abc123..., def456..."
276 className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono"
277 />
278 </div>
279 <div className="space-y-1">
280 <label className="text-sm font-medium text-muted-foreground">Event IDs (hex, comma-separated)</label>
281 <input
282 type="text"
283 value={filterState.ids}
284 onChange={(e) => updateField('ids', e.target.value)}
285 placeholder="abc123..., def456..."
286 className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono"
287 />
288 </div>
289 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
290 <div className="space-y-1">
291 <label className="text-sm font-medium text-muted-foreground">Since</label>
292 <input
293 type="datetime-local"
294 value={filterState.since}
295 onChange={(e) => updateField('since', e.target.value)}
296 className="w-full rounded-md border bg-background px-3 py-2 text-sm"
297 />
298 </div>
299 <div className="space-y-1">
300 <label className="text-sm font-medium text-muted-foreground">Until</label>
301 <input
302 type="datetime-local"
303 value={filterState.until}
304 onChange={(e) => updateField('until', e.target.value)}
305 className="w-full rounded-md border bg-background px-3 py-2 text-sm"
306 />
307 </div>
308 </div>
309 </div>
310 )}
311
312 <div className="text-xs text-muted-foreground">
313 {events.length} events loaded from {getRelayWsUrl()}
314 </div>
315
316 <div className="space-y-1">
317 {events.length === 0 && !isLoading && (
318 <div className="text-center py-8 text-muted-foreground">
319 No events. Enter a filter and press Query.
320 </div>
321 )}
322
323 {events.map((event) => (
324 <div key={event.id} className="rounded-md bg-card border">
325 <div
326 className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-accent/20"
327 onClick={() => toggleExpand(event.id)}
328 >
329 <div className="shrink-0 min-w-[100px]">
330 <span className="font-mono text-xs text-muted-foreground">
331 {truncatePubkey(event.pubkey)}
332 </span>
333 </div>
334 <div className="flex items-center gap-1.5 shrink-0">
335 <span
336 className={cn(
337 'rounded px-1.5 py-0.5 font-mono text-xs font-semibold',
338 event.kind === 5
339 ? 'bg-destructive text-destructive-foreground'
340 : 'bg-secondary text-secondary-foreground'
341 )}
342 >
343 {event.kind}
344 </span>
345 <span className="text-xs text-muted-foreground">
346 {getKindName(event.kind)}
347 </span>
348 </div>
349 <div className="flex-1 min-w-0">
350 <span className="text-xs text-muted-foreground truncate block">
351 {truncate(event.content, 80)}
352 </span>
353 </div>
354 <span className="text-xs text-muted-foreground shrink-0">
355 {new Date(event.created_at * 1000).toLocaleString()}
356 </span>
357 {pubkey && event.kind !== 5 && (
358 <Button
359 variant="ghost-destructive"
360 size="sm"
361 onClick={(e) => {
362 e.stopPropagation()
363 deleteEvent(event)
364 }}
365 className="shrink-0 h-7 px-2 text-xs"
366 >
367 Delete
368 </Button>
369 )}
370 </div>
371 {expandedIds.has(event.id) && (
372 <div className="border-t px-3 py-2 relative">
373 <pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all bg-background p-3 rounded">
374 {JSON.stringify(event, null, 2)}
375 </pre>
376 <Button
377 variant="outline"
378 size="sm"
379 className="absolute top-4 right-5"
380 onClick={(e) => {
381 e.stopPropagation()
382 copyEvent(event)
383 }}
384 >
385 Copy
386 </Button>
387 </div>
388 )}
389 </div>
390 ))}
391
392 {isLoading && (
393 <div className="text-center py-8 text-muted-foreground">Loading events...</div>
394 )}
395 </div>
396 </div>
397 )
398 }
399