From ca3b61ec61e2c2f0141eefd373fade86e9925c85 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 18:17:21 -0700 Subject: A thread never spins: the post reads at once, its replies come in whole — faces and pictures fetched ahead. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, 2026-10-16: "I still see spinner when there's no replies. I'd rather have replies pop in if they have to than wait just to see there are none. … for ones we've preloaded they appear fast but they still take a moment to render and shift the page when they do, can we not pre-render them as well so they're just instantly there on screen?" (1) No spinner at all now: the post is on screen the moment it is tapped, replies land as they arrive, and "no replies" is said only once the relays have spoken. (2) What shifted was each row's face, pictures, previews and quote cards landing after the row: a thread's rows in hand — seeded from the count fetch, fetched ahead on a moment's reading, adopted when the count answers — are now warmed the way the feed warms a row before painting it (warmThread → noteReady: the face thumb, the pictures' sizes, the link cards, the quoted notes), so the tap paints the thread in its final shape; a reply arriving from a relay waits up to 1.5 s for its face before it goes in, so it pops in whole rather than shifting as it fills. tools/thread-instant-test.js: the post fetched ahead opens with its reply and, at its first paint, the replier's face already a thumb; a post with nothing in hand reads at once with no spinner and no "no replies", its first reply popping in at ~480 ms with the slow one still to come, never a spinner; the rest as before. thread-cache, thread-pager, thread-back-scroll, feed-conversations, moderation, reply-count, feed-live and notif-report-fold pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) (limited to 'src/features/messages.js') diff --git a/src/features/messages.js b/src/features/messages.js index 88e032a..ffdf0e3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4039,10 +4039,20 @@ export function messagesFeature(ctx) { // fetch answers for a thread fetched ahead before the answer came function adoptRepliesInHand(c) { const have = new Set(c.replies.map((e) => e.id)); - let added = 0; - for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added++; } - if (added) c.replies.sort((a, b) => a.created_at - b.created_at); - return added; + let added = []; + for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); } + if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); } + return added.length; + } + // A thread's rows painted in their final shape: the faces, pictures, + // previews and quotes of what is in hand are fetched ahead (noteReady, as + // the feed does per row), so an opened thread doesn't shift as they land. + const threadWarmed = new WeakSet(); + const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face + function warmThread(evs, deadline = Date.now() + READY_MS) { + const fresh = evs.filter((e) => e && !threadWarmed.has(e)); + for (const e of fresh) threadWarmed.add(e); + return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {}))); } const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set }) // Resolve from this reaction's own tag: packs can reuse the same shortcode @@ -10289,6 +10299,7 @@ export function messagesFeature(ctx) { adoptRepliesInHand(c); if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed); c.replies.sort((a, b) => a.created_at - b.created_at); + warmThread([c.root, ...c.replies]); threadCache.set(requestedRootId, c); threadCache.set(rootId, c); if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId; @@ -10311,11 +10322,16 @@ export function messagesFeature(ctx) { let paintT = null; const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); }; const got = new Set(c.replies.map((e) => e.id)); + const streamed = new Map(); const take = (e) => { if (!e || e.id === c.rootId || got.has(e.id)) return; - got.add(e.id); noteForSpam(e); - c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at); - paint(); + got.add(e.id); noteForSpam(e); streamed.set(e.id, e); + // painted whole: its face and pictures first (briefly), then the row + warmThread([e], Date.now() + THREAD_ROW_MS).then(() => { + if (c.replies.some((x) => x.id === e.id)) return; + c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at); + paint(); + }); }; await Promise.all([ c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll @@ -10326,7 +10342,7 @@ export function messagesFeature(ctx) { queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}), ]); clearTimeout(paintT); paintT = null; - let all = [...c.replies]; + let all = [...c.replies, ...streamed.values()]; // A client that tags only the note it answered leaves the root // unnamed: what we resolved as the root is itself a reply. Climb — // fetch ITS root and re-root the thread there — a few hops at most. @@ -10690,10 +10706,10 @@ export function messagesFeature(ctx) { } }; walk(c.rootId, 0); - // the spinner only while there is nothing to read yet: replies in hand - // (or arriving) are the conversation, and the rest slot in as they come - if (c.status === 'loading' && !c.replies.length) kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); - else if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); + // No spinner: the post reads at once and its replies come in whole as + // they arrive (Adam: rather have them pop in than wait to learn there + // are none); "no replies" only once the relays have spoken. + if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); if (!boxPlaced) kids.push(replyBox(false)); // A tapped reply deep in a long thread has to be ON SCREEN when the // thread opens — highlighting it somewhere below the fold reads as the -- cgit v1.2.3