From 88636e5c158c9e390eec25c4471207780857abeb Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 15 Sep 2026 21:37:47 -0700 Subject: Offline, the chat shows what it read: messages cache on arrival. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The room cache was written only on our own sends, deletes and attachments, so a member who only reads had nothing on an offline boot — "No messages yet" over a room read the day before, and no faces or names with it. Messages now persist as they arrive (debounced past the backfill burst), so the last fifty per channel are there without a network. Our own face never waits its turn for a thumbnail and never gives it up to the budget, so the header avatar paints offline too. tools/offline-cache-test.js boots the PWA build, caches a room, reloads fully offline and checks messages, names and thumbnails are all there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z --- src/features/messages.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index e812520..cb63e04 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -545,7 +545,9 @@ export function messagesFeature(ctx) { // least recently seen faces give theirs up first. The row itself stays — // that face just paints the way it used to. if (JSON.stringify(s).length > THUMB_BUDGET) { - const oldestFirst = Object.keys(s).filter((k) => s[k].thumb).sort((a, b) => (s[a].t || 0) - (s[b].t || 0)); + // never our own: the header face is the one that must paint offline + const mine = new Set(myPubkeys()); + const oldestFirst = Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)).sort((a, b) => (s[a].t || 0) - (s[b].t || 0)); for (const k of oldestFirst) { delete s[k].thumb; delete s[k].thumbFor; if (JSON.stringify(s).length <= THUMB_BUDGET) break; @@ -648,7 +650,7 @@ export function messagesFeature(ctx) { // won't have us is asked about twice a month rather than every boot. if (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === p.picture && Date.now() - (p.thumbFailAt || 0) < Math.min(THUMB_RETRY * 2 ** ((p.thumbFails || 1) - 1), THUMB_RETRY_MAX)) return; - if (thumbing.has(pk) || thumbing.size >= 3) return; // a few at a time + if (thumbing.has(pk) || (thumbing.size >= 3 && !isMe(pk))) return; // a few at a time — our own face never waits // Making the thumbnail costs one more fetch of the original today to // save every fetch after it — but not on a connection someone is // nursing. Data Saver keeps today's behaviour. @@ -1128,11 +1130,13 @@ export function messagesFeature(ctx) { msgs.set(rumor.id, { rumor, author }); bumpMsgRev(); room.typing.delete(author); // the message itself ends the "typing…" + schedulePersist(room); } else if (rumor.kind === 5) { for (const e of rumor.tags.filter((x) => x[0] === 'e')) { const m = room.byChannel.get(channelId)?.get(e[1]); if (!m || m.author === author) room.deletes.add(e[1]); } + schedulePersist(room); } else if (rumor.kind === 3302) { const target = tag('e')?.[1]; if (target) { @@ -1162,6 +1166,16 @@ export function messagesFeature(ctx) { return room.jm.channels || []; }; + // Messages that ARRIVE get cached too, not only ones we send: the cache + // used to be written on our own actions alone, so a member who only reads + // had nothing on an offline boot — the room said "No messages yet" with a + // hundred messages read the day before. A backfill lands in a burst, so + // the write waits for it to settle. + const persistTimers = new Map(); // room -> timer + function schedulePersist(room) { + clearTimeout(persistTimers.get(room)); + persistTimers.set(room, setTimeout(() => { persistTimers.delete(room); try { persistCache(room); } catch {} }, 2500)); + } function persistCache(room) { const s = st(); for (const [chId, msgs] of room.byChannel) { -- cgit v1.2.3