web: local message store — instant reloads, boot scene becomes first-run-only (#457 phase 1) (#776)
* web: local message store — instant reloads, boot scene becomes first-run-only (#457 phase 1) New store.js: an IndexedDB cache (DB lchat-store) of the RAW server responses — ciphertext message rows and wrapped keys, never plaintext — so its at-rest posture equals the server's own. Boot hydrates through the SAME apply pipeline as the network path (loaders split into fetch + apply halves), paints instantly, then the real fetches reconcile: folds dedupe by id, edits refold via edited_at, deletes replay via tombstones (#581/#583). - boot(): hydrateFromCache() first; startBootScene() only when there is no snapshot (first run); offline network failure over a cached paint keeps the app readable instead of blanking it - thread opens fold cached rows first (instant switches), then catchUpActiveMessages() reconciles; all message fetch sites write through - live msg/edit/delete frames mirror into the store; a delete drops the local ciphertext immediately - history edit folds now carry the post-edit text (load-bearing over a pre-seeded store; noted on the edit event vector) - invalidation: sign-out wipes the store; leaving a group clears its rows; loadThreads prunes rows for threads no longer on the live list (guarded so an error body can never wipe the cache) - navigator.storage.persist() requested; snapshot format versioned - pure decision helpers Node-tested (test/store.test.mjs, in npm test) Web-only by design: the native apps already have their own MessageStore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: review fixes — no double warm-boot catch-up, consistent write-through gate, plainer tx idiom, owner-bound snapshot, seed-then-edit test - boot's painted branch no longer fires a second catchUpActiveMessages (the cached open already reconciles; its network path IS a fresh fetch) - catchUpActiveMessages writes through only for chat-feed threads (matching the open path's gate — app-thread rows would be dead weight) - store.js tx() takes fn(t, done) instead of the __req box sentinel - snapshot carries owner (session.userPub): a claim flow can replace lchat_session without a sign-out; a mismatched cache wipes, never hydrates - event-vectors test pins the seed-then-edit sequence that makes the history chat.edit's text load-bearing over a pre-seeded store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: re-mount an app-thread pane after late app registration; gate live-frame writes Review finding: on a warm reload whose last-open thread is a member-built or admin custom app, hydrate mounts the pane before those apps register (their code loads from the network after the cached paint), so renderMiniApp no-ops over a cleared pane and it stayed blank until manual navigation. The painted boot branch now re-mounts the active pane when it's empty once the registry is complete — which also heals a chat pane whose offline-at-boot open failed. Also gates the live msg frame's store write on chatAppFor, matching every other write site. Reviewer-verified safe (no code change needed): reaction re-folds over cached rows are idempotent (reactionEvents keyed by emoji+uid; reactions re-derived per fold), and snapshot:true app-threads can't double-apply cached events (seedAppState's seededUpTo horizon + foldedIds dedupe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+578
−18
CLAUDE.md+5 −0changelog/2026-07-12-instant-reloads.md+6 −0package.json+1 −1test/event-vectors.test.mjs+22 −0test/event-vectors/chat.json+2 −1test/store.test.mjs+76 −0web/static/shell.js+229 −16web/static/store.js+237 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 86c5611..4a7fc81 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -172,6 +172,11 @@ Frontend module layout: |
| retiring the original chat). |
| - `format.js`, `ui.js` — **pure, framework-free helpers kept separate so they're |
| unit-testable in Node** (`tokenizeText`, `formatBytes`, `shouldSend`). |
| +- `store.js` — the local message store (#457): IndexedDB cache of RAW server |
| + responses (ciphertext rows + wrapped keys, never plaintext) that boot and |
| + thread-opens hydrate from before reconciling over the network. Its pure |
| + decision helpers are Node-tested (`test/store.test.mjs`); the DB is |
| + `lchat-store`, deliberately separate from the SW's version-pinned `lchat` DB. |
| - `apps.js` + `apps/*.js` — the mini-app framework and the apps themselves (one |
| self-contained file each). See `web/static/apps/README.md`. |
| - `push.js` / `sw.js` — push subscription + service worker. |
diff --git a/changelog/2026-07-12-instant-reloads.md b/changelog/2026-07-12-instant-reloads.md| new file mode 100644 |
| index 0000000..6f9ffe9 |
| --- /dev/null |
| +++ b/changelog/2026-07-12-instant-reloads.md |
| @@ -0,0 +1,6 @@ |
| +🤖 The app now remembers your chats on your device, so reloading opens |
| +instantly — no more waiting for everything to re-download, and recently |
| +visited chats open on the spot even with a shaky connection. What's kept |
| +locally is exactly as locked as what the server holds: your messages stay |
| +sealed, and they're cleared if you sign out or leave a group. The loading |
| +scene now only greets you on your very first visit. |
diff --git a/package.json b/package.json| index f9980c7..1bb551c 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index b966e81..85eacf0 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -290,6 +290,28 @@ allVectors.chat.forEach((vector) => { |
| }); |
| } |
| + if (vector.name === "edit") { |
| + test("chat.edit over a pre-seeded store: history fold's text overwrites stale cached text (#457)", () => { |
| + // The offline-edit path over the local message store: the cache seeded |
| + // state with the ORIGINAL text; the reconcile refetch folds the edited |
| + // row — its chat.msg dedupes against the stale bubble (same sid), so |
| + // the synthetic chat.edit MUST carry the post-edit text or the stale |
| + // text survives forever. This is why shell.js's history fold includes |
| + // `text` — do not drop it as redundant (see the vector's note). |
| + const sid = vector.server_message.id; |
| + // 1. cached paint: the pre-edit payload folds in |
| + let state = testChatMsgFold({ msgs: [] }, { ...vector.original_payload }, { id: sid, ts: vector.original_payload.ts }); |
| + assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "cache seeded the original text"); |
| + // 2. reconcile: the refetched (edited) row's chat.msg is a dedupe no-op… |
| + state = testChatMsgFold(state, { ...vector.edited_payload }, { id: sid, ts: vector.edited_payload.ts }); |
| + assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "chat.msg dedupes — stale text still shown"); |
| + // …so only the history chat.edit (WITH text) can heal the bubble. |
| + state = testChatEditFold(state, { type: "chat.edit", msgId: sid, text: vector.edited_payload.text }); |
| + assert.strictEqual(state.msgs[0].text, vector.expected_fold_event.text, "edit fold healed the stale text"); |
| + assert.strictEqual(state.msgs[0].edited, true, "marked edited"); |
| + }); |
| + } |
| + |
| if (vector.expected_fold_event?.type === "chat.delete") { |
| test(`chat.delete: ${vector.name} — removes message from state`, () => { |
| const msgId = vector.expected_fold_event.msgId; |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index ceda38d..58aa825 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -352,7 +352,8 @@ |
| "type": "chat.edit", |
| "msgId": 12350, |
| "text": "no typo here" |
| - } |
| + }, |
| + "note": "History/catch-up folds of an edited row (edited_at set) emit this SAME event, text included (#457): over a pre-seeded local store the chat.msg fold dedupes against the cached text, so this event is what carries an edit made while the device was away." |
| }, |
| { |
| "name": "delete", |
diff --git a/test/store.test.mjs b/test/store.test.mjs| new file mode 100644 |
| index 0000000..c1591fd |
| --- /dev/null |
| +++ b/test/store.test.mjs |
| @@ -0,0 +1,76 @@ |
| +// Pure-logic tests for the local message store (web/static/store.js, #457). |
| +// The IndexedDB plumbing can't run in Node; these pin the decision logic the |
| +// plumbing and shell.js rely on: when a snapshot is trusted, how tombstones in |
| +// a fetched page translate to store writes, and which cached threads must be |
| +// pruned against a live thread list. |
| +import assert from "assert"; |
| +import { SNAP_VERSION, snapshotUsable, partitionRows, staleThreadIds } from "../web/static/store.js"; |
| + |
| +function test(name, fn) { |
| + try { |
| + fn(); |
| + console.log(`PASS ${name}`); |
| + } catch (e) { |
| + console.error(`FAIL ${name}`); |
| + console.error(e); |
| + process.exit(1); |
| + } |
| +} |
| + |
| +const goodSnap = () => ({ |
| + v: SNAP_VERSION, |
| + ts: 1, |
| + threads: { threads: [{ id: "t1" }] }, |
| + roster: { roster: [{ user_id: "u1" }] }, |
| +}); |
| + |
| +test("snapshotUsable accepts a well-formed snapshot (optional parts absent)", () => { |
| + assert.strictEqual(snapshotUsable(goodSnap()), true); |
| +}); |
| + |
| +test("snapshotUsable rejects null, wrong version, and missing core responses", () => { |
| + assert.strictEqual(snapshotUsable(null), false); |
| + assert.strictEqual(snapshotUsable({}), false); |
| + assert.strictEqual(snapshotUsable({ ...goodSnap(), v: SNAP_VERSION + 1 }), false, "future format must not hydrate"); |
| + assert.strictEqual(snapshotUsable({ ...goodSnap(), threads: null }), false); |
| + assert.strictEqual(snapshotUsable({ ...goodSnap(), threads: { threads: "nope" } }), false); |
| + assert.strictEqual(snapshotUsable({ ...goodSnap(), roster: {} }), false); |
| +}); |
| + |
| +test("snapshotUsable accepts empty-but-present lists (a brand-new space)", () => { |
| + assert.strictEqual(snapshotUsable({ ...goodSnap(), threads: { threads: [] }, roster: { roster: [] } }), true); |
| +}); |
| + |
| +test("partitionRows keeps every row and lists tombstoned targets to drop", () => { |
| + const rows = [ |
| + { id: 10, blob: "aaa" }, |
| + { id: 11, blob: "", tombstone_of: 7 }, |
| + { id: 12, blob: "bbb" }, |
| + ]; |
| + const { puts, unsend } = partitionRows(rows); |
| + assert.strictEqual(puts.length, 3, "tombstone rows are stored too — they keep the delete durable locally"); |
| + assert.deepStrictEqual(unsend, [7], "the unsent message's ciphertext must drop"); |
| +}); |
| + |
| +test("partitionRows tolerates junk: null rows, id-less rows, empty input", () => { |
| + assert.deepStrictEqual(partitionRows(null), { puts: [], unsend: [] }); |
| + const { puts, unsend } = partitionRows([null, {}, { id: 0 }, { id: 5, blob: "x" }]); |
| + assert.strictEqual(puts.length, 1); |
| + assert.deepStrictEqual(unsend, []); |
| +}); |
| + |
| +test("staleThreadIds prunes cached threads missing from the live list", () => { |
| + const live = { threads: [{ id: "a" }, { id: "b" }] }; |
| + assert.deepStrictEqual(staleThreadIds(["a", "b", "gone"], live), ["gone"]); |
| + assert.deepStrictEqual(staleThreadIds(["a", "b"], live), []); |
| +}); |
| + |
| +test("staleThreadIds never prunes on a malformed live list (an error body must not wipe the cache)", () => { |
| + assert.deepStrictEqual(staleThreadIds(["a", "b"], null), []); |
| + assert.deepStrictEqual(staleThreadIds(["a", "b"], {}), []); |
| + assert.deepStrictEqual(staleThreadIds(["a", "b"], { error: "boom" }), []); |
| + // An EMPTY live list is well-formed — a member with no threads keeps no rows. |
| + assert.deepStrictEqual(staleThreadIds(["a"], { threads: [] }), ["a"]); |
| +}); |
| + |
| +console.log("\nALL PASSED"); |
diff --git a/web/static/shell.js b/web/static/shell.js| index bd56552..2275444 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -144,6 +144,23 @@ import { |
| isSubscribed, |
| idbSet, |
| } from "/static/push.js"; |
| +// The local message store (#457): raw server responses cached in IndexedDB so |
| +// reloads and thread switches paint instantly (and, later, search works). See |
| +// store.js's header for the trust-boundary rationale (ciphertext only). |
| +import { |
| + SNAP_VERSION, |
| + snapshotUsable, |
| + staleThreadIds, |
| + getSnapshot as storeGetSnapshot, |
| + putSnapshot as storePutSnapshot, |
| + getRecentMessages as storeRecentMessages, |
| + putMessages as storePutMessages, |
| + applyEditRow as storeApplyEdit, |
| + deleteMessageRow as storeDeleteMessage, |
| + clearThread as storeClearThread, |
| + cachedThreadIds as storeCachedThreadIds, |
| + wipeStore, |
| +} from "/static/store.js"; |
| const SESSION_KEY = "lchat_session"; |
| const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it |
| @@ -967,9 +984,89 @@ async function loadMemberAppsNow() { |
| } |
| } |
| +// ---- local-store boot cache (#457) ---------------------------------------- |
| +// The raw server responses the last boot saw, cloned at fetch time (before any |
| +// apply-side decryption touches them) and persisted as one snapshot. On the |
| +// next load, hydrateFromCache() replays them through the same apply pipeline — |
| +// instant paint, zero network — then the real fetches reconcile. |
| +const bootRaw = { threads: null, roster: null, nick: null, tiers: null, instance: null }; |
| +let cachePainted = false; // a cached paint happened; network failures must not blank it |
| +let snapTimer = null; |
| +function scheduleStoreSnapshot() { |
| + if (embed || !bootRaw.threads || !bootRaw.roster) return; |
| + clearTimeout(snapTimer); |
| + snapTimer = setTimeout(() => { |
| + storePutSnapshot({ |
| + v: SNAP_VERSION, |
| + ts: Date.now(), |
| + // Bind the snapshot to this session's identity: a claim flow can replace |
| + // lchat_session without a sign-out (add-device, paste-to-join), and the |
| + // next boot must not hydrate the previous person's cache. |
| + owner: session.userPub || "", |
| + threads: bootRaw.threads, |
| + roster: bootRaw.roster, |
| + nick: bootRaw.nick, |
| + tiers: bootRaw.tiers, |
| + instance: bootRaw.instance, |
| + }).catch(() => {}); |
| + }, 1500); |
| +} |
| + |
| +// Paint the whole app from the cached snapshot: same apply pipeline, no |
| +// network. Returns true when the shell painted (the boot scene then stays |
| +// down and the reveal has already happened). Any failure means "no cache" — |
| +// boot proceeds exactly as it always has. |
| +async function hydrateFromCache() { |
| + if (embed) return false; |
| + let snap = null; |
| + try { |
| + snap = await storeGetSnapshot(); |
| + } catch {} |
| + if (!snapshotUsable(snap)) return false; |
| + // A different member's leftovers (session replaced by a claim flow, no |
| + // sign-out): their ciphertext is useless to this key anyway — wipe, don't |
| + // hydrate a cache that can only render undecryptable rows. |
| + if (snap.owner !== (session.userPub || "")) { |
| + wipeStore().catch(() => {}); |
| + return false; |
| + } |
| + try { |
| + await applyRoster(snap.roster); |
| + if (snap.instance && snap.instance.wrapped) { |
| + try { |
| + instanceKeyB64 = await unwrapThreadKey(snap.instance.wrapped, session.userPriv); |
| + instanceKey = await importKey(instanceKeyB64); |
| + } catch {} |
| + } |
| + if (snap.nick) { |
| + const n = await applyNicknamesBody(snap.nick).catch(() => null); |
| + if (n) nicknames = n; |
| + } |
| + if (Array.isArray(snap.tiers)) await applyTiers(snap.tiers, false); |
| + await applyThreads(snap.threads); |
| + await decryptRosterLabels(); |
| + await recomputeThreadNames(); |
| + const opened = renderApp(); // the initial thread's feed folds from the store |
| + connectWS(); // live frames flow into the cached paint immediately |
| + // A failed initial open (e.g. offline + a never-cached thread) still leaves |
| + // a usable shell — reveal rather than fall back to the boot scene. |
| + await Promise.resolve(opened).catch(() => {}); |
| + cachePainted = true; |
| + hideLoading(); |
| + return true; |
| + } catch (e) { |
| + console.error("hydrate from cache", e); |
| + return cachePainted; |
| + } |
| +} |
| + |
| async function boot() { |
| try { |
| - startBootScene(); |
| + // An instant paint from the local snapshot, when one exists. The boot |
| + // scene is first-run theatre: it only shows when there's nothing to paint. |
| + const painted = await hydrateFromCache(); |
| + if (!painted) startBootScene(); |
| + if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); |
| await loadCustomApps(); |
| await ensurePubkey(); |
| // Role + first-run state, fetched in parallel with the heavy loads: |
| @@ -1009,8 +1106,32 @@ async function boot() { |
| await recomputeThreadNames(); // titles were cached (by loadThreads) before the key was ready |
| syncThreadKeysToSW(); // and so were the SW's notification titles |
| await publishMyLabel(); |
| - const opened = renderApp(); // paints the shell; opens the initial thread (async) |
| - connectWS(); |
| + if (!painted) { |
| + const opened = renderApp(); // paints the shell; opens the initial thread (async) |
| + connectWS(); |
| + // Reveal only once the initial feed is loaded + scrolled — so the first |
| + // paint is the finished layout, not an empty feed that then jumps to the |
| + // bottom. |
| + await opened; |
| + } else { |
| + // The shell painted from the snapshot before these loads ran — refresh |
| + // its surfaces with server truth (folds dedupe; renders are idempotent). |
| + // No catch-up here: the cached open already reconciled the active feed |
| + // (openAppThreadView fires one on its cache path, and its network path |
| + // IS a fresh fetch) — a second identical fetch would be pure waste. |
| + renderThreadList(); |
| + refreshNamesUI(); |
| + // Member/custom apps register only in the loads above — a cached open of |
| + // one of their threads mounted BEFORE registration and drew nothing |
| + // (renderMiniApp on an unknown app is a no-op over a cleared pane). |
| + // An empty pane with an active thread is exactly that case: mount it |
| + // again now the registry is complete. |
| + const pane = document.getElementById("app-pane"); |
| + if (activeId && pane && !pane.childNodes.length && renderAppFor(activeId)) { |
| + delete appPaneCache[activeId]; |
| + await openAppThreadView(activeId, ++openSeq, renderAppFor(activeId), threadApp[activeId] || null); |
| + } |
| + } |
| registerSW(session); // best-effort; subscribes if notifications already on |
| startHeartbeat(); |
| // The backup banner is initialised inside maybeStartSetup (below): while the |
| @@ -1022,9 +1143,6 @@ async function boot() { |
| if (e.data && e.data.type === "open-thread" && e.data.thread) openThreadById(e.data.thread); |
| }); |
| } |
| - // Reveal only once the initial feed is loaded + scrolled — so the first paint |
| - // is the finished layout, not an empty feed that then jumps to the bottom. |
| - await opened; |
| // First-run: open the admin's setup conversation before the reveal, so the |
| // first thing an admin ever sees IS the chat with Eleven (no-op otherwise). |
| await maybeStartSetup(mePromise); |
| @@ -1033,9 +1151,13 @@ async function boot() { |
| await maybeStartWelcome(); |
| } catch (e) { |
| console.error(e); |
| - app.innerHTML = |
| - `<div class="center"><div class="card"><h1>Something went wrong</h1>` + |
| - `<p>Couldn't load your chats. Try reloading.</p></div></div>`; |
| + // A failed network boot over a successful cached paint is the OFFLINE case |
| + // working as designed — the app stays readable; never blank it. |
| + if (!cachePainted) { |
| + app.innerHTML = |
| + `<div class="center"><div class="card"><h1>Something went wrong</h1>` + |
| + `<p>Couldn't load your chats. Try reloading.</p></div></div>`; |
| + } |
| } finally { |
| hideLoading(); |
| } |
| @@ -1320,6 +1442,15 @@ function ensurePubkey() { |
| async function loadRoster() { |
| const r = await (await fetch(`/api/roster?token=${encodeURIComponent(session.token)}`)).json(); |
| + // Snapshot the response BEFORE apply: decryptRosterLabels writes decrypted |
| + // names onto these entries, and plaintext must never reach the store. |
| + if (Array.isArray(r.roster)) bootRaw.roster = structuredClone(r); |
| + await applyRoster(r); |
| + scheduleStoreSnapshot(); |
| +} |
| + |
| +// The apply half of loadRoster — also run over the cached response on boot. |
| +async function applyRoster(r) { |
| roster = r.roster || []; |
| // In embed mode the shell tells us our user id up front (it can't be matched by |
| // public key — the webview never holds one). Otherwise find ourselves by pubkey. |
| @@ -1457,15 +1588,23 @@ async function fetchServerNicknames() { |
| const r = await fetch(`/api/nicknames?token=${encodeURIComponent(session.token)}`); |
| if (!r.ok) return null; |
| const body = await r.json(); |
| - nicknamesVersion = body.version || 0; |
| - if (!body.nicknames_blob) return {}; |
| - const obj = await openSealed(body.nicknames_blob, session.userPriv); |
| - return (obj && obj.names) || {}; |
| + bootRaw.nick = { version: body.version || 0, nicknames_blob: body.nicknames_blob || "" }; |
| + scheduleStoreSnapshot(); |
| + return await applyNicknamesBody(body); |
| } catch { |
| return null; |
| } |
| } |
| +// Decode a /api/nicknames body (sealed to our own key) — shared with the |
| +// cached-snapshot hydrate. |
| +async function applyNicknamesBody(body) { |
| + nicknamesVersion = body.version || 0; |
| + if (!body.nicknames_blob) return {}; |
| + const obj = await openSealed(body.nicknames_blob, session.userPriv); |
| + return (obj && obj.names) || {}; |
| +} |
| + |
| async function loadNicknames() { |
| const server = await fetchServerNicknames(); |
| if (server) nicknames = server; |
| @@ -1663,6 +1802,8 @@ async function setupInstanceKey() { |
| return; |
| } |
| if (r.wrapped) { |
| + bootRaw.instance = { wrapped: r.wrapped }; |
| + scheduleStoreSnapshot(); |
| try { |
| instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv); |
| instanceKey = await importKey(instanceKeyB64); |
| @@ -1730,6 +1871,15 @@ async function loadTiers() { |
| } catch { |
| return; // offline — the next boot or WS prod retries |
| } |
| + bootRaw.tiers = structuredClone(rows); |
| + scheduleStoreSnapshot(); |
| + await applyTiers(rows, true); |
| +} |
| + |
| +// The apply half of loadTiers. `live` gates the nudge/grant side-effects — |
| +// a cached-snapshot hydrate only unwraps keys; the real fetch that follows |
| +// does the network housekeeping. |
| +async function applyTiers(rows, live) { |
| tiers = rows; |
| for (const t of tiers) { |
| if (!tierKeys[t.id] && t.wrapped) { |
| @@ -1741,6 +1891,7 @@ async function loadTiers() { |
| console.error("unwrap tier", t.id, e); |
| } |
| } |
| + if (!live) continue; |
| if (t.mine && !t.wrapped && (t.have || []).length) { |
| fetch(`/api/tiers/${encodeURIComponent(t.id)}/nudge`, { |
| method: "POST", |
| @@ -1966,6 +2117,24 @@ async function threadAvatarFor(threadId) { |
| async function loadThreads() { |
| const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json(); |
| + if (Array.isArray(r.threads) && !embed) { |
| + // Snapshot before apply mutates entries (e.g. the open_key "set" gate fix). |
| + bootRaw.threads = structuredClone(r); |
| + scheduleStoreSnapshot(); |
| + // Threads we've left / been removed from must not keep readable local rows |
| + // beyond what this device's keys already imply — prune the store to the |
| + // live list (an error body never reaches here, so it can't wipe the cache). |
| + storeCachedThreadIds() |
| + .then((ids) => Promise.all(staleThreadIds(ids, r).map((id) => storeClearThread(id)))) |
| + .catch(() => {}); |
| + } |
| + await applyThreads(r); |
| +} |
| + |
| +// The apply half of loadThreads (unwrap keys → titles → previews → dots) — |
| +// also run over the cached response on boot, so hydration and the network |
| +// path are one code path. |
| +async function applyThreads(r) { |
| threads = r.threads || []; |
| for (const t of threads) { |
| if (!threadKeys[t.id] && (t.wrapped || t.open_key)) { |
| @@ -4235,6 +4404,7 @@ async function chatAppLoadOlder(threadId, beforeSid) { |
| } catch { |
| return 0; |
| } |
| + if (!embed) storePutMessages(threadId, msgs).catch(() => {}); |
| for (const m of msgs) await chatAppFoldMessage(threadId, m); |
| if (threadId === activeId) renderActiveSurfaces(); |
| return msgs.length; |
| @@ -5585,7 +5755,12 @@ async function chatAppFoldMessage(threadId, m) { |
| } |
| if (data.type || (!data.text && !data.attachments)) return; |
| foldHostEvent(threadId, "chat", chatMsgEvent(data), { id: m.id, ts: data.ts || m.created_at * 1000 }); |
| - if (m.edited_at) foldHostEvent(threadId, "chat", { type: "chat.edit", msgId: m.id }); |
| + // Carry the (post-edit) text, matching the live edit frame's fold event. |
| + // With state built fresh from server rows this was redundant (the chat.msg |
| + // fold above already had the new text); over a pre-seeded local store it's |
| + // load-bearing — the chat.msg fold dedupes against the CACHED text, and |
| + // only this event updates a message edited while this device was away. |
| + if (m.edited_at) foldHostEvent(threadId, "chat", { type: "chat.edit", msgId: m.id, text: data.text || "" }); |
| } catch {} |
| } |
| // The app the active thread renders full-pane, if any: a personal app-thread's |
| @@ -5792,11 +5967,29 @@ async function openAppThreadView(id, gen, appName = threadApp[id], ownerApp = nu |
| resetThread(id); |
| await seedAppState(id); |
| if (gen !== openSeq) return; |
| + // Local-first for the chat app (#457): a thread we've seen before folds its |
| + // cached ciphertext rows — instant, offline-capable — then reconciles with a |
| + // background refetch (fold dedupe by id; edits refold via edited_at; deletes |
| + // replay via tombstone rows). First-ever opens still fetch below. |
| + if (appName === "chat" && !embed && threadKeys[id]) { |
| + const rows = await storeRecentMessages(id, 100); |
| + if (gen !== openSeq) return; |
| + if (rows.length) { |
| + for (const m of rows) await chatAppFoldMessage(id, m); |
| + if (gen !== openSeq) return; |
| + renderMiniApp(id, appName, pane); |
| + appPaneCache[id] = { root: null, scrollTop: null, loaded: true }; |
| + if (chatAppFor(id)) refreshGroupAppsUI(id); |
| + catchUpActiveMessages(); |
| + return; |
| + } |
| + } |
| try { |
| const r = await ( |
| await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| ).json(); |
| if (gen !== openSeq) return; |
| + if (appName === "chat" && !embed) storePutMessages(id, r.messages || []).catch(() => {}); |
| for (const m of r.messages || []) { |
| // The chat app consumes the classic protocol (chatAppFoldMessage folds each |
| // message with its server-side trimmings); a takeover app-thread folds only |
| @@ -6630,6 +6823,10 @@ async function catchUpActiveMessages() { |
| await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| ).json(); |
| if (id !== activeId) return; |
| + // Same gate as the open path: only chat-feed threads hydrate from the |
| + // store today, so only they write through (app-thread rows would be |
| + // dead weight until an app-thread hydrate exists). |
| + if (!embed && chatAppFor(id)) storePutMessages(id, r.messages || []).catch(() => {}); |
| for (const m of r.messages || []) await chatAppFoldMessage(id, m); |
| renderActiveSurfaces(); |
| refreshGroupAppsUI(id); // an app add/remove may have landed while offline |
| @@ -6882,6 +7079,7 @@ function connectWS() { |
| if (msg.type === "edit") { |
| // Chat app: an edit (server-verified author) replaces the message text in |
| // the fold; the original feed's in-place bubble update follows unchanged. |
| + if (!embed) storeApplyEdit(msg.thread_id, msg.id, msg.blob, msg.edited_at).catch(() => {}); |
| if (chatAppFor(msg.thread_id) && threadKeys[msg.thread_id]) { |
| try { |
| const nd = await decrypt(threadKeys[msg.thread_id], msg.blob); |
| @@ -6893,7 +7091,9 @@ function connectWS() { |
| } |
| if (msg.type === "delete") { |
| // Chat app: a delete (server-verified author, ciphertext hard-deleted |
| - // upstream) removes the message from the fold. |
| + // upstream) removes the message from the fold. The local store drops its |
| + // ciphertext too — deleting really deletes, here as on the server. |
| + if (!embed) storeDeleteMessage(msg.thread_id, msg.id).catch(() => {}); |
| if (chatAppFor(msg.thread_id)) { |
| foldHostEvent(msg.thread_id, "chat", { type: "chat.delete", msgId: msg.id }); |
| if (msg.thread_id === activeId) renderActiveSurfaces(); |
| @@ -6943,6 +7143,13 @@ function connectWS() { |
| } catch {} |
| return; |
| } |
| + // Persist the stored row (#457) — the same ciphertext the server just |
| + // stored, so the next reload paints this message without a fetch. Same |
| + // chat-feed gate as every other write site: only chat threads hydrate |
| + // from the store today. |
| + if (!embed && msg.id && chatAppFor(tid)) { |
| + storePutMessages(tid, [{ id: msg.id, created_at: msg.created_at, blob: msg.blob }]).catch(() => {}); |
| + } |
| // New activity normally reopens a closed chat — the server clears its hidden |
| // flag on every message insert (db.go's insertMessage), unconditionally, |
| // because it never learns which app owns a thread (see CLAUDE.md) and so |
| @@ -8160,6 +8367,7 @@ async function leaveGroup() { |
| } |
| threads = threads.filter((x) => x.id !== id); |
| delete threadKeys[id]; |
| + storeClearThread(id).catch(() => {}); // a left thread keeps no local rows (#457) |
| if (activeId === id) { |
| activeId = null; |
| localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone |
| @@ -9782,7 +9990,12 @@ function doSignout() { |
| body: JSON.stringify({ token: session.token }), |
| }) |
| .catch(() => {}) |
| - .finally(() => { |
| + .finally(async () => { |
| + // The local message store goes with the session (#457): the next person |
| + // on this browser profile must find nothing readable. |
| + try { |
| + await wipeStore(); |
| + } catch {} |
| localStorage.removeItem(SESSION_KEY); |
| location.reload(); |
| }); |
diff --git a/web/static/store.js b/web/static/store.js| new file mode 100644 |
| index 0000000..1802e8d |
| --- /dev/null |
| +++ b/web/static/store.js |
| @@ -0,0 +1,237 @@ |
| +// The local message store (#457): an IndexedDB cache that makes reloads and |
| +// thread switches paint instantly, and (later) feeds message search. |
| +// |
| +// What it holds — and deliberately nothing more: the RAW server responses. |
| +// Message rows are ciphertext blobs; the boot snapshot is the byte-equivalent |
| +// of /api/threads, /api/roster, /api/nicknames, /api/tiers and |
| +// /api/instance-key (wrapped keys, encrypted name/preview blobs). Nothing is |
| +// stored decrypted, so this cache's at-rest posture equals the server's own — |
| +// strictly tighter than the per-thread keys the service worker already keeps |
| +// in the sibling "lchat" DB (push.js). Reading this DB gains an attacker |
| +// nothing they couldn't already decrypt with those. |
| +// |
| +// The page hydrates by running the cached responses through the SAME apply + |
| +// fold pipeline the network path uses (unwrap keys → titles → fold rows), so |
| +// there is no parallel render path; the real fetches then reconcile — folds |
| +// dedupe by message id, edits refold via edited_at, deletes replay via |
| +// tombstone rows (#581/#583). |
| +// |
| +// The service worker will also write pushed rows here (phase 2); it can't |
| +// import this module (classic worker), so keep DB_NAME/DB_VERSION/store names |
| +// in sync with any inline writer added to sw.js. |
| +// |
| +// Every exported IDB call is best-effort: a quota error, a private-mode |
| +// browser, or a torn write must degrade to "no cache" — never break chat. |
| + |
| +const DB_NAME = "lchat-store"; // NOT "lchat": three openers pin that at v1 for the SW's kv |
| +const DB_VERSION = 1; |
| +const SNAP_KEY = "boot"; |
| +export const SNAP_VERSION = 1; // bump to shed incompatible old snapshots |
| + |
| +// ---- pure helpers (unit-tested in Node, no IndexedDB) --------------------- |
| + |
| +// A snapshot is usable only when it carries the current format version and the |
| +// two responses hydration cannot start without. Everything else is optional — |
| +// a partial snapshot (e.g. tiers never fetched) still paints. |
| +export function snapshotUsable(snap) { |
| + return !!( |
| + snap && |
| + snap.v === SNAP_VERSION && |
| + snap.threads && |
| + Array.isArray(snap.threads.threads) && |
| + snap.roster && |
| + Array.isArray(snap.roster.roster) |
| + ); |
| +} |
| + |
| +// Split a fetched page into rows to keep and the ids its tombstones unsend. |
| +// The tombstone row itself is stored too: it keeps the delete durable locally |
| +// (a later hydrate re-folds it as a no-op) while the target's ciphertext is |
| +// actually dropped — mirroring the server's own posture. |
| +export function partitionRows(rows) { |
| + const puts = []; |
| + const unsend = []; |
| + for (const m of rows || []) { |
| + if (!m || !m.id) continue; |
| + puts.push(m); |
| + if (m.tombstone_of) unsend.push(m.tombstone_of); |
| + } |
| + return { puts, unsend }; |
| +} |
| + |
| +// Which cached thread ids are no longer in the live list (left, evicted, or |
| +// deleted while this device was away) — their rows must drop. Only trust a |
| +// well-formed live list: an error body must never wipe the cache. |
| +export function staleThreadIds(cachedIds, liveList) { |
| + if (!liveList || !Array.isArray(liveList.threads)) return []; |
| + const live = new Set(liveList.threads.map((t) => t.id)); |
| + return (cachedIds || []).filter((id) => !live.has(id)); |
| +} |
| + |
| +// ---- IndexedDB plumbing --------------------------------------------------- |
| + |
| +let dbPromise = null; |
| +function openStore() { |
| + if (dbPromise) return dbPromise; |
| + dbPromise = new Promise((resolve, reject) => { |
| + let req; |
| + try { |
| + req = indexedDB.open(DB_NAME, DB_VERSION); |
| + } catch (e) { |
| + reject(e); // e.g. storage disabled entirely |
| + return; |
| + } |
| + req.onupgradeneeded = () => { |
| + const db = req.result; |
| + if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv"); |
| + if (!db.objectStoreNames.contains("msgs")) { |
| + // One row per server message row, newest read by id order within a |
| + // thread. keyPath mirrors the server's identity: (thread, message id). |
| + db.createObjectStore("msgs", { keyPath: ["t", "id"] }); |
| + } |
| + }; |
| + req.onsuccess = () => { |
| + // If another tab upgrades the schema later, close so it isn't blocked. |
| + req.result.onversionchange = () => { |
| + req.result.close(); |
| + dbPromise = null; |
| + }; |
| + resolve(req.result); |
| + }; |
| + req.onerror = () => reject(req.error); |
| + req.onblocked = () => reject(new Error("blocked")); |
| + }); |
| + dbPromise.catch(() => (dbPromise = null)); // let a later call retry |
| + return dbPromise; |
| +} |
| + |
| +// Run one transaction; resolves when it completes. `fn(t, done)` does the |
| +// requests — a read calls `done(value)` from a request's onsuccess to set what |
| +// the transaction resolves to; a write just issues its requests. Any failure |
| +// resolves `fallback` (best-effort contract — see the header). |
| +async function tx(storeNames, mode, fn, fallback = undefined) { |
| + try { |
| + const db = await openStore(); |
| + return await new Promise((resolve) => { |
| + const t = db.transaction(storeNames, mode); |
| + let out = fallback; |
| + const done = (v) => (out = v); |
| + try { |
| + fn(t, done); |
| + } catch { |
| + try { |
| + t.abort(); |
| + } catch {} |
| + resolve(fallback); |
| + return; |
| + } |
| + t.oncomplete = () => resolve(out); |
| + t.onabort = () => resolve(fallback); |
| + t.onerror = () => resolve(fallback); |
| + }); |
| + } catch { |
| + return fallback; |
| + } |
| +} |
| + |
| +// ---- boot snapshot -------------------------------------------------------- |
| + |
| +export function getSnapshot() { |
| + return tx(["kv"], "readonly", (t, done) => { |
| + const req = t.objectStore("kv").get(SNAP_KEY); |
| + req.onsuccess = () => done(req.result || null); |
| + }, null); |
| +} |
| + |
| +export function putSnapshot(snap) { |
| + return tx(["kv"], "readwrite", (t) => { |
| + t.objectStore("kv").put(snap, SNAP_KEY); |
| + }); |
| +} |
| + |
| +// ---- message rows --------------------------------------------------------- |
| + |
| +// The newest `limit` raw rows for a thread, ascending by id — the same shape |
| +// and order /api/messages returns, so folds can't tell cache from network. |
| +export function getRecentMessages(threadId, limit = 100) { |
| + return tx(["msgs"], "readonly", (t, done) => { |
| + const rows = []; |
| + const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); |
| + const cur = t.objectStore("msgs").openCursor(range, "prev"); |
| + cur.onsuccess = () => { |
| + const c = cur.result; |
| + if (c && rows.length < limit) { |
| + rows.push(c.value); |
| + c.continue(); |
| + } else { |
| + done(rows.reverse()); |
| + } |
| + }; |
| + }, []); |
| +} |
| + |
| +// Store fetched rows; apply any tombstones in the batch (drop their targets' |
| +// ciphertext). Rows are stored verbatim plus the keyPath's thread tag. |
| +export function putMessages(threadId, rows) { |
| + const { puts, unsend } = partitionRows(rows); |
| + if (!puts.length) return Promise.resolve(); |
| + return tx(["msgs"], "readwrite", (t) => { |
| + const s = t.objectStore("msgs"); |
| + for (const m of puts) s.put({ ...m, t: threadId }); |
| + for (const id of unsend) s.delete([threadId, id]); |
| + }); |
| +} |
| + |
| +// A live edit frame rewrote the ciphertext in place — mirror it. |
| +export function applyEditRow(threadId, id, blob, editedAt) { |
| + return tx(["msgs"], "readwrite", (t) => { |
| + const s = t.objectStore("msgs"); |
| + const g = s.get([threadId, id]); |
| + g.onsuccess = () => { |
| + if (g.result) s.put({ ...g.result, blob, edited_at: editedAt || Math.floor(Date.now() / 1000) }); |
| + }; |
| + }); |
| +} |
| + |
| +// A live delete frame — drop the ciphertext now; the tombstone row arrives |
| +// with the next fetched page and is stored then. |
| +export function deleteMessageRow(threadId, id) { |
| + return tx(["msgs"], "readwrite", (t) => { |
| + t.objectStore("msgs").delete([threadId, id]); |
| + }); |
| +} |
| + |
| +export function clearThread(threadId) { |
| + return tx(["msgs", "kv"], "readwrite", (t) => { |
| + t.objectStore("msgs").delete(IDBKeyRange.bound([threadId, 0], [threadId, Infinity])); |
| + // Any per-thread bookkeeping (e.g. a future backfill watermark) goes too. |
| + t.objectStore("kv").delete("thread:" + threadId); |
| + }); |
| +} |
| + |
| +// Thread ids present in the msgs store — the prune sweep's input. |
| +export function cachedThreadIds() { |
| + return tx(["msgs"], "readonly", (t, done) => { |
| + const ids = []; |
| + // Jump cursor: visit one key per thread, then skip past its rows. |
| + const cur = t.objectStore("msgs").openKeyCursor(); |
| + cur.onsuccess = () => { |
| + const c = cur.result; |
| + if (c) { |
| + ids.push(c.key[0]); |
| + c.continue([c.key[0], Infinity]); |
| + } else { |
| + done(ids); |
| + } |
| + }; |
| + }, []); |
| +} |
| + |
| +// Sign-out: everything goes, snapshot included — the next visitor to this |
| +// browser profile must find nothing. |
| +export function wipeStore() { |
| + return tx(["msgs", "kv"], "readwrite", (t) => { |
| + t.objectStore("msgs").clear(); |
| + t.objectStore("kv").clear(); |
| + }); |
| +} |