eleven / messenger Public

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>
Paul Campbell a0f064f5fbbddcde4ff5a568f0d5debca1dc3d86 parent 8328982
8 files changed, +578 −18
  • CLAUDE.md +5 −0
  • changelog/2026-07-12-instant-reloads.md +6 −0
  • package.json +1 −1
  • test/event-vectors.test.mjs +22 −0
  • test/event-vectors/chat.json +2 −1
  • test/store.test.mjs +76 −0
  • web/static/shell.js +229 −16
  • web/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();
+ });
+}