diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 07:24:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 07:24:33 -0700 |
| commit | 78cf670939133fc4f656f0f42b21a59431a53842 (patch) | |
| tree | 745ec53290fe37c91fb607e6013a322b887f9388 /src | |
| parent | 5bb7da55232c7a1debed2a505a85b5e7da5ba6db (diff) | |
| download | coinosv3-78cf670939133fc4f656f0f42b21a59431a53842.tar.gz coinosv3-78cf670939133fc4f656f0f42b21a59431a53842.zip | |
Rebuildable caches move from localStorage to IndexedDB (cache-store.js)
localStorage is ~5M characters per site whatever the device, and a phone
had filled it. Profiles, faceless list, reports, follow packs, profile
pages, relay lists, the moderation baseline, community directory, link
previews, video sizes/probes and search profiles (~3M on that phone) now
live in IndexedDB, which shares the site's disk allowance (GBs).
cacheStorage keeps localStorage's string API: reads from memory,
coalesced write-behind, flushed on hide. Copies migrate on open and are
removed once written. Boot waits for it (max 3s) beside the Ark store;
caches read at construction re-merge on ready; an early write can't
replace the stored copy. A copy an older build writes to localStorage
later moves in on first ask. Wallet wipes reach it; private mode stays on
localStorage. Read markers, drafts, follows, note threads and sync
outboxes stay in localStorage on purpose (cross-tab consistency, early
server-rendered seeding). Also asks navigator.storage.persist().
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 9 | ||||
| -rw-r--r-- | src/cache-store.js | 241 | ||||
| -rw-r--r-- | src/features/messages.js | 64 | ||||
| -rw-r--r-- | src/recipient-search.js | 14 | ||||
| -rw-r--r-- | src/wallet.js | 7 |
5 files changed, 312 insertions, 23 deletions
@@ -17,6 +17,7 @@ import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; +import { cacheReady, cacheStore } from './cache-store.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat, fmtFiatBare } from './rates.js'; import { @@ -2372,6 +2373,7 @@ function wipeAccountCache(acc) { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && bases.some((b) => k.startsWith(b))) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } catch {} + for (const b of bases) { try { cacheStore().removePrefix(b); } catch {} } for (const f of FEATURES) { try { f.wipeCache && f.wipeCache(bases); } catch {} } } @@ -6172,7 +6174,12 @@ loadLocale(getLang()).finally(async () => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000); const storeReady = featureHook('arkStoreReady'); - if (storeReady) await storeReady; + // the IndexedDB caches (profiles, reports, …) open alongside: a wallet's + // caches are read as soon as it opens + await Promise.all([storeReady, cacheReady()].filter(Boolean)).catch(() => {}); + // Ask the browser not to evict this site's storage under disk pressure. + // Chrome answers without a prompt (yes for an installed or busy site). + try { navigator.storage?.persist?.().catch(() => {}); } catch {} _bootDeciding = false; // from here on the screen is a decision, not a guess const opened = restoreAccountsState(); if (!opened) render(); diff --git a/src/cache-store.js b/src/cache-store.js new file mode 100644 index 0000000..dcfbad7 --- /dev/null +++ b/src/cache-store.js @@ -0,0 +1,241 @@ +// Disposable caches (profiles, reports, previews, …) in IndexedDB instead of +// localStorage, behind the same getItem/setItem/removeItem shape. +// +// Why: localStorage is about five million characters per site, whatever the +// device, and a phone that follows a few hundred people filled it — every +// cache then had to be squeezed (a 150-row profile cache churned through in +// twenty minutes, and a reload painted punks and npubs). IndexedDB shares the +// site's disk allowance instead: gigabytes. +// +// The values stay the strings localStorage held, so callers keep their +// JSON.parse/stringify and nothing about a cache's shape changes. Reads are +// synchronous from memory; writes are coalesced and land in IndexedDB in the +// background, flushed at once when the page hides. +// +// Before the store has opened (a few ms into boot) reads fall back to +// localStorage and writes go there only if the key still lives there — a +// write built from an early, empty read must never replace the full copy in +// IndexedDB. Boot waits for `cacheReady()` before restoring the wallet, so +// wallet-scoped caches never see that window; caches read at construction +// re-merge from `onCacheReady`. localStorage copies migrate on open (they're +// newer: written by this device before the move, or by an older build still +// open in another tab) and are removed once the IndexedDB write completed. +// If IndexedDB is unavailable everything stays in localStorage, as before. + +const DB = 'coinos-cache'; +const STORE = 'kv'; +const DEBOUNCE_MS = 1000; +const OPEN_WAIT_MS = 3000; + +// What lives here: rebuildable caches only. State a user would miss (read +// markers, drafts, the follow list, sync outboxes) stays in localStorage, +// where every tab sees every other tab's writes at once. +const WALLET_CACHES = ['profiles', 'profilesFaceless', 'reports', 'followPacks', 'profPages', 'relayLists']; +const WALLET_RE = new RegExp('^btc-wallet-cache:[0-9a-f]+:(' + WALLET_CACHES.join('|') + ')$'); +const GLOBAL_KEYS = new Set([ + // (btc-wallet-note-threads stays: a server-rendered note page seeds it + // before boot has waited for anything) + 'coinos-mod-baseline', 'coinos-community-directory-v1', + 'coinos-link-previews', 'coinos-video-info', 'coinos-video-dims', 'btc-wallet-search-profiles', +]); +export const isCacheKey = (k) => typeof k === 'string' && (GLOBAL_KEYS.has(k) || WALLET_RE.test(k)); + +function idbBackend() { + if (typeof indexedDB === 'undefined') return null; + let dbp = null; + const open = () => dbp || (dbp = new Promise((resolve, reject) => { + const req = indexedDB.open(DB, 1); + req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE); }; + req.onsuccess = () => { + const db = req.result; + db.onversionchange = () => { try { db.close(); } catch {} dbp = null; }; + db.onclose = () => { dbp = null; }; + resolve(db); + }; + req.onerror = () => { dbp = null; reject(req.error); }; + req.onblocked = () => { dbp = null; reject(new Error('IndexedDB blocked')); }; + })); + return { + async list() { + const db = await open(); + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, 'readonly'); + const s = t.objectStore(STORE); + const keys = s.getAllKeys(), vals = s.getAll(); + t.oncomplete = () => resolve(keys.result.map((k, i) => [k, vals.result[i]])); + t.onerror = () => reject(t.error); + }); + }, + async write(puts, dels) { + if (!puts.length && !dels.length) return; + const db = await open(); + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, 'readwrite'); + const s = t.objectStore(STORE); + for (const [k, v] of puts) s.put(v, k); + for (const k of dels) s.delete(k); + t.oncomplete = () => resolve(); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error || new Error('aborted')); + }); + }, + }; +} + +export class CacheStore { + constructor({ backend = idbBackend(), ls = () => globalThis.localStorage, listen = true } = {}) { + this.backend = backend; + this.ls = ls; + this.mem = new Map(); + this.dirty = new Set(); + this.ready = !backend; + this.timer = 0; + this.opening = null; + this._flushing = null; + this._onReady = []; + if (listen && typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this.flush(); }); + addEventListener('pagehide', () => this.flush()); + } + } + get lsMode() { return !this.backend; } + _ls() { try { return this.ls(); } catch { return null; } } + + open() { + if (this.ready) return Promise.resolve(); + if (this.opening) return this.opening; + this.opening = (async () => { + let rows; + try { rows = await this.backend.list(); } catch { + this.backend = null; // IndexedDB refused (private mode, quota): stay on localStorage + this._becomeReady(); + return; + } + for (const [k, v] of rows) if (typeof v === 'string') this.mem.set(k, v); + const migrated = []; + const ls = this._ls(); + if (ls) { + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (!isCacheKey(k)) continue; + const v = ls.getItem(k); + if (v != null) { this.mem.set(k, v); this.dirty.add(k); } + migrated.push(k); + } + } + this._becomeReady(); + if (migrated.length) { + this.flush().then(() => { + for (const k of migrated) { try { ls.removeItem(k); } catch {} } + }).catch(() => {}); + } + })(); + return this.opening; + } + _becomeReady() { + this.ready = true; + for (const fn of this._onReady.splice(0)) { try { fn(); } catch (e) { console.warn('cache ready hook:', e); } } + } + // Runs once the store has opened (at once if it already has). + onReady(fn) { if (this.ready) { try { fn(); } catch {} } else this._onReady.push(fn); } + + getItem(key) { + if (this.ready && !this.lsMode && isCacheKey(key)) { + if (this.mem.has(key)) return this.mem.get(key); + // a copy that reached localStorage after we opened (an older build in + // another tab) moves in on first ask + let v = null; + try { v = this._ls().getItem(key); } catch {} + if (v != null) this._adopt(key, v); + return v; + } + try { return this._ls().getItem(key); } catch { return null; } + } + _adopt(key, v) { + this.mem.set(key, v); + this.dirty.add(key); + this._schedule(); + const ls = this._ls(); + Promise.resolve(this._flushing || this.flush()).then(() => this.flush()) + .then(() => { try { if (ls.getItem(key) === v) ls.removeItem(key); } catch {} }).catch(() => {}); + } + setItem(key, value) { + value = String(value); + if (!isCacheKey(key) || this.lsMode) { this._ls()?.setItem(key, value); return; } + if (!this.ready) { + // early: only a key that hasn't migrated yet may be written (see above) + const ls = this._ls(); + try { if (ls && ls.getItem(key) != null) ls.setItem(key, value); } catch {} + return; + } + if (this.mem.get(key) === value) return; + this.mem.set(key, value); + this.dirty.add(key); + this._schedule(); + } + removeItem(key) { + try { this._ls()?.removeItem(key); } catch {} + if (!isCacheKey(key) || this.lsMode || !this.ready) return; + if (!this.mem.has(key)) return; + this.mem.delete(key); + this.dirty.add(key); + this._schedule(); + } + // Every cache key held, wherever it lives right now. + keys() { + const out = new Set(this.ready && !this.lsMode ? this.mem.keys() : []); + const ls = this._ls(); + try { for (let i = 0; i < ls.length; i++) { const k = ls.key(i); if (isCacheKey(k)) out.add(k); } } catch {} + return [...out]; + } + // Wallet wipe: every cache under the wallet's key prefix. + removePrefix(prefix) { + for (const k of [...this.mem.keys()]) if (k.startsWith(prefix)) this.removeItem(k); + } + + _schedule() { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { this.flush(); return; } + if (this.timer) return; + this.timer = setTimeout(() => { this.timer = 0; this.flush(); }, DEBOUNCE_MS); + } + flush() { + if (this.timer) { clearTimeout(this.timer); this.timer = 0; } + if (!this.ready || this.lsMode || !this.dirty.size) return this._flushing || Promise.resolve(); + if (this._flushing) return this._flushing.then(() => this.flush()); + const keys = [...this.dirty]; this.dirty.clear(); + const puts = [], dels = []; + for (const k of keys) { if (this.mem.has(k)) puts.push([k, this.mem.get(k)]); else dels.push(k); } + this._flushing = this.backend.write(puts, dels) + .catch(() => { for (const k of keys) if (!this.dirty.has(k)) this.dirty.add(k); }) + .finally(() => { this._flushing = null; }); + return this._flushing; + } +} + +let shared = null; +export function cacheStore() { + if (!shared) { + shared = new CacheStore(); + shared.open().catch(() => {}); + } + return shared; +} +// A Storage-shaped handle for code that used localStorage directly. +export const cacheStorage = { + getItem: (k) => cacheStore().getItem(k), + setItem: (k, v) => cacheStore().setItem(k, v), + removeItem: (k) => cacheStore().removeItem(k), + keys: () => cacheStore().keys(), + flush: () => cacheStore().flush(), +}; +export const onCacheReady = (fn) => cacheStore().onReady(fn); +// for tests and the console: what a cache holds, wherever it lives +try { globalThis.coinosCache = cacheStorage; } catch {} +// Boot waits for this, but never long: a wedged IndexedDB must not hold the +// wallet closed — the caches just start out empty. +export function cacheReady() { + const s = cacheStore(); + if (s.ready) return Promise.resolve(); + return Promise.race([s.open(), new Promise((r) => setTimeout(r, OPEN_WAIT_MS))]); +} +export const cacheIsReady = () => cacheStore().ready; diff --git a/src/features/messages.js b/src/features/messages.js index 6f34d48..f98df3a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -44,6 +44,7 @@ import { t, getLang } from '../i18n.js'; import { inLanguage, isMachinePost } from '../lang-guess.js'; import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; +import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js'; import { dlog } from '../debug.js'; import { qrSvg } from '../qr.js'; @@ -114,6 +115,19 @@ export function messagesFeature(ctx) { const communities = () => [COMMUNITY, ...st().communities]; + // A [key, value] list cache folded into its Map. What this session already + // learned stays (and stays newest); stored rows fill in around it — the + // same call serves the first read and the re-read once IndexedDB opens. + function mergeStored(map, key) { + let rows; + try { rows = JSON.parse(cacheStorage.getItem(key) || '[]'); } catch { return; } + if (!Array.isArray(rows) || !rows.length) return; + const mine = [...map]; + map.clear(); + for (const [k, v] of rows) map.set(k, v); + for (const [k, v] of mine) { map.delete(k); map.set(k, v); } + } + // ---- custom emoji (NIP-30 tags, NIP-51 emoji sets) ---------------------- // A message that says :pika_wave: carries an ["emoji", code, url] tag for // it, so RENDERING never needs anything held locally. Two sources feed @@ -1954,13 +1968,16 @@ export function messagesFeature(ctx) { .sort((a, b) => b.members - a.members); } const hub = { list: normalizeHub(COMMUNITY_DIRECTORY), at: 0, attempted: false, loading: false }; - try { - const cached = JSON.parse(localStorage.getItem(HUB_CACHE_KEY) || 'null'); - if (cached && Array.isArray(cached.list) && Number.isFinite(cached.at)) { - const list = normalizeHub(cached.list); - if (list.length || !cached.list.length) { hub.list = list; hub.at = cached.at; } - } - } catch {} + function loadHubCache() { + try { + const cached = JSON.parse(cacheStorage.getItem(HUB_CACHE_KEY) || 'null'); + if (cached && Array.isArray(cached.list) && Number.isFinite(cached.at) && cached.at > hub.at) { + const list = normalizeHub(cached.list); + if (list.length || !cached.list.length) { hub.list = list; hub.at = cached.at; } + } + } catch {} + } + loadHubCache(); async function loadHub() { if (hub.loading || hub.attempted || (hub.at > 0 && Date.now() - hub.at < HUB_REFRESH_MS)) return; hub.attempted = true; hub.loading = true; @@ -1976,7 +1993,7 @@ export function messagesFeature(ctx) { // Bound icon sizes so public suggestions do not crowd out wallet data. const cached = list.map((c) => ({ name: c.name, description: c.description, category: c.category, invite_url: c.invite, member_count: c.members, icon_url: c.icon })); - localStorage.setItem(HUB_CACHE_KEY, JSON.stringify({ at: hub.at, list: cached })); + cacheStorage.setItem(HUB_CACHE_KEY, JSON.stringify({ at: hub.at, list: cached })); } catch {} scheduleRepaint(); } catch { /* Keep the usable directory if refresh fails or we're offline. */ } @@ -5681,7 +5698,8 @@ export function messagesFeature(ctx) { const VIDEO_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims'; const videoDims = new Map(); // url -> { width, height } const videoUnknown = new Set(); // probed this session and no answer: paint 16:9, don't ask again - try { for (const [u, d] of JSON.parse(localStorage.getItem(VIDEO_DIMS_KEY) || '[]')) videoDims.set(u, d); } catch {} + const loadVideoDims = () => mergeStored(videoDims, VIDEO_DIMS_KEY); + loadVideoDims(); let videoDimsFlush = null; const rememberVideo = (url, width, height) => { if (!(width > 0 && height > 0)) return; @@ -5692,7 +5710,7 @@ export function messagesFeature(ctx) { if (videoDimsFlush) return; videoDimsFlush = setTimeout(() => { videoDimsFlush = null; - try { localStorage.setItem(VIDEO_DIMS_KEY, JSON.stringify([...videoDims].slice(-VIDEO_DIMS_KEEP))); } catch {} + try { cacheStorage.setItem(VIDEO_DIMS_KEY, JSON.stringify([...videoDims].slice(-VIDEO_DIMS_KEEP))); } catch {} }, 1000); }; const imetaDim = (ev, url) => { @@ -5714,13 +5732,14 @@ export function messagesFeature(ctx) { const VIDEO_INFO_KEY = 'coinos-video-info', VIDEO_INFO_KEEP = 2000, VIDEO_PROBE_BYTES = 65536; const VIDEO_LARGE_KEY = 'coinos-video-large'; const videoInfo = new Map(); // url -> { size, w, h, image, faststart, ranges, moovEnd, probed, failedAt } - try { for (const [u, d] of JSON.parse(localStorage.getItem(VIDEO_INFO_KEY) || '[]')) videoInfo.set(u, d); } catch {} + const loadVideoInfo = () => mergeStored(videoInfo, VIDEO_INFO_KEY); + loadVideoInfo(); let videoInfoFlush = null; const saveVideoInfo = () => { if (videoInfoFlush) return; videoInfoFlush = setTimeout(() => { videoInfoFlush = null; - try { localStorage.setItem(VIDEO_INFO_KEY, JSON.stringify([...videoInfo].slice(-VIDEO_INFO_KEEP))); } catch {} + try { cacheStorage.setItem(VIDEO_INFO_KEY, JSON.stringify([...videoInfo].slice(-VIDEO_INFO_KEEP))); } catch {} }, 1000); }; const noteVideoInfo = (url, d) => { videoInfo.set(url, { ...(videoInfo.get(url) || {}), ...d }); saveVideoInfo(); }; @@ -5913,16 +5932,15 @@ export function messagesFeature(ctx) { const PREVIEW_KEEP = 400; const previews = new Map(); // url -> { title, description, image, site } | null (nothing there) const previewWarming = new Map(); - try { - for (const [u, m] of JSON.parse(localStorage.getItem('coinos-link-previews') || '[]')) previews.set(u, m); - } catch {} + const loadPreviews = () => mergeStored(previews, 'coinos-link-previews'); + loadPreviews(); let previewFlush = null; const savePreviews = () => { if (previewFlush) return; previewFlush = setTimeout(() => { previewFlush = null; const rows = [...previews].filter(([, m]) => m).slice(-PREVIEW_KEEP); - try { localStorage.setItem('coinos-link-previews', JSON.stringify(rows)); } catch {} + try { cacheStorage.setItem('coinos-link-previews', JSON.stringify(rows)); } catch {} }, 1000); }; const previewable = (url) => /^https?:\/\//i.test(url) && !youtubeId(url) @@ -8022,7 +8040,7 @@ export function messagesFeature(ctx) { function baselineNow() { if (!baseline) { let b = null; - try { b = JSON.parse(localStorage.getItem(BASELINE_KEY) || 'null'); } catch {} + try { b = JSON.parse(cacheStorage.getItem(BASELINE_KEY) || 'null'); } catch {} const toMap = (o) => new Map(Object.entries(o || {}).map(([k, v]) => [k, new Set(v)])); baseline = { follows: new Set((b && b.follows) || []), mutes: withSets({ tags: (b && b.mutes) || [], priv: [], content: '', at: 0 }), ev: toMap(b && b.ev), pk: toMap(b && b.pk), mine: new Set(), at: (b && b.at) || 0 }; @@ -8057,7 +8075,7 @@ export function messagesFeature(ctx) { for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) indexReport(r, ev, false); baseline = { follows: new Set(follows), mutes: withSets({ tags: muteTags, priv: [], content: '', at: k10 ? k10.created_at : 0 }), ev: r.ev, pk: r.pk, mine: new Set(), at: Date.now() }; const toObj = (m) => Object.fromEntries([...m.entries()].slice(-1500).map(([k, v]) => [k, [...v].slice(0, 50)])); - try { localStorage.setItem(BASELINE_KEY, JSON.stringify({ follows, mutes: muteTags, ev: toObj(r.ev), pk: toObj(r.pk), at: baseline.at })); } catch {} + try { cacheStorage.setItem(BASELINE_KEY, JSON.stringify({ follows, mutes: muteTags, ev: toObj(r.ev), pk: toObj(r.pk), at: baseline.at })); } catch {} scheduleRepaint(); } catch {} finally { baselineSync = null; } })(); @@ -12699,6 +12717,16 @@ export function messagesFeature(ctx) { return homeView(); } + // The caches above live in IndexedDB (cache-store.js), which opens a few + // milliseconds into boot. Anything read before then came from what was + // left in localStorage — nothing, once migrated — so re-read it all. + if (!cacheIsReady()) onCacheReady(() => { + profBlob = null; facelessBlob = null; relayLists = null; packMembers = null; reportsCache = null; baseline = null; + profilesWarmed = false; warmProfiles(); + loadHubCache(); loadVideoDims(); loadVideoInfo(); loadPreviews(); + scheduleRepaint(); + }); + return { id: 'messages', // group-chat state is device state in IndexedDB: it goes with the account diff --git a/src/recipient-search.js b/src/recipient-search.js index fdeaeaf..5909078 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -10,6 +10,7 @@ import { queryOn, parseNostrPubkey, npubOf, PROFILE_RELAYS } from './nostr.js'; import { assetUrl } from './asset-url.js'; +import { cacheStorage, onCacheReady } from './cache-store.js'; // coinos punks: the deterministic default avatar — same formula the coinos // app uses (last byte of the pubkey picks one of 64). Initials show through @@ -118,8 +119,17 @@ const QUERY_TTL = 12 * 3600_000; const profileCache = new Map(); // pk -> { name, picture } const queryCache = new Map(); // q -> rows const queryAt = new Map(); // q -> when the rows were fetched +// the profile half lives in IndexedDB (cache-store.js): read again once it +// opens, keeping whatever this session already learned +const loadSearchProfiles = () => { + try { + for (const [pk, v] of Object.entries(JSON.parse(cacheStorage.getItem(PROF_KEY) || '{}'))) + if (!profileCache.has(pk)) profileCache.set(pk, v); + } catch {} +}; +loadSearchProfiles(); +onCacheReady(loadSearchProfiles); try { - for (const [pk, v] of Object.entries(JSON.parse(localStorage.getItem(PROF_KEY) || '{}'))) profileCache.set(pk, v); const now = Date.now(); for (const [q, e] of Object.entries(JSON.parse(localStorage.getItem(QUERY_KEY) || '{}'))) if (e && Array.isArray(e.rows) && now - (e.t || 0) < QUERY_TTL) { queryCache.set(q, e.rows); queryAt.set(q, e.t); } @@ -131,7 +141,7 @@ function persistCaches() { try { const prof = {}; for (const [pk, v] of [...profileCache].slice(-200)) prof[pk] = { name: v.name || null, picture: v.picture || null }; - localStorage.setItem(PROF_KEY, JSON.stringify(prof)); + cacheStorage.setItem(PROF_KEY, JSON.stringify(prof)); const qs = {}; for (const [q, rows] of [...queryCache].slice(-60)) qs[q] = { t: queryAt.get(q) || Date.now(), rows }; localStorage.setItem(QUERY_KEY, JSON.stringify(qs)); diff --git a/src/wallet.js b/src/wallet.js index 8b88477..bdcb451 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,6 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates } from './api.js'; import { ElectrumApi } from './electrum.js'; +import { cacheStorage } from './cache-store.js'; import { schnorr } from '@noble/curves/secp256k1'; // Resolve a same-origin '/path' WebSocket URL to an absolute ws(s):// URL against @@ -1226,11 +1227,13 @@ export class Wallet { } // Namespaced per-wallet persisted state for features (swaps, ark, ...). featureStateKey(name) { return this._cacheKey() + ':' + name; } + // Rebuildable caches (profiles, reports, …) live in IndexedDB through + // cacheStorage; everything else stays in localStorage. See cache-store.js. loadFeatureState(name, fallback = null) { - try { const v = JSON.parse(localStorage.getItem(this.featureStateKey(name)) || 'null'); return v == null ? fallback : v; } catch { return fallback; } + try { const v = JSON.parse(cacheStorage.getItem(this.featureStateKey(name)) || 'null'); return v == null ? fallback : v; } catch { return fallback; } } saveFeatureState(name, value) { - try { localStorage.setItem(this.featureStateKey(name), JSON.stringify(value)); } catch {} + try { cacheStorage.setItem(this.featureStateKey(name), JSON.stringify(value)); } catch {} } |
