web: fix first-open layout shift + prebuild visible chats (#49)
Opening a chat for the first time flashed and juddered: images reserved no space until their blob decrypted (bubbles collapsed, then jumped apart), and the feed was built one message at a time on-screen (messages stacked from the top, then snapped to the bottom once the load finished). - Reserve each image's box up front (definite width + aspect-ratio) so layout is stable before the photo decrypts. - Build the first-open history off-DOM in a fragment, attach in one shot. - Warm above-the-fold chats into feedCache in the background so the first tap into any of them is the instant-restore path (no fetch flash). appendMessage / renderReactions / bump+paintReplyCount now take an explicit cache entry, so a background prebuild writes only into its own thread's state, never the open thread's globals.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+119
−24
web/static/chat.js+119 −24
diff --git a/web/static/chat.js b/web/static/chat.js| index 0c8dd0c..4f66b81 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -311,6 +311,9 @@ async function boot() { |
| // 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; |
| + // With the open thread in place, warm the other visible chats in the background |
| + // so the first tap into any of them is instant (no fetch flash). |
| + prebuildVisibleThreads(); |
| } catch (e) { |
| console.error(e); |
| app.innerHTML = |
| @@ -871,6 +874,7 @@ function renderThreadList() { |
| for (const t of shown) { |
| const row = document.createElement("button"); |
| row.className = "thread-row" + (t.id === activeId ? " active" : ""); |
| + row.dataset.id = t.id; // lets prebuildVisibleThreads map a visible row to its thread |
| // DM/self rows show the person's profile photo; groups use initials (group |
| // photos are thread-scoped — phase 2). |
| @@ -1208,7 +1212,15 @@ async function openThread(id) { |
| await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| ).json(); |
| if (myOpen !== openSeq) return; // switched threads while loading — abandon this load |
| - for (const m of r.messages || []) await appendMessage(id, m, false); |
| + // Build the history off-DOM, then attach it in one shot. Appending live (one |
| + // node per awaited decrypt) reflowed #feed on every message and left it pinned |
| + // to the top until the whole load finished, then snapped to the bottom — a |
| + // cascade of visible layout shift on first open. A detached fragment never |
| + // paints mid-build, so the feed appears complete, already at the latest message. |
| + const frag = document.createDocumentFragment(); |
| + for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache); |
| + if (myOpen !== openSeq) return; // switched away mid-build — don't attach a stale feed |
| + feed.appendChild(frag); |
| cache.loaded = true; |
| } catch (e) { |
| console.error("load messages", e); |
| @@ -1244,8 +1256,69 @@ function pinFeedToBottom(feed, gen) { |
| requestAnimationFrame(tick); |
| } |
| -async function appendMessage(threadId, m, scroll = true) { |
| - if (threadId !== activeId) return; |
| +const PREBUILD_MAX = 10; // cap on chats warmed on load (a safety net over "visible") |
| + |
| +// Build a thread's feed off-screen into its feedCache entry, so the *first* tap |
| +// into it is the instant-restore path in openThread — no fetch round-trip, no |
| +// flash. Idempotent, and isolated from the open thread: it writes only into this |
| +// thread's own cache entry (the explicit `cache` arg threaded through |
| +// appendMessage), never the active-thread globals, so it's safe to run in the |
| +// background while another chat is open. |
| +async function prebuildThread(id) { |
| + if (id === activeId || feedCache[id] || !threadKeys[id]) return; |
| + const cache = (feedCache[id] = newFeedCache()); // claim the slot so nothing double-builds |
| + try { |
| + const r = await ( |
| + await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`) |
| + ).json(); |
| + if (feedCache[id] !== cache) return; // opened for real meanwhile — let that build win |
| + const frag = document.createDocumentFragment(); |
| + for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache); |
| + if (feedCache[id] !== cache) return; // superseded while decrypting |
| + cache.nodes = frag; // parked; openThread re-attaches it instantly |
| + cache.scrollTop = null; // null → pin to bottom on first show |
| + cache.loaded = true; |
| + } catch { |
| + if (feedCache[id] === cache) delete feedCache[id]; // failed — let a real open retry cleanly |
| + } |
| +} |
| + |
| +// After first paint, warm the chats visible in the list so tapping any of them is |
| +// instant. Only above-the-fold rows (the flash the user sees is the initial fetch), |
| +// one at a time at idle so it never competes with the open thread or first paint. |
| +function prebuildVisibleThreads() { |
| + const run = async () => { |
| + for (const id of visibleThreadIds()) await prebuildThread(id); |
| + }; |
| + if (typeof requestIdleCallback === "function") requestIdleCallback(() => run()); |
| + else setTimeout(run, 200); |
| +} |
| + |
| +// Thread ids whose list rows are on-screen (capped). Falls back to the top rows |
| +// when the list isn't laid out — e.g. mobile currently showing a thread. |
| +function visibleThreadIds() { |
| + const list = document.getElementById("thread-list"); |
| + if (!list) return []; |
| + const rows = [...list.querySelectorAll(".thread-row")]; |
| + const vh = window.innerHeight || document.documentElement.clientHeight || 0; |
| + const onScreen = []; |
| + for (const row of rows) { |
| + const b = row.getBoundingClientRect(); |
| + if (b.width === 0 && b.height === 0) continue; // not laid out (hidden pane) |
| + if (b.bottom > 0 && b.top < vh) onScreen.push(row.dataset.id); |
| + } |
| + const ids = onScreen.length ? onScreen : rows.map((r) => r.dataset.id); |
| + return ids.filter(Boolean).slice(0, PREBUILD_MAX); |
| +} |
| + |
| +// `container` lets the first open build the whole history into a detached fragment |
| +// (see openThread) so there's no per-message reflow; live appends omit it and |
| +// target the real #feed. Scroll/query still resolve against #feed either way. |
| +async function appendMessage(threadId, m, scroll = true, container = null, cache = null) { |
| + // A live append (no container) must target the open thread. Off-DOM builds — the |
| + // first-open fragment and prebuildThread — pass a container and write into an |
| + // explicit cache entry, so they're allowed to run for any thread. |
| + if (!container && threadId !== activeId) return; |
| const key = threadKeys[threadId]; |
| if (!key) return; |
| let data; |
| @@ -1254,11 +1327,18 @@ async function appendMessage(threadId, m, scroll = true) { |
| } catch { |
| return; // can't decrypt — skip |
| } |
| - if (threadId !== activeId) return; // switched threads mid-decrypt — don't bleed into the new feed |
| + if (!container && threadId !== activeId) return; // switched mid-decrypt — don't bleed into the new feed |
| + // The state maps this message registers into: an explicit entry for off-DOM |
| + // builds, else the active thread's — whose maps ARE the globals (bindThreadState), |
| + // so live/catch-up appends behave exactly as before. |
| + const st = cache || feedCache[threadId]; |
| + if (!st) return; |
| // Their message just arrived → they're no longer typing. Clear it immediately |
| - // rather than waiting out the typing-beat expiry (the guaranteed signal). |
| - if (data.name && typingUntil.delete(data.name)) renderTyping(); |
| + // rather than waiting out the typing-beat expiry (the guaranteed signal). A |
| + // background prebuild (cache set) must never touch the open thread's typing. |
| + if (!cache && data.name && typingUntil.delete(data.name)) renderTyping(); |
| const feed = document.getElementById("feed"); |
| + const target = container || feed; // where the node lands; feed for live appends |
| const mine = data.name === session.name; |
| const t = threads.find((x) => x.id === threadId); |
| const wrap = document.createElement("div"); |
| @@ -1288,7 +1368,17 @@ async function appendMessage(threadId, m, scroll = true) { |
| const img = document.createElement("img"); |
| img.className = "msg-image"; |
| img.alt = a.name || "photo"; |
| - if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`; |
| + // Reserve the image's final box *before* the blob decrypts. aspect-ratio |
| + // alone can't do it: an unloaded <img> has no intrinsic width, so with only |
| + // a max-width it lays out 0×0 and the bubble collapses until the photo pops |
| + // in — the "bubbles run into each other then jump" shift on first open. Give |
| + // it a definite width (the same 260×320 cap as .msg-image, scaled to the |
| + // real dimensions, never upscaled) so the height is reserved up front. |
| + if (a.w && a.h) { |
| + img.style.aspectRatio = `${a.w} / ${a.h}`; |
| + const scale = Math.min(260 / a.w, 320 / a.h, 1); |
| + img.style.width = Math.round(a.w * scale) + "px"; |
| + } |
| loadBlob(a.id, threadId).then((url) => { |
| if (url) { |
| img.src = url; |
| @@ -1344,12 +1434,12 @@ async function appendMessage(threadId, m, scroll = true) { |
| // Reactions: track which are mine (server's per-message flag), render the chips |
| // below the bubble (outside .content), and wire the press-hold / double-tap picker. |
| for (const r of m.reactions || []) { |
| - if (r.mine) myReactions.add(`${m.id}|${r.emoji}`); |
| + if (r.mine) st.myReactions.add(`${m.id}|${r.emoji}`); |
| } |
| - renderReactions(wrap, m.id, m.reactions || []); |
| + renderReactions(wrap, m.id, m.reactions || [], st.myReactions); |
| attachReactionGesture(wrap, m.id); |
| // Count this reply against its parent, so the parent's tag can show "N Replies". |
| - if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id); |
| + if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id, st); |
| // A persistent "N Replies" tag below the message, tappable to jump to the first |
| // reply. Hidden until a reply arrives. |
| const replyCount = document.createElement("div"); |
| @@ -1360,13 +1450,13 @@ async function appendMessage(threadId, m, scroll = true) { |
| if (first) scrollToMessage(first.dataset.id); |
| }; |
| wrap.appendChild(replyCount); |
| - replyCountEls[m.id] = replyCount; |
| - paintReplyCount(m.id); // reflect replies that may already have arrived |
| + st.replyCountEls[m.id] = replyCount; |
| + paintReplyCount(m.id, st); // reflect replies that may already have arrived |
| if (m.edited_at) markEdited(wrap); // persistent "Edited" label below the bubble |
| - msgEls[m.id] = wrap; |
| - msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta }; |
| + st.msgEls[m.id] = wrap; |
| + st.msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta }; |
| if (scroll) wrap.classList.add("appear"); // live send/receive eases in; history doesn't |
| - feed.appendChild(wrap); |
| + target.appendChild(wrap); |
| refreshSeparator(wrap); // centered time separator above, if the gap warrants |
| refreshSenderName(wrap); // hide a repeated "them" name within a run |
| if (scroll) { |
| @@ -1442,22 +1532,27 @@ function markEdited(wrap) { |
| } |
| // Bump (and repaint) the reply tally for a parent message as replies to it arrive. |
| -function bumpReplyCount(parentId) { |
| - replyCounts[parentId] = (replyCounts[parentId] || 0) + 1; |
| - paintReplyCount(parentId); |
| +// `st` targets a specific thread's cache entry (prebuild / first-open); omitted, it |
| +// falls back to the active thread's globals. |
| +function bumpReplyCount(parentId, st) { |
| + const counts = st ? st.replyCounts : replyCounts; |
| + counts[parentId] = (counts[parentId] || 0) + 1; |
| + paintReplyCount(parentId, st); |
| } |
| -function paintReplyCount(parentId) { |
| - const el = replyCountEls[parentId]; |
| +function paintReplyCount(parentId, st) { |
| + const el = (st ? st.replyCountEls : replyCountEls)[parentId]; |
| if (!el) return; // parent older than the loaded window — nothing to mark |
| - const n = replyCounts[parentId] || 0; |
| + const n = (st ? st.replyCounts : replyCounts)[parentId] || 0; |
| el.textContent = n === 1 ? "1 Reply" : n + " Replies"; |
| el.hidden = n === 0; |
| } |
| // Render a message's reaction chips (rebuilds the row in place). "Mine" chips are |
| -// highlighted; tapping a chip toggles that reaction. |
| -function renderReactions(wrap, msgId, reactions) { |
| +// highlighted; tapping a chip toggles that reaction. `mineSet` targets a specific |
| +// thread's reaction set (prebuild / first-open); omitted, it uses the active one. |
| +function renderReactions(wrap, msgId, reactions, mineSet) { |
| + const mine = mineSet || myReactions; |
| const old = wrap.querySelector(".reactions"); |
| if (old) old.remove(); |
| if (!reactions || !reactions.length) return; |
| @@ -1466,7 +1561,7 @@ function renderReactions(wrap, msgId, reactions) { |
| for (const r of reactions) { |
| if (!r.count) continue; |
| const chip = document.createElement("button"); |
| - chip.className = "reaction-chip" + (myReactions.has(`${msgId}|${r.emoji}`) ? " mine" : ""); |
| + chip.className = "reaction-chip" + (mine.has(`${msgId}|${r.emoji}`) ? " mine" : ""); |
| chip.textContent = `${r.emoji} ${r.count}`; |
| chip.onclick = (e) => { |
| e.stopPropagation(); |