aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 07:24:33 -0700
committerAdam Soltys <[email protected]>2026-10-07 07:24:33 -0700
commit78cf670939133fc4f656f0f42b21a59431a53842 (patch)
tree745ec53290fe37c91fb607e6013a322b887f9388 /src
parent5bb7da55232c7a1debed2a505a85b5e7da5ba6db (diff)
downloadcoinosv3-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.js9
-rw-r--r--src/cache-store.js241
-rw-r--r--src/features/messages.js64
-rw-r--r--src/recipient-search.js14
-rw-r--r--src/wallet.js7
5 files changed, 312 insertions, 23 deletions
diff --git a/src/app.js b/src/app.js
index 8908258..56ab639 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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 {}
}