index.tsx raw
1 /**
2 * NRC Settings Component
3 *
4 * UI for managing Nostr Relay Connect (NRC) connections and listener settings.
5 * Includes both:
6 * - Listener mode: Allow other devices to connect to this one
7 * - Client mode: Connect to and sync from other devices
8 */
9
10 import { useState, useCallback, useRef } from 'react'
11 import { useTranslation } from 'react-i18next'
12 import { useNRC } from '@/providers/NRCProvider'
13 import { useNostr } from '@/providers/NostrProvider'
14 import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
15 import { Button } from '@/components/ui/button'
16 import { Input } from '@/components/ui/input'
17 import { Label } from '@/components/ui/label'
18 import { Switch } from '@/components/ui/switch'
19 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
20 import {
21 Dialog,
22 DialogContent,
23 DialogDescription,
24 DialogFooter,
25 DialogHeader,
26 DialogTitle
27 } from '@/components/ui/dialog'
28 import {
29 AlertDialog,
30 AlertDialogAction,
31 AlertDialogCancel,
32 AlertDialogContent,
33 AlertDialogDescription,
34 AlertDialogFooter,
35 AlertDialogHeader,
36 AlertDialogTitle,
37 AlertDialogTrigger
38 } from '@/components/ui/alert-dialog'
39 import {
40 Link2,
41 Plus,
42 Trash2,
43 Copy,
44 Check,
45 QrCode,
46 Wifi,
47 WifiOff,
48 Users,
49 Server,
50 RefreshCw,
51 Smartphone,
52 Download,
53 Camera,
54 Zap
55 } from 'lucide-react'
56 import { NRCConnection, RemoteConnection } from '@/services/nrc'
57 import QRCode from 'qrcode'
58 import { Html5Qrcode } from 'html5-qrcode'
59
60 export default function NRCSettings() {
61 const { t } = useTranslation()
62 const { pubkey } = useNostr()
63 const {
64 // Listener state
65 isEnabled,
66 isConnected,
67 connections,
68 activeSessions,
69 rendezvousUrl,
70 enable,
71 disable,
72 addConnection,
73 removeConnection,
74 getConnectionURI,
75 setRendezvousUrl,
76 // Client state
77 remoteConnections,
78 isSyncing,
79 syncProgress,
80 addRemoteConnection,
81 removeRemoteConnection,
82 testRemoteConnection,
83 syncFromDevice,
84 syncAllRemotes
85 } = useNRC()
86
87 // Listener state
88 const [newConnectionLabel, setNewConnectionLabel] = useState('')
89 const [newConnectionRendezvousUrl, setNewConnectionRendezvousUrl] = useState('')
90 const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
91 const [isQRDialogOpen, setIsQRDialogOpen] = useState(false)
92 const [currentQRConnection, setCurrentQRConnection] = useState<NRCConnection | null>(null)
93 const [currentQRUri, setCurrentQRUri] = useState('')
94 const [qrDataUrl, setQrDataUrl] = useState('')
95 const [copiedUri, setCopiedUri] = useState(false)
96 const [isLoading, setIsLoading] = useState(false)
97 const [enableError, setEnableError] = useState<string | null>(null)
98 const [addConnectionError, setAddConnectionError] = useState<string | null>(null)
99
100 // Client state
101 const [connectionUri, setConnectionUri] = useState('')
102 const [newRemoteLabel, setNewRemoteLabel] = useState('')
103 const [isConnectDialogOpen, setIsConnectDialogOpen] = useState(false)
104 const [isScannerOpen, setIsScannerOpen] = useState(false)
105 const [scannerError, setScannerError] = useState('')
106 const scannerRef = useRef<Html5Qrcode | null>(null)
107 const scannerContainerRef = useRef<HTMLDivElement>(null)
108
109 // Private config sync setting
110 const [nrcOnlyConfigSync, setNrcOnlyConfigSync] = useState(storage.getNrcOnlyConfigSync())
111
112 const handleToggleNrcOnlyConfig = useCallback((checked: boolean) => {
113 storage.setNrcOnlyConfigSync(checked)
114 setNrcOnlyConfigSync(checked)
115 dispatchSettingsChanged()
116 }, [])
117
118 // Generate QR code when URI changes
119 const generateQRCode = useCallback(async (uri: string) => {
120 try {
121 const dataUrl = await QRCode.toDataURL(uri, {
122 width: 256,
123 margin: 2,
124 color: { dark: '#000000', light: '#ffffff' }
125 })
126 setQrDataUrl(dataUrl)
127 } catch (error) {
128 console.error('Failed to generate QR code:', error)
129 }
130 }, [])
131
132 const handleToggleEnabled = useCallback(async () => {
133 if (isEnabled) {
134 disable()
135 setEnableError(null)
136 } else {
137 setIsLoading(true)
138 setEnableError(null)
139 try {
140 await enable()
141 } catch (error) {
142 const message = error instanceof Error ? error.message : 'Failed to enable NRC'
143 setEnableError(message)
144 console.error('Failed to enable NRC:', error)
145 } finally {
146 setIsLoading(false)
147 }
148 }
149 }, [isEnabled, enable, disable])
150
151 const handleAddConnection = useCallback(async () => {
152 if (!newConnectionLabel.trim()) return
153
154 setIsLoading(true)
155 setAddConnectionError(null)
156 try {
157 // Use connection-specific URL if provided, otherwise uses global default
158 const connectionRendezvousUrl = newConnectionRendezvousUrl.trim() || undefined
159 const { uri, connection } = await addConnection(newConnectionLabel.trim(), connectionRendezvousUrl)
160 setIsAddDialogOpen(false)
161 setNewConnectionLabel('')
162 setNewConnectionRendezvousUrl('')
163
164 // Show QR code
165 setCurrentQRConnection(connection)
166 setCurrentQRUri(uri)
167 await generateQRCode(uri)
168 setIsQRDialogOpen(true)
169 } catch (error) {
170 const message = error instanceof Error ? error.message : 'Failed to add connection'
171 setAddConnectionError(message)
172 console.error('Failed to add connection:', error)
173 } finally {
174 setIsLoading(false)
175 }
176 }, [newConnectionLabel, newConnectionRendezvousUrl, addConnection])
177
178 const handleShowQR = useCallback(
179 async (connection: NRCConnection) => {
180 try {
181 const uri = getConnectionURI(connection)
182 setCurrentQRConnection(connection)
183 setCurrentQRUri(uri)
184 await generateQRCode(uri)
185 setIsQRDialogOpen(true)
186 } catch (error) {
187 console.error('Failed to get connection URI:', error)
188 }
189 },
190 [getConnectionURI, generateQRCode]
191 )
192
193 const handleCopyUri = useCallback(async () => {
194 try {
195 await navigator.clipboard.writeText(currentQRUri)
196 setCopiedUri(true)
197 setTimeout(() => setCopiedUri(false), 2000)
198 } catch (error) {
199 console.error('Failed to copy URI:', error)
200 }
201 }, [currentQRUri])
202
203 const handleRemoveConnection = useCallback(
204 async (id: string) => {
205 try {
206 await removeConnection(id)
207 } catch (error) {
208 console.error('Failed to remove connection:', error)
209 }
210 },
211 [removeConnection]
212 )
213
214 // ===== Client Handlers =====
215 const handleAddRemoteConnection = useCallback(async () => {
216 if (!connectionUri.trim() || !newRemoteLabel.trim()) return
217
218 setIsLoading(true)
219 try {
220 await addRemoteConnection(connectionUri.trim(), newRemoteLabel.trim())
221 setIsConnectDialogOpen(false)
222 setConnectionUri('')
223 setNewRemoteLabel('')
224 } catch (error) {
225 console.error('Failed to add remote connection:', error)
226 } finally {
227 setIsLoading(false)
228 }
229 }, [connectionUri, newRemoteLabel, addRemoteConnection])
230
231 const handleRemoveRemoteConnection = useCallback(
232 async (id: string) => {
233 try {
234 await removeRemoteConnection(id)
235 } catch (error) {
236 console.error('Failed to remove remote connection:', error)
237 }
238 },
239 [removeRemoteConnection]
240 )
241
242 const handleSyncDevice = useCallback(
243 async (id: string) => {
244 try {
245 await syncFromDevice(id)
246 } catch (error) {
247 console.error('Failed to sync from device:', error)
248 }
249 },
250 [syncFromDevice]
251 )
252
253 const handleTestConnection = useCallback(
254 async (id: string) => {
255 try {
256 await testRemoteConnection(id)
257 } catch (error) {
258 console.error('Failed to test connection:', error)
259 }
260 },
261 [testRemoteConnection]
262 )
263
264 const handleSyncAll = useCallback(async () => {
265 try {
266 await syncAllRemotes()
267 } catch (error) {
268 console.error('Failed to sync all remotes:', error)
269 }
270 }, [syncAllRemotes])
271
272 const startScanner = useCallback(async () => {
273 if (!scannerContainerRef.current) return
274
275 setScannerError('')
276 try {
277 const scanner = new Html5Qrcode('qr-scanner-container')
278 scannerRef.current = scanner
279
280 await scanner.start(
281 { facingMode: 'environment' },
282 {
283 fps: 10,
284 qrbox: { width: 250, height: 250 }
285 },
286 (decodedText) => {
287 // Found a QR code
288 if (decodedText.startsWith('nostr+relayconnect://')) {
289 setConnectionUri(decodedText)
290 stopScanner()
291 setIsScannerOpen(false)
292 setIsConnectDialogOpen(true)
293 }
294 },
295 () => {
296 // Ignore errors while scanning
297 }
298 )
299 } catch (error) {
300 console.error('Failed to start scanner:', error)
301 setScannerError(error instanceof Error ? error.message : 'Failed to start camera')
302 }
303 }, [])
304
305 const stopScanner = useCallback(() => {
306 if (scannerRef.current) {
307 scannerRef.current.stop().catch(() => {
308 // Ignore errors when stopping
309 })
310 scannerRef.current = null
311 }
312 }, [])
313
314 const handleOpenScanner = useCallback(() => {
315 setIsScannerOpen(true)
316 // Start scanner after dialog renders
317 setTimeout(startScanner, 100)
318 }, [startScanner])
319
320 const handleCloseScanner = useCallback(() => {
321 stopScanner()
322 setIsScannerOpen(false)
323 setScannerError('')
324 }, [stopScanner])
325
326 if (!pubkey) {
327 return (
328 <div className="text-muted-foreground text-sm">
329 {t('Login required to use NRC')}
330 </div>
331 )
332 }
333
334 return (
335 <div className="space-y-6">
336 {/* Private Configuration Sync Toggle */}
337 <div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
338 <div className="space-y-1">
339 <Label htmlFor="nrc-only-config" className="text-base font-medium">
340 {t('Private Configuration Sync')}
341 </Label>
342 <p className="text-sm text-muted-foreground">
343 {t('Only sync configurations between paired devices, not to public relays')}
344 </p>
345 </div>
346 <Switch
347 id="nrc-only-config"
348 checked={nrcOnlyConfigSync}
349 onCheckedChange={handleToggleNrcOnlyConfig}
350 />
351 </div>
352
353 <Tabs defaultValue="listener" className="w-full">
354 <TabsList className="grid w-full grid-cols-2">
355 <TabsTrigger value="listener" className="gap-2">
356 <Server className="w-4 h-4" />
357 {t('Share')}
358 </TabsTrigger>
359 <TabsTrigger value="client" className="gap-2">
360 <Smartphone className="w-4 h-4" />
361 {t('Connect')}
362 </TabsTrigger>
363 </TabsList>
364
365 {/* ===== LISTENER TAB ===== */}
366 <TabsContent value="listener" className="space-y-6 mt-4">
367 {/* Enable/Disable Toggle */}
368 <div className="flex items-center justify-between">
369 <div className="space-y-1">
370 <Label htmlFor="nrc-enabled" className="text-base font-medium">
371 {t('Enable Relay Connect')}
372 </Label>
373 <p className="text-sm text-muted-foreground">
374 {t('Allow other devices to sync with this client')}
375 </p>
376 </div>
377 <Switch
378 id="nrc-enabled"
379 checked={isEnabled}
380 onCheckedChange={handleToggleEnabled}
381 disabled={isLoading}
382 />
383 </div>
384
385 {/* Enable Error */}
386 {enableError && (
387 <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
388 {enableError}
389 </div>
390 )}
391
392 {/* Status Indicator */}
393 {isEnabled && (
394 <div className="flex items-center gap-4 p-3 bg-muted/50 rounded-lg">
395 <div className="flex items-center gap-2">
396 {isConnected ? (
397 <Wifi className="w-4 h-4 text-green-500" />
398 ) : (
399 <WifiOff className="w-4 h-4 text-yellow-500" />
400 )}
401 <span className="text-sm">
402 {isConnected ? t('Connected') : t('Connecting...')}
403 </span>
404 </div>
405 {activeSessions > 0 && (
406 <div className="flex items-center gap-2">
407 <Users className="w-4 h-4" />
408 <span className="text-sm">
409 {activeSessions} {t('active session(s)')}
410 </span>
411 </div>
412 )}
413 </div>
414 )}
415
416 {/* Rendezvous Relay */}
417 <div className="space-y-2">
418 <Label htmlFor="rendezvous-url" className="flex items-center gap-2">
419 <Server className="w-4 h-4" />
420 {t('Rendezvous Relay')}
421 </Label>
422 <Input
423 id="rendezvous-url"
424 value={rendezvousUrl}
425 onChange={(e) => setRendezvousUrl(e.target.value)}
426 placeholder="wss://relay.example.com"
427 disabled={isEnabled}
428 />
429 {isEnabled && (
430 <p className="text-xs text-muted-foreground">
431 {t('Disable NRC to change the relay')}
432 </p>
433 )}
434 </div>
435
436 {/* Connections List */}
437 <div className="space-y-3">
438 <div className="flex items-center justify-between">
439 <Label className="flex items-center gap-2">
440 <Link2 className="w-4 h-4" />
441 {t('Authorized Devices')}
442 </Label>
443 <Button
444 variant="outline"
445 size="sm"
446 onClick={() => setIsAddDialogOpen(true)}
447 className="gap-1"
448 >
449 <Plus className="w-4 h-4" />
450 {t('Add')}
451 </Button>
452 </div>
453
454 {connections.length === 0 ? (
455 <div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
456 {t('No devices connected yet')}
457 </div>
458 ) : (
459 <div className="space-y-2">
460 {connections.map((connection) => (
461 <div
462 key={connection.id}
463 className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
464 >
465 <div className="flex-1 min-w-0">
466 <div className="font-medium truncate">{connection.label}</div>
467 <div className="text-xs text-muted-foreground">
468 {new Date(connection.createdAt).toLocaleDateString()}
469 </div>
470 </div>
471 <div className="flex items-center gap-1">
472 <Button
473 variant="ghost"
474 size="icon"
475 onClick={() => handleShowQR(connection)}
476 title={t('Show QR Code')}
477 >
478 <QrCode className="w-4 h-4" />
479 </Button>
480 <AlertDialog>
481 <AlertDialogTrigger asChild>
482 <Button
483 variant="ghost"
484 size="icon"
485 className="text-destructive hover:text-destructive"
486 title={t('Remove')}
487 >
488 <Trash2 className="w-4 h-4" />
489 </Button>
490 </AlertDialogTrigger>
491 <AlertDialogContent>
492 <AlertDialogHeader>
493 <AlertDialogTitle>{t('Remove Device?')}</AlertDialogTitle>
494 <AlertDialogDescription>
495 {t('This will revoke access for "{{label}}". The device will no longer be able to sync.', {
496 label: connection.label
497 })}
498 </AlertDialogDescription>
499 </AlertDialogHeader>
500 <AlertDialogFooter>
501 <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
502 <AlertDialogAction
503 onClick={() => handleRemoveConnection(connection.id)}
504 className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
505 >
506 {t('Remove')}
507 </AlertDialogAction>
508 </AlertDialogFooter>
509 </AlertDialogContent>
510 </AlertDialog>
511 </div>
512 </div>
513 ))}
514 </div>
515 )}
516 </div>
517 </TabsContent>
518
519 {/* ===== CLIENT TAB ===== */}
520 <TabsContent value="client" className="space-y-6 mt-4">
521 {/* Sync Progress */}
522 {isSyncing && syncProgress && (
523 <div className="p-3 bg-muted/50 rounded-lg space-y-2">
524 <div className="flex items-center gap-2">
525 <RefreshCw className="w-4 h-4 animate-spin" />
526 <span className="text-sm font-medium">
527 {syncProgress.phase === 'connecting' && t('Connecting...')}
528 {syncProgress.phase === 'requesting' && t('Requesting events...')}
529 {syncProgress.phase === 'receiving' && t('Receiving events...')}
530 {syncProgress.phase === 'complete' && t('Sync complete')}
531 {syncProgress.phase === 'error' && t('Error')}
532 </span>
533 </div>
534 {syncProgress.eventsReceived > 0 && (
535 <div className="text-xs text-muted-foreground">
536 {t('{{count}} events received', { count: syncProgress.eventsReceived })}
537 </div>
538 )}
539 {syncProgress.message && syncProgress.phase === 'error' && (
540 <div className="text-xs text-destructive">{syncProgress.message}</div>
541 )}
542 </div>
543 )}
544
545 {/* Connect to Device */}
546 <div className="space-y-3">
547 <div className="flex items-center justify-between">
548 <Label className="flex items-center gap-2">
549 <Download className="w-4 h-4" />
550 {t('Remote Devices')}
551 </Label>
552 <div className="flex gap-2">
553 <Button
554 variant="outline"
555 size="sm"
556 onClick={handleOpenScanner}
557 className="gap-1"
558 >
559 <Camera className="w-4 h-4" />
560 {t('Scan')}
561 </Button>
562 <Button
563 variant="outline"
564 size="sm"
565 onClick={() => setIsConnectDialogOpen(true)}
566 className="gap-1"
567 >
568 <Plus className="w-4 h-4" />
569 {t('Add')}
570 </Button>
571 </div>
572 </div>
573
574 {remoteConnections.length === 0 ? (
575 <div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
576 {t('No remote devices configured')}
577 </div>
578 ) : (
579 <div className="space-y-2">
580 {/* Sync All Button */}
581 {remoteConnections.length > 1 && (
582 <Button
583 variant="secondary"
584 size="sm"
585 onClick={handleSyncAll}
586 disabled={isSyncing}
587 className="w-full gap-2"
588 >
589 <RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
590 {t('Sync All Devices')}
591 </Button>
592 )}
593
594 {remoteConnections.map((remote: RemoteConnection) => (
595 <div
596 key={remote.id}
597 className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
598 >
599 <div className="flex-1 min-w-0">
600 <div className="font-medium truncate">{remote.label}</div>
601 <div className="text-xs text-muted-foreground">
602 {remote.lastSync ? (
603 <>
604 {t('Last sync')}: {new Date(remote.lastSync).toLocaleString()}
605 {remote.eventCount !== undefined && (
606 <span className="ml-2">({remote.eventCount} {t('events')})</span>
607 )}
608 </>
609 ) : (
610 t('Never synced')
611 )}
612 </div>
613 </div>
614 <div className="flex items-center gap-1">
615 {/* Show Test button if never synced, Sync button otherwise */}
616 {!remote.lastSync ? (
617 <Button
618 variant="ghost"
619 size="icon"
620 onClick={() => handleTestConnection(remote.id)}
621 disabled={isSyncing}
622 title={t('Test Connection')}
623 >
624 <Zap className={`w-4 h-4 ${isSyncing ? 'animate-pulse' : ''}`} />
625 </Button>
626 ) : null}
627 <Button
628 variant="ghost"
629 size="icon"
630 onClick={() => handleSyncDevice(remote.id)}
631 disabled={isSyncing}
632 title={t('Sync')}
633 >
634 <RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
635 </Button>
636 <AlertDialog>
637 <AlertDialogTrigger asChild>
638 <Button
639 variant="ghost"
640 size="icon"
641 className="text-destructive hover:text-destructive"
642 title={t('Remove')}
643 >
644 <Trash2 className="w-4 h-4" />
645 </Button>
646 </AlertDialogTrigger>
647 <AlertDialogContent>
648 <AlertDialogHeader>
649 <AlertDialogTitle>{t('Remove Remote Device?')}</AlertDialogTitle>
650 <AlertDialogDescription>
651 {t('This will remove "{{label}}" from your remote devices list.', {
652 label: remote.label
653 })}
654 </AlertDialogDescription>
655 </AlertDialogHeader>
656 <AlertDialogFooter>
657 <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
658 <AlertDialogAction
659 onClick={() => handleRemoveRemoteConnection(remote.id)}
660 className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
661 >
662 {t('Remove')}
663 </AlertDialogAction>
664 </AlertDialogFooter>
665 </AlertDialogContent>
666 </AlertDialog>
667 </div>
668 </div>
669 ))}
670 </div>
671 )}
672 </div>
673 </TabsContent>
674 </Tabs>
675
676 {/* ===== DIALOGS ===== */}
677
678 {/* Add Connection Dialog (Listener) */}
679 <Dialog open={isAddDialogOpen} onOpenChange={(open) => {
680 setIsAddDialogOpen(open)
681 if (open) {
682 // Pre-populate with global rendezvous URL when opening
683 setNewConnectionRendezvousUrl(rendezvousUrl)
684 setAddConnectionError(null)
685 } else {
686 // Clear when closing
687 setNewConnectionLabel('')
688 setNewConnectionRendezvousUrl('')
689 setAddConnectionError(null)
690 }
691 }}>
692 <DialogContent>
693 <DialogHeader>
694 <DialogTitle>{t('Add Device')}</DialogTitle>
695 <DialogDescription>
696 {t('Create a connection URI to link another device')}
697 </DialogDescription>
698 </DialogHeader>
699 <div className="space-y-4 py-4">
700 <div className="space-y-2">
701 <Label htmlFor="device-label">{t('Device Name')}</Label>
702 <Input
703 id="device-label"
704 value={newConnectionLabel}
705 onChange={(e) => setNewConnectionLabel(e.target.value)}
706 placeholder={t('e.g., Phone, Laptop')}
707 />
708 </div>
709 <div className="space-y-2">
710 <Label htmlFor="device-rendezvous" className="flex items-center gap-2">
711 <Server className="w-4 h-4" />
712 {t('Rendezvous Relay')}
713 </Label>
714 <Input
715 id="device-rendezvous"
716 value={newConnectionRendezvousUrl}
717 onChange={(e) => setNewConnectionRendezvousUrl(e.target.value)}
718 placeholder="wss://relay.example.com"
719 onKeyDown={(e) => {
720 if (e.key === 'Enter') {
721 handleAddConnection()
722 }
723 }}
724 />
725 <p className="text-xs text-muted-foreground">
726 {t('Relay used to establish the connection')}
727 </p>
728 </div>
729 {addConnectionError && (
730 <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
731 {addConnectionError}
732 </div>
733 )}
734 </div>
735 <DialogFooter>
736 <Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
737 {t('Cancel')}
738 </Button>
739 <Button
740 onClick={handleAddConnection}
741 disabled={!newConnectionLabel.trim() || isLoading}
742 >
743 {t('Create')}
744 </Button>
745 </DialogFooter>
746 </DialogContent>
747 </Dialog>
748
749 {/* QR Code Dialog */}
750 <Dialog open={isQRDialogOpen} onOpenChange={setIsQRDialogOpen}>
751 <DialogContent className="sm:max-w-md">
752 <DialogHeader>
753 <DialogTitle>{t('Connection QR Code')}</DialogTitle>
754 <DialogDescription>
755 {currentQRConnection && (
756 <>
757 {t('Scan this code with "{{label}}" to connect', {
758 label: currentQRConnection.label
759 })}
760 </>
761 )}
762 </DialogDescription>
763 </DialogHeader>
764 <div className="flex flex-col items-center gap-4 py-4">
765 {qrDataUrl && (
766 <div className="p-4 bg-white rounded-lg">
767 <img src={qrDataUrl} alt="Connection QR Code" className="w-64 h-64" />
768 </div>
769 )}
770 <div className="w-full">
771 <div className="flex items-center gap-2">
772 <Input
773 value={currentQRUri}
774 readOnly
775 className="font-mono text-xs"
776 />
777 <Button
778 variant="outline"
779 size="icon"
780 onClick={handleCopyUri}
781 title={t('Copy')}
782 >
783 {copiedUri ? (
784 <Check className="w-4 h-4 text-green-500" />
785 ) : (
786 <Copy className="w-4 h-4" />
787 )}
788 </Button>
789 </div>
790 </div>
791 </div>
792 <DialogFooter>
793 <Button onClick={() => setIsQRDialogOpen(false)}>{t('Done')}</Button>
794 </DialogFooter>
795 </DialogContent>
796 </Dialog>
797
798 {/* Connect to Remote Dialog (Client) */}
799 <Dialog open={isConnectDialogOpen} onOpenChange={setIsConnectDialogOpen}>
800 <DialogContent>
801 <DialogHeader>
802 <DialogTitle>{t('Connect to Device')}</DialogTitle>
803 <DialogDescription>
804 {t('Enter a connection URI from another device to sync with it')}
805 </DialogDescription>
806 </DialogHeader>
807 <div className="space-y-4 py-4">
808 <div className="space-y-2">
809 <Label htmlFor="connection-uri">{t('Connection URI')}</Label>
810 <Input
811 id="connection-uri"
812 value={connectionUri}
813 onChange={(e) => setConnectionUri(e.target.value)}
814 placeholder="nostr+relayconnect://..."
815 className="font-mono text-xs"
816 />
817 </div>
818 <div className="space-y-2">
819 <Label htmlFor="remote-label">{t('Device Name')}</Label>
820 <Input
821 id="remote-label"
822 value={newRemoteLabel}
823 onChange={(e) => setNewRemoteLabel(e.target.value)}
824 placeholder={t('e.g., Desktop, Main Phone')}
825 onKeyDown={(e) => {
826 if (e.key === 'Enter') {
827 handleAddRemoteConnection()
828 }
829 }}
830 />
831 </div>
832 </div>
833 <DialogFooter>
834 <Button variant="outline" onClick={() => setIsConnectDialogOpen(false)}>
835 {t('Cancel')}
836 </Button>
837 <Button
838 onClick={handleAddRemoteConnection}
839 disabled={!connectionUri.trim() || !newRemoteLabel.trim() || isLoading}
840 >
841 {t('Connect')}
842 </Button>
843 </DialogFooter>
844 </DialogContent>
845 </Dialog>
846
847 {/* QR Scanner Dialog */}
848 <Dialog open={isScannerOpen} onOpenChange={handleCloseScanner}>
849 <DialogContent className="sm:max-w-md">
850 <DialogHeader>
851 <DialogTitle>{t('Scan QR Code')}</DialogTitle>
852 <DialogDescription>
853 {t('Point your camera at a connection QR code')}
854 </DialogDescription>
855 </DialogHeader>
856 <div className="py-4">
857 <div
858 id="qr-scanner-container"
859 ref={scannerContainerRef}
860 className="w-full aspect-square bg-muted rounded-lg overflow-hidden"
861 />
862 {scannerError && (
863 <div className="mt-2 text-sm text-destructive">{scannerError}</div>
864 )}
865 </div>
866 <DialogFooter>
867 <Button variant="outline" onClick={handleCloseScanner}>
868 {t('Cancel')}
869 </Button>
870 </DialogFooter>
871 </DialogContent>
872 </Dialog>
873 </div>
874 )
875 }
876