aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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/features
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/features')
-rw-r--r--src/features/messages.js64
1 files changed, 46 insertions, 18 deletions
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