aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-14 23:38:07 -0700
committerAdam Soltys <[email protected]>2026-10-14 23:38:07 -0700
commitc0f1f1de0c7dca3d35cc19bb3e18487666c3ba5c (patch)
tree64996bfca176d9b6112235de94d7d70cae6a1202
parent14de2121fa1c806048689010cf85d4f65b6e28c1 (diff)
downloadcoinosv3-c0f1f1de0c7dca3d35cc19bb3e18487666c3ba5c.tar.gz
coinosv3-c0f1f1de0c7dca3d35cc19bb3e18487666c3ba5c.zip
Share eager media pager loading across feed, profile, and grid
-rw-r--r--src/features/messages.js74
-rw-r--r--tools/pager-loading-test.js82
-rw-r--r--tools/pager-wheel-test.js2
3 files changed, 131 insertions, 27 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index c273d32..59dc966 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -11512,7 +11512,7 @@ export function messagesFeature(ctx) {
// time, no waiting on avatars, pictures and link cards (the grid shows
// none of that), and the feed's own page of posts left as it was.
let mediaCrawling = null;
- function crawlMedia(c = feedNow()) {
+ function crawlMedia(c = feedNow(), kind = mediaMode === 'videos' ? 'videos' : 'images') {
const def = feedDef(c.id);
if (!def || !feedHasQuery(def)) return Promise.resolve();
if (def.curated) return loadOlderFeed(); // a curated feed pages its own way
@@ -11522,7 +11522,6 @@ export function messagesFeature(ctx) {
scheduleRepaint();
// Clips are rare (one post in a hundred, on a busy relay), so a crawl
// keeps reading pages until it has found a few, or read several.
- const kind = mediaMode === 'videos' ? 'videos' : 'images';
const had = r[kind].size;
for (let pass = 0; pass < 5 && r[kind].size - had < 6; pass++) {
const from = [r.until, c.notes.at(-1)?.created_at].filter(Boolean);
@@ -12824,9 +12823,7 @@ export function messagesFeature(ctx) {
else if (pager.profile) markSeen(slide._ev);
playSlide(slide);
pagerCounts(slide);
- // near the end: ask the feed for more, the pager picks them up
- if (pagerSource()) pagerFeedAhead();
- else { const gs = pagerGroups(); if (gs.indexOf(slide.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); }
+ pagerLoadAhead();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -12844,6 +12841,7 @@ export function messagesFeature(ctx) {
if (first) pagerShow(first);
pager.anchor = first; // the slide meant to be on screen, from the first moment
pager.screenH = scroller.clientHeight;
+ pagerLoadAhead();
// the clip on screen's seek bar, every frame (timeupdate comes a few times a second)
if (video) {
const p = pager;
@@ -12866,33 +12864,55 @@ export function messagesFeature(ctx) {
};
window.addEventListener('resize', pager.onResize);
// new posts land in the feed while the pager is open; counts move
- pager.tick = setInterval(() => { if (pager) { growPager(); pagerFeedAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
+ pager.tick = setInterval(() => { if (pager) { growPager(); pagerLoadAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
}
- // A pager opened from the feed walks the feed's own posts, which page in
- // ten at a time as the feed is scrolled. Swiping is the scrolling here:
- // with only a few posts left below the one on screen (PAGER_AHEAD), the feed pages on
- // (the posts it holds, then older ones from the relays) until there are
- // more — several pages if those posts carry none. A page that brings
- // nothing (the feed busy, or at its end) stops it; the tick tries again.
- // how many posts ahead to keep: clips are fewer and further between
- // (more pages to find the next) and each is watched a while, so further
- const PAGER_AHEAD = { images: 4, videos: 6 };
- let pagerFeeding = false;
- async function pagerFeedAhead() {
- const src = pagerSource();
- if (pagerFeeding || !src) return;
- pagerFeeding = true;
+ // All media pagers build the same buffer, retaining the source's ordering
+ // and filters. The furthest post reached never moves back when we wrap,
+ // so pending refills continue while the reader circles through older items.
+ const PAGER_INITIAL = 30, PAGER_AHEAD = 20;
+ function pagerLoadSource(p) {
+ const src = pagerSource(p);
+ if (src) return {
+ more: src.more, done: src.done,
+ progress: () => src.c.shown + ':' + src.c.notes.length,
+ };
+ // A profile cache can be replaced while refreshing; never fall back
+ // to another source in that gap. The next timer tick will try again.
+ if (p.profile || p.fromFeed) return { more: async () => {}, done: () => true, progress: () => '' };
+ const c = feedNow(), r = mediaIndex(c.id);
+ const curated = feedDef(c.id)?.curated;
+ return {
+ more: () => crawlMedia(c, p.kind),
+ done: () => c.stopped || (curated ? c.end && c.shown >= c.notes.length : !!r.end),
+ progress: () => [r.until, r[p.kind].size, c.shown, c.notes.length].join(':'),
+ };
+ }
+ async function pagerLoadAhead() {
+ const p = pager;
+ if (!p) return;
+ const groups = pagerGroups();
+ const slide = p.cur || p.anchor;
+ p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group')));
+ if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return;
+ p.loadingAhead = true;
+ let stalled = false;
try {
- const c = src.c;
- for (let pages = 0; pages < 8 && pager && pagerSource(); pages++) {
- const gs = pagerGroups();
- if (gs.length - 1 - gs.indexOf(pager.cur && pager.cur.closest('.mp-group')) > (PAGER_AHEAD[pager.kind] || 4)) break;
- const shown = c.shown, held = c.notes.length;
+ // Bound each run; the timer resumes if sparse media needs more pages.
+ for (let pages = 0; pages < 8 && pager === p; pages++) {
+ if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
+ const src = pagerLoadSource(p);
+ if (src.done()) break;
+ const before = src.progress();
await src.more();
+ if (pager !== p) return;
growPager();
- if (c.shown === shown && c.notes.length === held) break;
+ if (src.progress() === before) { stalled = true; break; }
}
- } catch {} finally { pagerFeeding = false; }
+ } catch { stalled = true; } finally {
+ p.loadingAhead = false;
+ // An empty or busy source gets a breather, never concurrent requests.
+ p.retryAheadAt = Date.now() + (stalled ? 10000 : 1500);
+ }
}
// Slides for feed items the pager doesn't have yet: into their post's row
// (a quote fetched late joins its post), a new post appended below (never
diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js
new file mode 100644
index 0000000..76e2764
--- /dev/null
+++ b/tools/pager-loading-test.js
@@ -0,0 +1,82 @@
+// Exercise the production refill scheduler with delayed and sparse sources.
+// Run: bun tools/pager-loading-test.js
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import assert from 'node:assert/strict';
+
+const source = readFileSync(new URL('../src/features/messages.js', import.meta.url), 'utf8');
+const scheduler = source.slice(source.indexOf(' const PAGER_INITIAL ='), source.indexOf(' // Slides for feed items', source.indexOf(' const PAGER_INITIAL =')));
+function fixture(kind = 'images', grid = false) {
+ let clock = 100000, calls = 0, pending = null, count = 3;
+ const groups = Array.from({ length: 300 }, () => ({}));
+ const cache = { shown: 3, notes: Array(3), stopped: false };
+ const index = { until: 10000, images: new Map(), videos: new Map() };
+ const p = { kind, cur: { closest: () => groups[0] } };
+ const more = async () => {
+ calls++;
+ if (pending) await pending;
+ count += 10;
+ cache.shown += 10; cache.notes.length += 10;
+ index.until -= 100;
+ };
+ const src = { c: cache, more, done: () => false };
+ const ctx = vm.createContext({
+ pager: p, pagerGroups: () => groups.slice(0, count),
+ pagerSource: () => grid ? null : src, feedNow: () => cache, mediaIndex: () => index, feedDef: () => ({}),
+ crawlMedia: (c, k) => { assert.equal(c, cache); assert.equal(k, kind); return more(); },
+ growPager: () => {}, Date: { now: () => clock },
+ });
+ vm.runInContext(scheduler, ctx);
+ return {
+ ctx, p, src, cache, index, load: () => ctx.pagerLoadAhead(),
+ go: (i) => { p.cur = { closest: () => groups[i] }; },
+ advance: () => { clock += 11000; },
+ delay: () => { let resolve; pending = new Promise((r) => { resolve = r; }); return () => { pending = null; resolve(); }; },
+ count: () => count, calls: () => calls,
+ };
+}
+
+for (const grid of [false, true]) for (const kind of ['images', 'videos']) {
+ const f = fixture(kind, grid);
+ await f.load();
+ assert.equal(f.calls(), 3, 'opening starts loading immediately to at least 30 posts');
+ f.advance(); await f.load();
+ assert.equal(f.calls(), 3, 'a full buffer does not download forever');
+ f.go(29); f.advance();
+ const finish = f.delay();
+ const loading = f.load();
+ assert.equal(f.calls(), 4, 'refill starts well before the end');
+ f.go(0); await f.load();
+ assert.equal(f.calls(), 4, 'wrapping during a request does not start a concurrent one');
+ finish(); await loading;
+ assert.equal(f.count(), 53, 'refill continues after wrapping, keeping 20 beyond the furthest post');
+ console.log('✓ early loading and wrap refill:', grid ? 'grid' : 'feed/profile', kind);
+}
+{
+ const f = fixture();
+ f.src.more = async () => { f.cache.notes.length++; };
+ await f.load();
+ assert.equal(f.cache.notes.length, 11, 'sparse results are limited to eight requests per run');
+ f.advance(); await f.load();
+ assert.equal(f.cache.notes.length, 19, 'timer can resume sparse results from the first post');
+}
+{
+ const f = fixture();
+ let calls = 0;
+ f.src.more = async () => { calls++; };
+ await f.load(); await f.load();
+ assert.equal(calls, 1, 'an empty result backs off');
+ f.advance(); await f.load();
+ assert.equal(calls, 2, 'an empty source can retry');
+ f.src.done = () => true;
+ f.advance(); await f.load();
+ assert.equal(calls, 2, 'an exhausted source is not requested');
+}
+{
+ const f = fixture();
+ const finish = f.delay(), loading = f.load();
+ f.ctx.pager = { kind: 'videos' };
+ finish(); await loading;
+ assert.equal(f.calls(), 1, 'closing or switching pagers stops the old refill');
+}
+console.log('✓ sparse sources, backoff, exhaustion, and pager lifecycle');
diff --git a/tools/pager-wheel-test.js b/tools/pager-wheel-test.js
index 1828f77..45a5115 100644
--- a/tools/pager-wheel-test.js
+++ b/tools/pager-wheel-test.js
@@ -94,6 +94,8 @@ try {
console.log('\n[a wheel notch is a step]');
await openPic('two pictures');
check('the picture opens in the pager', await resting('a1.png'), await curPic());
+ check('opening fills the media buffer without scrolling to the end', await waitFor(() => document.querySelectorAll('.mp-group').length === 15, null, 8000));
+ check('background loading keeps the first picture on screen', await curPic() === 'a1.png');
await page.mouse.move(640, 450);
await wheel(100);
check('a notch down: the next post\'s picture', await resting('b.png'), await curPic());