diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 100 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/style.css | 9 |
3 files changed, 90 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; } } |
