aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js100
1 files changed, 80 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;