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