aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 12:59:57 -0700
committerAdam Soltys <[email protected]>2026-09-14 12:59:57 -0700
commitd730ea49feb389b43362b1044964f5a9e03ae4ff (patch)
tree5f99d011d6c994b57f45d657d3eb8868e1d4ded8
parentdd1489303f59ccef6b322142f94e08d750c06e79 (diff)
downloadcoinosv3-d730ea49feb389b43362b1044964f5a9e03ae4ff.tar.gz
coinosv3-d730ea49feb389b43362b1044964f5a9e03ae4ff.zip
A post arrives whole.
A feed row painted the moment its event landed and then finished itself over the next second or two: a punk turning into a photograph, a blank gap becoming the picture. Now a post waits at the door until its author's profile has landed and their face and the images in its body are fetched and decoded — bounded by two seconds, so a slow host holds a post briefly rather than the feed forever. This covers the first load, live arrivals (which are counted behind the pill only once whole), paging older posts, and profile pages. The cached page at boot still paints instantly and warms in the background. feed-live-test serves a deliberately slow picture and checks, from the first instant the row exists, that its image is already decoded. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
-rw-r--r--src/features/messages.js73
-rw-r--r--tools/feed-live-test.js39
2 files changed, 105 insertions, 7 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index b28b899..3328d74 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2453,6 +2453,7 @@ export function messagesFeature(ctx) {
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => b.created_at - a.created_at);
if (fresh.length || !c.notes.length) {
+ await notesReady(fresh.slice(0, FEED_PAGE)); // the first screen arrives whole
c.notes = fresh;
persistPage('notes', pk, fresh.slice(0, 20).map(slimNote)); // cache stays bounded
}
@@ -2493,7 +2494,7 @@ export function messagesFeature(ctx) {
const older = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => b.created_at - a.created_at);
- if (older.length) c.notes = [...c.notes, ...older];
+ if (older.length) { await notesReady(older); c.notes = [...c.notes, ...older]; }
else c.end = true; // the relays have nothing older
} catch {} finally {
c.loadingMore = false;
@@ -2774,6 +2775,9 @@ export function messagesFeature(ctx) {
let stored = [];
try { stored = wallet.loadFeatureState(FEED_CACHE, []) || []; } catch {}
feed = { status: stored.length ? 'ready' : 'loading', notes: stored, shown: FEED_PAGE };
+ // the cached page is on screen already; warm what it shows so the
+ // scroll below the fold, and the next boot, paint whole too
+ notesReady(stored.slice(0, FEED_PAGE)).catch(() => {});
refreshFeed();
}
return feed;
@@ -2792,11 +2796,70 @@ export function messagesFeature(ctx) {
try { return (window.scrollY || 0) < FEED_TOP_PX; } catch { return true; }
};
- function mergeFeed(evs, opts = {}) {
+ // ---- a post arrives whole ------------------------------------------------
+ // A row used to paint the moment its event landed, and then finish itself
+ // over the next second or two: a punk turning into a photograph, a blank
+ // gap becoming the picture. So a post now waits at the door until its
+ // author's face and the images in its body are fetched and decoded — or
+ // have had a fair chance. A slow host holds a post for a couple of seconds,
+ // not forever, and a batch never waits longer than one post would.
+ const READY_MS = 2000;
+ const mediaReady = new Set(); // URLs decoded (or given up on) this session
+ const warmMedia = (url) => new Promise((resolve) => {
+ if (!url || mediaReady.has(url) || typeof Image === 'undefined') return resolve();
+ const done = () => { mediaReady.add(url); resolve(); };
+ try {
+ const img = new Image();
+ img.onload = () => { (img.decode ? img.decode() : Promise.resolve()).then(done, done); };
+ img.onerror = done; // a picture that won't load won't get better by waiting
+ img.src = url;
+ } catch { done(); }
+ });
+ // The pictures a body will show: inline images (by extension, or markdown
+ // saying so outright) and a video's poster still. Four at most — a
+ // gallery post shows its first row whole and fills in the rest.
+ function noteMediaUrls(content) {
+ const urls = [];
+ for (const part of String(content || '').split(NOTE_SPLIT)) {
+ if (!part || urls.length >= 4) continue;
+ const md = MD_PARTS.exec(part);
+ const url = md ? md[3] : (/^https?:\/\//i.test(part) ? part : null);
+ if (!url) continue;
+ if ((md && md[1]) || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) urls.push(url);
+ else if (youtubeId(url)) urls.push('https://i.ytimg.com/vi/' + youtubeId(url) + '/hqdefault.jpg');
+ }
+ return urls;
+ }
+ // The face: wait for the profile to land (the batch asks within a beat),
+ // then for whatever avatarBg would paint from — the local thumbnail is a
+ // data URL and costs nothing; an original goes through the network once.
+ async function warmAvatar(pk, deadline) {
+ let p = profileOf(pk);
+ while ((p === null || (p && p.loading && !p.picture)) && Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 100));
+ p = profiles.get(pk);
+ }
+ if (!p || !p.picture) return; // punk art: drawn from the pubkey, no fetch
+ if (localPunk(p.picture) || (p.thumb && p.thumbFor === p.picture)) return;
+ await warmMedia(p.picture);
+ }
+ function noteReady(ev, deadline = Date.now() + READY_MS) {
+ const wait = Promise.all([warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia)]);
+ return Promise.race([wait, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]).catch(() => {});
+ }
+ const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); };
+
+ // Posts at the door: filtered in synchronously, so the same note from a
+ // second relay is dropped while the first copy is still warming.
+ const feedStaged = new Set();
+ async function mergeFeed(evs, opts = {}) {
const c = feedNow();
const known = new Set([...c.notes, ...(c.pending || [])].map((e) => e.id));
- const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !known.has(e.id) && known.add(e.id));
+ const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey)
+ && !known.has(e.id) && !feedStaged.has(e.id) && known.add(e.id) && feedStaged.add(e.id));
if (!add.length) return false;
+ await notesReady(add);
+ for (const e of add) feedStaged.delete(e.id);
// A post that arrived on its own while you were reading waits behind the
// pill instead of shoving the page down. Anything you asked for — a
// refresh, a scroll to the bottom, the first load — goes straight in.
@@ -2845,7 +2908,7 @@ export function messagesFeature(ctx) {
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
return chunks.map(async (chunk) => {
const evs = await queryOn(relays, { kinds: [1], authors: chunk, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
- if (mergeFeed(evs, merge)) { got = true; scheduleRepaint(); }
+ if (await mergeFeed(evs, merge)) { got = true; scheduleRepaint(); }
});
}));
return got;
@@ -2869,7 +2932,7 @@ export function messagesFeature(ctx) {
for (const { relays, authors } of outboxPlan(feedAuthors()))
for (let i = 0; i < authors.length; i += REQ_AUTHORS)
feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: authors.slice(i, i + REQ_AUTHORS), since },
- (ev) => { if (mergeFeed([ev], { live: true })) scheduleRepaint(); }));
+ (ev) => { mergeFeed([ev], { live: true }).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js
index fd50191..afbbf10 100644
--- a/tools/feed-live-test.js
+++ b/tools/feed-live-test.js
@@ -30,7 +30,15 @@ const signed = (content, ago) => finalizeEvent(
const seeded = Array.from({ length: 25 }, (_, i) => signed('post number ' + (i + 1), 600 + i * 60));
const html = await buildHtml({ minify: true, pwa: false });
-const server = Bun.serve({ port: 5296, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+// a picture that takes its time, so a post showing it must have waited
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
+const server = Bun.serve({ port: 5296, fetch: async (req) => {
+ if (new URL(req.url).pathname === '/slow.png') {
+ await sleep(700);
+ return new Response(PNG, { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } });
+ }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
@@ -111,7 +119,10 @@ try {
const fresh = signed('BRAND NEW POST', 0);
const delivered = await page.evaluate((ev) => window.__inject(ev), fresh);
check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)');
- await sleep(900);
+ // A post waits at the door for its author's face and its pictures (or the
+ // two-second deadline, for a face nobody has) before it is even counted —
+ // so give it that long, and no longer.
+ await sleep(2600);
const after = await page.evaluate(() => ({
y: Math.round(window.scrollY),
pill: (document.querySelector('.feed-new-pill') || {}).textContent || '',
@@ -145,6 +156,30 @@ try {
await page.evaluate(() => /ANOTHER ONE/.test(document.querySelector('.notes-feed')?.innerText || '')
&& !document.querySelector('.feed-new-pill')));
+ console.log('\n[a post arrives whole]');
+ // A row used to paint first and fill in its picture after. Watch the feed
+ // from the instant the post is injected: the first time the row exists,
+ // its image must already be decoded.
+ await page.evaluate(() => {
+ window.__firstSight = null;
+ const feed = document.querySelector('.notes-feed');
+ const look = () => {
+ if (window.__firstSight) return;
+ const row = [...feed.querySelectorAll('.row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
+ if (!row) return;
+ const img = row.querySelector('img.note-img');
+ window.__firstSight = { img: !!img, ready: !!(img && img.complete && img.naturalWidth > 0), at: performance.now() };
+ };
+ new MutationObserver(look).observe(feed, { childList: true, subtree: true });
+ });
+ const t0 = await page.evaluate(() => performance.now());
+ await page.evaluate((ev) => window.__inject(ev), signed('PICTURE POST http://localhost:5296/slow.png?' + Date.now(), 0));
+ await sleep(3000);
+ const sight = await page.evaluate(() => window.__firstSight);
+ check('the post is in the feed', !!sight && sight.img, JSON.stringify(sight));
+ check('...and its picture was already decoded the first time the row existed', !!sight && sight.ready);
+ check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : '');
+
console.log('\n[coming back after being away]');
// the throttle would otherwise swallow a refresh this soon after the last
// the app was hidden for a while, then focused again