local-storage.service.ts raw

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