aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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