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