NRCProvider.tsx raw
1 /**
2 * NRC (Nostr Relay Connect) Provider
3 *
4 * Manages NRC state for both:
5 * - Listener mode: Accept connections from other devices
6 * - Client mode: Connect to and sync from other devices
7 */
8
9 import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
10 import { Filter, Event } from 'nostr-tools'
11 import { useNostr } from './NostrProvider'
12 import client from '@/services/client.service'
13 import indexedDb from '@/services/indexed-db.service'
14 import {
15 NRCConnection,
16 NRCListenerConfig,
17 generateConnectionURI,
18 getNRCListenerService,
19 syncFromRemote,
20 testConnection,
21 parseConnectionURI,
22 requestRemoteIDs,
23 sendEventsToRemote,
24 EventManifestEntry
25 } from '@/services/nrc'
26 import type { SyncProgress, RemoteConnection } from '@/services/nrc'
27
28 // Kinds to sync bidirectionally
29 const SYNC_KINDS = [0, 3, 10000, 10001, 10002, 10003, 10012, 30002]
30
31 // Storage keys
32 const STORAGE_KEY_ENABLED = 'nrc:enabled'
33 const STORAGE_KEY_CONNECTIONS = 'nrc:connections'
34 const STORAGE_KEY_REMOTE_CONNECTIONS = 'nrc:remoteConnections'
35 const STORAGE_KEY_RENDEZVOUS_URL = 'nrc:rendezvousUrl'
36
37 // No default rendezvous relay - user must configure to protect privacy
38 // Using a default would leak NRC connection attempts to a third party
39 const DEFAULT_RENDEZVOUS_URL = ''
40
41 interface NRCContextType {
42 // Listener State (this device accepts connections)
43 isEnabled: boolean
44 isListening: boolean
45 isConnected: boolean
46 connections: NRCConnection[] // Devices authorized to connect to us
47 activeSessions: number
48 rendezvousUrl: string
49
50 // Client State (this device connects to others)
51 remoteConnections: RemoteConnection[] // Devices we connect to
52 isSyncing: boolean
53 syncProgress: SyncProgress | null
54
55 // Listener Actions
56 enable: () => Promise<void>
57 disable: () => void
58 addConnection: (label: string, rendezvousUrlOverride?: string) => Promise<{ uri: string; connection: NRCConnection }>
59 removeConnection: (id: string) => Promise<void>
60 getConnectionURI: (connection: NRCConnection) => string
61 setRendezvousUrl: (url: string) => void
62
63 // Client Actions
64 addRemoteConnection: (uri: string, label: string) => Promise<RemoteConnection>
65 removeRemoteConnection: (id: string) => Promise<void>
66 testRemoteConnection: (id: string) => Promise<boolean>
67 syncFromDevice: (id: string, filters?: Filter[]) => Promise<Event[]>
68 syncAllRemotes: (filters?: Filter[]) => Promise<Event[]>
69 }
70
71 const NRCContext = createContext<NRCContextType | undefined>(undefined)
72
73 export const useNRC = () => {
74 const context = useContext(NRCContext)
75 if (!context) {
76 throw new Error('useNRC must be used within an NRCProvider')
77 }
78 return context
79 }
80
81 interface NRCProviderProps {
82 children: ReactNode
83 }
84
85 export function NRCProvider({ children }: NRCProviderProps) {
86 const { pubkey } = useNostr()
87
88 // ===== Listener State =====
89 const [isEnabled, setIsEnabled] = useState<boolean>(() => {
90 const stored = localStorage.getItem(STORAGE_KEY_ENABLED)
91 return stored === 'true'
92 })
93
94 const [connections, setConnections] = useState<NRCConnection[]>(() => {
95 const stored = localStorage.getItem(STORAGE_KEY_CONNECTIONS)
96 if (stored) {
97 try {
98 return JSON.parse(stored)
99 } catch {
100 return []
101 }
102 }
103 return []
104 })
105
106 const [rendezvousUrl, setRendezvousUrlState] = useState<string>(() => {
107 return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
108 })
109
110 const [isListening, setIsListening] = useState(false)
111 const [isConnected, setIsConnected] = useState(false)
112 const [activeSessions, setActiveSessions] = useState(0)
113
114 // ===== Client State =====
115 const [remoteConnections, setRemoteConnections] = useState<RemoteConnection[]>(() => {
116 const stored = localStorage.getItem(STORAGE_KEY_REMOTE_CONNECTIONS)
117 if (stored) {
118 try {
119 return JSON.parse(stored)
120 } catch {
121 return []
122 }
123 }
124 return []
125 })
126
127 const [isSyncing, setIsSyncing] = useState(false)
128 const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
129
130 const listenerService = getNRCListenerService()
131
132 // ===== Persist State =====
133 useEffect(() => {
134 localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled))
135 }, [isEnabled])
136
137 useEffect(() => {
138 localStorage.setItem(STORAGE_KEY_CONNECTIONS, JSON.stringify(connections))
139 }, [connections])
140
141 useEffect(() => {
142 localStorage.setItem(STORAGE_KEY_REMOTE_CONNECTIONS, JSON.stringify(remoteConnections))
143 }, [remoteConnections])
144
145 useEffect(() => {
146 localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
147 }, [rendezvousUrl])
148
149 // ===== Listener Logic =====
150 const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
151 const map = new Map<string, string>()
152 for (const conn of connections) {
153 if (conn.secret && conn.clientPubkey) {
154 map.set(conn.clientPubkey, conn.label)
155 }
156 }
157 return map
158 }, [connections])
159
160 useEffect(() => {
161 if (!isEnabled || !client.signer || !pubkey) {
162 if (listenerService.isRunning()) {
163 listenerService.stop()
164 setIsListening(false)
165 setIsConnected(false)
166 setActiveSessions(0)
167 }
168 return
169 }
170
171 // Stop existing listener before starting with new config
172 if (listenerService.isRunning()) {
173 listenerService.stop()
174 }
175
176 let statusInterval: ReturnType<typeof setInterval> | null = null
177
178 const startListener = async () => {
179 try {
180 const config: NRCListenerConfig = {
181 rendezvousUrl,
182 signer: client.signer!,
183 authorizedSecrets: buildAuthorizedSecrets()
184 }
185
186 console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
187
188 listenerService.setOnSessionChange((count) => {
189 setActiveSessions(count)
190 })
191
192 await listenerService.start(config)
193 setIsListening(true)
194 setIsConnected(listenerService.isConnected())
195
196 statusInterval = setInterval(() => {
197 setIsConnected(listenerService.isConnected())
198 setActiveSessions(listenerService.getActiveSessionCount())
199 }, 5000)
200 } catch (error) {
201 console.error('[NRC] Failed to start listener:', error)
202 setIsListening(false)
203 setIsConnected(false)
204 }
205 }
206
207 startListener()
208
209 return () => {
210 if (statusInterval) {
211 clearInterval(statusInterval)
212 }
213 listenerService.stop()
214 setIsListening(false)
215 setIsConnected(false)
216 setActiveSessions(0)
217 }
218 }, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets])
219
220 useEffect(() => {
221 if (!isEnabled || !client.signer || !pubkey) return
222 }, [connections, isEnabled, pubkey])
223
224 // ===== Auto-sync remote connections (bidirectional) =====
225 // Sync interval: 15 minutes
226 const AUTO_SYNC_INTERVAL = 15 * 60 * 1000
227 // Minimum time between syncs for the same connection: 5 minutes
228 const MIN_SYNC_INTERVAL = 5 * 60 * 1000
229
230 /**
231 * Get local events for sync kinds and build manifest
232 */
233 const getLocalEventsAndManifest = async (): Promise<{
234 events: Event[]
235 manifest: EventManifestEntry[]
236 }> => {
237 const events = await indexedDb.queryEventsForNRC([{ kinds: SYNC_KINDS, limit: 1000 }])
238 const manifest: EventManifestEntry[] = events.map((e) => ({
239 kind: e.kind,
240 id: e.id,
241 created_at: e.created_at,
242 d: e.tags.find((t) => t[0] === 'd')?.[1]
243 }))
244 return { events, manifest }
245 }
246
247 /**
248 * Diff manifests to find what each side needs
249 * For replaceable events: compare by (kind, pubkey, d) and use newer created_at
250 */
251 const diffManifests = (
252 local: EventManifestEntry[],
253 remote: EventManifestEntry[],
254 localEvents: Event[]
255 ): { toSend: Event[]; toFetch: string[] } => {
256 // Build maps keyed by (kind, d) for replaceable events
257 const localMap = new Map<string, EventManifestEntry>()
258 const localEventsMap = new Map<string, Event>()
259
260 for (let i = 0; i < local.length; i++) {
261 const entry = local[i]
262 const key = `${entry.kind}:${entry.d || ''}`
263 const existing = localMap.get(key)
264 // Keep the newer one
265 if (!existing || entry.created_at > existing.created_at) {
266 localMap.set(key, entry)
267 localEventsMap.set(entry.id, localEvents[i])
268 }
269 }
270
271 const remoteMap = new Map<string, EventManifestEntry>()
272 for (const entry of remote) {
273 const key = `${entry.kind}:${entry.d || ''}`
274 const existing = remoteMap.get(key)
275 if (!existing || entry.created_at > existing.created_at) {
276 remoteMap.set(key, entry)
277 }
278 }
279
280 const toSend: Event[] = []
281 const toFetch: string[] = []
282
283 // Find events we have that are newer than remote's (or remote doesn't have)
284 for (const [key, localEntry] of localMap) {
285 const remoteEntry = remoteMap.get(key)
286 if (!remoteEntry || localEntry.created_at > remoteEntry.created_at) {
287 const event = localEventsMap.get(localEntry.id)
288 if (event) {
289 toSend.push(event)
290 }
291 }
292 }
293
294 // Find events remote has that are newer than ours (or we don't have)
295 for (const [key, remoteEntry] of remoteMap) {
296 const localEntry = localMap.get(key)
297 if (!localEntry || remoteEntry.created_at > localEntry.created_at) {
298 toFetch.push(remoteEntry.id)
299 }
300 }
301
302 return { toSend, toFetch }
303 }
304
305 useEffect(() => {
306 // Only auto-sync if we have remote connections and a signer
307 if (remoteConnections.length === 0 || !client.signer || !pubkey) {
308 return
309 }
310
311 // Don't auto-sync if already syncing
312 if (isSyncing) {
313 return
314 }
315
316 const bidirectionalSync = async () => {
317 const now = Date.now()
318
319 // Find connections that need syncing
320 const needsSync = remoteConnections.filter(
321 (c) => !c.lastSync || (now - c.lastSync) > MIN_SYNC_INTERVAL
322 )
323
324 if (needsSync.length === 0) {
325 return
326 }
327
328 console.log(`[NRC] Bidirectional sync: ${needsSync.length} connection(s) need syncing`)
329
330 for (const remote of needsSync) {
331 if (isSyncing) break
332
333 try {
334 console.log(`[NRC] Bidirectional sync with ${remote.label}...`)
335 setIsSyncing(true)
336 setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
337
338 // Step 1: Get remote's event IDs
339 setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
340 const remoteManifest = await requestRemoteIDs(
341 remote.uri,
342 [{ kinds: SYNC_KINDS, limit: 1000 }]
343 )
344 console.log(`[NRC] Remote has ${remoteManifest.length} events`)
345
346 // Step 2: Get our local events and manifest
347 const { events: localEvents, manifest: localManifest } = await getLocalEventsAndManifest()
348 console.log(`[NRC] Local has ${localManifest.length} events`)
349
350 // Step 3: Diff to find what each side needs
351 const { toSend, toFetch } = diffManifests(localManifest, remoteManifest, localEvents)
352 console.log(`[NRC] Diff: sending ${toSend.length}, fetching ${toFetch.length}`)
353
354 let eventsSent = 0
355 let eventsReceived = 0
356
357 // Step 4: Send events remote needs
358 if (toSend.length > 0) {
359 setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
360
361 eventsSent = await sendEventsToRemote(
362 remote.uri,
363 toSend,
364 (progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` })
365 )
366 console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
367 }
368
369 // Step 5: Fetch events we need using regular filter queries
370 if (toFetch.length > 0) {
371 setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
372
373 // Fetch by ID in batches (relay may limit number of IDs per filter)
374 const BATCH_SIZE = 50
375 const fetchedEvents: Event[] = []
376
377 for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
378 const batch = toFetch.slice(i, i + BATCH_SIZE)
379 const events = await syncFromRemote(
380 remote.uri,
381 [{ ids: batch }],
382 (progress) => setSyncProgress({
383 ...progress,
384 eventsSent,
385 message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
386 })
387 )
388 fetchedEvents.push(...events)
389 }
390
391 // Store fetched events
392 for (const event of fetchedEvents) {
393 try {
394 await indexedDb.putReplaceableEvent(event)
395 } catch {
396 // Ignore storage errors
397 }
398 }
399
400 eventsReceived = fetchedEvents.length
401 console.log(`[NRC] Received ${eventsReceived} events from ${remote.label}`)
402 }
403
404 // Update last sync time
405 setRemoteConnections((prev) =>
406 prev.map((c) =>
407 c.id === remote.id
408 ? { ...c, lastSync: Date.now(), eventCount: eventsReceived }
409 : c
410 )
411 )
412
413 console.log(`[NRC] Bidirectional sync complete with ${remote.label}: sent ${eventsSent}, received ${eventsReceived}`)
414 } catch (err) {
415 console.error(`[NRC] Bidirectional sync failed for ${remote.label}:`, err)
416 } finally {
417 setIsSyncing(false)
418 setSyncProgress(null)
419 }
420 }
421 }
422
423 // Run initial sync after a short delay
424 const initialTimer = setTimeout(bidirectionalSync, 3000)
425
426 // Set up periodic sync
427 const intervalTimer = setInterval(bidirectionalSync, AUTO_SYNC_INTERVAL)
428
429 return () => {
430 clearTimeout(initialTimer)
431 clearInterval(intervalTimer)
432 }
433 }, [remoteConnections.length, pubkey, isSyncing])
434
435 // ===== Listener Actions =====
436 const enable = useCallback(async () => {
437 if (!client.signer) {
438 throw new Error('Signer required to enable NRC')
439 }
440 if (!rendezvousUrl) {
441 throw new Error('Rendezvous relay URL required - configure in NRC settings')
442 }
443 setIsEnabled(true)
444 }, [rendezvousUrl])
445
446 const disable = useCallback(() => {
447 setIsEnabled(false)
448 listenerService.stop()
449 setIsListening(false)
450 setIsConnected(false)
451 setActiveSessions(0)
452 }, [])
453
454 const addConnection = useCallback(
455 async (label: string, rendezvousUrlOverride?: string): Promise<{ uri: string; connection: NRCConnection }> => {
456 if (!pubkey) {
457 throw new Error('Not logged in')
458 }
459
460 // Use override if provided, otherwise use global rendezvous URL
461 const effectiveRendezvousUrl = rendezvousUrlOverride || rendezvousUrl
462 if (!effectiveRendezvousUrl) {
463 throw new Error('Rendezvous relay URL required - configure in NRC settings')
464 }
465
466 const id = crypto.randomUUID()
467 const createdAt = Date.now()
468
469 const result = generateConnectionURI(pubkey, effectiveRendezvousUrl, undefined, label)
470 const uri = result.uri
471 const connection: NRCConnection = {
472 id,
473 label,
474 secret: result.secret,
475 clientPubkey: result.clientPubkey,
476 createdAt
477 }
478
479 setConnections((prev) => [...prev, connection])
480
481 return { uri, connection }
482 },
483 [pubkey, rendezvousUrl]
484 )
485
486 const removeConnection = useCallback(async (id: string) => {
487 setConnections((prev) => prev.filter((c) => c.id !== id))
488 }, [])
489
490 const getConnectionURI = useCallback(
491 (connection: NRCConnection): string => {
492 if (!pubkey) {
493 throw new Error('Not logged in')
494 }
495
496 if (!connection.secret) {
497 throw new Error('Connection has no secret')
498 }
499
500 const result = generateConnectionURI(
501 pubkey,
502 rendezvousUrl,
503 connection.secret,
504 connection.label
505 )
506 return result.uri
507 },
508 [pubkey, rendezvousUrl]
509 )
510
511 const setRendezvousUrl = useCallback((url: string) => {
512 setRendezvousUrlState(url)
513 }, [])
514
515 // ===== Client Actions =====
516 const addRemoteConnection = useCallback(
517 async (uri: string, label: string): Promise<RemoteConnection> => {
518 // Validate and parse the URI
519 const parsed = parseConnectionURI(uri)
520
521 const remoteConnection: RemoteConnection = {
522 id: crypto.randomUUID(),
523 uri,
524 label,
525 relayPubkey: parsed.relayPubkey,
526 rendezvousUrl: parsed.rendezvousUrl
527 }
528
529 setRemoteConnections((prev) => [...prev, remoteConnection])
530
531 return remoteConnection
532 },
533 []
534 )
535
536 const removeRemoteConnection = useCallback(async (id: string) => {
537 setRemoteConnections((prev) => prev.filter((c) => c.id !== id))
538 }, [])
539
540 const syncFromDevice = useCallback(
541 async (id: string, filters?: Filter[]): Promise<Event[]> => {
542 const remote = remoteConnections.find((c) => c.id === id)
543 if (!remote) {
544 throw new Error('Remote connection not found')
545 }
546
547 setIsSyncing(true)
548 setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
549
550 try {
551 // Default filters: sync everything
552 const syncFilters = filters || [
553 { kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
554 ]
555
556 const events = await syncFromRemote(
557 remote.uri,
558 syncFilters,
559 (progress) => setSyncProgress(progress)
560 )
561
562 // Store synced events in IndexedDB
563 for (const event of events) {
564 try {
565 await indexedDb.putReplaceableEvent(event)
566 } catch (err) {
567 console.warn('[NRC] Failed to store event:', err)
568 }
569 }
570
571 // Update last sync time
572 setRemoteConnections((prev) =>
573 prev.map((c) =>
574 c.id === id ? { ...c, lastSync: Date.now(), eventCount: events.length } : c
575 )
576 )
577
578 return events
579 } finally {
580 setIsSyncing(false)
581 setSyncProgress(null)
582 }
583 },
584 [remoteConnections]
585 )
586
587 const syncAllRemotes = useCallback(
588 async (filters?: Filter[]): Promise<Event[]> => {
589 const allEvents: Event[] = []
590
591 for (const remote of remoteConnections) {
592 try {
593 const events = await syncFromDevice(remote.id, filters)
594 allEvents.push(...events)
595 } catch (error) {
596 console.error(`[NRC] Failed to sync from ${remote.label}:`, error)
597 }
598 }
599
600 return allEvents
601 },
602 [remoteConnections, syncFromDevice]
603 )
604
605 const testRemoteConnection = useCallback(
606 async (id: string): Promise<boolean> => {
607 const remote = remoteConnections.find((c) => c.id === id)
608 if (!remote) {
609 throw new Error('Remote connection not found')
610 }
611
612 setIsSyncing(true)
613 setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
614
615 try {
616 const result = await testConnection(
617 remote.uri,
618 (progress) => setSyncProgress(progress)
619 )
620
621 // Update connection to mark it as tested
622 setRemoteConnections((prev) =>
623 prev.map((c) =>
624 c.id === id ? { ...c, lastSync: Date.now(), eventCount: 0 } : c
625 )
626 )
627
628 return result
629 } finally {
630 setIsSyncing(false)
631 setSyncProgress(null)
632 }
633 },
634 [remoteConnections]
635 )
636
637 const value: NRCContextType = {
638 // Listener
639 isEnabled,
640 isListening,
641 isConnected,
642 connections,
643 activeSessions,
644 rendezvousUrl,
645 enable,
646 disable,
647 addConnection,
648 removeConnection,
649 getConnectionURI,
650 setRendezvousUrl,
651 // Client
652 remoteConnections,
653 isSyncing,
654 syncProgress,
655 addRemoteConnection,
656 removeRemoteConnection,
657 testRemoteConnection,
658 syncFromDevice,
659 syncAllRemotes
660 }
661
662 return <NRCContext.Provider value={value}>{children}</NRCContext.Provider>
663 }
664