SettingsSyncProvider.tsx raw

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