SettingsSyncProvider.tsx raw

   1  import { ApplicationDataKey } from '@/constants'
   2  import { createSettingsDraftEvent } from '@/lib/draft-event'
   3  import { getReplaceableEventIdentifier } from '@/lib/event'
   4  import client from '@/services/client.service'
   5  import storage, { SETTINGS_CHANGED_EVENT } from '@/services/local-storage.service'
   6  import relayStatsService from '@/services/relay-stats.service'
   7  import { TSyncSettings } from '@/types'
   8  import { kinds } from 'nostr-tools'
   9  import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
  10  import { useNostr } from './NostrProvider'
  11  
  12  type TSettingsSyncContext = {
  13    syncSettings: () => Promise<void>
  14    isLoading: boolean
  15  }
  16  
  17  const SettingsSyncContext = createContext<TSettingsSyncContext | undefined>(undefined)
  18  
  19  export const useSettingsSync = () => {
  20    const context = useContext(SettingsSyncContext)
  21    if (!context) {
  22      throw new Error('useSettingsSync must be used within a SettingsSyncProvider')
  23    }
  24    return context
  25  }
  26  
  27  function getCurrentSettings(pubkey: string | null): TSyncSettings {
  28    return {
  29      themeSetting: storage.getThemeSetting(),
  30      primaryColor: storage.getPrimaryColor(),
  31      defaultZapSats: storage.getDefaultZapSats(),
  32      defaultZapComment: storage.getDefaultZapComment(),
  33      quickZap: storage.getQuickZap(),
  34      autoplay: storage.getAutoplay(),
  35      hideUntrustedInteractions: storage.getHideUntrustedInteractions(),
  36      hideUntrustedNotifications: storage.getHideUntrustedNotifications(),
  37      hideUntrustedNotes: storage.getHideUntrustedNotes(),
  38      nsfwDisplayPolicy: storage.getNsfwDisplayPolicy(),
  39      showKinds: storage.getShowKinds(),
  40      hideContentMentioningMutedUsers: storage.getHideContentMentioningMutedUsers(),
  41      notificationListStyle: storage.getNotificationListStyle(),
  42      mediaAutoLoadPolicy: storage.getMediaAutoLoadPolicy(),
  43      sidebarCollapse: storage.getSidebarCollapse(),
  44      enableSingleColumnLayout: storage.getEnableSingleColumnLayout(),
  45      faviconUrlTemplate: storage.getFaviconUrlTemplate(),
  46      filterOutOnionRelays: storage.getFilterOutOnionRelays(),
  47      quickReaction: storage.getQuickReaction(),
  48      quickReactionEmoji: storage.getQuickReactionEmoji(),
  49      noteListMode: storage.getNoteListMode(),
  50      nrcOnlyConfigSync: storage.getNrcOnlyConfigSync(),
  51      autoInsertNewNotes: storage.getAutoInsertNewNotes(),
  52      addClientTag: storage.getAddClientTag(),
  53      enableMarkdown: storage.getEnableMarkdown(),
  54      verboseLogging: storage.getVerboseLogging(),
  55      fallbackRelayCount: storage.getFallbackRelayCount(),
  56      preferNip44: storage.getPreferNip44(),
  57      dmConversationFilter: storage.getDMConversationFilter(),
  58      graphQueriesEnabled: storage.getGraphQueriesEnabled(),
  59      socialGraphProximity: storage.getSocialGraphProximity(),
  60      socialGraphIncludeMode: storage.getSocialGraphIncludeMode(),
  61      llmConfig: pubkey ? storage.getLlmConfig(pubkey) : null,
  62      mediaUploadServiceConfig: pubkey ? storage.getMediaUploadServiceConfig(pubkey) : undefined,
  63      // Non-NIP relay configurations (application-specific)
  64      searchRelays: storage.getSearchRelays(),
  65      nrcRendezvousUrl: storage.getNrcRendezvousUrl() || undefined,
  66      // Outbox relay management
  67      outboxMode: storage.getOutboxMode() as 'automatic' | 'managed',
  68      relayStatsData: btoa(String.fromCharCode(...relayStatsService.encodeBinary()))
  69    }
  70  }
  71  
  72  function applySettings(settings: TSyncSettings, pubkey: string | null) {
  73    if (settings.themeSetting !== undefined) {
  74      storage.setThemeSetting(settings.themeSetting)
  75    }
  76    if (settings.primaryColor !== undefined) {
  77      storage.setPrimaryColor(settings.primaryColor as any)
  78    }
  79    if (settings.defaultZapSats !== undefined) {
  80      storage.setDefaultZapSats(settings.defaultZapSats)
  81    }
  82    if (settings.defaultZapComment !== undefined) {
  83      storage.setDefaultZapComment(settings.defaultZapComment)
  84    }
  85    if (settings.quickZap !== undefined) {
  86      storage.setQuickZap(settings.quickZap)
  87    }
  88    if (settings.autoplay !== undefined) {
  89      storage.setAutoplay(settings.autoplay)
  90    }
  91    if (settings.hideUntrustedInteractions !== undefined) {
  92      storage.setHideUntrustedInteractions(settings.hideUntrustedInteractions)
  93    }
  94    if (settings.hideUntrustedNotifications !== undefined) {
  95      storage.setHideUntrustedNotifications(settings.hideUntrustedNotifications)
  96    }
  97    if (settings.hideUntrustedNotes !== undefined) {
  98      storage.setHideUntrustedNotes(settings.hideUntrustedNotes)
  99    }
 100    if (settings.nsfwDisplayPolicy !== undefined) {
 101      storage.setNsfwDisplayPolicy(settings.nsfwDisplayPolicy)
 102    }
 103    if (settings.showKinds !== undefined) {
 104      storage.setShowKinds(settings.showKinds)
 105    }
 106    if (settings.hideContentMentioningMutedUsers !== undefined) {
 107      storage.setHideContentMentioningMutedUsers(settings.hideContentMentioningMutedUsers)
 108    }
 109    if (settings.notificationListStyle !== undefined) {
 110      storage.setNotificationListStyle(settings.notificationListStyle)
 111    }
 112    if (settings.mediaAutoLoadPolicy !== undefined) {
 113      storage.setMediaAutoLoadPolicy(settings.mediaAutoLoadPolicy)
 114    }
 115    if (settings.sidebarCollapse !== undefined) {
 116      storage.setSidebarCollapse(settings.sidebarCollapse)
 117    }
 118    if (settings.enableSingleColumnLayout !== undefined) {
 119      storage.setEnableSingleColumnLayout(settings.enableSingleColumnLayout)
 120    }
 121    if (settings.faviconUrlTemplate !== undefined) {
 122      storage.setFaviconUrlTemplate(settings.faviconUrlTemplate)
 123    }
 124    if (settings.filterOutOnionRelays !== undefined) {
 125      storage.setFilterOutOnionRelays(settings.filterOutOnionRelays)
 126    }
 127    if (settings.quickReaction !== undefined) {
 128      storage.setQuickReaction(settings.quickReaction)
 129    }
 130    if (settings.quickReactionEmoji !== undefined) {
 131      storage.setQuickReactionEmoji(settings.quickReactionEmoji)
 132    }
 133    if (settings.noteListMode !== undefined) {
 134      storage.setNoteListMode(settings.noteListMode)
 135    }
 136    if (settings.nrcOnlyConfigSync !== undefined) {
 137      storage.setNrcOnlyConfigSync(settings.nrcOnlyConfigSync)
 138    }
 139    if (settings.autoInsertNewNotes !== undefined) {
 140      storage.setAutoInsertNewNotes(settings.autoInsertNewNotes)
 141    }
 142    if (settings.addClientTag !== undefined) {
 143      storage.setAddClientTag(settings.addClientTag)
 144    }
 145    if (settings.enableMarkdown !== undefined) {
 146      storage.setEnableMarkdown(settings.enableMarkdown)
 147    }
 148    if (settings.verboseLogging !== undefined) {
 149      storage.setVerboseLogging(settings.verboseLogging)
 150    }
 151    if (settings.fallbackRelayCount !== undefined) {
 152      storage.setFallbackRelayCount(settings.fallbackRelayCount)
 153    }
 154    if (settings.preferNip44 !== undefined) {
 155      storage.setPreferNip44(settings.preferNip44)
 156    }
 157    if (settings.dmConversationFilter !== undefined) {
 158      storage.setDMConversationFilter(settings.dmConversationFilter)
 159    }
 160    if (settings.graphQueriesEnabled !== undefined) {
 161      storage.setGraphQueriesEnabled(settings.graphQueriesEnabled)
 162    }
 163    if (settings.socialGraphProximity !== undefined) {
 164      storage.setSocialGraphProximity(settings.socialGraphProximity)
 165    }
 166    if (settings.socialGraphIncludeMode !== undefined) {
 167      storage.setSocialGraphIncludeMode(settings.socialGraphIncludeMode)
 168    }
 169    // Non-NIP relay configurations (application-specific)
 170    if (settings.searchRelays !== undefined) {
 171      storage.setSearchRelays(settings.searchRelays.length > 0 ? settings.searchRelays : null)
 172    }
 173    if (settings.nrcRendezvousUrl !== undefined) {
 174      storage.setNrcRendezvousUrl(settings.nrcRendezvousUrl)
 175    }
 176    // Outbox relay management
 177    if (settings.outboxMode !== undefined) {
 178      storage.setOutboxMode(settings.outboxMode)
 179    }
 180    if (settings.relayStatsData) {
 181      try {
 182        const binaryStr = atob(settings.relayStatsData)
 183        const bytes = new Uint8Array(binaryStr.length)
 184        for (let i = 0; i < binaryStr.length; i++) {
 185          bytes[i] = binaryStr.charCodeAt(i)
 186        }
 187        relayStatsService.decodeBinary(bytes)
 188      } catch {
 189        console.error('Failed to decode relay stats data')
 190      }
 191    }
 192    // Per-pubkey settings
 193    if (pubkey) {
 194      if (settings.llmConfig !== undefined) {
 195        if (settings.llmConfig) {
 196          storage.setLlmConfig(pubkey, settings.llmConfig)
 197        }
 198      }
 199      if (settings.mediaUploadServiceConfig !== undefined) {
 200        storage.setMediaUploadServiceConfig(pubkey, settings.mediaUploadServiceConfig)
 201      }
 202    }
 203  }
 204  
 205  export function SettingsSyncProvider({ children }: { children: React.ReactNode }) {
 206    const { pubkey, account, publish, nip44Encrypt, nip44Decrypt, hasNip44Support } = useNostr()
 207    const [isLoading, setIsLoading] = useState(false)
 208    const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null)
 209    const lastSyncedSettingsRef = useRef<string | null>(null)
 210    const hasLoadedRef = useRef(false)
 211  
 212    // Store encryption functions in refs so callbacks don't change identity
 213    // when the signer initializes asynchronously
 214    const encryptRef = useRef({ nip44Encrypt, nip44Decrypt, hasNip44Support })
 215    encryptRef.current = { nip44Encrypt, nip44Decrypt, hasNip44Support }
 216  
 217    /**
 218     * Decrypt settings content from an event.
 219     * Tries plain JSON first (backward compat), then NIP-44 decryption.
 220     */
 221    const decryptContent = useCallback(async (content: string, authorPubkey: string): Promise<TSyncSettings | null> => {
 222      // Try plain JSON first (backward compat with old unencrypted events)
 223      try {
 224        const parsed = JSON.parse(content)
 225        if (typeof parsed === 'object' && parsed !== null) {
 226          return parsed as TSyncSettings
 227        }
 228      } catch {
 229        // Not valid JSON — likely NIP-44 encrypted ciphertext
 230      }
 231  
 232      // Try NIP-44 decryption (self-encrypted to own pubkey)
 233      const { hasNip44Support, nip44Decrypt } = encryptRef.current
 234      if (hasNip44Support) {
 235        try {
 236          const decrypted = await nip44Decrypt(authorPubkey, content)
 237          return JSON.parse(decrypted) as TSyncSettings
 238        } catch (err) {
 239          console.error('Failed to decrypt settings:', err)
 240        }
 241      }
 242  
 243      return null
 244    }, []) // stable — reads from encryptRef
 245  
 246    const fetchRemoteSettings = useCallback(async (): Promise<TSyncSettings | null> => {
 247      if (!pubkey) return null
 248  
 249      try {
 250        const relayList = await client.fetchRelayList(pubkey)
 251        const relays = relayList.write.length > 0 ? relayList.write.slice(0, 5) : client.currentRelays.slice(0, 5)
 252  
 253        const events = await client.fetchEvents(relays, {
 254          kinds: [kinds.Application],
 255          authors: [pubkey],
 256          '#d': [ApplicationDataKey.SETTINGS],
 257          limit: 1
 258        })
 259  
 260        const settingsEvent = events
 261          .filter((e) => getReplaceableEventIdentifier(e) === ApplicationDataKey.SETTINGS)
 262          .sort((a, b) => b.created_at - a.created_at)[0]
 263  
 264        if (settingsEvent) {
 265          return await decryptContent(settingsEvent.content, settingsEvent.pubkey)
 266        }
 267      } catch (err) {
 268        console.error('Failed to fetch remote settings:', err)
 269      }
 270      return null
 271    }, [pubkey, decryptContent])
 272  
 273    const syncSettings = useCallback(async () => {
 274      if (!pubkey || !account) return
 275  
 276      // Skip relay-based settings sync if NRC-only config sync is enabled
 277      if (storage.getNrcOnlyConfigSync()) return
 278  
 279      const currentSettings = getCurrentSettings(pubkey)
 280      const settingsJson = JSON.stringify(currentSettings)
 281  
 282      // Don't sync if settings haven't changed since last sync
 283      if (settingsJson === lastSyncedSettingsRef.current) {
 284        return
 285      }
 286  
 287      setIsLoading(true)
 288      try {
 289        // Encrypt settings with NIP-44 self-encryption if available
 290        const { hasNip44Support, nip44Encrypt } = encryptRef.current
 291        let content: string
 292        if (hasNip44Support) {
 293          content = await nip44Encrypt(pubkey, settingsJson)
 294        } else {
 295          content = settingsJson
 296        }
 297  
 298        const draftEvent = createSettingsDraftEvent(content)
 299        await publish(draftEvent)
 300        lastSyncedSettingsRef.current = settingsJson
 301      } catch (err) {
 302        console.error('Failed to sync settings:', err)
 303      } finally {
 304        setIsLoading(false)
 305      }
 306    }, [pubkey, account, publish])
 307  
 308    // Debounced sync on settings change
 309    const debouncedSync = useCallback(() => {
 310      if (syncTimeoutRef.current) {
 311        clearTimeout(syncTimeoutRef.current)
 312      }
 313      syncTimeoutRef.current = setTimeout(() => {
 314        syncSettings()
 315      }, 2000)
 316    }, [syncSettings])
 317  
 318    // Load settings from network on login — runs once per pubkey
 319    useEffect(() => {
 320      if (!pubkey) {
 321        lastSyncedSettingsRef.current = null
 322        hasLoadedRef.current = false
 323        return
 324      }
 325  
 326      // Only load once per pubkey to prevent reload loops
 327      if (hasLoadedRef.current) return
 328      hasLoadedRef.current = true
 329  
 330      // Skip relay-based settings sync if NRC-only config sync is enabled
 331      if (storage.getNrcOnlyConfigSync()) {
 332        lastSyncedSettingsRef.current = JSON.stringify(getCurrentSettings(pubkey))
 333        return
 334      }
 335  
 336      const loadRemoteSettings = async () => {
 337        // Wait briefly for signer to initialize so we can decrypt
 338        if (!encryptRef.current.hasNip44Support) {
 339          await new Promise((r) => setTimeout(r, 500))
 340        }
 341  
 342        setIsLoading(true)
 343        try {
 344          const currentSettings = getCurrentSettings(pubkey)
 345          const currentSettingsJson = JSON.stringify(currentSettings)
 346  
 347          const remoteSettings = await fetchRemoteSettings()
 348          if (remoteSettings) {
 349            applySettings(remoteSettings, pubkey)
 350            const appliedSettingsJson = JSON.stringify(getCurrentSettings(pubkey))
 351  
 352            if (currentSettingsJson !== appliedSettingsJson) {
 353              lastSyncedSettingsRef.current = appliedSettingsJson
 354              // Notify providers to re-render with new values instead of reloading
 355              window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT))
 356            } else {
 357              lastSyncedSettingsRef.current = currentSettingsJson
 358            }
 359          } else {
 360            lastSyncedSettingsRef.current = currentSettingsJson
 361          }
 362        } catch (err) {
 363          console.error('Failed to load remote settings:', err)
 364        } finally {
 365          setIsLoading(false)
 366        }
 367      }
 368  
 369      loadRemoteSettings()
 370    }, [pubkey, fetchRemoteSettings])
 371  
 372    // Listen for settings changes and sync
 373    useEffect(() => {
 374      if (!pubkey || !account) return
 375  
 376      const handleSettingsChange = () => {
 377        debouncedSync()
 378      }
 379  
 380      window.addEventListener(SETTINGS_CHANGED_EVENT, handleSettingsChange)
 381  
 382      return () => {
 383        window.removeEventListener(SETTINGS_CHANGED_EVENT, handleSettingsChange)
 384        if (syncTimeoutRef.current) {
 385          clearTimeout(syncTimeoutRef.current)
 386        }
 387      }
 388    }, [pubkey, account, debouncedSync])
 389  
 390    return (
 391      <SettingsSyncContext.Provider value={{ syncSettings, isLoading }}>
 392        {children}
 393      </SettingsSyncContext.Provider>
 394    )
 395  }
 396