aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js100
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css9
-rw-r--r--tools/profile-stream-test.js58
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);