/** * NRC Settings Component * * UI for managing Nostr Relay Connect (NRC) connections and listener settings. * Includes both: * - Listener mode: Allow other devices to connect to this one * - Client mode: Connect to and sync from other devices */ import { useState, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNRC } from '@/providers/NRCProvider' import { useNostr } from '@/providers/NostrProvider' import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' import { Link2, Plus, Trash2, Copy, Check, QrCode, Wifi, WifiOff, Users, Server, RefreshCw, Smartphone, Download, Camera, Zap } from 'lucide-react' import { NRCConnection, RemoteConnection } from '@/services/nrc' import QRCode from 'qrcode' import { Html5Qrcode } from 'html5-qrcode' export default function NRCSettings() { const { t } = useTranslation() const { pubkey } = useNostr() const { // Listener state isEnabled, isConnected, connections, activeSessions, rendezvousUrl, enable, disable, addConnection, removeConnection, getConnectionURI, setRendezvousUrl, // Client state remoteConnections, isSyncing, syncProgress, addRemoteConnection, removeRemoteConnection, testRemoteConnection, syncFromDevice, syncAllRemotes } = useNRC() // Listener state const [newConnectionLabel, setNewConnectionLabel] = useState('') const [newConnectionRendezvousUrl, setNewConnectionRendezvousUrl] = useState('') const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isQRDialogOpen, setIsQRDialogOpen] = useState(false) const [currentQRConnection, setCurrentQRConnection] = useState(null) const [currentQRUri, setCurrentQRUri] = useState('') const [qrDataUrl, setQrDataUrl] = useState('') const [copiedUri, setCopiedUri] = useState(false) const [isLoading, setIsLoading] = useState(false) const [enableError, setEnableError] = useState(null) const [addConnectionError, setAddConnectionError] = useState(null) // Client state const [connectionUri, setConnectionUri] = useState('') const [newRemoteLabel, setNewRemoteLabel] = useState('') const [isConnectDialogOpen, setIsConnectDialogOpen] = useState(false) const [isScannerOpen, setIsScannerOpen] = useState(false) const [scannerError, setScannerError] = useState('') const scannerRef = useRef(null) const scannerContainerRef = useRef(null) // Private config sync setting const [nrcOnlyConfigSync, setNrcOnlyConfigSync] = useState(storage.getNrcOnlyConfigSync()) const handleToggleNrcOnlyConfig = useCallback((checked: boolean) => { storage.setNrcOnlyConfigSync(checked) setNrcOnlyConfigSync(checked) dispatchSettingsChanged() }, []) // Generate QR code when URI changes const generateQRCode = useCallback(async (uri: string) => { try { const dataUrl = await QRCode.toDataURL(uri, { width: 256, margin: 2, color: { dark: '#000000', light: '#ffffff' } }) setQrDataUrl(dataUrl) } catch (error) { console.error('Failed to generate QR code:', error) } }, []) const handleToggleEnabled = useCallback(async () => { if (isEnabled) { disable() setEnableError(null) } else { setIsLoading(true) setEnableError(null) try { await enable() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to enable NRC' setEnableError(message) console.error('Failed to enable NRC:', error) } finally { setIsLoading(false) } } }, [isEnabled, enable, disable]) const handleAddConnection = useCallback(async () => { if (!newConnectionLabel.trim()) return setIsLoading(true) setAddConnectionError(null) try { // Use connection-specific URL if provided, otherwise uses global default const connectionRendezvousUrl = newConnectionRendezvousUrl.trim() || undefined const { uri, connection } = await addConnection(newConnectionLabel.trim(), connectionRendezvousUrl) setIsAddDialogOpen(false) setNewConnectionLabel('') setNewConnectionRendezvousUrl('') // Show QR code setCurrentQRConnection(connection) setCurrentQRUri(uri) await generateQRCode(uri) setIsQRDialogOpen(true) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to add connection' setAddConnectionError(message) console.error('Failed to add connection:', error) } finally { setIsLoading(false) } }, [newConnectionLabel, newConnectionRendezvousUrl, addConnection]) const handleShowQR = useCallback( async (connection: NRCConnection) => { try { const uri = getConnectionURI(connection) setCurrentQRConnection(connection) setCurrentQRUri(uri) await generateQRCode(uri) setIsQRDialogOpen(true) } catch (error) { console.error('Failed to get connection URI:', error) } }, [getConnectionURI, generateQRCode] ) const handleCopyUri = useCallback(async () => { try { await navigator.clipboard.writeText(currentQRUri) setCopiedUri(true) setTimeout(() => setCopiedUri(false), 2000) } catch (error) { console.error('Failed to copy URI:', error) } }, [currentQRUri]) const handleRemoveConnection = useCallback( async (id: string) => { try { await removeConnection(id) } catch (error) { console.error('Failed to remove connection:', error) } }, [removeConnection] ) // ===== Client Handlers ===== const handleAddRemoteConnection = useCallback(async () => { if (!connectionUri.trim() || !newRemoteLabel.trim()) return setIsLoading(true) try { await addRemoteConnection(connectionUri.trim(), newRemoteLabel.trim()) setIsConnectDialogOpen(false) setConnectionUri('') setNewRemoteLabel('') } catch (error) { console.error('Failed to add remote connection:', error) } finally { setIsLoading(false) } }, [connectionUri, newRemoteLabel, addRemoteConnection]) const handleRemoveRemoteConnection = useCallback( async (id: string) => { try { await removeRemoteConnection(id) } catch (error) { console.error('Failed to remove remote connection:', error) } }, [removeRemoteConnection] ) const handleSyncDevice = useCallback( async (id: string) => { try { await syncFromDevice(id) } catch (error) { console.error('Failed to sync from device:', error) } }, [syncFromDevice] ) const handleTestConnection = useCallback( async (id: string) => { try { await testRemoteConnection(id) } catch (error) { console.error('Failed to test connection:', error) } }, [testRemoteConnection] ) const handleSyncAll = useCallback(async () => { try { await syncAllRemotes() } catch (error) { console.error('Failed to sync all remotes:', error) } }, [syncAllRemotes]) const startScanner = useCallback(async () => { if (!scannerContainerRef.current) return setScannerError('') try { const scanner = new Html5Qrcode('qr-scanner-container') scannerRef.current = scanner await scanner.start( { facingMode: 'environment' }, { fps: 10, qrbox: { width: 250, height: 250 } }, (decodedText) => { // Found a QR code if (decodedText.startsWith('nostr+relayconnect://')) { setConnectionUri(decodedText) stopScanner() setIsScannerOpen(false) setIsConnectDialogOpen(true) } }, () => { // Ignore errors while scanning } ) } catch (error) { console.error('Failed to start scanner:', error) setScannerError(error instanceof Error ? error.message : 'Failed to start camera') } }, []) const stopScanner = useCallback(() => { if (scannerRef.current) { scannerRef.current.stop().catch(() => { // Ignore errors when stopping }) scannerRef.current = null } }, []) const handleOpenScanner = useCallback(() => { setIsScannerOpen(true) // Start scanner after dialog renders setTimeout(startScanner, 100) }, [startScanner]) const handleCloseScanner = useCallback(() => { stopScanner() setIsScannerOpen(false) setScannerError('') }, [stopScanner]) if (!pubkey) { return (
{t('Login required to use NRC')}
) } return (
{/* Private Configuration Sync Toggle */}

{t('Only sync configurations between paired devices, not to public relays')}

{t('Share')} {t('Connect')} {/* ===== LISTENER TAB ===== */} {/* Enable/Disable Toggle */}

{t('Allow other devices to sync with this client')}

{/* Enable Error */} {enableError && (
{enableError}
)} {/* Status Indicator */} {isEnabled && (
{isConnected ? ( ) : ( )} {isConnected ? t('Connected') : t('Connecting...')}
{activeSessions > 0 && (
{activeSessions} {t('active session(s)')}
)}
)} {/* Rendezvous Relay */}
setRendezvousUrl(e.target.value)} placeholder="wss://relay.example.com" disabled={isEnabled} /> {isEnabled && (

{t('Disable NRC to change the relay')}

)}
{/* Connections List */}
{connections.length === 0 ? (
{t('No devices connected yet')}
) : (
{connections.map((connection) => (
{connection.label}
{new Date(connection.createdAt).toLocaleDateString()}
{t('Remove Device?')} {t('This will revoke access for "{{label}}". The device will no longer be able to sync.', { label: connection.label })} {t('Cancel')} handleRemoveConnection(connection.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {t('Remove')}
))}
)}
{/* ===== CLIENT TAB ===== */} {/* Sync Progress */} {isSyncing && syncProgress && (
{syncProgress.phase === 'connecting' && t('Connecting...')} {syncProgress.phase === 'requesting' && t('Requesting events...')} {syncProgress.phase === 'receiving' && t('Receiving events...')} {syncProgress.phase === 'complete' && t('Sync complete')} {syncProgress.phase === 'error' && t('Error')}
{syncProgress.eventsReceived > 0 && (
{t('{{count}} events received', { count: syncProgress.eventsReceived })}
)} {syncProgress.message && syncProgress.phase === 'error' && (
{syncProgress.message}
)}
)} {/* Connect to Device */}
{remoteConnections.length === 0 ? (
{t('No remote devices configured')}
) : (
{/* Sync All Button */} {remoteConnections.length > 1 && ( )} {remoteConnections.map((remote: RemoteConnection) => (
{remote.label}
{remote.lastSync ? ( <> {t('Last sync')}: {new Date(remote.lastSync).toLocaleString()} {remote.eventCount !== undefined && ( ({remote.eventCount} {t('events')}) )} ) : ( t('Never synced') )}
{/* Show Test button if never synced, Sync button otherwise */} {!remote.lastSync ? ( ) : null} {t('Remove Remote Device?')} {t('This will remove "{{label}}" from your remote devices list.', { label: remote.label })} {t('Cancel')} handleRemoveRemoteConnection(remote.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {t('Remove')}
))}
)}
{/* ===== DIALOGS ===== */} {/* Add Connection Dialog (Listener) */} { setIsAddDialogOpen(open) if (open) { // Pre-populate with global rendezvous URL when opening setNewConnectionRendezvousUrl(rendezvousUrl) setAddConnectionError(null) } else { // Clear when closing setNewConnectionLabel('') setNewConnectionRendezvousUrl('') setAddConnectionError(null) } }}> {t('Add Device')} {t('Create a connection URI to link another device')}
setNewConnectionLabel(e.target.value)} placeholder={t('e.g., Phone, Laptop')} />
setNewConnectionRendezvousUrl(e.target.value)} placeholder="wss://relay.example.com" onKeyDown={(e) => { if (e.key === 'Enter') { handleAddConnection() } }} />

{t('Relay used to establish the connection')}

{addConnectionError && (
{addConnectionError}
)}
{/* QR Code Dialog */} {t('Connection QR Code')} {currentQRConnection && ( <> {t('Scan this code with "{{label}}" to connect', { label: currentQRConnection.label })} )}
{qrDataUrl && (
Connection QR Code
)}
{/* Connect to Remote Dialog (Client) */} {t('Connect to Device')} {t('Enter a connection URI from another device to sync with it')}
setConnectionUri(e.target.value)} placeholder="nostr+relayconnect://..." className="font-mono text-xs" />
setNewRemoteLabel(e.target.value)} placeholder={t('e.g., Desktop, Main Phone')} onKeyDown={(e) => { if (e.key === 'Enter') { handleAddRemoteConnection() } }} />
{/* QR Scanner Dialog */} {t('Scan QR Code')} {t('Point your camera at a connection QR code')}
{scannerError && (
{scannerError}
)}
) }