index.tsx raw

   1  /**
   2   * Relay Discovery Tool
   3   *
   4   * Discovers all known relays on the Nostr network and displays them
   5   * sorted by frequency of occurrence in NIP-65 relay lists.
   6   */
   7  
   8  import { Button } from '@/components/ui/button'
   9  import { Label } from '@/components/ui/label'
  10  import { ScrollArea } from '@/components/ui/scroll-area'
  11  import { Slider } from '@/components/ui/slider'
  12  import relayDiscoveryService, {
  13    DiscoveryProgress,
  14    DiscoveryResult,
  15    RelayFrequency
  16  } from '@/services/relay-discovery.service'
  17  import storage from '@/services/local-storage.service'
  18  import { Copy, Download, Loader2, Play, RefreshCw, Square } from 'lucide-react'
  19  import { useCallback, useEffect, useState } from 'react'
  20  import { useTranslation } from 'react-i18next'
  21  import { toast } from 'sonner'
  22  
  23  export default function RelayDiscovery() {
  24    const { t } = useTranslation()
  25    const [isRunning, setIsRunning] = useState(false)
  26    const [progress, setProgress] = useState<DiscoveryProgress | null>(null)
  27    const [result, setResult] = useState<DiscoveryResult | null>(null)
  28    const [copied, setCopied] = useState(false)
  29    const [fallbackCount, setFallbackCount] = useState(storage.getFallbackRelayCount())
  30  
  31    // Load cached result on mount
  32    useEffect(() => {
  33      const cached = relayDiscoveryService.getCachedResult()
  34      if (cached) {
  35        setResult(cached)
  36      }
  37    }, [])
  38  
  39    const handleStart = useCallback(async () => {
  40      setIsRunning(true)
  41      setProgress({
  42        phase: 'phase1',
  43        relaysQueried: 0,
  44        totalRelays: 0,
  45        eventsFound: 0,
  46        uniqueRelaysFound: 0
  47      })
  48  
  49      try {
  50        const discoveryResult = await relayDiscoveryService.discover((prog) => {
  51          setProgress(prog)
  52        })
  53        setResult(discoveryResult)
  54        toast.success(t('Discovery complete'), {
  55          description: `${discoveryResult.relays.length} relays found`
  56        })
  57      } catch (err) {
  58        console.error('[RelayDiscovery] Error:', err)
  59        toast.error(t('Discovery failed'))
  60      } finally {
  61        setIsRunning(false)
  62        setProgress(null)
  63      }
  64    }, [t])
  65  
  66    const handleStop = useCallback(() => {
  67      relayDiscoveryService.abort()
  68      setIsRunning(false)
  69      setProgress(null)
  70    }, [])
  71  
  72    const handleRefresh = useCallback(() => {
  73      relayDiscoveryService.clearCache()
  74      setResult(null)
  75      handleStart()
  76    }, [handleStart])
  77  
  78    const handleCopy = useCallback(() => {
  79      if (!result) return
  80      const text = relayDiscoveryService.exportAsPlaintext(result.relays)
  81      navigator.clipboard.writeText(text)
  82      setCopied(true)
  83      setTimeout(() => setCopied(false), 2000)
  84      toast.success(t('Copied to clipboard'))
  85    }, [result, t])
  86  
  87    const handleDownload = useCallback(() => {
  88      if (!result) return
  89      relayDiscoveryService.downloadAsFile(result.relays)
  90      toast.success(t('Downloaded'))
  91    }, [result, t])
  92  
  93    const getPhaseLabel = (phase: string): string => {
  94      switch (phase) {
  95        case 'phase1':
  96          return t('Phase 1: Querying bootstrap relays')
  97        case 'phase2':
  98          return t('Phase 2: Querying discovered relays')
  99        case 'complete':
 100          return t('Complete')
 101        default:
 102          return ''
 103      }
 104    }
 105  
 106    const getProgressPercent = (): number => {
 107      if (!progress) return 0
 108      if (progress.totalRelays === 0) return 0
 109  
 110      const basePercent = progress.phase === 'phase1' ? 0 : 50
 111      const phasePercent = (progress.relaysQueried / progress.totalRelays) * 50
 112      return Math.round(basePercent + phasePercent)
 113    }
 114  
 115    return (
 116      <div className="space-y-4">
 117        <div className="text-sm text-muted-foreground">
 118          {t('Discover all known relays on the Nostr network by querying NIP-65 relay lists.')}
 119        </div>
 120  
 121        {/* Fallback Relay Configuration */}
 122        <div className="space-y-2">
 123          <Label>
 124            {t('Fallback relay count')}: {fallbackCount}
 125          </Label>
 126          <div className="text-xs text-muted-foreground">
 127            {t('Number of top discovered relays to search when notes aren\'t found.')}
 128          </div>
 129          <Slider
 130            value={[fallbackCount]}
 131            onValueChange={([value]) => {
 132              setFallbackCount(value)
 133              storage.setFallbackRelayCount(value)
 134            }}
 135            min={3}
 136            max={50}
 137            step={1}
 138            disabled={!result}
 139          />
 140          <div className="flex justify-between text-xs text-muted-foreground">
 141            <span>3</span>
 142            <span>50</span>
 143          </div>
 144        </div>
 145  
 146        {/* Controls */}
 147        <div className="flex gap-2 flex-wrap">
 148          {!isRunning ? (
 149            <>
 150              {!result ? (
 151                <Button onClick={handleStart} size="sm">
 152                  <Play className="h-4 w-4 mr-2" />
 153                  {t('Start Discovery')}
 154                </Button>
 155              ) : (
 156                <Button onClick={handleRefresh} size="sm" variant="outline">
 157                  <RefreshCw className="h-4 w-4 mr-2" />
 158                  {t('Refresh')}
 159                </Button>
 160              )}
 161            </>
 162          ) : (
 163            <Button onClick={handleStop} size="sm" variant="destructive">
 164              <Square className="h-4 w-4 mr-2" />
 165              {t('Stop')}
 166            </Button>
 167          )}
 168  
 169          {result && !isRunning && (
 170            <>
 171              <Button onClick={handleCopy} size="sm" variant="outline">
 172                <Copy className="h-4 w-4 mr-2" />
 173                {copied ? t('Copied!') : t('Copy')}
 174              </Button>
 175              <Button onClick={handleDownload} size="sm" variant="outline">
 176                <Download className="h-4 w-4 mr-2" />
 177                {t('Download')}
 178              </Button>
 179            </>
 180          )}
 181        </div>
 182  
 183        {/* Progress */}
 184        {isRunning && progress && (
 185          <div className="space-y-2">
 186            <div className="flex items-center gap-2 text-sm">
 187              <Loader2 className="h-4 w-4 animate-spin" />
 188              <span>{getPhaseLabel(progress.phase)}</span>
 189            </div>
 190            <div className="h-2 w-full rounded-full bg-muted overflow-hidden">
 191              <div
 192                className="h-full bg-primary transition-all duration-300"
 193                style={{ width: `${getProgressPercent()}%` }}
 194              />
 195            </div>
 196            <div className="text-xs text-muted-foreground">
 197              {t('Relays queried')}: {progress.relaysQueried}/{progress.totalRelays} |{' '}
 198              {t('Events found')}: {progress.eventsFound} |{' '}
 199              {t('Unique relays')}: {progress.uniqueRelaysFound}
 200            </div>
 201          </div>
 202        )}
 203  
 204        {/* Results */}
 205        {result && !isRunning && (
 206          <div className="space-y-2">
 207            <div className="text-sm font-medium">
 208              {t('Found {{count}} relays from {{events}} relay list events', {
 209                count: result.relays.length,
 210                events: result.totalEvents
 211              })}
 212            </div>
 213            <div className="text-xs text-muted-foreground">
 214              {t('Last updated')}: {new Date(result.timestamp).toLocaleString()}
 215            </div>
 216  
 217            <ScrollArea className="h-[300px] rounded-md border">
 218              <div className="p-2">
 219                <table className="w-full text-sm">
 220                  <thead>
 221                    <tr className="border-b">
 222                      <th className="text-left py-2 px-2">#</th>
 223                      <th className="text-left py-2 px-2">{t('Relay URL')}</th>
 224                      <th className="text-right py-2 px-2">{t('Count')}</th>
 225                      <th className="text-right py-2 px-2">%</th>
 226                    </tr>
 227                  </thead>
 228                  <tbody>
 229                    {result.relays.map((relay, index) => (
 230                      <RelayRow key={relay.url} relay={relay} index={index + 1} />
 231                    ))}
 232                  </tbody>
 233                </table>
 234              </div>
 235            </ScrollArea>
 236          </div>
 237        )}
 238      </div>
 239    )
 240  }
 241  
 242  function RelayRow({ relay, index }: { relay: RelayFrequency; index: number }) {
 243    return (
 244      <tr className="border-b border-border/50 hover:bg-muted/50">
 245        <td className="py-1.5 px-2 text-muted-foreground">{index}</td>
 246        <td className="py-1.5 px-2 font-mono text-xs break-all">{relay.url}</td>
 247        <td className="py-1.5 px-2 text-right tabular-nums">{relay.count}</td>
 248        <td className="py-1.5 px-2 text-right tabular-nums text-muted-foreground">
 249          {relay.percentage}%
 250        </td>
 251      </tr>
 252    )
 253  }
 254