diff options
| -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); |
