aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 18:17:21 -0700
committerAdam Soltys <[email protected]>2026-10-16 18:17:21 -0700
commitca3b61ec61e2c2f0141eefd373fade86e9925c85 (patch)
treeb3ab7450e7f4a9b3fbb25cf92921e785aef012ad
parent46181214dd880934f620424376d8aa29ba8794eb (diff)
downloadcoinosv3-ca3b61ec61e2c2f0141eefd373fade86e9925c85.tar.gz
coinosv3-ca3b61ec61e2c2f0141eefd373fade86e9925c85.zip
A thread never spins: the post reads at once, its replies come in whole — faces and pictures fetched ahead.
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
-rw-r--r--src/features/messages.js40
-rw-r--r--tools/thread-instant-test.js20
2 files changed, 41 insertions, 19 deletions
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<pubkey> })
// 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
diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js
index 095b6cf..bd80408 100644
--- a/tools/thread-instant-test.js
+++ b/tools/thread-instant-test.js
@@ -24,9 +24,11 @@ const S2 = post('alice on bob, slow relay', 300, SKA, [['e', B1.id, '', 'root'],
const C1 = post('carol posts last, fetched ahead', 200, SKC);
const U1 = post('bob on carol, held back', 100, SKB, [['e', C1.id, '', 'root'], ['p', CAROL]]);
const html = await buildHtml({ minify: true, pwa: false });
+const FACE = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=96x96', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
const server = Bun.serve({ port: 0, fetch: (req) => {
const path = new URL(req.url).pathname;
if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ if (path === '/bob.png') return new Response(FACE, { headers: { 'content-type': 'image/png' } });
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
@@ -82,6 +84,7 @@ const thread = () => page.evaluate(() => ({
rows: [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')),
spinner: !!document.querySelector('.thread-card .spinner'),
text: document.querySelector('.thread-card')?.innerText || '',
+ face: [...document.querySelectorAll('.thread-page [data-zap-post]')].slice(-1)[0]?.querySelector('.note-avatar')?.style.backgroundImage || '',
}));
const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id);
@@ -100,7 +103,7 @@ try {
await page.evaluate(([k, alice, bob, carol, notes]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
localStorage.setItem(k + ':feedNotes', JSON.stringify(notes));
- localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Asker', t: Date.now() }, [bob]: { name: 'Bob Quiet', t: Date.now() }, [carol]: { name: 'Carol Quick', t: Date.now() } }));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Asker', t: Date.now() }, [bob]: { name: 'Bob Quiet', picture: location.origin + '/bob.png', t: Date.now() }, [carol]: { name: 'Carol Quick', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
}, [base, ALICE, BOB, CAROL, [C1, B1, A1]]);
await page.reload({ waitUntil: 'domcontentloaded' });
@@ -109,15 +112,16 @@ try {
check('the feed paints the posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 3, null, 20000));
console.log('\n[fetched ahead before the count answered]');
check('reading carol\'s post for a moment fetches its thread ahead', await waitFor((id) => window.__reqs.some((r) => r.fs.some((f) => (f['#e'] || []).includes(id) && f.limit)), C1.id, 15000));
+ await page.evaluate(() => { window.__conn.saveData = true; }); // no more fetching ahead: the later parts want their threads untouched
await sleep(300);
await page.evaluate(() => window.__releaseC()); // now the count answer lands, after the thread entry was made
check('…and the count answer brings its reply', await waitFor((id) => /\b1\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), C1.id, 8000));
- await page.evaluate(() => { window.__conn.saveData = true; }); // no more fetching ahead: the next parts want threads untouched
const t2 = Date.now();
await openRow(C1.id);
let ahead = null;
for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.includes(U1.id)) { ahead = { ...th, ms: Date.now() - t2 }; break; } await sleep(25); }
check('the thread opens with the reply from the count, not after its own 6 s ask', !!ahead && ahead.ms < 1500 && !ahead.spinner, ahead ? ahead.ms + ' ms' : 'never');
+ check('…painted in its final shape: bob\'s face was fetched ahead, on the row at its first paint', !!ahead && /blob:|data:image/.test(ahead.face || ''), ahead ? String(ahead.face).slice(0, 60) : '');
await page.goBack(); await sleep(800);
await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
@@ -140,11 +144,13 @@ try {
console.log('\n[a post with nothing in hand]');
const t1 = Date.now();
await openRow(B1.id);
- check('a spinner while nothing is there to read', await waitFor(() => !!document.querySelector('.thread-card .spinner'), null, 1500, 20));
- let streamed = null;
- for (let i = 0; i < 80; i++) { const th = await thread(); if (th.rows.includes(S1.id)) { streamed = { ...th, ms: Date.now() - t1 }; break; } await sleep(25); }
- check('the first reply shows as it arrives, the slow one still on its way', !!streamed && streamed.ms < 2000 && !streamed.rows.includes(S2.id), streamed ? streamed.ms + ' ms' : 'never');
- check('…the spinner gone once there is something to read', !!streamed && !streamed.spinner);
+ let opened = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows[0] === B1.id) { opened = { ...th, ms: Date.now() - t1 }; break; } await sleep(20); }
+ check('the post reads at once: no spinner, no "no replies" while they are on their way', !!opened && !opened.spinner && !/no replies/i.test(opened.text), opened ? opened.ms + ' ms' : 'never');
+ let streamed = null, spun = false;
+ for (let i = 0; i < 80; i++) { const th = await thread(); spun ||= th.spinner; if (th.rows.includes(S1.id)) { streamed = { ...th, ms: Date.now() - t1 }; break; } await sleep(25); }
+ check('the first reply pops in as it arrives, the slow one still on its way', !!streamed && streamed.ms < 2500 && !streamed.rows.includes(S2.id), streamed ? streamed.ms + ' ms' : 'never');
+ check('…never a spinner', !spun && !!streamed && !streamed.spinner);
check('the slow one lands in its place', await waitFor((id) => { const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return rows.length === 3 && rows[2] === id && !document.querySelector('.thread-card .spinner'); }, S2.id, 6000));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }