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