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