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.js73
1 files changed, 68 insertions, 5 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 {} }