From 8f80948eb174a273f3b1dea963bb91745bcf323b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 13 Oct 2026 06:17:39 +0000 Subject: Media pager: up and down go from post to post, left and right along a post's pictures. Adam: swipes and arrows up/down should go to the next post's media, left/right to the next within a post. The pager is now posts stacked top to bottom, each a row of its pictures or clips side by side — a quoted post's join the post that quotes it — with dots along the top when a post has more than one, saying how many and which. Up and down (swipe, arrows, j/k, Page) move a post at a time and land on a post where its row was left; left and right (swipe, arrows, h/l) move within the post and stop at its ends. A post's pictures all load as it comes near (those along its row too); a clip's head start goes to the next post's (where its row rests), the next along this one, and the post after next, at half; the look-ahead (the feed paging on, the media crawl) counts posts. Pictures pan sideways as well as up and down at 1×. Tests find the slide on screen by where it sits (the pager's children are posts now); feed-picture-pager walks right within a post, down past one with none, back up to where a post was left, left to its first, and a sideways swipe next to a swipe up; quote-pager walks the quoting post's row (its own picture, then the quote's). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt --- src/features/messages.js | 104 ++++++++++++++++++++++++++++++++++++----------- src/style.css | 21 +++++++++- 2 files changed, 100 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index a3c7cd5..6548e99 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12225,8 +12225,33 @@ export function messagesFeature(ctx) { }; return bar; } - // the pager's slides, without the loading one at the end - const pagerSlides = () => (pager ? [...pager.scroller.children].filter((x) => !x._tail) : []); + // The pager is posts stacked top to bottom (up and down go from post to + // post), each a row of its pictures or clips — its quotes' too — side by + // side (left and right go within it). Its slides, in reading order: + const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []); + const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []); + // 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 + function pagerShow(slide) { + const g = slide && slide.closest('.mp-group'); + if (!g) return; + pager.scroller.scrollTop = g.offsetTop; + g._row.scrollLeft = slide.offsetLeft; + } + // what's next from a slide: down (the next post, where its row rests), + // along (the next in this post), and the post after next + function pagerAhead(slide) { + const out = []; + const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; }; + const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1); + const down = g1 && rowSlide(g1); + if (down) out.push(down); + if (slide.nextElementSibling) out.push(slide.nextElementSibling); + const down2 = g2 && rowSlide(g2); + if (down2) out.push(down2); + return out; + } function pagerAttach(slide) { const m = slide.querySelector('.mp-media'); if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src')); @@ -12252,19 +12277,21 @@ export function messagesFeature(ctx) { const slides = pagerSlides(); const i = slides.indexOf(slide); const eager = !frugalData(); + const ahead = pagerAhead(slide); slides.forEach((s, j) => { - if (Math.abs(j - i) <= 2) slidePoster(s); + const k = ahead.indexOf(s); + if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s); if (j === i) return; const v = s.querySelector('video.mp-media'); - if (eager && (j === i + 1 || j === i + 2)) { + if (eager && k >= 0) { pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src')), j === i + 1 ? 1 : 0.5); + headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); return; } - if (j === i + 1) { + if (k === 0) { const url = v.getAttribute('data-src'); probeVideo(url).then(() => { - if (!pager || pager.cur === s || videoHeavy(url) || slides.indexOf(pager.cur) !== i) return; + if (!pager || pager.cur === s || videoHeavy(url) || pager.cur !== slide) return; v.preload = 'metadata'; pagerAttach(s); }); @@ -12468,7 +12495,10 @@ export function messagesFeature(ctx) { for (const e of entries) { if (!e.isIntersecting) continue; watchZaps([e.target._ev.id]); - if (pager.kind === 'images') pagerAttach(e.target); // pictures are cheap; clips are pagerNeighbours' call + // pictures are cheap — a post's all at once, the ones along its row + // too (out of sight sideways, they'd load only on arrival); clips + // are pagerNeighbours' call + if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); } }, { root: scroller, rootMargin: '150% 0px' }); pager.io = new IntersectionObserver((entries) => { @@ -12489,14 +12519,14 @@ export function messagesFeature(ctx) { pagerCounts(e.target); // near the end: ask the feed for more, the pager picks them up if (pager.fromFeed) pagerFeedAhead(); - else { const slides = pagerSlides(); if (slides.indexOf(e.target) >= slides.length - 3) crawlMedia().catch(() => {}); } + else { const gs = pagerGroups(); if (gs.indexOf(e.target.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); } } }, { root: scroller, threshold: [0.6] }); growPager(); document.body.append(el); document.documentElement.classList.add('no-scroll'); - const first = pagerSlides().find((s) => s.getAttribute('data-k') === want.start) || scroller.firstChild; - if (first) scroller.scrollTop = first.offsetTop; + const first = pagerSlides().find((s) => s.getAttribute('data-k') === want.start) || pagerSlides()[0]; + if (first) pagerShow(first); // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) if (video) { const p = pager; @@ -12504,18 +12534,18 @@ export function messagesFeature(ctx) { p.raf = requestAnimationFrame(frame); } // turned on its side (or back), the slides change height: stay on this one - pager.onResize = () => { if (pager && pager.cur) pager.scroller.scrollTop = pager.cur.offsetTop; }; + pager.onResize = () => { if (pager && pager.cur) pagerShow(pager.cur); }; 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); } // 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 slides left past the one on screen (PAGER_AHEAD), the feed pages on + // 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 slides ahead to keep: clips are fewer and further between + // 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; @@ -12525,8 +12555,8 @@ export function messagesFeature(ctx) { try { const c = feedNow(); for (let pages = 0; pages < 8 && pager && pager.fromFeed; pages++) { - const slides = pagerSlides(); - if (slides.length - 1 - slides.indexOf(pager.cur) > (PAGER_AHEAD[pager.kind] || 4)) break; + 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; await loadOlderFeed(); growPager(); @@ -12534,27 +12564,53 @@ export function messagesFeature(ctx) { } } catch {} finally { pagerFeeding = false; } } - // Slides for feed items the pager doesn't have yet, appended (never + // 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 // inserted above the one on screen). function growPager() { if (!pager) return; const c = feedNow(); const waiting = pager.onTail; let first = null; + pager.groups ||= new Map(); for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); const s = pagerSlide(it); - pager.scroller.insertBefore(s, pager.tail || null); - first ||= s; + const gk = it.row ? it.row.id : it.ev.id; + let g = pager.groups.get(gk); + if (!g) { + g = pagerGroup(); + pager.groups.set(gk, g); + pager.scroller.insertBefore(g, pager.tail || null); + first ||= s; + } + g._row.append(s); + pagerDots(g); pager.near.observe(s); pager.io.observe(s); } // the reader was waiting at the end: onto the first that came - if (waiting && first) { pager.onTail = false; pager.scroller.scrollTop = first.offsetTop; } + if (waiting && first) { pager.onTail = false; pagerShow(first); } pagerTailState(); } + // A post in the pager: its row of slides, side by side, and — with more + // than one — dots saying how many and which. + function pagerGroup() { + const row = h('div', { class: 'mp-row' }); + const dots = h('div', { class: 'mp-dots', 'aria-hidden': 'true' }); + const g = h('div', { class: 'mp-group' }, row, dots); + g._row = row; g._dots = dots; + row.addEventListener('scroll', () => pagerDots(g), { passive: true }); + return g; + } + function pagerDots(g) { + const n = g._row.children.length; + if (g._dots.children.length !== (n > 1 ? n : 0)) g._dots.replaceChildren(...(n > 1 ? Array.from({ length: n }, () => h('i')) : [])); + const at = rowSlide(g); + [...g._dots.children].forEach((d, i) => d.classList.toggle('on', g._row.children[i] === at)); + } // the end says "more coming", or — the feed (or the media index) has no // more to give — that this is everything function pagerTailState() { @@ -12569,9 +12625,11 @@ export function messagesFeature(ctx) { window.addEventListener('keydown', (e) => { if (!pager || e.defaultPrevented) return; if (e.key === 'Escape') { e.preventDefault(); dismissPager(); return; } - // the arrows, any of the four: next (down, right) or previous (up, left) - const step = ['ArrowDown', 'ArrowRight', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'ArrowLeft', 'k', 'PageUp'].includes(e.key) ? -1 : 0; - if (step) { e.preventDefault(); pager.scroller.scrollBy({ top: step * pager.scroller.clientHeight, behavior: 'smooth' }); return; } + // up and down: the next post, or the one before; left and right: along this post's row + const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0; + const hz = ['ArrowRight', 'l'].includes(e.key) ? 1 : ['ArrowLeft', 'h'].includes(e.key) ? -1 : 0; + if (v) { e.preventDefault(); pager.scroller.scrollBy({ top: v * pager.scroller.clientHeight, behavior: 'smooth' }); return; } + if (hz) { e.preventDefault(); const row = pager.onTail ? null : pager.cur && pager.cur.parentElement; if (row) row.scrollBy({ left: hz * row.clientWidth, behavior: 'smooth' }); return; } if (e.key === ' ' && pager.cur) { const v = pager.cur.querySelector('video.mp-media'); if (v) { e.preventDefault(); if (v.paused) v.play().catch(() => {}); else v.pause(); } diff --git a/src/style.css b/src/style.css index e03ff14..f908a05 100644 --- a/src/style.css +++ b/src/style.css @@ -1482,14 +1482,31 @@ body::after { overflow-anchor: none; } .mp-scroll::-webkit-scrollbar { display: none; } -.mp-slide { +/* a post: a screen tall, its pictures or clips in a row side by side */ +.mp-group { position: relative; height: 100vh; height: 100dvh; width: 100%; scroll-snap-align: start; scroll-snap-stop: always; +} +.mp-row { + display: flex; height: 100%; width: 100%; overflow-x: auto; overflow-y: hidden; + scroll-snap-type: x mandatory; overscroll-behavior-x: contain; scrollbar-width: none; +} +.mp-row::-webkit-scrollbar { display: none; } +.mp-slide { + position: relative; flex: 0 0 100%; height: 100%; width: 100%; + scroll-snap-align: start; scroll-snap-stop: always; display: flex; align-items: center; justify-content: center; overflow: hidden; } +/* how many in this post, and which: dots along the top */ +.mp-dots { + position: absolute; left: 50%; transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 16px); + display: flex; gap: 6px; z-index: 2; pointer-events: none; +} +.mp-dots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(255, 255, 255, .38); box-shadow: 0 0 3px rgba(0, 0, 0, .5); transition: background .15s, transform .15s; } +.mp-dots i.on { background: #fff; transform: scale(1.15); } .mp-media { max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain; display: block; user-select: none; -webkit-user-select: none; } /* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe */ -.mp-slide img.mp-media { touch-action: pan-y; transform-origin: center center; will-change: transform; } +.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; will-change: transform; } /* nothing but the picture until a tap */ .mp-meta, .mp-top { opacity: 0; pointer-events: none; transition: opacity .18s; } .media-pager.show .mp-meta, .media-pager.show .mp-top { opacity: 1; pointer-events: auto; } -- cgit v1.2.3