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