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