import { DEFAULT_FONT_SIZE, DEFAULT_NIP_96_SERVICE, ExtendedKind, MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE, DEFAULT_FEED_SHOW_KINDS, StorageKey } from '@/constants' import { kinds } from 'nostr-tools' import { isSameAccount } from '@/lib/account' import { DEFAULT_ZAP_SATS } from '@/lib/lightning' import { isPaytoCategory } from '@/lib/payto-category-display' import type { PaytoCategory } from '@/lib/payto-registry' import { randomString } from '@/lib/random' import { TAccount, TAccountPointer, TFontSize, TMediaAutoLoadPolicy, TMediaUploadServiceConfig, TNoteListMode, TNotificationStyle, TRelaySet, TTheme, TThemeSetting, } from '@/types' /** * Lazy-load IndexedDB service to avoid a static import cycle: `indexed-db` pulls modules that can * re-import this file during evaluation; the `indexedDb` binding would still be in the TDZ when * {@link LocalStorageService} runs its eager constructor. */ let indexedDbSingletonPromise: ReturnType | null = null function importIndexedDbModule() { return import('./indexed-db.service').then((m) => m.default) } function loadIndexedDb() { if (!indexedDbSingletonPromise) { indexedDbSingletonPromise = importIndexedDbModule() } return indexedDbSingletonPromise } /** Keys we persist to IndexedDB (and migrate from localStorage when IDB is empty). */ const SETTINGS_KEYS = [ StorageKey.RELAY_SETS, StorageKey.THEME_SETTING, StorageKey.THEME, StorageKey.ADD_CLIENT_TAG, StorageKey.FONT_SIZE, StorageKey.NOTE_LIST_MODE, StorageKey.ACCOUNTS, StorageKey.CURRENT_ACCOUNT, StorageKey.DEFAULT_ZAP_SATS, StorageKey.DEFAULT_ZAP_COMMENT, StorageKey.PREFERRED_PAYTO_CATEGORY, StorageKey.QUICK_ZAP, StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, StorageKey.AUTOPLAY, StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP, StorageKey.DEFAULT_SHOW_NSFW, StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT, StorageKey.SHOW_KINDS, StorageKey.SHOW_KINDS_VERSION, StorageKey.SHOW_KIND_1_OPs, StorageKey.SHOW_KIND_1_REPLIES, StorageKey.SHOW_KIND_1111, StorageKey.FEED_KIND_FILTER_BYPASS, StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS, StorageKey.NOTIFICATION_LIST_STYLE, StorageKey.MEDIA_AUTO_LOAD_POLICY, StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS, StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL, StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH, StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS, StorageKey.SHOW_DETAILED_PUBLISH_TOASTS, StorageKey.SHOW_LIVE_ACTIVITIES_BANNER, StorageKey.DEFAULT_EXPIRATION_ENABLED, StorageKey.DEFAULT_EXPIRATION_MONTHS, StorageKey.SHOW_RSS_FEED, StorageKey.USE_NOSTR_ARCHIVES_API, StorageKey.PANE_MODE ] as const class LocalStorageService { static instance: LocalStorageService private relaySets: TRelaySet[] = [] private themeSetting: TThemeSetting = 'system' private theme: TTheme = 'light' private addClientTag: boolean = true private fontSize: TFontSize = DEFAULT_FONT_SIZE private accounts: TAccount[] = [] private currentAccount: TAccount | null = null private noteListMode: TNoteListMode = 'postsAndReplies' private defaultZapSats: number = DEFAULT_ZAP_SATS private defaultZapComment: string = 'Zap!' private preferredPaytoCategory: PaytoCategory | null = null private quickZap: boolean = false private includePublicZapReceipt: boolean = true private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private autoplay: boolean = true private mediaUploadServiceConfigMap: Record = {} private defaultShowNsfw: boolean = false private dismissedTooManyRelaysAlert: boolean = false private showKinds: number[] = [] private showKind1OPs: boolean = true private showKind1Replies: boolean = true private showKind1111: boolean = true /** Omit kinds in feed REQ + skip client kind filtering (testing). */ private feedKindFilterBypass: boolean = false private hideContentMentioningMutedUsers: boolean = false private notificationListStyle: TNotificationStyle = NOTIFICATION_LIST_STYLE.DETAILED private mediaAutoLoadPolicy: TMediaAutoLoadPolicy = MEDIA_AUTO_LOAD_POLICY.FOLLOWS_ONLY private showRecommendedRelaysPanel: boolean = false private shownCreateWalletGuideToastPubkeys: Set = new Set() private defaultExpirationEnabled: boolean = false private defaultExpirationMonths: number = 6 private showRssFeed: boolean = true /** Nostr Archives REST (discovery, stats prefetch). Default on; set `'false'` to disable. */ private useNostrArchivesApi: boolean = true private panelMode: 'single' | 'double' = 'single' private addRandomRelaysToPublish: boolean = true private showPublishSuccessToasts: boolean = false private showDetailedPublishToasts: boolean = true private showLiveActivitiesBanner: boolean = true constructor() { if (!LocalStorageService.instance) { this.init() LocalStorageService.instance = this } return LocalStorageService.instance } init() { this.themeSetting = (window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system' const themeStr = window.localStorage.getItem(StorageKey.THEME) as TTheme | null this.theme = themeStr === 'dark' || themeStr === 'light' ? themeStr : 'light' const addClientTagStr = window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) this.addClientTag = addClientTagStr === null ? true : addClientTagStr === 'true' this.fontSize = (window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? DEFAULT_FONT_SIZE const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS) this.accounts = accountsStr ? JSON.parse(accountsStr) : [] const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT) this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null if ( this.currentAccount != null && !this.accounts.some((a) => isSameAccount(a, this.currentAccount)) ) { this.currentAccount = null window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(null)) } const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE) this.noteListMode = noteListModeStr && ['posts', 'postsAndReplies', 'media'].includes(noteListModeStr) ? (noteListModeStr as TNoteListMode) : 'posts' const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS) if (!relaySetsStr) { let relaySets: TRelaySet[] = [] const legacyRelayGroupsStr = window.localStorage.getItem('relayGroups') if (legacyRelayGroupsStr) { const legacyRelayGroups = JSON.parse(legacyRelayGroupsStr) relaySets = legacyRelayGroups.map((group: any) => { return { id: randomString(), name: group.groupName, relayUrls: group.relayUrls } }) } if (!relaySets.length) { relaySets = [] } this.persistSetting(StorageKey.RELAY_SETS, JSON.stringify(relaySets)) this.relaySets = relaySets } else { this.relaySets = JSON.parse(relaySetsStr) } const defaultZapSatsStr = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_SATS) if (defaultZapSatsStr) { const num = parseInt(defaultZapSatsStr) if (!isNaN(num)) { this.defaultZapSats = num } } this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!' const preferredPaytoCategoryStr = window.localStorage.getItem(StorageKey.PREFERRED_PAYTO_CATEGORY) this.preferredPaytoCategory = preferredPaytoCategoryStr && isPaytoCategory(preferredPaytoCategoryStr) ? preferredPaytoCategoryStr : null this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true' const includeReceiptStr = window.localStorage.getItem(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT) if (includeReceiptStr != null) { this.includePublicZapReceipt = includeReceiptStr !== 'false' } // deprecated this.mediaUploadService = window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false' const mediaUploadServiceConfigMapStr = window.localStorage.getItem( StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP ) if (mediaUploadServiceConfigMapStr) { this.mediaUploadServiceConfigMap = JSON.parse(mediaUploadServiceConfigMapStr) } this.defaultShowNsfw = window.localStorage.getItem(StorageKey.DEFAULT_SHOW_NSFW) === 'true' this.dismissedTooManyRelaysAlert = window.localStorage.getItem(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT) === 'true' const storedValue = window.localStorage.getItem(StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL) this.showRecommendedRelaysPanel = storedValue === 'true' // Default to false if not explicitly set to true const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS) if (!showKindsStr) { this.showKinds = [...DEFAULT_FEED_SHOW_KINDS] } else { const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION) const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0 const showKinds = JSON.parse(showKindsStr) as number[] if (showKindsVersion < 1) { showKinds.push(ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO) } if (showKindsVersion < 2) { showKinds.push(ExtendedKind.ZAP_RECEIPT) } if (showKindsVersion < 3) { // Remove boosts (kind 6) from existing users' filters const repostIndex = showKinds.indexOf(kinds.Repost) if (repostIndex !== -1) { showKinds.splice(repostIndex, 1) } } if (showKindsVersion < 4) { // Add publications and wiki articles to existing users' filters if (!showKinds.includes(ExtendedKind.PUBLICATION)) { showKinds.push(ExtendedKind.PUBLICATION) } if (!showKinds.includes(ExtendedKind.PUBLICATION_CONTENT)) { showKinds.push(ExtendedKind.PUBLICATION_CONTENT) } if (!showKinds.includes(ExtendedKind.WIKI_ARTICLE)) { showKinds.push(ExtendedKind.WIKI_ARTICLE) } } if (showKindsVersion < 5) { // Remove publication content from existing users' filters (should only be embedded) const pubContentIndex = showKinds.indexOf(ExtendedKind.PUBLICATION_CONTENT) if (pubContentIndex !== -1) { showKinds.splice(pubContentIndex, 1) } } if (showKindsVersion < 6) { // Remove publications and publication content from existing users' filters (should only be embedded, not in feeds) const pubIndex = showKinds.indexOf(ExtendedKind.PUBLICATION) if (pubIndex !== -1) { showKinds.splice(pubIndex, 1) } const pubContentIndex = showKinds.indexOf(ExtendedKind.PUBLICATION_CONTENT) if (pubContentIndex !== -1) { showKinds.splice(pubContentIndex, 1) } } if (showKindsVersion < 7) { // Remove NIP-89 handler kinds from feed (not in filter UI; avoid showing in main feed) const nip89RecIndex = showKinds.indexOf(ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) if (nip89RecIndex !== -1) { showKinds.splice(nip89RecIndex, 1) } const nip89InfoIndex = showKinds.indexOf(ExtendedKind.APPLICATION_HANDLER_INFO) if (nip89InfoIndex !== -1) { showKinds.splice(nip89InfoIndex, 1) } } if (showKindsVersion < 8) { // Boosts (kind 6) and publications removed from feed filter UI — strip from saved preferences for (let i = showKinds.length - 1; i >= 0; i--) { const k = showKinds[i] if (k === kinds.Repost || k === ExtendedKind.PUBLICATION) { showKinds.splice(i, 1) } } } if (showKindsVersion < 11) { if (!showKinds.includes(ExtendedKind.GIT_RELEASE)) { showKinds.push(ExtendedKind.GIT_RELEASE) } } if (showKindsVersion < 12) { // Add NOSTR_SPECIFICATION (30817) for users who already have long-form articles (30023) or // wiki articles (30818) enabled — it was omitted from the earlier v4 migration. if ( (showKinds.includes(kinds.LongFormArticle) || showKinds.includes(ExtendedKind.WIKI_ARTICLE)) && !showKinds.includes(ExtendedKind.NOSTR_SPECIFICATION) ) { showKinds.push(ExtendedKind.NOSTR_SPECIFICATION) } } if (showKindsVersion < 13) { // NIP-71 addressable normal video (34235): add when user already had regular video kinds enabled. if ( showKinds.includes(ExtendedKind.VIDEO) || showKinds.includes(ExtendedKind.SHORT_VIDEO) ) { if (!showKinds.includes(ExtendedKind.VIDEO_ADDRESSABLE)) { showKinds.push(ExtendedKind.VIDEO_ADDRESSABLE) } } } if (showKindsVersion < 14) { // Kind 34236 (NIP-71 addressable short video) removed from the app — strip from saved filters. const deprecatedShortVideoAddressable = 34236 for (let i = showKinds.length - 1; i >= 0; i--) { if (showKinds[i] === deprecatedShortVideoAddressable) { showKinds.splice(i, 1) } } } if (showKindsVersion < 15) { if ( (showKinds.includes(ExtendedKind.VOICE) || showKinds.includes(ExtendedKind.PICTURE)) && !showKinds.includes(ExtendedKind.MUSIC_TRACK) ) { showKinds.push(ExtendedKind.MUSIC_TRACK) } } // v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent). this.showKinds = showKinds // Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and // keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's // saved filter before initAsync/applySettings runs. this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '15') } // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs) const showRepliesStr = window.localStorage.getItem(StorageKey.SHOW_REPLIES_AND_COMMENTS) const showKind1RepliesStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_REPLIES) const showKind1111Str = window.localStorage.getItem(StorageKey.SHOW_KIND_1111) if (showKind1OPsStr !== null) { this.showKind1OPs = showKind1OPsStr === 'true' } else { this.showKind1OPs = this.showKinds.includes(kinds.ShortTextNote) } if (showKind1RepliesStr !== null) { this.showKind1Replies = showKind1RepliesStr === 'true' } else if (showRepliesStr !== null) { this.showKind1Replies = showRepliesStr === 'true' } else { this.showKind1Replies = this.showKinds.includes(kinds.ShortTextNote) } if (showKind1111Str !== null) { this.showKind1111 = showKind1111Str === 'true' } else if (showRepliesStr !== null) { this.showKind1111 = showRepliesStr === 'true' } else { this.showKind1111 = this.showKinds.includes(ExtendedKind.COMMENT) } const feedKindFilterBypassStr = window.localStorage.getItem(StorageKey.FEED_KIND_FILTER_BYPASS) this.feedKindFilterBypass = feedKindFilterBypassStr === 'true' this.hideContentMentioningMutedUsers = window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true' this.notificationListStyle = window.localStorage.getItem(StorageKey.NOTIFICATION_LIST_STYLE) === NOTIFICATION_LIST_STYLE.COMPACT ? NOTIFICATION_LIST_STYLE.COMPACT : NOTIFICATION_LIST_STYLE.DETAILED const mediaAutoLoadPolicy = window.localStorage.getItem(StorageKey.MEDIA_AUTO_LOAD_POLICY) if ( mediaAutoLoadPolicy && Object.values(MEDIA_AUTO_LOAD_POLICY).includes(mediaAutoLoadPolicy as TMediaAutoLoadPolicy) ) { this.mediaAutoLoadPolicy = mediaAutoLoadPolicy as TMediaAutoLoadPolicy } const shownCreateWalletGuideToastPubkeysStr = window.localStorage.getItem( StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS ) this.shownCreateWalletGuideToastPubkeys = shownCreateWalletGuideToastPubkeysStr ? new Set(JSON.parse(shownCreateWalletGuideToastPubkeysStr)) : new Set() // Initialize expiration settings const defaultExpirationEnabledStr = window.localStorage.getItem(StorageKey.DEFAULT_EXPIRATION_ENABLED) this.defaultExpirationEnabled = defaultExpirationEnabledStr === 'true' const defaultExpirationMonthsStr = window.localStorage.getItem(StorageKey.DEFAULT_EXPIRATION_MONTHS) if (defaultExpirationMonthsStr) { const num = parseInt(defaultExpirationMonthsStr) if (!isNaN(num) && num >= 0 && Number.isInteger(num)) { this.defaultExpirationMonths = num } } const showRssFeedStr = window.localStorage.getItem(StorageKey.SHOW_RSS_FEED) this.showRssFeed = showRssFeedStr === null ? true : showRssFeedStr === 'true' // Default to true const panelModeStr = window.localStorage.getItem(StorageKey.PANE_MODE) this.panelMode = panelModeStr === 'double' ? 'double' : 'single' // Default to 'single' const addRandomRelaysStr = window.localStorage.getItem(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH) this.addRandomRelaysToPublish = addRandomRelaysStr === null ? true : addRandomRelaysStr === 'true' const showPublishSuccessStr = window.localStorage.getItem(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS) this.showPublishSuccessToasts = showPublishSuccessStr === 'true' const showDetailedPublishStr = window.localStorage.getItem(StorageKey.SHOW_DETAILED_PUBLISH_TOASTS) this.showDetailedPublishToasts = showDetailedPublishStr === null ? true : showDetailedPublishStr === 'true' const showLiveActivitiesStr = window.localStorage.getItem(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER) this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false' // Clean up deprecated data window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP) window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID) window.localStorage.removeItem(StorageKey.FEED_TYPE) window.localStorage.removeItem(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS) } /** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */ private persistSetting(key: string, value: string): void { if ((SETTINGS_KEYS as readonly string[]).includes(key)) { void this.persistSettingToIndexedDb(key, value) return } window.localStorage.setItem(key, value) } /** Awaited write to the IndexedDB `settings` store (source of truth for {@link SETTINGS_KEYS}). */ private async persistSettingToIndexedDb(key: string, value: string): Promise { try { const idb = await loadIndexedDb() await idb.setSetting(key, value) } catch { // IndexedDB unavailable; in-memory value still updated for this session } } private initPromise: Promise | null = null /** * Async init: hydrate from IndexedDB when available, otherwise migrate localStorage into IndexedDB. * Call this before app render so settings are read from IndexedDB. * * Merges any {@link SETTINGS_KEYS} still present only in localStorage into the IDB-backed map, applies * them to memory, then writes changed keys back to IndexedDB **before** stripping localStorage. * Otherwise values like pane mode were applied from LS then lost on the next refresh (LS cleared, IDB never had the row). */ async initAsync(): Promise { if (this.initPromise) return this.initPromise this.initPromise = (async () => { const idb = await loadIndexedDb() await idb.init() let idbBefore = await idb.getAllSettings() if (Object.keys(idbBefore).length === 0) { await this.migrateToIdb() idbBefore = await idb.getAllSettings() } const merged = this.mergeSettingsRecordWithLocalStorage(idbBefore) this.applySettings(merged) await this.persistSettingsKeysDiffToIdb(idbBefore, merged) this.clearSettingsFromLocalStorage() })() return this.initPromise } /** Fill gaps from localStorage (used when IDB predates a key or a write only landed in LS). */ private mergeSettingsRecordWithLocalStorage(idb: Record): Record { const out: Record = { ...idb } for (const key of SETTINGS_KEYS) { if (out[key] != null) continue const fromLs = window.localStorage.getItem(key) if (fromLs != null) { out[key] = fromLs } } return out } /** Persist keys that differ from the pre-merge IDB snapshot so the next cold load reads from IDB only. */ private async persistSettingsKeysDiffToIdb( idbBefore: Record, merged: Record ): Promise { const idb = await loadIndexedDb() for (const key of SETTINGS_KEYS) { const v = merged[key] if (v == null) continue if (idbBefore[key] !== v) { await idb.setSetting(key, v).catch(() => {}) } } } /** Remove SETTINGS_KEYS from localStorage so we don't duplicate; source of truth is IndexedDB. */ private clearSettingsFromLocalStorage(): void { for (const key of SETTINGS_KEYS) { window.localStorage.removeItem(key) } } private async migrateToIdb(): Promise { const idb = await loadIndexedDb() for (const key of SETTINGS_KEYS) { const value = window.localStorage.getItem(key) if (value != null) await idb.setSetting(key, value) } } private applySettings(record: Record): void { const get = (k: string) => record[k] ?? window.localStorage.getItem(k) if (get(StorageKey.THEME_SETTING) != null) { this.themeSetting = (get(StorageKey.THEME_SETTING) as TThemeSetting) ?? this.themeSetting } const themeStr = get(StorageKey.THEME) if (themeStr === 'dark' || themeStr === 'light') this.theme = themeStr const addClientTagStr = get(StorageKey.ADD_CLIENT_TAG) if (addClientTagStr != null) this.addClientTag = addClientTagStr === 'true' if (get(StorageKey.FONT_SIZE) != null) { this.fontSize = (get(StorageKey.FONT_SIZE) as TFontSize) ?? this.fontSize } const noteListModeStr = get(StorageKey.NOTE_LIST_MODE) if (noteListModeStr != null && ['posts', 'postsAndReplies', 'media'].includes(noteListModeStr)) { this.noteListMode = noteListModeStr as TNoteListMode } const accountsStr = get(StorageKey.ACCOUNTS) if (accountsStr != null) this.accounts = JSON.parse(accountsStr) as TAccount[] const currentAccountStr = get(StorageKey.CURRENT_ACCOUNT) if (currentAccountStr != null) this.currentAccount = JSON.parse(currentAccountStr) as TAccount | null if ( this.currentAccount != null && !this.accounts.some((a) => isSameAccount(a, this.currentAccount)) ) { this.currentAccount = null this.persistSetting(StorageKey.CURRENT_ACCOUNT, JSON.stringify(null)) } const relaySetsStr = get(StorageKey.RELAY_SETS) if (relaySetsStr != null) this.relaySets = JSON.parse(relaySetsStr) as TRelaySet[] const defaultZapSatsStr = get(StorageKey.DEFAULT_ZAP_SATS) if (defaultZapSatsStr != null) { const num = parseInt(defaultZapSatsStr) if (!isNaN(num)) this.defaultZapSats = num } const defaultZapCommentStr = get(StorageKey.DEFAULT_ZAP_COMMENT) if (defaultZapCommentStr != null) this.defaultZapComment = defaultZapCommentStr const preferredPaytoCategoryStr = get(StorageKey.PREFERRED_PAYTO_CATEGORY) if (preferredPaytoCategoryStr != null) { this.preferredPaytoCategory = preferredPaytoCategoryStr && isPaytoCategory(preferredPaytoCategoryStr) ? preferredPaytoCategoryStr : null } const quickZapStr = get(StorageKey.QUICK_ZAP) if (quickZapStr != null) this.quickZap = quickZapStr === 'true' const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT) if (includeReceiptStr != null) this.includePublicZapReceipt = includeReceiptStr !== 'false' this.autoplay = get(StorageKey.AUTOPLAY) !== 'false' const mediaConfigStr = get(StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP) if (mediaConfigStr != null) this.mediaUploadServiceConfigMap = JSON.parse(mediaConfigStr) as Record this.defaultShowNsfw = get(StorageKey.DEFAULT_SHOW_NSFW) === 'true' this.dismissedTooManyRelaysAlert = get(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT) === 'true' this.showRecommendedRelaysPanel = get(StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL) === 'true' const addRandomRelaysStr = get(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH) if (addRandomRelaysStr != null) this.addRandomRelaysToPublish = addRandomRelaysStr === 'true' const showPublishSuccessStr = get(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS) if (showPublishSuccessStr != null) this.showPublishSuccessToasts = showPublishSuccessStr !== 'false' const showDetailedPublishStr = get(StorageKey.SHOW_DETAILED_PUBLISH_TOASTS) if (showDetailedPublishStr != null) this.showDetailedPublishToasts = showDetailedPublishStr === 'true' const showLiveActivitiesStr = get(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER) if (showLiveActivitiesStr != null) this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false' const showKindsStr = get(StorageKey.SHOW_KINDS) if (showKindsStr != null) this.showKinds = JSON.parse(showKindsStr) as number[] const showKind1OPsStr = get(StorageKey.SHOW_KIND_1_OPs) if (showKind1OPsStr != null) this.showKind1OPs = showKind1OPsStr === 'true' const showKind1RepliesStr = get(StorageKey.SHOW_KIND_1_REPLIES) if (showKind1RepliesStr != null) this.showKind1Replies = showKind1RepliesStr === 'true' const showKind1111Str = get(StorageKey.SHOW_KIND_1111) if (showKind1111Str != null) this.showKind1111 = showKind1111Str === 'true' const feedKindFilterBypassStr = get(StorageKey.FEED_KIND_FILTER_BYPASS) if (feedKindFilterBypassStr != null) this.feedKindFilterBypass = feedKindFilterBypassStr === 'true' this.hideContentMentioningMutedUsers = get(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true' const notifStyle = get(StorageKey.NOTIFICATION_LIST_STYLE) if (notifStyle != null) this.notificationListStyle = notifStyle === NOTIFICATION_LIST_STYLE.COMPACT ? NOTIFICATION_LIST_STYLE.COMPACT : NOTIFICATION_LIST_STYLE.DETAILED const mediaPolicy = get(StorageKey.MEDIA_AUTO_LOAD_POLICY) if (mediaPolicy != null && Object.values(MEDIA_AUTO_LOAD_POLICY).includes(mediaPolicy as TMediaAutoLoadPolicy)) { this.mediaAutoLoadPolicy = mediaPolicy as TMediaAutoLoadPolicy } const shownWalletStr = get(StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS) if (shownWalletStr != null) this.shownCreateWalletGuideToastPubkeys = new Set(JSON.parse(shownWalletStr) as string[]) this.defaultExpirationEnabled = get(StorageKey.DEFAULT_EXPIRATION_ENABLED) === 'true' const defaultExpirationMonthsStr = get(StorageKey.DEFAULT_EXPIRATION_MONTHS) if (defaultExpirationMonthsStr != null) { const num = parseInt(defaultExpirationMonthsStr) if (!isNaN(num) && num >= 0) this.defaultExpirationMonths = num } const showRssStr = get(StorageKey.SHOW_RSS_FEED) if (showRssStr != null) this.showRssFeed = showRssStr === 'true' const archivesApiStr = get(StorageKey.USE_NOSTR_ARCHIVES_API) if (archivesApiStr != null) this.useNostrArchivesApi = archivesApiStr !== 'false' const paneStr = get(StorageKey.PANE_MODE) if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr } getRelaySets() { return this.relaySets } setRelaySets(relaySets: TRelaySet[]) { this.relaySets = relaySets this.persistSetting(StorageKey.RELAY_SETS, JSON.stringify(this.relaySets)) } getThemeSetting() { return this.themeSetting } setThemeSetting(themeSetting: TThemeSetting) { this.persistSetting(StorageKey.THEME_SETTING, themeSetting) this.themeSetting = themeSetting } getTheme(): TTheme { return this.theme } setTheme(theme: TTheme) { this.theme = theme this.persistSetting(StorageKey.THEME, theme) } getAddClientTag(): boolean { return this.addClientTag } setAddClientTag(value: boolean) { this.addClientTag = value this.persistSetting(StorageKey.ADD_CLIENT_TAG, value.toString()) } getFontSize() { return this.fontSize } setFontSize(fontSize: TFontSize) { this.persistSetting(StorageKey.FONT_SIZE, fontSize) this.fontSize = fontSize } getNoteListMode() { return this.noteListMode } setNoteListMode(mode: TNoteListMode) { this.persistSetting(StorageKey.NOTE_LIST_MODE, mode) this.noteListMode = mode } getAccounts() { return this.accounts } findAccount(account: TAccountPointer) { return this.accounts.find((act) => isSameAccount(act, account)) } getCurrentAccount() { return this.currentAccount } getAccountNsec(pubkey: string) { const account = this.accounts.find((act) => act.pubkey === pubkey && act.signerType === 'nsec') return account?.nsec } getAccountNcryptsec(pubkey: string) { const account = this.accounts.find( (act) => act.pubkey === pubkey && act.signerType === 'ncryptsec' ) return account?.ncryptsec } addAccount(account: TAccount) { const index = this.accounts.findIndex((act) => isSameAccount(act, account)) if (index !== -1) { this.accounts[index] = account } else { this.accounts.push(account) } this.persistSetting(StorageKey.ACCOUNTS, JSON.stringify(this.accounts)) return this.accounts } removeAccount(account: TAccount) { this.accounts = this.accounts.filter((act) => !isSameAccount(act, account)) this.persistSetting(StorageKey.ACCOUNTS, JSON.stringify(this.accounts)) if (isSameAccount(this.currentAccount, account)) { this.currentAccount = null this.persistSetting(StorageKey.CURRENT_ACCOUNT, JSON.stringify(null)) } else if ( this.currentAccount != null && !this.accounts.some((a) => isSameAccount(a, this.currentAccount)) ) { this.currentAccount = null this.persistSetting(StorageKey.CURRENT_ACCOUNT, JSON.stringify(null)) } return this.accounts } switchAccount(account: TAccount | null) { if (account === null) { if (this.currentAccount === null) return this.currentAccount = null this.persistSetting(StorageKey.CURRENT_ACCOUNT, JSON.stringify(null)) return } if (isSameAccount(this.currentAccount, account)) { return } const act = this.accounts.find((act) => isSameAccount(act, account)) if (!act) { return } this.currentAccount = act this.persistSetting(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act)) } getDefaultZapSats() { return this.defaultZapSats } setDefaultZapSats(sats: number) { this.defaultZapSats = sats this.persistSetting(StorageKey.DEFAULT_ZAP_SATS, sats.toString()) } getDefaultZapComment() { return this.defaultZapComment } setDefaultZapComment(comment: string) { this.defaultZapComment = comment this.persistSetting(StorageKey.DEFAULT_ZAP_COMMENT, comment) } getPreferredPaytoCategory(): PaytoCategory | null { return this.preferredPaytoCategory } setPreferredPaytoCategory(category: PaytoCategory | null) { this.preferredPaytoCategory = category if (category) { this.persistSetting(StorageKey.PREFERRED_PAYTO_CATEGORY, category) } else { window.localStorage.removeItem(StorageKey.PREFERRED_PAYTO_CATEGORY) void this.persistSettingToIndexedDb(StorageKey.PREFERRED_PAYTO_CATEGORY, '') } } getQuickZap() { return this.quickZap } setQuickZap(quickZap: boolean) { this.quickZap = quickZap this.persistSetting(StorageKey.QUICK_ZAP, quickZap.toString()) } getIncludePublicZapReceipt() { return this.includePublicZapReceipt } setIncludePublicZapReceipt(include: boolean) { this.includePublicZapReceipt = include void this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString()) } /** Persist include-public-zap-receipt to IndexedDB settings (await for callers that need flush). */ async setIncludePublicZapReceiptAsync(include: boolean): Promise { this.includePublicZapReceipt = include await this.persistSettingToIndexedDb(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, include.toString()) } getAutoplay() { return this.autoplay } setAutoplay(autoplay: boolean) { this.autoplay = autoplay this.persistSetting(StorageKey.AUTOPLAY, autoplay.toString()) } getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig { const defaultConfig = { type: 'nip96', service: this.mediaUploadService } as const if (!pubkey) { return defaultConfig } const cfg = this.mediaUploadServiceConfigMap[pubkey] ?? defaultConfig // Legacy `{ type: 'lotus' }` matched `blossom` uploads; migrate to `blossom`. if ((cfg as { type?: string }).type === 'lotus') { const migrated: TMediaUploadServiceConfig = { type: 'blossom' } this.mediaUploadServiceConfigMap[pubkey] = migrated this.persistSetting( StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP, JSON.stringify(this.mediaUploadServiceConfigMap) ) return migrated } return cfg } setMediaUploadServiceConfig( pubkey: string, config: TMediaUploadServiceConfig ): TMediaUploadServiceConfig { this.mediaUploadServiceConfigMap[pubkey] = config this.persistSetting( StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP, JSON.stringify(this.mediaUploadServiceConfigMap) ) return config } getDefaultShowNsfw() { return this.defaultShowNsfw } setDefaultShowNsfw(defaultShowNsfw: boolean) { this.defaultShowNsfw = defaultShowNsfw this.persistSetting(StorageKey.DEFAULT_SHOW_NSFW, defaultShowNsfw.toString()) } getDismissedTooManyRelaysAlert() { return this.dismissedTooManyRelaysAlert } setDismissedTooManyRelaysAlert(dismissed: boolean) { this.dismissedTooManyRelaysAlert = dismissed this.persistSetting(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT, dismissed.toString()) } getShowRecommendedRelaysPanel() { return this.showRecommendedRelaysPanel } setShowRecommendedRelaysPanel(show: boolean) { this.showRecommendedRelaysPanel = show this.persistSetting(StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL, show.toString()) } getAddRandomRelaysToPublish(): boolean { return this.addRandomRelaysToPublish } setAddRandomRelaysToPublish(value: boolean) { this.addRandomRelaysToPublish = value this.persistSetting(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH, value.toString()) } getShowLiveActivitiesBanner(): boolean { return this.showLiveActivitiesBanner } setShowLiveActivitiesBanner(value: boolean) { this.showLiveActivitiesBanner = value this.persistSetting(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER, value ? 'true' : 'false') } getShowKinds() { return this.showKinds } setShowKinds(newKinds: number[]) { this.showKinds = newKinds this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(newKinds)) } getShowKind1OPs(): boolean { return this.showKind1OPs } setShowKind1OPs(value: boolean) { this.showKind1OPs = value this.persistSetting(StorageKey.SHOW_KIND_1_OPs, value.toString()) } getShowKind1Replies(): boolean { return this.showKind1Replies } setShowKind1Replies(value: boolean) { this.showKind1Replies = value this.persistSetting(StorageKey.SHOW_KIND_1_REPLIES, value.toString()) } getShowKind1111(): boolean { return this.showKind1111 } setShowKind1111(value: boolean) { this.showKind1111 = value this.persistSetting(StorageKey.SHOW_KIND_1111, value.toString()) } getFeedKindFilterBypass(): boolean { return this.feedKindFilterBypass } setFeedKindFilterBypass(value: boolean) { this.feedKindFilterBypass = value this.persistSetting(StorageKey.FEED_KIND_FILTER_BYPASS, value.toString()) } getHideContentMentioningMutedUsers() { return this.hideContentMentioningMutedUsers } setHideContentMentioningMutedUsers(hide: boolean) { this.hideContentMentioningMutedUsers = hide this.persistSetting(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS, hide.toString()) } getNotificationListStyle() { return this.notificationListStyle } setNotificationListStyle(style: TNotificationStyle) { this.notificationListStyle = style this.persistSetting(StorageKey.NOTIFICATION_LIST_STYLE, style) } getMediaAutoLoadPolicy() { return this.mediaAutoLoadPolicy } setMediaAutoLoadPolicy(policy: TMediaAutoLoadPolicy) { this.mediaAutoLoadPolicy = policy this.persistSetting(StorageKey.MEDIA_AUTO_LOAD_POLICY, policy) } hasShownCreateWalletGuideToast(pubkey: string) { return this.shownCreateWalletGuideToastPubkeys.has(pubkey) } markCreateWalletGuideToastAsShown(pubkey: string) { if (this.shownCreateWalletGuideToastPubkeys.has(pubkey)) { return } this.shownCreateWalletGuideToastPubkeys.add(pubkey) this.persistSetting( StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS, JSON.stringify(Array.from(this.shownCreateWalletGuideToastPubkeys)) ) } // Expiration settings getDefaultExpirationEnabled() { return this.defaultExpirationEnabled } setDefaultExpirationEnabled(enabled: boolean) { this.defaultExpirationEnabled = enabled this.persistSetting(StorageKey.DEFAULT_EXPIRATION_ENABLED, enabled.toString()) } getDefaultExpirationMonths() { return this.defaultExpirationMonths } setDefaultExpirationMonths(months: number) { if (Number.isInteger(months) && months >= 0) { this.defaultExpirationMonths = months this.persistSetting(StorageKey.DEFAULT_EXPIRATION_MONTHS, months.toString()) } } getShowRssFeed() { return this.showRssFeed } setShowRssFeed(show: boolean) { this.showRssFeed = show this.persistSetting(StorageKey.SHOW_RSS_FEED, show.toString()) } getUseNostrArchivesApi(): boolean { return this.useNostrArchivesApi } setUseNostrArchivesApi(enabled: boolean) { this.useNostrArchivesApi = enabled this.persistSetting(StorageKey.USE_NOSTR_ARCHIVES_API, enabled ? 'true' : 'false') } getShowPublishSuccessToasts(): boolean { return this.showPublishSuccessToasts } setShowPublishSuccessToasts(show: boolean) { this.showPublishSuccessToasts = show this.persistSetting(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS, show.toString()) } getShowDetailedPublishToasts(): boolean { return this.showDetailedPublishToasts } setShowDetailedPublishToasts(show: boolean) { this.showDetailedPublishToasts = show this.persistSetting(StorageKey.SHOW_DETAILED_PUBLISH_TOASTS, show.toString()) } getPanelMode(): 'single' | 'double' { return this.panelMode } setPanelMode(mode: 'single' | 'double') { this.panelMode = mode this.persistSetting(StorageKey.PANE_MODE, mode) } getAccountNetworkHydrateAt(pubkey: string): number | undefined { try { const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP) if (!raw) return undefined const map = JSON.parse(raw) as Record const pk = pubkey.trim().toLowerCase() const v = map[pk] return typeof v === 'number' && Number.isFinite(v) ? v : undefined } catch { return undefined } } setAccountNetworkHydrateAt(pubkey: string, atMs: number): void { try { const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP) const map: Record = raw ? (JSON.parse(raw) as Record) : {} map[pubkey.trim().toLowerCase()] = atMs window.localStorage.setItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP, JSON.stringify(map)) } catch { /* ignore quota / privacy mode */ } } } const instance = new LocalStorageService() export default instance