You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1070 lines
40 KiB

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<typeof importIndexedDbModule> | 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<string, TMediaUploadServiceConfig> = {}
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<string> = 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<void> {
try {
const idb = await loadIndexedDb()
await idb.setSetting(key, value)
} catch {
// IndexedDB unavailable; in-memory value still updated for this session
}
}
private initPromise: Promise<void> | 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<void> {
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<string, string>): Record<string, string> {
const out: Record<string, string> = { ...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<string, string>,
merged: Record<string, string>
): Promise<void> {
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<void> {
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<string, string>): 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<string, TMediaUploadServiceConfig>
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<void> {
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<string, unknown>
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<string, number> = raw ? (JSON.parse(raw) as Record<string, number>) : {}
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