aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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); }