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.js150
1 files changed, 123 insertions, 27 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 0ea7520..82810c3 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -747,11 +747,16 @@ export function messagesFeature(ctx) {
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
- repaintTimer = setTimeout(() => {
+ const run = () => {
+ // not under a pager in motion: the page beneath is out of sight, and
+ // its repaint (40–55 ms on Adam's phone) dropped frames from flights
+ // through the pictures (2026-10-17) — it waits for the landing
+ if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
- }, 80);
+ };
+ repaintTimer = setTimeout(run, 80);
};
// Profiles persist across sessions (capped) so known faces paint right
@@ -12790,6 +12795,8 @@ export function messagesFeature(ctx) {
// is kept out — see pagerReveal, pagerFit), or every one built (all)
const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)';
const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []);
+ // the posts found: the ready ones and those still warming, not those kept out for their shape
+ const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []);
// the slide a post's row rests on
const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; };
// bring a slide to the screen: its post, and within it, the slide
@@ -13073,7 +13080,7 @@ export function messagesFeature(ctx) {
// still after a fast flick; snapping stays off until it lands.
// A longer glide (a flight past several posts) says how long it takes;
// onDone hears the landing, or the interruption (true) that caught it.
- function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) {
+ function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) {
pagerHold(box); // a glide under way is caught where it is
const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from;
box.style.scrollSnapType = 'none';
@@ -13086,7 +13093,7 @@ export function messagesFeature(ctx) {
let t0 = 0;
const step = (now) => {
if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still)
- const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3);
+ const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease);
if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e;
if (k < 1) box._glide = requestAnimationFrame(step); else done();
};
@@ -13139,6 +13146,15 @@ export function messagesFeature(ctx) {
// it is passing and steps back from there; a touch or a key catches it
// too (pagerHold → onDone). A notch from rest is the single step it was.
let fly = null; // { to: the post flown to, n: notches in this run, at, dir }
+ // A pager flies only once ten posts are ready (in the carousel: clips
+ // ready to play, pictures decoded): before that a fling outran the
+ // warming, landing on whatever was ready and fighting the posts joining
+ // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at
+ // least 10 videos fully warmed up"; the picture pager the same: "image
+ // pager should work similar"). Until then a flick or a spin of the wheel
+ // is one post at a time.
+ const PAGER_FLY_READY = 10;
+ const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY;
function pagerFly(dir, now) {
if (!pager) return;
const stops = pagerGroups();
@@ -13153,7 +13169,7 @@ export function messagesFeature(ctx) {
const here = pagerHere();
from = here && here.closest('.mp-group');
}
- const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2);
+ const next = stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2);
const i = (next % stops.length + stops.length) % stops.length;
if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
const to = stops[i];
@@ -13170,9 +13186,16 @@ export function messagesFeature(ctx) {
pager.flying = true;
const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way
if (land) slideFrame(land, -1);
+ // A flight eases out on a square, not a cube: on a cube a long one
+ // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a
+ // nine-post flight), and the picture coming to rest stepped a pixel
+ // at a time — the stutter "towards the end of the fly as it's
+ // snapping" (Adam, 2026-10-17); and it set off at three times its
+ // average speed, faster than the finger that flung it.
pagerGlide(pager.scroller, 'y', dest, vel, {
+ ease: screens > 1.05 ? 2 : 3,
ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0,
- onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); },
+ onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); },
});
}
const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
@@ -13194,31 +13217,44 @@ export function messagesFeature(ctx) {
const slide = rowSlide(group);
if (slide) { pagerShow(slide); pagerArrive(slide); }
}
- // Counted: every post the pager has found, the ones still waiting for
- // their clip to be ready included — only those kept out for their shape
- // aren't. Counting the carousel alone, a refresh showed "1 / 2" over a
- // grid of a dozen clips, the total creeping up as clips warmed (Adam,
- // 2026-10-17: "the total count persists").
+ // Counted: the posts in the carousel — the ones that can be reached. (All
+ // the posts found, the waiting ones too, was tried and undone: a count
+ // that can't be scrolled to confused — Adam, 2026-10-17.)
function pagerPosition() {
if (!pager) return;
- const groups = pagerGroups(true).filter((g) => !g.classList.contains('mp-off'));
+ const groups = pagerGroups();
const slide = pager.cur || pager.anchor;
- const index = groups.indexOf(slide && slide.closest('.mp-group'));
- pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length;
+ let index = groups.indexOf(slide && slide.closest('.mp-group'));
+ // in a flight, the post passing under the reader: the count runs with
+ // the slides rather than jumping on landing (Adam, 2026-10-17: "the
+ // counter should animate as we fly")
+ if (pager.flying && groups.length) {
+ const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2;
+ let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen
+ while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; }
+ index = lo;
+ }
+ const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position');
+ if (el.textContent !== text) el.textContent = text;
+ // a video pager shows no count until it can fly: ten clips ready (Adam,
+ // 2026-10-17: "let's also not show the position/total counter until we have 10")
+ // (one with fewer posts than that, every one of them ready, counts them)
+ pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)'));
}
function buildPager(want) {
const video = want.kind === 'videos';
const scroller = h('div', { class: 'mp-scroll' });
+ scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true });
const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' },
scroller,
h('div', { class: 'mp-position', role: 'status' }, '0 / 0'),
h('div', { class: 'mp-top' },
h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'),
- video ? h('div', { class: 'mp-top-r' },
- clipsToggle('mp-fit', () => pagerFit()),
- h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
- h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute')))) : null),
+ h('div', { class: 'mp-top-r' },
+ clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }),
+ video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
+ h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)),
// a mouse's way through (shown where there is a pointer to hover with,
// style.css): up and down the posts at the right edge, left and right
// along a post's pictures when it has more than one (has-left/right)
@@ -13312,7 +13348,15 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? dy : dx;
d.far = Math.max(d.far || 0, Math.hypot(dx, dy));
if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta;
- d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp)
+ // Timed when the finger moved (the event's timeStamp), not when the
+ // move was handled: right after a refresh, with clips warming, moves
+ // queue behind other work and arrive in bursts — timed on handling, a
+ // burst after a stall read as a fling (a short flick flew) and a
+ // stall at the end as a crawl (a long flick took one step) (Adam,
+ // 2026-10-17: "sometimes a quick long flick only proceeds to the next
+ // video … sometimes … a short flick does fly"). The handling time is
+ // kept for moves that share a timeStamp (synthetic ones can).
+ d.track.push({ t: e.timeStamp, h: performance.now(), v: delta });
while (d.track.length > 2 && d.track[d.track.length - 1].t - d.track[1].t >= TRACK_MS) d.track.shift();
}, { passive: false });
// let go of a drag: on to the next (or the one before), or back
@@ -13322,7 +13366,7 @@ export function messagesFeature(ctx) {
if (!stops.length) return;
// the stop it set off from, then one along (or back) — or, flung, several
const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0);
- const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
const next = from + step * posts;
// Past either end, round to the other: the first post (going up, the
// last) — a step, or a fling whose coast is mostly beyond the end. A
@@ -13349,7 +13393,8 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? t.clientY - d.y : t.clientX - d.x;
const size = d.axis === 'y' ? d.box.clientHeight : d.box.clientWidth;
const a = d.track[0], b = d.track[d.track.length - 1];
- const vel = a && b && b.t > a.t ? (b.v - a.v) / (b.t - a.t) : 0;
+ const dt = a && b ? (b.t - a.t >= 8 ? b.t - a.t : b.h - a.h) : 0;
+ const vel = dt > 0 ? (b.v - a.v) / dt : 0;
const tap = d.far < TAP_SLOP && Date.now() - d.at < TAP_MS;
const go = !tap && (Math.abs(delta) > size * (d.axis === 'y' ? SWIPE_Y : SWIPE_X) || (Math.abs(delta) >= FLICK_MIN && Math.abs(vel) > FLICK && Math.sign(vel) === Math.sign(delta)));
settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0);
@@ -13442,6 +13487,7 @@ export function messagesFeature(ctx) {
playSlide(slide);
pagerCounts(slide);
pagerLoadAhead();
+ pagerWarmPics();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -13466,8 +13512,9 @@ export function messagesFeature(ctx) {
// clip's slide is off its mark, and the bar rode along with it, a
// white line sliding up the screen (Adam, 2026-10-16: "i don't want to
// see the seek bar while i'm transitioning to the next video"). It
- // fades back in once the clip is square on the screen.
- if (video) {
+ // fades back in once the clip is square on the screen. (The motion flag
+ // is a picture pager's too: its counter shows while it moves.)
+ {
const p = pager;
const frame = () => {
if (pager !== p) return;
@@ -13533,7 +13580,8 @@ export function messagesFeature(ctx) {
try {
// 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;
+ // (the posts found, waiting ones too: ready ones come only as fast as they warm)
+ if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
const src = pagerLoadSource(p);
if (src.done()) break;
const before = src.progress();
@@ -13565,7 +13613,8 @@ export function messagesFeature(ctx) {
let g = pager.groups.get(gk);
if (!g) {
g = pagerGroup();
- if (pager.kind === 'videos' && !frugalData()) g.classList.add('mp-wait'); // out of the carousel until its clip is ready to play
+ // out of the carousel until it's ready: a clip to play, a picture decoded
+ if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait');
pager.groups.set(gk, g);
pager.scroller.append(g);
}
@@ -13577,6 +13626,53 @@ export function messagesFeature(ctx) {
}
pagerFit();
pagerPosition();
+ pagerWarmPics();
+ }
+ // A picture post is ready once its picture is decoded: only then does it
+ // join the carousel, so a flight passes over pictures already in hand.
+ // They were fetched and decoded as the flight came near them, and the
+ // flight stuttered — dropped frames with no script running, the decoding
+ // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen
+ // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a
+ // time are decoded ahead of the reader (and a few behind, for the way
+ // back), out of sight; its size learned, a picture the wrong way round
+ // for the screen stays out like a clip (pagerFit). One that can't be had
+ // at all — copy and original both gone — stays out.
+ const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3;
+ function pagerWarmPics() {
+ if (!pager || pager.kind !== 'images') return;
+ const all = pagerFound();
+ const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group')));
+ let busy = all.filter((g) => g._warming).length;
+ const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed;
+ for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) {
+ if (!all[j].classList.contains('mp-wait')) { ready++; continue; }
+ if (want(all[j])) { warmPic(all[j]); busy++; }
+ }
+ for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; }
+ }
+ function warmPic(g) {
+ const p = pager;
+ g._warming = true;
+ for (const x of g._row.children) pagerAttach(x);
+ const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media');
+ // the copy failing falls back to the original (its onError): decoded again
+ const decode = (n) => {
+ const src = m.getAttribute('src');
+ return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() =>
+ n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false));
+ };
+ const timeout = new Promise((r) => setTimeout(() => r(false), 20000));
+ Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => {
+ g._warming = false;
+ if (pager !== p || !g.isConnected) return;
+ if (!ok) { g._failed = true; pagerWarmPics(); return; }
+ const url = m && m.getAttribute('data-full');
+ if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape
+ pagerFit();
+ pagerToggleGroup(g, 'mp-wait', false);
+ pagerWarmPics();
+ });
}
// A post's clip is ready to play when its first frame is on the slide and
// its opening is in the cache (the head start done): only then does the
@@ -13602,10 +13698,10 @@ export function messagesFeature(ctx) {
// the posts whose clips are the wrong way round for the screen stay out
// (not the one opened on; a shape not yet known is let in)
function pagerFit() {
- if (!pager || pager.kind !== 'videos') return;
+ if (!pager) return;
const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
for (const g of pagerGroups(true)) {
- const fits = g._opened || g === cg || [...g._row.children].some((s) => { const v = s.querySelector('video.mp-media'); return !v || clipFits(v.getAttribute('data-src')) !== false; });
+ const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; });
pagerToggleGroup(g, 'mp-off', !fits);
}
}