index.tsx raw
1 /**
2 * Cache Relays Setting Component
3 *
4 * Configure NRC connections as cache relays for faster event loading.
5 * Cache relays are queried first (400ms timeout) before regular relays,
6 * and loaded events are pushed to them in background.
7 */
8
9 import { useState, useCallback } from 'react'
10 import { useTranslation } from 'react-i18next'
11 import { useNostr } from '@/providers/NostrProvider'
12 import nrcCacheRelayService, { NRCCacheRelayConfig } from '@/services/nrc/nrc-cache-relay.service'
13 import { Button } from '@/components/ui/button'
14 import { Input } from '@/components/ui/input'
15 import { Label } from '@/components/ui/label'
16 import { Switch } from '@/components/ui/switch'
17 import {
18 Dialog,
19 DialogContent,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogTitle
24 } from '@/components/ui/dialog'
25 import {
26 AlertDialog,
27 AlertDialogAction,
28 AlertDialogCancel,
29 AlertDialogContent,
30 AlertDialogDescription,
31 AlertDialogFooter,
32 AlertDialogHeader,
33 AlertDialogTitle,
34 AlertDialogTrigger
35 } from '@/components/ui/alert-dialog'
36 import { Plus, Trash2, Zap, Database } from 'lucide-react'
37
38 export default function CacheRelaysSetting() {
39 const { t } = useTranslation()
40 const { pubkey } = useNostr()
41
42 // Cache relay state
43 const [cacheRelays, setCacheRelays] = useState<NRCCacheRelayConfig[]>(nrcCacheRelayService.getAll())
44 const [isDialogOpen, setIsDialogOpen] = useState(false)
45 const [uri, setUri] = useState('')
46 const [label, setLabel] = useState('')
47 const [queryFirst, setQueryFirst] = useState(true)
48 const [pushEvents, setPushEvents] = useState(true)
49 const [isLoading, setIsLoading] = useState(false)
50 const [isTesting, setIsTesting] = useState<string | null>(null)
51 const [error, setError] = useState<string | null>(null)
52
53 const handleAdd = useCallback(async () => {
54 if (!uri.trim() || !label.trim()) return
55
56 setIsLoading(true)
57 setError(null)
58 try {
59 // Test connection first
60 await nrcCacheRelayService.testConnection(uri.trim())
61
62 // Add the cache relay
63 nrcCacheRelayService.add({
64 uri: uri.trim(),
65 label: label.trim(),
66 enabled: true,
67 queryFirst,
68 pushEvents
69 })
70
71 setCacheRelays(nrcCacheRelayService.getAll())
72 setIsDialogOpen(false)
73 setUri('')
74 setLabel('')
75 setQueryFirst(true)
76 setPushEvents(true)
77 } catch (err) {
78 console.error('Failed to add cache relay:', err)
79 setError(err instanceof Error ? err.message : 'Connection failed')
80 } finally {
81 setIsLoading(false)
82 }
83 }, [uri, label, queryFirst, pushEvents])
84
85 const handleRemove = useCallback((id: string) => {
86 nrcCacheRelayService.remove(id)
87 setCacheRelays(nrcCacheRelayService.getAll())
88 }, [])
89
90 const handleToggleEnabled = useCallback((id: string, enabled: boolean) => {
91 nrcCacheRelayService.update(id, { enabled })
92 setCacheRelays(nrcCacheRelayService.getAll())
93 }, [])
94
95 const handleToggleQueryFirst = useCallback((id: string, queryFirst: boolean) => {
96 nrcCacheRelayService.update(id, { queryFirst })
97 setCacheRelays(nrcCacheRelayService.getAll())
98 }, [])
99
100 const handleTogglePushEvents = useCallback((id: string, pushEvents: boolean) => {
101 nrcCacheRelayService.update(id, { pushEvents })
102 setCacheRelays(nrcCacheRelayService.getAll())
103 }, [])
104
105 const handleTest = useCallback(async (id: string, relayUri: string) => {
106 setIsTesting(id)
107 setError(null)
108 try {
109 await nrcCacheRelayService.testConnection(relayUri)
110 nrcCacheRelayService.update(id, { lastConnected: Date.now(), lastError: undefined })
111 setCacheRelays(nrcCacheRelayService.getAll())
112 } catch (err) {
113 const errorMsg = err instanceof Error ? err.message : 'Connection failed'
114 nrcCacheRelayService.update(id, { lastError: errorMsg })
115 setCacheRelays(nrcCacheRelayService.getAll())
116 } finally {
117 setIsTesting(null)
118 }
119 }, [])
120
121 if (!pubkey) {
122 return (
123 <div className="text-muted-foreground text-sm p-4 text-center">
124 {t('Login required to configure cache relays')}
125 </div>
126 )
127 }
128
129 return (
130 <div className="space-y-4">
131 <div className="p-3 bg-muted/30 rounded-lg">
132 <p className="text-sm text-muted-foreground">
133 {t('Cache relays store events via NRC for faster loading. They are queried first (400ms timeout) before regular relays.')}
134 </p>
135 </div>
136
137 {/* Header with Add button */}
138 <div className="flex items-center justify-between">
139 <Label className="flex items-center gap-2">
140 <Database className="w-4 h-4" />
141 {t('Cache Relays')}
142 </Label>
143 <Button
144 variant="outline"
145 size="sm"
146 onClick={() => setIsDialogOpen(true)}
147 className="gap-1"
148 >
149 <Plus className="w-4 h-4" />
150 {t('Add')}
151 </Button>
152 </div>
153
154 {/* Cache Relays List */}
155 {cacheRelays.length === 0 ? (
156 <div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
157 {t('No cache relays configured')}
158 </div>
159 ) : (
160 <div className="space-y-2">
161 {cacheRelays.map((relay) => (
162 <div
163 key={relay.id}
164 className="p-3 bg-muted/30 rounded-lg space-y-3"
165 >
166 <div className="flex items-center justify-between">
167 <div className="flex-1 min-w-0">
168 <div className="flex items-center gap-2">
169 <span className="font-medium truncate">{relay.label}</span>
170 {relay.lastError && (
171 <span className="text-xs text-destructive">({t('Error')})</span>
172 )}
173 {relay.lastConnected && !relay.lastError && (
174 <span className="text-xs text-green-500">({t('Connected')})</span>
175 )}
176 </div>
177 <div className="text-xs text-muted-foreground truncate">
178 {relay.uri.length > 60 ? relay.uri.substring(0, 60) + '...' : relay.uri}
179 </div>
180 </div>
181 <div className="flex items-center gap-1">
182 <Switch
183 checked={relay.enabled}
184 onCheckedChange={(checked) => handleToggleEnabled(relay.id, checked)}
185 title={t('Enable/Disable')}
186 />
187 <Button
188 variant="ghost"
189 size="icon"
190 onClick={() => handleTest(relay.id, relay.uri)}
191 disabled={isTesting === relay.id}
192 title={t('Test Connection')}
193 >
194 <Zap className={`w-4 h-4 ${isTesting === relay.id ? 'animate-pulse' : ''}`} />
195 </Button>
196 <AlertDialog>
197 <AlertDialogTrigger asChild>
198 <Button
199 variant="ghost"
200 size="icon"
201 className="text-destructive hover:text-destructive"
202 title={t('Remove')}
203 >
204 <Trash2 className="w-4 h-4" />
205 </Button>
206 </AlertDialogTrigger>
207 <AlertDialogContent>
208 <AlertDialogHeader>
209 <AlertDialogTitle>{t('Remove Cache Relay?')}</AlertDialogTitle>
210 <AlertDialogDescription>
211 {t('This will remove "{{label}}" from your cache relays.', {
212 label: relay.label
213 })}
214 </AlertDialogDescription>
215 </AlertDialogHeader>
216 <AlertDialogFooter>
217 <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
218 <AlertDialogAction
219 onClick={() => handleRemove(relay.id)}
220 className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
221 >
222 {t('Remove')}
223 </AlertDialogAction>
224 </AlertDialogFooter>
225 </AlertDialogContent>
226 </AlertDialog>
227 </div>
228 </div>
229
230 {/* Options */}
231 <div className="flex items-center gap-4 text-sm">
232 <label className="flex items-center gap-2">
233 <Switch
234 checked={relay.queryFirst}
235 onCheckedChange={(checked) => handleToggleQueryFirst(relay.id, checked)}
236 disabled={!relay.enabled}
237 />
238 <span className="text-muted-foreground">{t('Query first')}</span>
239 </label>
240 <label className="flex items-center gap-2">
241 <Switch
242 checked={relay.pushEvents}
243 onCheckedChange={(checked) => handleTogglePushEvents(relay.id, checked)}
244 disabled={!relay.enabled}
245 />
246 <span className="text-muted-foreground">{t('Push events')}</span>
247 </label>
248 </div>
249
250 {relay.lastError && (
251 <div className="text-xs text-destructive">
252 {relay.lastError}
253 </div>
254 )}
255 </div>
256 ))}
257 </div>
258 )}
259
260 {/* Add Cache Relay Dialog */}
261 <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
262 <DialogContent>
263 <DialogHeader>
264 <DialogTitle>{t('Add Cache Relay')}</DialogTitle>
265 <DialogDescription>
266 {t('Add an NRC connection as a cache relay for faster event loading')}
267 </DialogDescription>
268 </DialogHeader>
269 <div className="space-y-4 py-4">
270 <div className="space-y-2">
271 <Label htmlFor="cache-relay-uri">{t('Connection URI')}</Label>
272 <Input
273 id="cache-relay-uri"
274 value={uri}
275 onChange={(e) => setUri(e.target.value)}
276 placeholder="nostr+relayconnect://..."
277 className="font-mono text-xs"
278 />
279 </div>
280 <div className="space-y-2">
281 <Label htmlFor="cache-relay-label">{t('Label')}</Label>
282 <Input
283 id="cache-relay-label"
284 value={label}
285 onChange={(e) => setLabel(e.target.value)}
286 placeholder={t('e.g., Home Relay, Personal Cache')}
287 />
288 </div>
289 <div className="space-y-3">
290 <div className="flex items-center justify-between">
291 <div>
292 <Label>{t('Query first')}</Label>
293 <p className="text-xs text-muted-foreground">
294 {t('Check cache relay before regular relays (400ms timeout)')}
295 </p>
296 </div>
297 <Switch
298 checked={queryFirst}
299 onCheckedChange={setQueryFirst}
300 />
301 </div>
302 <div className="flex items-center justify-between">
303 <div>
304 <Label>{t('Push events')}</Label>
305 <p className="text-xs text-muted-foreground">
306 {t('Store loaded events in cache relay in background')}
307 </p>
308 </div>
309 <Switch
310 checked={pushEvents}
311 onCheckedChange={setPushEvents}
312 />
313 </div>
314 </div>
315 {error && (
316 <div className="text-sm text-destructive">{error}</div>
317 )}
318 </div>
319 <DialogFooter>
320 <Button variant="outline" onClick={() => {
321 setIsDialogOpen(false)
322 setError(null)
323 }}>
324 {t('Cancel')}
325 </Button>
326 <Button
327 onClick={handleAdd}
328 disabled={!uri.trim() || !label.trim() || isLoading}
329 >
330 {isLoading ? t('Testing...') : t('Add')}
331 </Button>
332 </DialogFooter>
333 </DialogContent>
334 </Dialog>
335 </div>
336 )
337 }
338