local-storage.service.ts raw
1 import {
2 ALLOWED_FILTER_KINDS,
3 DEFAULT_FAVICON_URL_TEMPLATE,
4 ExtendedKind,
5 MEDIA_AUTO_LOAD_POLICY,
6 NOTIFICATION_LIST_STYLE,
7 NSFW_DISPLAY_POLICY,
8 StorageKey,
9 TPrimaryColor
10 } from '@/constants'
11 import { isSameAccount } from '@/lib/account'
12 import { randomString } from '@/lib/random'
13 import { isTorBrowser } from '@/lib/utils'
14 import {
15 TAccount,
16 TAccountPointer,
17 TEmoji,
18 TFeedInfo,
19 TMediaAutoLoadPolicy,
20 TLlmConfig,
21 TMediaUploadServiceConfig,
22 TNoteListMode,
23 TNsfwDisplayPolicy,
24 TNotificationStyle,
25 TRelaySet,
26 TThemeSetting
27 } from '@/types'
28 import { kinds } from 'nostr-tools'
29
30 class LocalStorageService {
31 static instance: LocalStorageService
32
33 private relaySets: TRelaySet[] = []
34 private themeSetting: TThemeSetting = 'system'
35 private accounts: TAccount[] = []
36 private currentAccount: TAccount | null = null
37 private noteListMode: TNoteListMode = 'posts'
38 private lastReadNotificationTimeMap: Record<string, number> = {}
39 private defaultZapSats: number = 21
40 private defaultZapComment: string = 'Zap!'
41 private quickZap: boolean = false
42 private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
43 private autoplay: boolean = true
44 private hideUntrustedInteractions: boolean = false
45 private hideUntrustedNotifications: boolean = false
46 private hideUntrustedNotes: boolean = false
47 private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {}
48 private dismissedTooManyRelaysAlert: boolean = false
49 private showKinds: number[] = []
50 private hideContentMentioningMutedUsers: boolean = false
51 private notificationListStyle: TNotificationStyle = NOTIFICATION_LIST_STYLE.DETAILED
52 private mediaAutoLoadPolicy: TMediaAutoLoadPolicy = MEDIA_AUTO_LOAD_POLICY.ALWAYS
53 private shownCreateWalletGuideToastPubkeys: Set<string> = new Set()
54 private sidebarCollapse: boolean = false
55 private primaryColor: TPrimaryColor = 'DEFAULT'
56 private enableSingleColumnLayout: boolean = true
57 private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
58 private filterOutOnionRelays: boolean = !isTorBrowser()
59 private autoInsertNewNotes: boolean = false
60 private quickReaction: boolean = false
61 private quickReactionEmoji: string | TEmoji = '+'
62 private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
63 private preferNip44: boolean = false
64 private dmConversationFilter: 'all' | 'follows' = 'all'
65 private graphQueriesEnabled: boolean = true
66 private socialGraphProximity: number | null = null
67 private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
68 private nrcOnlyConfigSync: boolean = false
69 private verboseLogging: boolean = false
70 private enableMarkdown: boolean = true
71 private addClientTag: boolean = true
72 private searchRelays: string[] | null = null
73 private fallbackRelayCount: number = 7
74 private llmConfigMap: Record<string, TLlmConfig> = {}
75 private outboxMode: string = 'automatic'
76
77 constructor() {
78 if (!LocalStorageService.instance) {
79 this.init()
80 LocalStorageService.instance = this
81 }
82 return LocalStorageService.instance
83 }
84
85 init() {
86 this.themeSetting =
87 (window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
88 const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
89 try { this.accounts = accountsStr ? JSON.parse(accountsStr) : [] } catch { this.accounts = [] }
90 const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
91 try { this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null } catch { this.currentAccount = null }
92 const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
93 this.noteListMode =
94 noteListModeStr && ['postsAndReplies', 'you'].includes(noteListModeStr)
95 ? (noteListModeStr as TNoteListMode)
96 : 'postsAndReplies'
97 const lastReadNotificationTimeMapStr =
98 window.localStorage.getItem(StorageKey.LAST_READ_NOTIFICATION_TIME_MAP) ?? '{}'
99 try { this.lastReadNotificationTimeMap = JSON.parse(lastReadNotificationTimeMapStr) } catch { this.lastReadNotificationTimeMap = {} }
100
101 const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
102 if (!relaySetsStr) {
103 let relaySets: TRelaySet[] = []
104 const legacyRelayGroupsStr = window.localStorage.getItem('relayGroups')
105 if (legacyRelayGroupsStr) {
106 let legacyRelayGroups: any[]
107 try { legacyRelayGroups = JSON.parse(legacyRelayGroupsStr) } catch { legacyRelayGroups = [] }
108 relaySets = legacyRelayGroups.map((group: any) => {
109 const id = randomString()
110 return {
111 id,
112 aTag: [],
113 name: group.groupName,
114 relayUrls: group.relayUrls
115 }
116 })
117 }
118 if (!relaySets.length) {
119 relaySets = []
120 }
121 window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(relaySets))
122 this.relaySets = relaySets
123 } else {
124 try { this.relaySets = JSON.parse(relaySetsStr) } catch { this.relaySets = [] }
125 }
126
127 const defaultZapSatsStr = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_SATS)
128 if (defaultZapSatsStr) {
129 const num = parseInt(defaultZapSatsStr)
130 if (!isNaN(num)) {
131 this.defaultZapSats = num
132 }
133 }
134 this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!'
135 this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true'
136
137 const accountFeedInfoMapStr =
138 window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
139 try { this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr) } catch { this.accountFeedInfoMap = {} }
140
141 this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
142 this.enableMarkdown = window.localStorage.getItem(StorageKey.ENABLE_MARKDOWN) !== 'false'
143
144 const hideUntrustedEvents =
145 window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_EVENTS) === 'true'
146 const storedHideUntrustedInteractions = window.localStorage.getItem(
147 StorageKey.HIDE_UNTRUSTED_INTERACTIONS
148 )
149 const storedHideUntrustedNotifications = window.localStorage.getItem(
150 StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS
151 )
152 const storedHideUntrustedNotes = window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_NOTES)
153 this.hideUntrustedInteractions = storedHideUntrustedInteractions
154 ? storedHideUntrustedInteractions === 'true'
155 : hideUntrustedEvents
156 this.hideUntrustedNotifications = storedHideUntrustedNotifications
157 ? storedHideUntrustedNotifications === 'true'
158 : hideUntrustedEvents
159 this.hideUntrustedNotes = storedHideUntrustedNotes
160 ? storedHideUntrustedNotes === 'true'
161 : hideUntrustedEvents
162
163 const mediaUploadServiceConfigMapStr = window.localStorage.getItem(
164 StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP
165 )
166 if (mediaUploadServiceConfigMapStr) {
167 try { this.mediaUploadServiceConfigMap = JSON.parse(mediaUploadServiceConfigMapStr) } catch { /* ignore corrupt data */ }
168 }
169
170 const llmConfigMapStr = window.localStorage.getItem(StorageKey.LLM_CONFIG_MAP)
171 if (llmConfigMapStr) {
172 try { this.llmConfigMap = JSON.parse(llmConfigMapStr) } catch { /* ignore corrupt data */ }
173 }
174
175 // Migrate old boolean setting to new policy
176 const nsfwDisplayPolicyStr = window.localStorage.getItem(StorageKey.NSFW_DISPLAY_POLICY)
177 if (
178 nsfwDisplayPolicyStr &&
179 Object.values(NSFW_DISPLAY_POLICY).includes(nsfwDisplayPolicyStr as TNsfwDisplayPolicy)
180 ) {
181 this.nsfwDisplayPolicy = nsfwDisplayPolicyStr as TNsfwDisplayPolicy
182 } else {
183 // Migration: convert old boolean to new policy
184 const defaultShowNsfwStr = window.localStorage.getItem(StorageKey.DEFAULT_SHOW_NSFW)
185 this.nsfwDisplayPolicy =
186 defaultShowNsfwStr === 'true' ? NSFW_DISPLAY_POLICY.SHOW : NSFW_DISPLAY_POLICY.HIDE_CONTENT
187 window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, this.nsfwDisplayPolicy)
188 }
189
190 this.dismissedTooManyRelaysAlert =
191 window.localStorage.getItem(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT) === 'true'
192
193 const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS)
194 if (!showKindsStr) {
195 this.showKinds = ALLOWED_FILTER_KINDS
196 } else {
197 const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION)
198 const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0
199 let parsedKinds: number[]
200 try { parsedKinds = JSON.parse(showKindsStr) as number[] } catch { parsedKinds = ALLOWED_FILTER_KINDS }
201 const showKindSet = new Set(parsedKinds)
202 if (showKindsVersion < 1) {
203 showKindSet.add(ExtendedKind.VIDEO)
204 showKindSet.add(ExtendedKind.SHORT_VIDEO)
205 }
206 if (showKindsVersion < 2 && showKindSet.has(ExtendedKind.VIDEO)) {
207 showKindSet.add(ExtendedKind.ADDRESSABLE_NORMAL_VIDEO)
208 showKindSet.add(ExtendedKind.ADDRESSABLE_SHORT_VIDEO)
209 }
210 if (showKindsVersion < 3 && showKindSet.has(24236)) {
211 showKindSet.delete(24236) // remove typo kind
212 showKindSet.add(ExtendedKind.ADDRESSABLE_SHORT_VIDEO)
213 }
214 if (showKindsVersion < 4 && showKindSet.has(kinds.Repost)) {
215 showKindSet.add(kinds.GenericRepost)
216 }
217 this.showKinds = Array.from(showKindSet)
218 }
219 window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
220 window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '4')
221
222 this.hideContentMentioningMutedUsers =
223 window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true'
224
225 this.notificationListStyle =
226 window.localStorage.getItem(StorageKey.NOTIFICATION_LIST_STYLE) ===
227 NOTIFICATION_LIST_STYLE.COMPACT
228 ? NOTIFICATION_LIST_STYLE.COMPACT
229 : NOTIFICATION_LIST_STYLE.DETAILED
230
231 const mediaAutoLoadPolicy = window.localStorage.getItem(StorageKey.MEDIA_AUTO_LOAD_POLICY)
232 if (
233 mediaAutoLoadPolicy &&
234 Object.values(MEDIA_AUTO_LOAD_POLICY).includes(mediaAutoLoadPolicy as TMediaAutoLoadPolicy)
235 ) {
236 this.mediaAutoLoadPolicy = mediaAutoLoadPolicy as TMediaAutoLoadPolicy
237 }
238
239 const shownCreateWalletGuideToastPubkeysStr = window.localStorage.getItem(
240 StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS
241 )
242 if (shownCreateWalletGuideToastPubkeysStr) {
243 try { this.shownCreateWalletGuideToastPubkeys = new Set(JSON.parse(shownCreateWalletGuideToastPubkeysStr)) } catch { this.shownCreateWalletGuideToastPubkeys = new Set() }
244 }
245
246 this.sidebarCollapse = window.localStorage.getItem(StorageKey.SIDEBAR_COLLAPSE) === 'true'
247
248 this.primaryColor =
249 (window.localStorage.getItem(StorageKey.PRIMARY_COLOR) as TPrimaryColor) ?? 'DEFAULT'
250
251 this.enableSingleColumnLayout =
252 window.localStorage.getItem(StorageKey.ENABLE_SINGLE_COLUMN_LAYOUT) !== 'false'
253
254 this.faviconUrlTemplate =
255 window.localStorage.getItem(StorageKey.FAVICON_URL_TEMPLATE) ?? DEFAULT_FAVICON_URL_TEMPLATE
256
257 const filterOutOnionRelaysStr = window.localStorage.getItem(StorageKey.FILTER_OUT_ONION_RELAYS)
258 if (filterOutOnionRelaysStr) {
259 this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false'
260 }
261
262 this.autoInsertNewNotes =
263 window.localStorage.getItem(StorageKey.AUTO_INSERT_NEW_NOTES) === 'true'
264 this.quickReaction = window.localStorage.getItem(StorageKey.QUICK_REACTION) === 'true'
265 const quickReactionEmojiStr =
266 window.localStorage.getItem(StorageKey.QUICK_REACTION_EMOJI) ?? '+'
267 if (quickReactionEmojiStr.startsWith('{')) {
268 this.quickReactionEmoji = JSON.parse(quickReactionEmojiStr) as TEmoji
269 } else {
270 this.quickReactionEmoji = quickReactionEmojiStr
271 }
272
273 this.preferNip44 = window.localStorage.getItem(StorageKey.PREFER_NIP44) === 'true'
274 this.dmConversationFilter =
275 (window.localStorage.getItem(StorageKey.DM_CONVERSATION_FILTER) as 'all' | 'follows') || 'all'
276 this.graphQueriesEnabled =
277 window.localStorage.getItem(StorageKey.GRAPH_QUERIES_ENABLED) !== 'false'
278
279 const socialGraphProximityStr = window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
280 if (socialGraphProximityStr) {
281 const parsed = parseInt(socialGraphProximityStr)
282 if (!isNaN(parsed) && parsed >= 1 && parsed <= 2) {
283 this.socialGraphProximity = parsed
284 }
285 }
286
287 this.socialGraphIncludeMode =
288 window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE) !== 'false'
289
290 this.nrcOnlyConfigSync =
291 window.localStorage.getItem(StorageKey.NRC_ONLY_CONFIG_SYNC) === 'true'
292
293 this.verboseLogging =
294 window.localStorage.getItem(StorageKey.VERBOSE_LOGGING) === 'true'
295
296 // Default to true if not set (enabled by default)
297 this.addClientTag =
298 window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) !== 'false'
299
300 // Search relays - user-configurable, defaults to empty (opt-in for privacy)
301 const searchRelaysStr = window.localStorage.getItem(StorageKey.SEARCH_RELAYS)
302 if (searchRelaysStr) {
303 try {
304 this.searchRelays = JSON.parse(searchRelaysStr)
305 } catch {
306 this.searchRelays = null
307 }
308 }
309
310 // Fallback relay count - how many top discovered relays to use as fallback
311 const fallbackRelayCountStr = window.localStorage.getItem(StorageKey.FALLBACK_RELAY_COUNT)
312 if (fallbackRelayCountStr) {
313 const num = parseInt(fallbackRelayCountStr)
314 if (!isNaN(num) && num >= 3 && num <= 50) {
315 this.fallbackRelayCount = num
316 }
317 }
318
319 this.outboxMode = window.localStorage.getItem(StorageKey.OUTBOX_MODE) ?? 'automatic'
320
321 // Clean up deprecated data
322 window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
323 window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
324 window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
325 window.localStorage.removeItem(StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP)
326 window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP)
327 window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP)
328 window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID)
329 window.localStorage.removeItem(StorageKey.FEED_TYPE)
330 }
331
332 getRelaySets() {
333 return this.relaySets
334 }
335
336 setRelaySets(relaySets: TRelaySet[]) {
337 this.relaySets = relaySets
338 window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(this.relaySets))
339 }
340
341 getThemeSetting() {
342 return this.themeSetting
343 }
344
345 setThemeSetting(themeSetting: TThemeSetting) {
346 window.localStorage.setItem(StorageKey.THEME_SETTING, themeSetting)
347 this.themeSetting = themeSetting
348 }
349
350 getNoteListMode() {
351 return this.noteListMode
352 }
353
354 setNoteListMode(mode: TNoteListMode) {
355 window.localStorage.setItem(StorageKey.NOTE_LIST_MODE, mode)
356 this.noteListMode = mode
357 }
358
359 getAccounts() {
360 return this.accounts
361 }
362
363 findAccount(account: TAccountPointer) {
364 return this.accounts.find((act) => isSameAccount(act, account))
365 }
366
367 getCurrentAccount() {
368 return this.currentAccount
369 }
370
371 getAccountNsec(pubkey: string) {
372 const account = this.accounts.find((act) => act.pubkey === pubkey && act.signerType === 'nsec')
373 return account?.nsec
374 }
375
376 getAccountNcryptsec(pubkey: string) {
377 const account = this.accounts.find(
378 (act) => act.pubkey === pubkey && act.signerType === 'ncryptsec'
379 )
380 return account?.ncryptsec
381 }
382
383 addAccount(account: TAccount) {
384 const index = this.accounts.findIndex((act) => isSameAccount(act, account))
385 if (index !== -1) {
386 this.accounts[index] = account
387 } else {
388 this.accounts.push(account)
389 }
390 window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
391 return this.accounts
392 }
393
394 removeAccount(account: TAccount) {
395 this.accounts = this.accounts.filter((act) => !isSameAccount(act, account))
396 window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
397 return this.accounts
398 }
399
400 switchAccount(account: TAccount | null) {
401 if (isSameAccount(this.currentAccount, account)) {
402 return
403 }
404 const act = this.accounts.find((act) => isSameAccount(act, account))
405 if (!act) {
406 return
407 }
408 this.currentAccount = act
409 window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act))
410 }
411
412 getDefaultZapSats() {
413 return this.defaultZapSats
414 }
415
416 setDefaultZapSats(sats: number) {
417 this.defaultZapSats = sats
418 window.localStorage.setItem(StorageKey.DEFAULT_ZAP_SATS, sats.toString())
419 }
420
421 getDefaultZapComment() {
422 return this.defaultZapComment
423 }
424
425 setDefaultZapComment(comment: string) {
426 this.defaultZapComment = comment
427 window.localStorage.setItem(StorageKey.DEFAULT_ZAP_COMMENT, comment)
428 }
429
430 getQuickZap() {
431 return this.quickZap
432 }
433
434 setQuickZap(quickZap: boolean) {
435 this.quickZap = quickZap
436 window.localStorage.setItem(StorageKey.QUICK_ZAP, quickZap.toString())
437 }
438
439 getLastReadNotificationTime(pubkey: string) {
440 return this.lastReadNotificationTimeMap[pubkey] ?? 0
441 }
442
443 setLastReadNotificationTime(pubkey: string, time: number) {
444 this.lastReadNotificationTimeMap[pubkey] = time
445 window.localStorage.setItem(
446 StorageKey.LAST_READ_NOTIFICATION_TIME_MAP,
447 JSON.stringify(this.lastReadNotificationTimeMap)
448 )
449 }
450
451 getFeedInfo(pubkey: string) {
452 return this.accountFeedInfoMap[pubkey]
453 }
454
455 setFeedInfo(info: TFeedInfo, pubkey?: string | null) {
456 this.accountFeedInfoMap[pubkey ?? 'default'] = info
457 window.localStorage.setItem(
458 StorageKey.ACCOUNT_FEED_INFO_MAP,
459 JSON.stringify(this.accountFeedInfoMap)
460 )
461 }
462
463 getAutoplay() {
464 return this.autoplay
465 }
466
467 setAutoplay(autoplay: boolean) {
468 this.autoplay = autoplay
469 window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
470 }
471
472 getHideUntrustedInteractions() {
473 return this.hideUntrustedInteractions
474 }
475
476 setHideUntrustedInteractions(hideUntrustedInteractions: boolean) {
477 this.hideUntrustedInteractions = hideUntrustedInteractions
478 window.localStorage.setItem(
479 StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
480 hideUntrustedInteractions.toString()
481 )
482 }
483
484 getHideUntrustedNotifications() {
485 return this.hideUntrustedNotifications
486 }
487
488 setHideUntrustedNotifications(hideUntrustedNotifications: boolean) {
489 this.hideUntrustedNotifications = hideUntrustedNotifications
490 window.localStorage.setItem(
491 StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS,
492 hideUntrustedNotifications.toString()
493 )
494 }
495
496 getHideUntrustedNotes() {
497 return this.hideUntrustedNotes
498 }
499
500 setHideUntrustedNotes(hideUntrustedNotes: boolean) {
501 this.hideUntrustedNotes = hideUntrustedNotes
502 window.localStorage.setItem(StorageKey.HIDE_UNTRUSTED_NOTES, hideUntrustedNotes.toString())
503 }
504
505 getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
506 const defaultConfig = { type: 'blossom' } as const
507 if (!pubkey) {
508 return defaultConfig
509 }
510 // Always read from localStorage directly to avoid stale cache issues
511 const mapStr = window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP)
512 if (mapStr) {
513 try {
514 const map = JSON.parse(mapStr) as Record<string, TMediaUploadServiceConfig>
515 return map[pubkey] ?? defaultConfig
516 } catch {
517 return defaultConfig
518 }
519 }
520 return defaultConfig
521 }
522
523 setMediaUploadServiceConfig(
524 pubkey: string,
525 config: TMediaUploadServiceConfig
526 ): TMediaUploadServiceConfig {
527 this.mediaUploadServiceConfigMap[pubkey] = config
528 window.localStorage.setItem(
529 StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP,
530 JSON.stringify(this.mediaUploadServiceConfigMap)
531 )
532 return config
533 }
534
535 getLlmConfig(pubkey?: string | null): TLlmConfig | null {
536 if (!pubkey) return null
537 const mapStr = window.localStorage.getItem(StorageKey.LLM_CONFIG_MAP)
538 if (mapStr) {
539 try {
540 const map = JSON.parse(mapStr) as Record<string, TLlmConfig>
541 return map[pubkey] ?? null
542 } catch {
543 return null
544 }
545 }
546 return null
547 }
548
549 setLlmConfig(pubkey: string, config: TLlmConfig) {
550 this.llmConfigMap[pubkey] = config
551 window.localStorage.setItem(StorageKey.LLM_CONFIG_MAP, JSON.stringify(this.llmConfigMap))
552 }
553
554 getDismissedTooManyRelaysAlert() {
555 return this.dismissedTooManyRelaysAlert
556 }
557
558 setDismissedTooManyRelaysAlert(dismissed: boolean) {
559 this.dismissedTooManyRelaysAlert = dismissed
560 window.localStorage.setItem(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT, dismissed.toString())
561 }
562
563 getShowKinds() {
564 return this.showKinds
565 }
566
567 setShowKinds(kinds: number[]) {
568 this.showKinds = kinds
569 window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(kinds))
570 }
571
572 getHideContentMentioningMutedUsers() {
573 return this.hideContentMentioningMutedUsers
574 }
575
576 setHideContentMentioningMutedUsers(hide: boolean) {
577 this.hideContentMentioningMutedUsers = hide
578 window.localStorage.setItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS, hide.toString())
579 }
580
581 getNotificationListStyle() {
582 return this.notificationListStyle
583 }
584
585 setNotificationListStyle(style: TNotificationStyle) {
586 this.notificationListStyle = style
587 window.localStorage.setItem(StorageKey.NOTIFICATION_LIST_STYLE, style)
588 }
589
590 getMediaAutoLoadPolicy() {
591 return this.mediaAutoLoadPolicy
592 }
593
594 setMediaAutoLoadPolicy(policy: TMediaAutoLoadPolicy) {
595 this.mediaAutoLoadPolicy = policy
596 window.localStorage.setItem(StorageKey.MEDIA_AUTO_LOAD_POLICY, policy)
597 }
598
599 hasShownCreateWalletGuideToast(pubkey: string) {
600 return this.shownCreateWalletGuideToastPubkeys.has(pubkey)
601 }
602
603 markCreateWalletGuideToastAsShown(pubkey: string) {
604 if (this.shownCreateWalletGuideToastPubkeys.has(pubkey)) {
605 return
606 }
607 this.shownCreateWalletGuideToastPubkeys.add(pubkey)
608 window.localStorage.setItem(
609 StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS,
610 JSON.stringify(Array.from(this.shownCreateWalletGuideToastPubkeys))
611 )
612 }
613
614 getSidebarCollapse() {
615 return this.sidebarCollapse
616 }
617
618 setSidebarCollapse(collapse: boolean) {
619 this.sidebarCollapse = collapse
620 window.localStorage.setItem(StorageKey.SIDEBAR_COLLAPSE, collapse.toString())
621 }
622
623 getPrimaryColor() {
624 return this.primaryColor
625 }
626
627 setPrimaryColor(color: TPrimaryColor) {
628 this.primaryColor = color
629 window.localStorage.setItem(StorageKey.PRIMARY_COLOR, color)
630 }
631
632 getEnableSingleColumnLayout() {
633 return this.enableSingleColumnLayout
634 }
635
636 setEnableSingleColumnLayout(enable: boolean) {
637 this.enableSingleColumnLayout = enable
638 window.localStorage.setItem(StorageKey.ENABLE_SINGLE_COLUMN_LAYOUT, enable.toString())
639 }
640
641 getFaviconUrlTemplate() {
642 return this.faviconUrlTemplate
643 }
644
645 setFaviconUrlTemplate(template: string) {
646 this.faviconUrlTemplate = template
647 window.localStorage.setItem(StorageKey.FAVICON_URL_TEMPLATE, template)
648 }
649
650 getFilterOutOnionRelays() {
651 return this.filterOutOnionRelays
652 }
653
654 setFilterOutOnionRelays(filterOut: boolean) {
655 this.filterOutOnionRelays = filterOut
656 window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
657 }
658
659 getAutoInsertNewNotes() {
660 return this.autoInsertNewNotes
661 }
662
663 setAutoInsertNewNotes(value: boolean) {
664 this.autoInsertNewNotes = value
665 window.localStorage.setItem(StorageKey.AUTO_INSERT_NEW_NOTES, value.toString())
666 }
667
668 getQuickReaction() {
669 return this.quickReaction
670 }
671
672 setQuickReaction(quickReaction: boolean) {
673 this.quickReaction = quickReaction
674 window.localStorage.setItem(StorageKey.QUICK_REACTION, quickReaction.toString())
675 }
676
677 getQuickReactionEmoji() {
678 return this.quickReactionEmoji
679 }
680
681 setQuickReactionEmoji(emoji: string | TEmoji) {
682 this.quickReactionEmoji = emoji
683 window.localStorage.setItem(
684 StorageKey.QUICK_REACTION_EMOJI,
685 typeof emoji === 'string' ? emoji : JSON.stringify(emoji)
686 )
687 }
688
689 getNsfwDisplayPolicy() {
690 return this.nsfwDisplayPolicy
691 }
692
693 setNsfwDisplayPolicy(policy: TNsfwDisplayPolicy) {
694 this.nsfwDisplayPolicy = policy
695 window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, policy)
696 }
697
698 getPreferNip44() {
699 return this.preferNip44
700 }
701
702 setPreferNip44(prefer: boolean) {
703 this.preferNip44 = prefer
704 window.localStorage.setItem(StorageKey.PREFER_NIP44, prefer.toString())
705 }
706
707 getDMConversationFilter() {
708 return this.dmConversationFilter
709 }
710
711 setDMConversationFilter(filter: 'all' | 'follows') {
712 this.dmConversationFilter = filter
713 window.localStorage.setItem(StorageKey.DM_CONVERSATION_FILTER, filter)
714 }
715
716 getDMLastSeenTimestamp(pubkey: string): number {
717 const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
718 if (!mapStr) return 0
719 try {
720 const map = JSON.parse(mapStr) as Record<string, number>
721 return map[pubkey] ?? 0
722 } catch {
723 return 0
724 }
725 }
726
727 setDMLastSeenTimestamp(pubkey: string, timestamp: number) {
728 const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
729 let map: Record<string, number> = {}
730 if (mapStr) {
731 try {
732 map = JSON.parse(mapStr)
733 } catch {
734 // ignore
735 }
736 }
737 map[pubkey] = timestamp
738 window.localStorage.setItem(StorageKey.DM_LAST_SEEN_TIMESTAMP, JSON.stringify(map))
739 }
740
741 getGraphQueriesEnabled() {
742 return this.graphQueriesEnabled
743 }
744
745 setGraphQueriesEnabled(enabled: boolean) {
746 this.graphQueriesEnabled = enabled
747 window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
748 }
749
750 getSocialGraphProximity(): number | null {
751 return this.socialGraphProximity
752 }
753
754 setSocialGraphProximity(depth: number | null) {
755 this.socialGraphProximity = depth
756 if (depth === null) {
757 window.localStorage.removeItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
758 } else {
759 window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_PROXIMITY, depth.toString())
760 }
761 }
762
763 getSocialGraphIncludeMode(): boolean {
764 return this.socialGraphIncludeMode
765 }
766
767 setSocialGraphIncludeMode(include: boolean) {
768 this.socialGraphIncludeMode = include
769 window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString())
770 }
771
772 getNrcOnlyConfigSync() {
773 return this.nrcOnlyConfigSync
774 }
775
776 setNrcOnlyConfigSync(nrcOnly: boolean) {
777 this.nrcOnlyConfigSync = nrcOnly
778 window.localStorage.setItem(StorageKey.NRC_ONLY_CONFIG_SYNC, nrcOnly.toString())
779 }
780
781 getVerboseLogging() {
782 return this.verboseLogging
783 }
784
785 setVerboseLogging(verbose: boolean) {
786 this.verboseLogging = verbose
787 window.localStorage.setItem(StorageKey.VERBOSE_LOGGING, verbose.toString())
788 }
789
790 getEnableMarkdown() {
791 return this.enableMarkdown
792 }
793
794 setEnableMarkdown(enable: boolean) {
795 this.enableMarkdown = enable
796 window.localStorage.setItem(StorageKey.ENABLE_MARKDOWN, enable.toString())
797 }
798
799 getAddClientTag() {
800 return this.addClientTag
801 }
802
803 setAddClientTag(add: boolean) {
804 this.addClientTag = add
805 window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, add.toString())
806 }
807
808 /**
809 * Get user-configured search relays. Returns empty array if not configured.
810 * Search is opt-in to protect user privacy - queries are not sent to
811 * third-party relays without explicit user configuration.
812 */
813 getSearchRelays(): string[] {
814 return this.searchRelays ?? []
815 }
816
817 /**
818 * Set custom search relays. Pass null to reset to defaults.
819 */
820 setSearchRelays(relays: string[] | null) {
821 this.searchRelays = relays
822 if (relays === null) {
823 window.localStorage.removeItem(StorageKey.SEARCH_RELAYS)
824 } else {
825 window.localStorage.setItem(StorageKey.SEARCH_RELAYS, JSON.stringify(relays))
826 }
827 }
828
829 /**
830 * Check if user has custom search relays configured.
831 */
832 hasCustomSearchRelays(): boolean {
833 return this.searchRelays !== null && this.searchRelays.length > 0
834 }
835
836 getFallbackRelayCount(): number {
837 return this.fallbackRelayCount
838 }
839
840 setFallbackRelayCount(count: number) {
841 this.fallbackRelayCount = Math.max(3, Math.min(50, count))
842 window.localStorage.setItem(StorageKey.FALLBACK_RELAY_COUNT, this.fallbackRelayCount.toString())
843 }
844
845 getOutboxMode(): string {
846 return this.outboxMode
847 }
848
849 setOutboxMode(mode: string) {
850 this.outboxMode = mode
851 window.localStorage.setItem(StorageKey.OUTBOX_MODE, mode)
852 }
853
854 // NRC rendezvous URL - stored separately by NRCProvider but accessed here for sync
855 private static readonly NRC_RENDEZVOUS_KEY = 'nrc:rendezvousUrl'
856
857 /**
858 * Get the NRC rendezvous relay URL.
859 * Returns empty string if not configured.
860 */
861 getNrcRendezvousUrl(): string {
862 return window.localStorage.getItem(LocalStorageService.NRC_RENDEZVOUS_KEY) || ''
863 }
864
865 /**
866 * Set the NRC rendezvous relay URL.
867 * Pass empty string to clear.
868 */
869 setNrcRendezvousUrl(url: string) {
870 if (url) {
871 window.localStorage.setItem(LocalStorageService.NRC_RENDEZVOUS_KEY, url)
872 } else {
873 window.localStorage.removeItem(LocalStorageService.NRC_RENDEZVOUS_KEY)
874 }
875 }
876 }
877
878 const instance = new LocalStorageService()
879 export default instance
880
881 // Custom event for settings sync
882 export const SETTINGS_CHANGED_EVENT = 'smesh-settings-changed'
883 export function dispatchSettingsChanged() {
884 window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT))
885 }
886