diff options
| author | Adam Soltys <[email protected]> | 2026-10-14 23:21:55 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-14 23:21:55 -0700 |
| commit | 686309a584a8e7a13091cb194e42aa09e2aec5d5 (patch) | |
| tree | e1e934385b262e5162686fa69743774dda099937 | |
| parent | 63d43428468bb5dc336006bc345769b071905204 (diff) | |
| download | coinosv3-686309a584a8e7a13091cb194e42aa09e2aec5d5.tar.gz coinosv3-686309a584a8e7a13091cb194e42aa09e2aec5d5.zip | |
Loop media pager through loaded posts and show position
| -rw-r--r-- | src/features/messages.js | 100 | ||||
| -rw-r--r-- | src/style.css | 3 | ||||
| -rw-r--r-- | tools/profile-pager-test.js | 16 |
3 files changed, 60 insertions, 59 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 6d3aefa..ccba6ad 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12142,7 +12142,7 @@ export function messagesFeature(ctx) { function dropPager() { const p = pager; pager = null; - try { p.io.disconnect(); p.near.disconnect(); p.tailIo.disconnect(); } catch {} + try { p.io.disconnect(); p.near.disconnect(); } catch {} clearInterval(p.tick); cancelAnimationFrame(p.raf); window.removeEventListener('resize', p.onResize); @@ -12513,13 +12513,18 @@ export function messagesFeature(ctx) { if (!pager) return false; const here = pagerHere(); if (v) { - const from = pager.onTail || !here ? pager.tail : here.closest('.mp-group'); - const to = from && (v > 0 ? from.nextElementSibling : from.previousElementSibling); - if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); + const groups = pagerGroups(); + const from = here && here.closest('.mp-group'); + const index = groups.indexOf(from) + v; + const to = groups[(index + groups.length) % groups.length]; + if (to) { + if (index < 0 || index >= groups.length) pagerWrap(to); + else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); + } return !!to; } if (hz) { - const to = !pager.onTail && here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling); + const to = here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling); if (to) pagerGlide(here.parentElement, 'x', pagerAt(here.parentElement, to, false)); return !!to; } @@ -12536,7 +12541,8 @@ export function messagesFeature(ctx) { let fly = null; // { to: the post flown to, n: notches in this run, at, dir } function pagerFly(dir, now) { if (!pager) return; - const stops = [...pager.scroller.children]; // the posts, then the tail + const stops = pagerGroups(); + if (!stops.length) return; const live = fly && now - fly.at < 400 && pager.scroller._glide; let from, n = 1; if (live && fly.dir === dir) { from = fly.to; n = fly.n + 1; } @@ -12545,9 +12551,11 @@ export function messagesFeature(ctx) { from = stops.reduce((b, g) => (Math.abs(g.offsetTop - y) < Math.abs(b.offsetTop - y) ? g : b)); } else { const here = pagerHere(); - from = pager.onTail || !here ? pager.tail : here.closest('.mp-group'); + from = here && here.closest('.mp-group'); } - const i = Math.max(0, Math.min(stops.length - 1, stops.indexOf(from) + dir * (n <= 2 ? 1 : 2))); + const next = stops.indexOf(from) + dir * (n <= 2 ? 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]; fly = { to, n, at: now, dir }; if (live) pager.scroller._onDone = null; // the flight goes on: not a landing @@ -12567,11 +12575,30 @@ export function messagesFeature(ctx) { pager.el.classList.toggle('has-right', !!slide.nextElementSibling); } + // Crossing the boundary jumps directly to the other end, without flying + // backwards through every loaded post or waiting for a network request. + function pagerWrap(group) { + if (!pager || !group) return; + pager.scroller._onDone = null; + pagerHold(pager.scroller); + pager.scroller.style.scrollSnapType = ''; + pager.flying = false; + const slide = rowSlide(group); + if (slide) { pagerShow(slide); pagerArrive(slide); } + } + function pagerPosition() { + if (!pager) return; + const slides = pagerSlides(); + const index = slides.indexOf(pager.cur || pager.anchor); + pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + slides.length; + } + function buildPager(want) { const video = want.kind === 'videos'; const scroller = h('div', { class: 'mp-scroll' }); 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('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, @@ -12662,11 +12689,13 @@ export function messagesFeature(ctx) { // let go of a drag: on to the next (or the one before), or back function settle(d, step, vel = 0) { const box = d.box, y = d.axis === 'y'; - const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group, .mp-tail' : '.mp-slide')).map((c) => pagerAt(box, c, y)); + const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y)); if (!stops.length) return; // the stop it set off from, then one along (or back) const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); - pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, from + step))], vel); + const next = from + step; + if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(next + stops.length) % stops.length]); return; } + pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel); } el.addEventListener('touchend', (e) => { const d = touch; @@ -12700,7 +12729,7 @@ export function messagesFeature(ctx) { }); // a tap shows (or hides) who posted it and what they said function pagerTap(target) { - if (target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return; + if (target.closest('button, a, .mp-caption, .mp-seek')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it if (pager && video) { // A clip (from the feed or the media grid): a tap anywhere but the @@ -12727,32 +12756,6 @@ export function messagesFeature(ctx) { if (el.classList.contains('show')) pagerCounts(pager && pager.cur); } pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, start: want.start, at: want.at || 0 }; - // The end of the pager: swiping past the last slide lands here, on - // something saying more is coming (not a nudge that goes nowhere) — and - // asks for it at once. Slides that arrive go in above it; one arriving - // while the reader waits here is moved onto, and plays. With nothing - // more to be had, it says so. - const tail = h('div', { class: 'mp-tail', 'aria-live': 'polite' }, - h('div', { class: 'mp-loader', 'aria-hidden': 'true', html: video - ? '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="30" height="30"><path d="M8 5.5v13l10.5-6.5z" fill="currentColor"/></svg></span>' - : '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M3.5 16l5-5 4 4 2.5-2.5 5 5"/><circle cx="15.5" cy="9" r="1.6" fill="currentColor" stroke="none"/></svg></span>' }), - h('div', { class: 'mp-tail-text' }, t(video ? 'pagerFetchingVideo' : 'pagerFetchingPictures'))); - tail._tail = true; - scroller.append(tail); - pager.tail = tail; - pager.tailIo = new IntersectionObserver((entries) => { - for (const e of entries) { - if (!pager) return; - pager.onTail = e.isIntersecting && e.intersectionRatio >= 0.6; - if (!pager.onTail) continue; - const v = pager.cur && pager.cur.querySelector('video.mp-media'); - if (v) v.pause(); - el.classList.remove('show'); - if (pagerSource()) pagerFeedAhead(); else crawlMedia().catch(() => {}); - pagerTailState(); - } - }, { root: scroller, threshold: [0, 0.6] }); - pager.tailIo.observe(tail); pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -12776,6 +12779,7 @@ export function messagesFeature(ctx) { pager.anchor = slide; pager.curAt = Date.now(); pagerArrows(slide); + pagerPosition(); if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); } else if (pager.profile) markSeen(slide._ev); playSlide(slide); @@ -12818,8 +12822,7 @@ export function messagesFeature(ctx) { pager.onResize = () => { if (!pager) return; pager.screenH = pager.scroller.clientHeight; - if (pager.onTail) pager.scroller.scrollTop = pager.tail.offsetTop; - else if (pager.anchor) pagerShow(pager.anchor); + if (pager.anchor) pagerShow(pager.anchor); }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move @@ -12857,8 +12860,6 @@ export function messagesFeature(ctx) { function growPager() { if (!pager) return; const c = feedNow(); - const waiting = pager.onTail; - let first = null; pager.groups ||= new Map(); const src = pagerSource(); for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) { @@ -12871,17 +12872,14 @@ export function messagesFeature(ctx) { if (!g) { g = pagerGroup(); pager.groups.set(gk, g); - pager.scroller.insertBefore(g, pager.tail || null); - first ||= s; + pager.scroller.append(g); } 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; pagerShow(first); } - pagerTailState(); + pagerPosition(); } // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. @@ -12899,16 +12897,6 @@ export function messagesFeature(ctx) { 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() { - if (!pager || !pager.tail) return; - const src = pagerSource(); - const done = src ? src.done() : !!mediaIndex(feedNow().id).end; - if (pager.tail.classList.contains('done') === done) return; - pager.tail.classList.toggle('done', done); - pager.tail.querySelector('.mp-tail-text').textContent = done ? t('pagerCaughtUp') : t(pager.kind === 'videos' ? 'pagerFetchingVideo' : 'pagerFetchingPictures'); - } if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (!pager || e.defaultPrevented) return; diff --git a/src/style.css b/src/style.css index 2c61072..2608d67 100644 --- a/src/style.css +++ b/src/style.css @@ -1804,3 +1804,6 @@ body::after { .chat-compose .chat-box > coinos-text:has(+ .compose-tools > :only-child) { padding-right: 48px; } .chat-compose .chat-box .compose-tools { bottom: 6px; right: 5px; } .compose-tool.chat-clip { width: 34px; } + +/* Always visible, including when the media controls are tucked away. */ +.mp-position { position: absolute; top: calc(env(safe-area-inset-top, 0px) + 16px); left: 50%; transform: translateX(-50%); padding: 5px 10px; border-radius: 20px; background: #0009; color: #fff; font-size: 13px; font-variant-numeric: tabular-nums; pointer-events: none; z-index: 5; } diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js index 9006bce..2835b34 100644 --- a/tools/profile-pager-test.js +++ b/tools/profile-pager-test.js @@ -60,7 +60,7 @@ const tapPager = async () => { const at = await page.evaluate(() => { const sc = const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); try { - await page.setViewport({ width: 390, height: 844 }); + await page.setViewport({ width: 390, height: 844, hasTouch: true }); await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); @@ -101,8 +101,18 @@ try { await page.keyboard.press('ArrowDown'); check('down again: her last', await resting('d.png'), await curPic()); await page.keyboard.press('ArrowDown'); await sleep(600); - check('past her last: the end, not somebody else\'s picture', (await curPic()) !== 'x.png' && !(await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].some((s) => s.getAttribute('data-k').endsWith('/x.png'))))); - await page.keyboard.press('ArrowUp'); await resting('d.png'); // back off the end + check('past her last wraps to her first post', await resting('a2.png'), await curPic()); + check('counter follows sideways position', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 4'); + check('no loading slide', await page.$('.mp-tail') === null); + await page.keyboard.press('ArrowUp'); await resting('d.png'); + check('up from first wraps to last', await curPic() === 'd.png'); + check('counter at the last picture', await page.$eval('.mp-position', (e) => e.textContent) === '4 / 4'); + await page.touchscreen.touchStart(195, 650); + for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); } + await page.touchscreen.touchEnd(); + check('touch swipe wraps to first post', await resting('a2.png'), await curPic()); + await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true }))); + check('wheel wraps back to last post', await resting('d.png'), await curPic()); await page.keyboard.press('ArrowUp'); check('up: her post before', await resting('b.png'), await curPic()); await page.keyboard.press('Escape'); |
