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 if (isSameAccount(this.currentAccount, account)) {
398 this.currentAccount = null
399 window.localStorage.removeItem(StorageKey.CURRENT_ACCOUNT)
400 }
401 return this.accounts
402 }
403
404 switchAccount(account: TAccount | null) {
405 if (isSameAccount(this.currentAccount, account)) {
406 return
407 }
408 const act = this.accounts.find((act) => isSameAccount(act, account))
409 if (!act) {
410 return
411 }
412 this.currentAccount = act
413 window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act))
414 }
415
416 getDefaultZapSats() {
417 return this.defaultZapSats
418 }
419
420 setDefaultZapSats(sats: number) {
421 this.defaultZapSats = sats
422 window.localStorage.setItem(StorageKey.DEFAULT_ZAP_SATS, sats.toString())
423 }
424
425 getDefaultZapComment() {
426 return this.defaultZapComment
427 }
428
429 setDefaultZapComment(comment: string) {
430 this.defaultZapComment = comment
431 window.localStorage.setItem(StorageKey.DEFAULT_ZAP_COMMENT, comment)
432 }
433
434 getQuickZap() {
435 return this.quickZap
436 }
437
438 setQuickZap(quickZap: boolean) {
439 this.quickZap = quickZap
440 window.localStorage.setItem(StorageKey.QUICK_ZAP, quickZap.toString())
441 }
442
443 getLastReadNotificationTime(pubkey: string) {
444 return this.lastReadNotificationTimeMap[pubkey] ?? 0
445 }
446
447 setLastReadNotificationTime(pubkey: string, time: number) {
448 this.lastReadNotificationTimeMap[pubkey] = time
449 window.localStorage.setItem(
450 StorageKey.LAST_READ_NOTIFICATION_TIME_MAP,
451 JSON.stringify(this.lastReadNotificationTimeMap)
452 )
453 }
454
455 getFeedInfo(pubkey: string) {
456 return this.accountFeedInfoMap[pubkey]
457 }
458
459 setFeedInfo(info: TFeedInfo, pubkey?: string | null) {
460 this.accountFeedInfoMap[pubkey ?? 'default'] = info
461 window.localStorage.setItem(
462 StorageKey.ACCOUNT_FEED_INFO_MAP,
463 JSON.stringify(this.accountFeedInfoMap)
464 )
465 }
466
467 getAutoplay() {
468 return this.autoplay
469 }
470
471 setAutoplay(autoplay: boolean) {
472 this.autoplay = autoplay
473 window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
474 }
475
476 getHideUntrustedInteractions() {
477 return this.hideUntrustedInteractions
478 }
479
480 setHideUntrustedInteractions(hideUntrustedInteractions: boolean) {
481 this.hideUntrustedInteractions = hideUntrustedInteractions
482 window.localStorage.setItem(
483 StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
484 hideUntrustedInteractions.toString()
485 )
486 }
487
488 getHideUntrustedNotifications() {
489 return this.hideUntrustedNotifications
490 }
491
492 setHideUntrustedNotifications(hideUntrustedNotifications: boolean) {
493 this.hideUntrustedNotifications = hideUntrustedNotifications
494 window.localStorage.setItem(
495 StorageKey.HIDE_UNTRUSTED_NOTIFICATIONS,
496 hideUntrustedNotifications.toString()
497 )
498 }
499
500 getHideUntrustedNotes() {
501 return this.hideUntrustedNotes
502 }
503
504 setHideUntrustedNotes(hideUntrustedNotes: boolean) {
505 this.hideUntrustedNotes = hideUntrustedNotes
506 window.localStorage.setItem(StorageKey.HIDE_UNTRUSTED_NOTES, hideUntrustedNotes.toString())
507 }
508
509 getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
510 const defaultConfig = { type: 'blossom' } as const
511 if (!pubkey) {
512 return defaultConfig
513 }
514 // Always read from localStorage directly to avoid stale cache issues
515 const mapStr = window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP)
516 if (mapStr) {
517 try {
518 const map = JSON.parse(mapStr) as Record<string, TMediaUploadServiceConfig>
519 return map[pubkey] ?? defaultConfig
520 } catch {
521 return defaultConfig
522 }
523 }
524 return defaultConfig
525 }
526
527 setMediaUploadServiceConfig(
528 pubkey: string,
529 config: TMediaUploadServiceConfig
530 ): TMediaUploadServiceConfig {
531 this.mediaUploadServiceConfigMap[pubkey] = config
532 window.localStorage.setItem(
533 StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP,
534 JSON.stringify(this.mediaUploadServiceConfigMap)
535 )
536 return config
537 }
538
539 getLlmConfig(pubkey?: string | null): TLlmConfig | null {
540 if (!pubkey) return null
541 const mapStr = window.localStorage.getItem(StorageKey.LLM_CONFIG_MAP)
542 if (mapStr) {
543 try {
544 const map = JSON.parse(mapStr) as Record<string, TLlmConfig>
545 return map[pubkey] ?? null
546 } catch {
547 return null
548 }
549 }
550 return null
551 }
552
553 setLlmConfig(pubkey: string, config: TLlmConfig) {
554 this.llmConfigMap[pubkey] = config
555 window.localStorage.setItem(StorageKey.LLM_CONFIG_MAP, JSON.stringify(this.llmConfigMap))
556 }
557
558 getDismissedTooManyRelaysAlert() {
559 return this.dismissedTooManyRelaysAlert
560 }
561
562 setDismissedTooManyRelaysAlert(dismissed: boolean) {
563 this.dismissedTooManyRelaysAlert = dismissed
564 window.localStorage.setItem(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT, dismissed.toString())
565 }
566
567 getShowKinds() {
568 return this.showKinds
569 }
570
571 setShowKinds(kinds: number[]) {
572 this.showKinds = kinds
573 window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(kinds))
574 }
575
576 getHideContentMentioningMutedUsers() {
577 return this.hideContentMentioningMutedUsers
578 }
579
580 setHideContentMentioningMutedUsers(hide: boolean) {
581 this.hideContentMentioningMutedUsers = hide
582 window.localStorage.setItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS, hide.toString())
583 }
584
585 getNotificationListStyle() {
586 return this.notificationListStyle
587 }
588
589 setNotificationListStyle(style: TNotificationStyle) {
590 this.notificationListStyle = style
591 window.localStorage.setItem(StorageKey.NOTIFICATION_LIST_STYLE, style)
592 }
593
594 getMediaAutoLoadPolicy() {
595 return this.mediaAutoLoadPolicy
596 }
597
598 setMediaAutoLoadPolicy(policy: TMediaAutoLoadPolicy) {
599 this.mediaAutoLoadPolicy = policy
600 window.localStorage.setItem(StorageKey.MEDIA_AUTO_LOAD_POLICY, policy)
601 }
602
603 hasShownCreateWalletGuideToast(pubkey: string) {
604 return this.shownCreateWalletGuideToastPubkeys.has(pubkey)
605 }
606
607 markCreateWalletGuideToastAsShown(pubkey: string) {
608 if (this.shownCreateWalletGuideToastPubkeys.has(pubkey)) {
609 return
610 }
611 this.shownCreateWalletGuideToastPubkeys.add(pubkey)
612 window.localStorage.setItem(
613 StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS,
614 JSON.stringify(Array.from(this.shownCreateWalletGuideToastPubkeys))
615 )
616 }
617
618 getSidebarCollapse() {
619 return this.sidebarCollapse
620 }
621
622 setSidebarCollapse(collapse: boolean) {
623 this.sidebarCollapse = collapse
624 window.localStorage.setItem(StorageKey.SIDEBAR_COLLAPSE, collapse.toString())
625 }
626
627 getPrimaryColor() {
628 return this.primaryColor
629 }
630
631 setPrimaryColor(color: TPrimaryColor) {
632 this.primaryColor = color
633 window.localStorage.setItem(StorageKey.PRIMARY_COLOR, color)
634 }
635
636 getEnableSingleColumnLayout() {
637 return this.enableSingleColumnLayout
638 }
639
640 setEnableSingleColumnLayout(enable: boolean) {
641 this.enableSingleColumnLayout = enable
642 window.localStorage.setItem(StorageKey.ENABLE_SINGLE_COLUMN_LAYOUT, enable.toString())
643 }
644
645 getFaviconUrlTemplate() {
646 return this.faviconUrlTemplate
647 }
648
649 setFaviconUrlTemplate(template: string) {
650 this.faviconUrlTemplate = template
651 window.localStorage.setItem(StorageKey.FAVICON_URL_TEMPLATE, template)
652 }
653
654 getFilterOutOnionRelays() {
655 return this.filterOutOnionRelays
656 }
657
658 setFilterOutOnionRelays(filterOut: boolean) {
659 this.filterOutOnionRelays = filterOut
660 window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
661 }
662
663 getAutoInsertNewNotes() {
664 return this.autoInsertNewNotes
665 }
666
667 setAutoInsertNewNotes(value: boolean) {
668 this.autoInsertNewNotes = value
669 window.localStorage.setItem(StorageKey.AUTO_INSERT_NEW_NOTES, value.toString())
670 }
671
672 getQuickReaction() {
673 return this.quickReaction
674 }
675
676 setQuickReaction(quickReaction: boolean) {
677 this.quickReaction = quickReaction
678 window.localStorage.setItem(StorageKey.QUICK_REACTION, quickReaction.toString())
679 }
680
681 getQuickReactionEmoji() {
682 return this.quickReactionEmoji
683 }
684
685 setQuickReactionEmoji(emoji: string | TEmoji) {
686 this.quickReactionEmoji = emoji
687 window.localStorage.setItem(
688 StorageKey.QUICK_REACTION_EMOJI,
689 typeof emoji === 'string' ? emoji : JSON.stringify(emoji)
690 )
691 }
692
693 getNsfwDisplayPolicy() {
694 return this.nsfwDisplayPolicy
695 }
696
697 setNsfwDisplayPolicy(policy: TNsfwDisplayPolicy) {
698 this.nsfwDisplayPolicy = policy
699 window.localStorage.setItem(StorageKey.NSFW_DISPLAY_POLICY, policy)
700 }
701
702 getPreferNip44() {
703 return this.preferNip44
704 }
705
706 setPreferNip44(prefer: boolean) {
707 this.preferNip44 = prefer
708 window.localStorage.setItem(StorageKey.PREFER_NIP44, prefer.toString())
709 }
710
711 getDMConversationFilter() {
712 return this.dmConversationFilter
713 }
714
715 setDMConversationFilter(filter: 'all' | 'follows') {
716 this.dmConversationFilter = filter
717 window.localStorage.setItem(StorageKey.DM_CONVERSATION_FILTER, filter)
718 }
719
720 getDMLastSeenTimestamp(pubkey: string): number {
721 const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
722 if (!mapStr) return 0
723 try {
724 const map = JSON.parse(mapStr) as Record<string, number>
725 return map[pubkey] ?? 0
726 } catch {
727 return 0
728 }
729 }
730
731 setDMLastSeenTimestamp(pubkey: string, timestamp: number) {
732 const mapStr = window.localStorage.getItem(StorageKey.DM_LAST_SEEN_TIMESTAMP)
733 let map: Record<string, number> = {}
734 if (mapStr) {
735 try {
736 map = JSON.parse(mapStr)
737 } catch {
738 // ignore
739 }
740 }
741 map[pubkey] = timestamp
742 window.localStorage.setItem(StorageKey.DM_LAST_SEEN_TIMESTAMP, JSON.stringify(map))
743 }
744
745 getGraphQueriesEnabled() {
746 return this.graphQueriesEnabled
747 }
748
749 setGraphQueriesEnabled(enabled: boolean) {
750 this.graphQueriesEnabled = enabled
751 window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
752 }
753
754 getSocialGraphProximity(): number | null {
755 return this.socialGraphProximity
756 }
757
758 setSocialGraphProximity(depth: number | null) {
759 this.socialGraphProximity = depth
760 if (depth === null) {
761 window.localStorage.removeItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
762 } else {
763 window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_PROXIMITY, depth.toString())
764 }
765 }
766
767 getSocialGraphIncludeMode(): boolean {
768 return this.socialGraphIncludeMode
769 }
770
771 setSocialGraphIncludeMode(include: boolean) {
772 this.socialGraphIncludeMode = include
773 window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString())
774 }
775
776 getNrcOnlyConfigSync() {
777 return this.nrcOnlyConfigSync
778 }
779
780 setNrcOnlyConfigSync(nrcOnly: boolean) {
781 this.nrcOnlyConfigSync = nrcOnly
782 window.localStorage.setItem(StorageKey.NRC_ONLY_CONFIG_SYNC, nrcOnly.toString())
783 }
784
785 getVerboseLogging() {
786 return this.verboseLogging
787 }
788
789 setVerboseLogging(verbose: boolean) {
790 this.verboseLogging = verbose
791 window.localStorage.setItem(StorageKey.VERBOSE_LOGGING, verbose.toString())
792 }
793
794 getEnableMarkdown() {
795 return this.enableMarkdown
796 }
797
798 setEnableMarkdown(enable: boolean) {
799 this.enableMarkdown = enable
800 window.localStorage.setItem(StorageKey.ENABLE_MARKDOWN, enable.toString())
801 }
802
803 getAddClientTag() {
804 return this.addClientTag
805 }
806
807 setAddClientTag(add: boolean) {
808 this.addClientTag = add
809 window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, add.toString())
810 }
811
812 /**
813 * Get user-configured search relays. Returns empty array if not configured.
814 * Search is opt-in to protect user privacy - queries are not sent to
815 * third-party relays without explicit user configuration.
816 */
817 getSearchRelays(): string[] {
818 return this.searchRelays ?? []
819 }
820
821 /**
822 * Set custom search relays. Pass null to reset to defaults.
823 */
824 setSearchRelays(relays: string[] | null) {
825 this.searchRelays = relays
826 if (relays === null) {
827 window.localStorage.removeItem(StorageKey.SEARCH_RELAYS)
828 } else {
829 window.localStorage.setItem(StorageKey.SEARCH_RELAYS, JSON.stringify(relays))
830 }
831 }
832
833 /**
834 * Check if user has custom search relays configured.
835 */
836 hasCustomSearchRelays(): boolean {
837 return this.searchRelays !== null && this.searchRelays.length > 0
838 }
839
840 getFallbackRelayCount(): number {
841 return this.fallbackRelayCount
842 }
843
844 setFallbackRelayCount(count: number) {
845 this.fallbackRelayCount = Math.max(3, Math.min(50, count))
846 window.localStorage.setItem(StorageKey.FALLBACK_RELAY_COUNT, this.fallbackRelayCount.toString())
847 }
848
849 getOutboxMode(): string {
850 return this.outboxMode
851 }
852
853 setOutboxMode(mode: string) {
854 this.outboxMode = mode
855 window.localStorage.setItem(StorageKey.OUTBOX_MODE, mode)
856 }
857
858 // NRC rendezvous URL - stored separately by NRCProvider but accessed here for sync
859 private static readonly NRC_RENDEZVOUS_KEY = 'nrc:rendezvousUrl'
860
861 /**
862 * Get the NRC rendezvous relay URL.
863 * Returns empty string if not configured.
864 */
865 getNrcRendezvousUrl(): string {
866 return window.localStorage.getItem(LocalStorageService.NRC_RENDEZVOUS_KEY) || ''
867 }
868
869 /**
870 * Set the NRC rendezvous relay URL.
871 * Pass empty string to clear.
872 */
873 setNrcRendezvousUrl(url: string) {
874 if (url) {
875 window.localStorage.setItem(LocalStorageService.NRC_RENDEZVOUS_KEY, url)
876 } else {
877 window.localStorage.removeItem(LocalStorageService.NRC_RENDEZVOUS_KEY)
878 }
879 }
880 }
881
882 const instance = new LocalStorageService()
883 export default instance
884
885 // Custom event for settings sync
886 export const SETTINGS_CHANGED_EVENT = 'smesh-settings-changed'
887 export function dispatchSettingsChanged() {
888 window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT))
889 }
890