aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 06:17:39 +0000
committerAdam Soltys <[email protected]>2026-10-13 06:17:39 +0000
commit8f80948eb174a273f3b1dea963bb91745bcf323b (patch)
treea8e704dd6896be17df41c7f844e21f18be5fdb92
parentb330cc707fab62365c29b57d414c46aa198febef (diff)
downloadcoinosv3-8f80948eb174a273f3b1dea963bb91745bcf323b.tar.gz
coinosv3-8f80948eb174a273f3b1dea963bb91745bcf323b.zip
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).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
-rw-r--r--src/features/messages.js104
-rw-r--r--src/style.css21
-rw-r--r--tools/feed-media-views-test.js8
-rw-r--r--tools/feed-pager-ahead-test.js20
-rw-r--r--tools/feed-picture-pager-test.js35
-rw-r--r--tools/feed-video-pager-test.js2
-rw-r--r--tools/pager-tail-test.js2
-rw-r--r--tools/quote-pager-test.js25
-rw-r--r--tools/video-touch-tap-test.js2
9 files changed, 152 insertions, 67 deletions
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; }
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 6649a93..2c84b14 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -95,8 +95,8 @@ const pagerState = () => page.evaluate(() => {
const p = document.querySelector('.media-pager');
if (!p) return null;
const sc = p.querySelector('.mp-scroll');
- const slides = [...sc.querySelectorAll(':scope > .mp-slide')]; // (not the loading screen at the end)
- const i = Math.round(sc.scrollTop / sc.clientHeight);
+ const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
const vids = slides.map((s) => s.querySelector('video'));
return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
@@ -145,12 +145,12 @@ try {
await page.evaluate(() => { const p = document.querySelector('.media-pager'); p.querySelector('.mp-slide').dispatchEvent(new MouseEvent('click', { bubbles: true })); });
await sleep(300);
st = await pagerState();
- const meta = await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')][Math.round(document.querySelector('.mp-scroll').scrollTop / document.querySelector('.mp-scroll').clientHeight)]; return { author: s.querySelector('.mp-author > span:last-child').textContent, caption: s.querySelector('.mp-caption')?.textContent }; });
+ const meta = await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return { author: s.querySelector('.mp-author > span:last-child').textContent, caption: s.querySelector('.mp-caption')?.textContent }; });
check('a tap shows the author and the words, without the links', st.show && st.meta === '1' && meta.author === 'Shutterbug' && meta.caption === 'two at once', JSON.stringify(meta));
await page.keyboard.press('ArrowUp'); await sleep(900);
st = await pagerState();
check('↑ goes to the previous picture', st.src === 'p1.jpg', JSON.stringify(st));
- check('its like shows in the corner', await waitFor(() => { const p = document.querySelector('.media-pager'); const sc = p.querySelector('.mp-scroll'); const s = sc.children[Math.round(sc.scrollTop / sc.clientHeight)]; return s.querySelector('.mp-like .n').textContent === '1'; }, 6000));
+ check('its like shows in the corner', await waitFor(() => { const p = document.querySelector('.media-pager'); const sc = p.querySelector('.mp-scroll'); const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return s.querySelector('.mp-like .n').textContent === '1'; }, 6000));
await shot('pager-meta');
await page.goBack(); await sleep(600);
check('Back closes the pager, leaving the grid', await page.evaluate(() => !document.querySelector('.media-pager') && !!document.querySelector('.media-grid')));
diff --git a/tools/feed-pager-ahead-test.js b/tools/feed-pager-ahead-test.js
index 90cbe96..057ac3e 100644
--- a/tools/feed-pager-ahead-test.js
+++ b/tools/feed-pager-ahead-test.js
@@ -70,10 +70,10 @@ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i
// the slide the pager rests on, by its picture
const curPic = () => page.evaluate(() => {
const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
- const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4);
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
});
-const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
const openPic = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
@@ -82,12 +82,12 @@ const openPic = async (word) => {
await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
return at.y0;
};
-const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
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);
-const slides = () => page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-slide').length);
-const curIdx = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return -1; return [...sc.querySelectorAll(':scope > .mp-slide')].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); });
-const curName = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = sc && [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop() : null; });
+const slides = () => page.evaluate(() => document.querySelectorAll('.mp-scroll .mp-slide').length);
+const curIdx = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return -1; return [...sc.querySelectorAll('.mp-slide')].findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); });
+const curName = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = sc && [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return s ? s.getAttribute('data-k').split('/').pop() : null; });
try {
await page.setViewport({ width: 390, height: 844 });
@@ -128,11 +128,11 @@ try {
maxSlides = Math.max(maxSlides, n);
if (i >= 0 && n - 1 - i <= 4) {
// give the look-ahead a moment: it should get past three ahead again (or the feed's end)
- await waitFor((k) => { const sc = document.querySelector('.mp-scroll'); const j = [...sc.querySelectorAll(':scope > .mp-slide')].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return sc.querySelectorAll(':scope > .mp-slide').length - 1 - j > 4 || sc.querySelectorAll(':scope > .mp-slide').length >= k; }, TOTAL, 6000);
+ await waitFor((k) => { const sc = document.querySelector('.mp-scroll'); const j = [...sc.querySelectorAll('.mp-slide')].findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return sc.querySelectorAll('.mp-slide').length - 1 - j > 4 || sc.querySelectorAll('.mp-slide').length >= k; }, TOTAL, 6000);
}
const before = await curIdx();
await page.keyboard.press('ArrowDown');
- await waitFor((b) => { const sc = document.querySelector('.mp-scroll'); return [...sc.querySelectorAll(':scope > .mp-slide')].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) > b; }, before, 2000);
+ await waitFor((b) => { const sc = document.querySelector('.mp-scroll'); return [...sc.querySelectorAll('.mp-slide')].findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) > b; }, before, 2000);
}
// past the last picture is the pager's end (its loading screen): back up onto the last
if (await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const t = sc.querySelector('.mp-tail'); return t && Math.abs(t.offsetTop - sc.scrollTop) < 4; })) { await page.keyboard.press('ArrowUp'); await sleep(800); }
@@ -140,11 +140,11 @@ try {
check('the pager grew as the end came near', grew, first + ' → ' + (await slides()));
check('every picture in the feed became reachable', (await slides()) === TOTAL, (await slides()) + ' of ' + TOTAL);
check('…past fifteen posts with none', (await curName()) === 'p' + (TOTAL - 1) + '.png', await curName());
- check('…in the feed\'s order', await page.evaluate((t) => [...document.querySelectorAll('.mp-scroll > .mp-slide')].every((s, i) => s.getAttribute('data-k').endsWith('/p' + i + '.png')), TOTAL));
+ check('…in the feed\'s order', await page.evaluate((t) => [...document.querySelectorAll('.mp-scroll .mp-slide')].every((s, i) => s.getAttribute('data-k').endsWith('/p' + i + '.png')), TOTAL));
console.log('\n[closing lands the feed on the last picture\'s post]');
await page.keyboard.press('ArrowUp');
- await waitFor((n) => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s && s.getAttribute('data-k').endsWith('/p' + n + '.png'); }, TOTAL - 2, 3000);
+ await waitFor((n) => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return s && s.getAttribute('data-k').endsWith('/p' + n + '.png'); }, TOTAL - 2, 3000);
const want = 'picture number ' + (TOTAL - 2) + ' ';
await page.keyboard.press('Escape');
check('the pager closes', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
index 6c45756..32625c9 100644
--- a/tools/feed-picture-pager-test.js
+++ b/tools/feed-picture-pager-test.js
@@ -44,10 +44,10 @@ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i
// the slide the pager rests on, by its picture
const curPic = () => page.evaluate(() => {
const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
- const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4);
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
});
-const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
const openPic = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
@@ -56,7 +56,7 @@ const openPic = async (word) => {
await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
return at.y0;
};
-const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
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 {
@@ -90,25 +90,31 @@ try {
check('one tap closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
check('the feed is where it was', Math.abs((await page.evaluate(() => window.scrollY)) - y0) <= 2);
- console.log('\n[arrows walk the pictures, a post\'s own first]');
+ console.log('\n[left and right: along a post\'s pictures; up and down: post to post]');
await openPic('two pictures');
+ check('a post with two pictures shows two dots, the first lit', await page.evaluate(() => { const d = document.querySelector('.mp-group .mp-dots'); return d && d.children.length === 2 && d.children[0].classList.contains('on'); }));
+ await page.keyboard.press('ArrowRight');
+ check('right: the post\'s second picture', await resting('a2.png'), await curPic());
+ check('…its dot lit', await page.evaluate(() => { const d = document.querySelector('.mp-group .mp-dots'); return d.children[1].classList.contains('on') && !d.children[0].classList.contains('on'); }));
+ await page.keyboard.press('ArrowRight'); await sleep(600);
+ check('right at the post\'s last: stays (left and right stay in the post)', (await curPic()) === 'a2.png', await curPic());
await page.keyboard.press('ArrowDown');
- check('down: the post\'s second picture', await resting('a2.png'), await curPic());
+ check('down: the next post\'s picture', await resting('b.png'), await curPic());
await page.keyboard.press('ArrowDown');
- check('down again: the next post\'s picture', await resting('b.png'), await curPic());
- await page.keyboard.press('ArrowRight');
- check('right: the next picture, past a post with none', await resting('d.png'), await curPic());
- await page.keyboard.press('ArrowLeft');
- check('left: back one', await resting('b.png'), await curPic());
+ check('down again: the next, past a post with none', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowUp');
- check('up: back one more', await resting('a2.png'), await curPic());
+ check('up: back one post', await resting('b.png'), await curPic());
+ await page.keyboard.press('ArrowUp');
+ check('up again: the first post, on the picture it was left at', await resting('a2.png'), await curPic());
+ await page.keyboard.press('ArrowLeft');
+ check('left: its first picture', await resting('a1.png'), await curPic());
check('no history entry per step', await page.evaluate(() => history.length) < 50);
console.log('\n[moved on: a tap shows the post, the next closes onto it]');
await page.keyboard.press('ArrowDown'); await resting('b.png');
await tapPager(); await sleep(400);
check('the first tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
- check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return /Picture Poster/.test(s.textContent) && /one picture/.test(s.textContent); }));
+ check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Picture Poster/.test(s.textContent) && /one picture/.test(s.textContent); }));
await tapPager();
check('the second tap closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r && r.getBoundingClientRect().top < 120 && r.getBoundingClientRect().top > -10; }, 'one picture', 3000);
@@ -117,9 +123,10 @@ try {
console.log('\n[a swipe, then Escape]');
await page.evaluate(() => window.scrollTo(0, 0)); await sleep(300);
await openPic('two pictures');
+ await page.evaluate(() => { const r = document.querySelector('.mp-row'); r.scrollTo({ left: r.clientWidth }); }); // what a swipe to the left does
+ check('a sideways swipe lands on the post\'s next picture', await resting('a2.png'), await curPic());
await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); sc.scrollTo({ top: sc.clientHeight }); }); // what a swipe up does
- check('the swipe lands on the next picture', await resting('a2.png'), await curPic());
- await page.keyboard.press('ArrowDown'); await resting('b.png');
+ check('a swipe up lands on the next post\'s', await resting('b.png'), await curPic());
await page.keyboard.press('ArrowDown'); await resting('d.png');
await page.keyboard.press('Escape');
check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js
index ab5aa2a..9b014f9 100644
--- a/tools/feed-video-pager-test.js
+++ b/tools/feed-video-pager-test.js
@@ -57,7 +57,7 @@ page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
-const CUR = () => { const sc = document.querySelector('.mp-scroll'); return sc && [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); };
+const CUR = () => { const sc = document.querySelector('.mp-scroll'); return sc && [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); };
const curClip = () => page.evaluate((cur) => { const s = eval(cur)(); return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : null; }, '(' + CUR + ')');
const resting = (name) => waitFor(([cur, n]) => { const s = eval(cur)(); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, ['(' + CUR + ')', name], 5000);
const curVideo = () => page.evaluate((cur) => { const s = eval(cur)(); const v = s && s.querySelector('video.mp-media'); return v ? { t: v.currentTime, d: v.duration, paused: v.paused, src: !!v.getAttribute('src') } : null; }, '(' + CUR + ')');
diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js
index c9d26cc..b294b8f 100644
--- a/tools/pager-tail-test.js
+++ b/tools/pager-tail-test.js
@@ -72,7 +72,7 @@ const click = (t) => page.evaluate((x) => { const e = [...document.querySelector
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
// what the pager rests on: a clip's name, or 'tail'
-const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !s ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); };
+const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll('.mp-slide, .mp-tail')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !s ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); };
const at = () => page.evaluate(AT);
const resting = (name, ms = 5000) => waitFor(([f, n]) => eval(f)() === n, ['(' + AT + ')', name], ms);
const playing = (name) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, name, 6000);
diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js
index 2a0e33b..5d9e536 100644
--- a/tools/quote-pager-test.js
+++ b/tools/quote-pager-test.js
@@ -1,8 +1,8 @@
// Pictures in a quoted post are in the pager too. A post quoting another
// draws the quoted one inside it; a tap on a picture there opened it alone
// (the pager's list held only the feed posts' own pictures), with nothing to
-// swipe to. Now the pager has, after each post's own pictures, those of the
-// posts it quotes: swipes walk through them and on to the next post's, the
+// swipe to. Now a post's row in the pager has, after its own pictures, those
+// of the posts it quotes: left and right walk them, down goes on to the next post's, the
// quoted picture's details name its author, and closing lands on the post
// that quoted it.
// Run: bun tools/quote-pager-test.js
@@ -69,7 +69,7 @@ page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
-const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : 'between'; };
+const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : 'between'; };
const at = () => page.evaluate(AT);
const resting = (n) => waitFor(([f, x]) => eval(f)() === x, ['(' + AT + ')', n], 4000);
@@ -102,19 +102,22 @@ try {
await page.mouse.click(pos2.x, pos2.y);
check('it opens in the pager (not alone)', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && !(await page.evaluate(() => !!document.querySelector('.lightbox'))));
check('…on the quoted picture', await resting('q1'), await at());
- await page.keyboard.press('ArrowDown');
- check('down: the quoted post\'s second picture', await resting('q2'), await at());
+ // the quote's pictures are in its post's row: its own first, then the quote's
+ check('…in the post\'s row, three along', await page.evaluate(() => document.querySelector('.mp-group .mp-dots').children.length === 3));
+ await page.keyboard.press('ArrowRight');
+ check('right: the quoted post\'s second picture', await resting('q2'), await at());
await page.keyboard.press('ArrowDown');
check('down: on to the next post\'s', await resting('b'), await at());
- await page.keyboard.press('ArrowUp'); await resting('q2');
- await page.keyboard.press('ArrowUp'); await resting('q1');
await page.keyboard.press('ArrowUp');
- check('up past the quote: the quoting post\'s own picture, before it', await resting('a'), await at());
- await page.keyboard.press('ArrowDown'); await resting('q1');
- await page.keyboard.press('ArrowDown'); await resting('q2');
+ check('up: back to the quoting post, where it was left', await resting('q2'), await at());
+ await page.keyboard.press('ArrowLeft'); await resting('q1');
+ await page.keyboard.press('ArrowLeft');
+ check('left past the quote: the quoting post\'s own picture, before it', await resting('a'), await at());
+ await page.keyboard.press('ArrowRight'); await resting('q1');
+ await page.keyboard.press('ArrowRight'); await resting('q2');
// details: a tap (moved on from the start, so the first tap shows them)
await page.mouse.click(195, 300); await sleep(400);
- check('the quoted picture\'s details name its author', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return /Store Maker/.test(s.textContent) && /two screenshots/.test(s.textContent); }));
+ check('the quoted picture\'s details name its author', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Store Maker/.test(s.textContent) && /two screenshots/.test(s.textContent); }));
await page.keyboard.press('Escape');
check('closing it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
await sleep(1500);
diff --git a/tools/video-touch-tap-test.js b/tools/video-touch-tap-test.js
index 6a45c9e..71ffe9c 100644
--- a/tools/video-touch-tap-test.js
+++ b/tools/video-touch-tap-test.js
@@ -111,7 +111,7 @@ try {
await sleep(1200);
check('…or land on the pager (pausing the clip, raising the details)', await page.evaluate(() => {
const p = document.querySelector('.media-pager'); const sc = p && p.querySelector('.mp-scroll');
- const cur = sc && [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4);
+ const cur = sc && [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
const v = cur && cur.querySelector('video.mp-media');
return !!p && !p.classList.contains('show') && !!v && !v.paused;
}));