From 32ae7775d6351ef7acc887de812d5200da95874d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 14 Nov 2026 00:04:40 -0700 Subject: A profile's posts stream in as the relays send them, with post-shaped placeholders until the first arrive, and search warms its top two results first. "when i open someone's profile that i'm visiting for the first time it can take several seconds for any posts to show up. there's no indication that it's loading posts. then suddenly a whole feed appears. can we stream them as they arrive … maybe it could start fetching posts for the top one or two search results before we even click them?" (Adam, 2026-11-13, Ben Arc from search). fetchNotes looked up the author's relay list first, then waited for every relay (4.5 s cap), then for every post's media (8 s cap) before painting anything. Now it asks the home and archive relays at once and the author's own relays as soon as their list is known (queryStreamOn), paints the first post the moment it's verified and a batch every 300 ms after, gives the rows about to be on screen 1.2 s to size their media rather than 8, and merges each batch into the page as before. With nothing in hand the page shows three faint breathing post shapes (aria-busy, "Loading posts…"; still no spinner) instead of an invisible empty line. User search used to queue a profile prefetch for every row of every letter typed, two at a time, so the one tapped waited behind a dozen; now only the top two of the current answer are warmed, ahead of anything queued. Measured on the real relays (tools/profile-stream-test.js, search "fiatjaf", tap the top result): first post 5.8 s / >10 s before, 0.35–0.9 s now, placeholder within 20 ms. profile-open, -feed, -replies, -repost, -pager, feed-custom and features-smoke pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS --- src/features/messages.js | 100 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 20 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 0573def..2892153 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4298,10 +4298,17 @@ export function messagesFeature(ctx) { scheduleRepaint(); }); } - function prefetchProfilePage(pk) { - if (!pk || prefetched.has(pk)) return; + // first: ahead of everything queued (the top search results — the queue + // can hold a dozen people from the letters typed before them) + function prefetchProfilePage(pk, { first = false } = {}) { + if (!pk) return; + if (prefetched.has(pk)) { + const i = prefetchQueue.indexOf(pk); + if (first && i > 0) { prefetchQueue.splice(i, 1); prefetchQueue.unshift(pk); } + return; + } prefetched.add(pk); - prefetchQueue.push(pk); + if (first) prefetchQueue.unshift(pk); else prefetchQueue.push(pk); drainPrefetch(); } function drainPrefetch() { @@ -5325,22 +5332,60 @@ export function messagesFeature(ctx) { fetchNotes(pk, c); return c; } + // Posts arrive as the relays send them: whatever the quick ones hold + // paints while a slow one (or the author's own relays, still being looked + // up) is still answering. It used to wait for the slowest relay (4.5 s), + // after looking up the author's relay list, then for every post's media + // (up to 8 s), and the whole page landed at once — several seconds of + // blank on a first visit (Ben Arc from search, Adam 2026-11-13). + // Batches merge into what's on the page, never swap it: the relays answer + // unevenly (primal held 2 of Joe Nakamoto's posts, ours 18, the archives 30). + const NOTES_FIRST_READY_MS = 1200; // a first screen's media: worth a beat, not a wait function fetchNotes(pk, c) { c.fetching = true; + const filter = { kinds: PROFILE_KINDS, authors: [pk], limit: 30 }; + const incoming = new Map(); + let chain = Promise.resolve(), timer = 0, any = false; + const flush = () => { + timer = 0; + if (!incoming.size) return chain; + const batch = [...incoming.values()]; incoming.clear(); + chain = chain.then(async () => { + const byId = new Map(c.notes.map((e) => [e.id, e])); + const fresh = batch.filter((e) => !byId.has(e.id)); + if (!fresh.length) return; + for (const e of fresh) byId.set(e.id, e); + const merged = newestArticles([...byId.values()]).sort((a, b) => b.created_at - a.created_at); + // the rows about to be on screen, ready (media sized, quotes in hand) + // — briefly: a slow picture host must not hold the page + const top = new Set(merged.slice(0, FEED_PAGE).map((e) => e.id)); + await notesReady(fresh.filter((e) => top.has(e.id)), undefined, NOTES_FIRST_READY_MS); + c.notes = merged; + c.end = false; + any = true; + if (ui.profilePk === pk) render(); + }).catch(() => {}); + return chain; + }; + const take = (e) => { + if (!e || e.pubkey !== pk) return; + incoming.set(e.id, e); + // the first post paints at once; after that, a batch every 300 ms + if (!timer) timer = setTimeout(flush, any || c.notes.length ? 300 : 0); + }; + const asked = new Set([...NOTE_RELAYS, ...PROFILE_ARCHIVE_RELAYS]); (async () => { - const evs = await queryOn(await notesRelays(pk), { kinds: PROFILE_KINDS, authors: [pk], limit: 30 }, 4500); - // Merged with what's already on the page, never swapped for it: the - // relays answer unevenly (primal held 2 of Joe Nakamoto's posts, ours - // 18, the archives 30), and a thin answer used to erase a full page. - const byId = new Map(c.notes.map((e) => [e.id, e])); - let added = 0; - for (const e of evs || []) if (!byId.has(e.id)) { byId.set(e.id, e); added++; } - if (!added && c.notes.length) return; - const merged = newestArticles([...byId.values()]).sort((a, b) => b.created_at - a.created_at); - await notesReady(merged.slice(0, FEED_PAGE)); // the first screen arrives whole - c.notes = merged; - c.end = false; - persistPage('notes', pk, merged.slice(0, 20).map(slimNote)); // cache stays bounded + await Promise.all([ + queryStreamOn([...asked], filter, take, 4500), + // the author's own relays, as soon as their list is known + relaysOf(pk).then((own) => { + const more = (own || []).filter((r) => !asked.has(r)); + return more.length ? queryStreamOn(more, filter, take, 4500) : null; + }).catch(() => {}), + ]); + clearTimeout(timer); + await flush(); + if (any) persistPage('notes', pk, c.notes.slice(0, 20).map(slimNote)); // cache stays bounded })().catch(() => {}).finally(() => { c.status = 'ready'; c.fetching = false; @@ -7410,7 +7455,7 @@ export function messagesFeature(ctx) { await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]); } catch {} finally { clearTimeout(timer); } } - const notesReady = (evs, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); }; + const notesReady = (evs, opts, ms = READY_MS) => { const deadline = Date.now() + ms; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); }; const keyed = (node, key) => { node.setAttribute('data-key', key); return node; }; // Keep only the resources this row actually uses. Copying the entire // session media cache into every row grows quadratically as you scroll. @@ -13902,8 +13947,18 @@ export function messagesFeature(ctx) { return h('div', { class: 'col', style: 'gap:8px' }, tabs, waiting && c.notes.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) - // nothing in hand yet: the empty line holds the height, unseen (no spinner) - : h('div', { class: 'small faint', style: 'text-align:center' + (waiting ? ';visibility:hidden' : '') }, none)); + // nothing in hand yet: the shapes of posts to come, so a first + // visit reads as loading rather than empty ("there's no + // indication that it's loading posts", Adam 2026-11-13) + : waiting + ? h('div', { class: 'card col notes-feed note-ghosts', style: 'gap:0', 'aria-busy': 'true', 'aria-label': t('profNotesLoading') }, + ...[0, 1, 2].map((i) => h('div', { class: 'note-ghost' }, + h('span', { class: 'ng-ava' }), + h('span', { class: 'col grow', style: 'gap:8px' }, + h('span', { class: 'ng-line', style: 'width:38%' }), + h('span', { class: 'ng-line', style: 'width:' + [92, 80, 86][i] + '%' }), + h('span', { class: 'ng-line', style: 'width:' + [64, 72, 48][i] + '%' }))))) + : h('div', { class: 'small faint', style: 'text-align:center' }, none)); } return h('div', { class: 'col', style: 'gap:8px' }, tabs, @@ -17094,7 +17149,12 @@ export function messagesFeature(ctx) { // lands here again since the search state survives. const userSearcher = makeSearcher((q, rows) => { if (ui.userSearch && ui.userSearch.q === q) { ui.userSearch.rows = rows; render(); } - for (const r of rows || []) if (r.pk) prefetchProfilePage(r.pk); + // the likely taps: the top two, warmed first (their posts, profile and + // cover) — not every row of every letter typed, which queued the one + // tapped behind a dozen others + if (!ui.userSearch || ui.userSearch.q !== q) return; + const top = (rows || []).filter((r) => r.pk).slice(0, 2); + for (const r of top.reverse()) prefetchProfilePage(r.pk, { first: true }); }); function userSearchScreen() { const s = ui.userSearch; -- cgit v1.2.3