diff options
| author | Adam Soltys <[email protected]> | 2026-11-14 00:04:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-14 00:04:40 -0700 |
| commit | 32ae7775d6351ef7acc887de812d5200da95874d (patch) | |
| tree | f03775602348909a45697131b6ed65ac9d2c0ed7 | |
| parent | 8d92028c0a4eeb19258e81156c5b9efc3c1d1f88 (diff) | |
| download | coinosv3-32ae7775d6351ef7acc887de812d5200da95874d.tar.gz coinosv3-32ae7775d6351ef7acc887de812d5200da95874d.zip | |
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
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS
| -rw-r--r-- | src/features/messages.js | 100 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/style.css | 9 | ||||
| -rw-r--r-- | tools/profile-stream-test.js | 58 |
4 files changed, 148 insertions, 20 deletions
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; diff --git a/src/i18n.js b/src/i18n.js index dde69c9..baf2012 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -510,6 +510,7 @@ const STR = { feedFindPeople: 'Find people', feedEmpty: 'Nothing posted yet.', feedLoadingMore: 'Loading more posts…', + profNotesLoading: 'Loading posts…', feedEnd: 'You’re all caught up.', liveBadge: 'LIVE', livePlanned: 'SOON', diff --git a/src/style.css b/src/style.css index ec9fd18..55badb6 100644 --- a/src/style.css +++ b/src/style.css @@ -2121,3 +2121,12 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .ptr.ready { color: var(--ink); } .ptr.spin svg { animation: ptr-spin .8s linear infinite; } @keyframes ptr-spin { to { transform: rotate(360deg); } } + +/* a profile's posts on their way: three faint post shapes, breathing */ +.note-ghost { display: flex; gap: 12px; padding: 14px 14px 18px; border-bottom: 1px solid var(--line); } +.note-ghost:last-child { border-bottom: 0; } +.note-ghost .ng-ava { width: 40px; height: 40px; border-radius: 50%; background: color-mix(in srgb, var(--ink) 9%, transparent); flex: none; } +.note-ghost .ng-line { display: block; height: 11px; border-radius: 6px; background: color-mix(in srgb, var(--ink) 9%, transparent); } +.note-ghosts { animation: ng-breathe 1.4s ease-in-out infinite; } +@keyframes ng-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .note-ghosts { animation: none; } } diff --git a/tools/profile-stream-test.js b/tools/profile-stream-test.js new file mode 100644 index 0000000..8d15775 --- /dev/null +++ b/tools/profile-stream-test.js @@ -0,0 +1,58 @@ +// A profile visited for the first time, from search: its posts used to land +// all at once after several seconds of blank (the slowest relay, then every +// post's media). Now the page shows post-shaped placeholders while it waits, +// the first posts paint as the quick relays answer, and the top search +// results are fetched before they're tapped. Reads real relays +// (relays: 'read'; nothing is published). +// Run: bun tools/profile-stream-test.js (Q="ben arc" to search someone else) +import puppeteer from './puppeteer.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const Q = process.env.Q || 'fiatjaf'; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +try { + await page.setViewport({ width: 390, height: 844 }); + if (process.env.DARK) await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); + await page.goto(server.url.href + 'feed', { waitUntil: 'domcontentloaded' }); + await sleep(1500); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'Search')?.click()); + await page.waitForSelector('.user-search-main', { timeout: 10000 }); + await page.type('.user-search-main', Q, { delay: 60 }); + // the result rows: people (not the "posts that say this" or #topic rows) + const rows = () => page.evaluate(() => [...document.querySelectorAll('.list .item')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim())).map((r) => r.textContent.trim().slice(0, 40))); + let found = []; + for (let i = 0; i < 60 && !found.length; i++) { found = await rows(); if (!found.length) await sleep(250); } + if (!found.length) console.log(await page.evaluate(() => document.querySelector('.user-search-main')?.closest('.col')?.parentElement?.innerHTML.slice(0, 1500))); + check('search finds people', found.length > 0, found.slice(0, 2).join(' | ')); + const wait = Number(process.env.WAIT || 0); // ms between results and the tap (prefetch's head start) + if (wait) await sleep(wait); + const t0 = Date.now(); + await page.evaluate(() => [...document.querySelectorAll('.list .item')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim()))[0].click()); + let ghostAt = 0, firstAt = 0, rowsAt3 = 0; + for (let i = 0; i < 200; i++) { + const s = await page.evaluate(() => ({ + ghost: !!document.querySelector('.note-ghosts'), + rows: document.querySelectorAll('.notes-feed:not(.note-ghosts) > [data-key]').length, + })); + const t = Date.now() - t0; + if (s.ghost && !ghostAt) { ghostAt = t; if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT }); } + if (s.rows && !firstAt) firstAt = t; + if (t >= 3000 && !rowsAt3) rowsAt3 = s.rows || -1; + if (firstAt && t >= 3000) break; + await sleep(50); + } + console.log(` placeholder ${ghostAt ? ghostAt + ' ms' : 'not seen'}, first post ${firstAt || '—'} ms, rows at 3 s ${rowsAt3}`); + check('posts arrive', firstAt > 0); + check('...while waiting, the page says posts are loading', !!ghostAt || firstAt < 300, ghostAt ? 'placeholder shown' : 'posts were already in hand'); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); } +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); |
