diff options
| author | Adam Soltys <[email protected]> | 2026-10-11 22:47:47 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-11 22:47:47 -0700 |
| commit | 6eda01863920ae45d56a65563a7f956a429fced8 (patch) | |
| tree | 5e01151020f152cb6131672ea1983fa26412b972 /src | |
| parent | 880abf73720acc13e07242568d56c14bca247c5e (diff) | |
| download | coinosv3-6eda01863920ae45d56a65563a7f956a429fced8.tar.gz coinosv3-6eda01863920ae45d56a65563a7f956a429fced8.zip | |
Feed: a picture opens in the media pager, with the feed's other pictures a swipe away. Tapping a picture in a feed row used to open it alone (the lightbox); it now opens the media grid's pager on that picture, over the feed's pictures in the feed's own order — every picture of a post before the next post's (a repost's inner post, the feed's moderation) — so a swipe up or down, or any arrow key (up/down as before, now left/right too), steps to the next or previous one. A tap on the picture that opened it still closes it, as a full-screen picture always has; once the reader has moved on, the first tap shows the post's details and actions, and the next (or Escape, or the ×) closes the pager with the feed landed on that post (glideToPost, once the pop has brought the feed back). The pager's pictures pinch to zoom (attachZoom, now exported, without its wheel — the wheel is the pager's; at 1× one finger is the pager's swipe). Pictures in threads, profiles and chats still open alone. tools/feed-picture-pager-test.js; image-tap-test and lightbox-scroll-test accept the pager as the full-screen picture.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 7 | ||||
| -rw-r--r-- | src/features/messages.js | 87 | ||||
| -rw-r--r-- | src/style.css | 2 |
3 files changed, 85 insertions, 11 deletions
@@ -620,7 +620,7 @@ function imageViewer() { // off, so the picture does its own. Only the first-mounted nodes keep these // listeners: later renders morph into them, and the img is left out of the // morph so its transform survives a background render. -function attachZoom(box, img) { +function attachZoom(box, img, { wheel = true } = {}) { img._skipMorph = true; const MAX = 5, pts = new Map(); let s = 1, x = 0, y = 0, pinch = null, pan = null, moved = false; @@ -634,6 +634,8 @@ function attachZoom(box, img) { img.style.transition = animate ? 'transform .2s ease' : 'none'; img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`; img.style.cursor = s > 1 ? 'grab' : ''; + // zoomed, the finger pans the picture; at 1× the touch is the page's (a pager's swipe) + img.style.touchAction = s > 1 ? 'none' : ''; }; // scale to ns keeping the picture point under (cx, cy) where it is const zoomAt = (ns, cx, cy) => { @@ -693,7 +695,7 @@ function attachZoom(box, img) { if (e.target.closest('.lightbox-x')) return; if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; } }, true); - box.addEventListener('wheel', (e) => { + if (wheel) box.addEventListener('wheel', (e) => { e.preventDefault(); zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY); clamp(); apply(false); @@ -6243,6 +6245,7 @@ const ctx = { parseAmount: parseField, worthLine, openImage: (src) => { ui.lightbox = src; render(); }, + attachZoom: (box, img, opts) => attachZoom(box, img, opts), // What woke this run, if anything did, and how to say we're finished. // Both read through a closure: ctx is built before these are defined, and // a bare `wakeDone` shorthand captured the binding while it was still diff --git a/src/features/messages.js b/src/features/messages.js index 7c81d27..b1da365 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7965,7 +7965,7 @@ export function messagesFeature(ctx) { if (mediaFailed.has(url)) return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label || url); return h('img', { src: url, class: 'note-img note-img-late clickable', loading: 'lazy', decoding: 'async', alt: label || '', - onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); }, + onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); openPicture(url, e); }, onError: (e) => { e.target.style.display = 'none'; }, }); } @@ -7978,7 +7978,7 @@ export function messagesFeature(ctx) { decoding: known && known.thumb ? 'sync' : 'async', width: size?.width, height: size?.height, style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height + ';--ar:' + (size.width / size.height).toFixed(4) : undefined, - onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); }, + onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); openPicture(url, e); }, // a picture we were TOLD was a picture and which won't load leaves // nothing behind — the alt text is already in the sentence above it onError: (e) => { if (!size) e.target.style.display = 'none'; }, @@ -11597,6 +11597,37 @@ export function messagesFeature(ctx) { } return out; } + // The feed's pictures in reading order — every one of a post before the + // next post's — for the pager opened from a picture in the feed: the + // feed's own list (its order, its moderation), not the media index. + function feedImageItems(c) { + const out = []; + const list = c.winList ? c.winList() : c.notes; + for (const row of list) { + const ev = row.kind === REPOST ? repostInner(row) || row : row; + if (modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; + for (const url of noteMediaUrls(ev.content)) { + if (url.startsWith('https://i.ytimg.com/')) continue; + out.push({ ev, url, row }); + } + } + return out; + } + // A picture tapped in the feed opens in the media pager, on that picture, + // with the feed's other pictures a swipe (or an arrow) away, the way the + // media grid's are. On any other page — a thread, a profile, a chat — the + // picture opens alone, as before. + function openPicture(url, e) { + try { + const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList; + const rowId = e && e.target && e.target.closest ? e.target.closest('.notes-feed > [data-key]')?.getAttribute('data-key') : null; + if (inFeed && rowId) { + const it = feedImageItems(feed).find((x) => x.row.id === rowId && x.url === url); + if (it) { openMediaPager('images', mediaKey(it), { fromFeed: true }); return; } + } + } catch {} + ctx.openImage && ctx.openImage(url); + } // The words of the post, without the links to the media it carries. function mediaCaption(ev) { let s = String(ev.content || ''); @@ -11722,11 +11753,35 @@ export function messagesFeature(ctx) { } } - function openMediaPager(kind, start) { - ui.mediaPager = { kind, start, feed: curFeedId }; + // `fromFeed`: opened from a picture in the feed (its pictures, in the + // feed's order), rather than from the media grid + function openMediaPager(kind, start, { fromFeed = false } = {}) { + ui.mediaPager = { kind, start, feed: curFeedId, fromFeed }; render(); } const closeMediaPager = () => ctx.goBack(() => { ui.mediaPager = null; }); + // still on the picture that opened the pager (nothing swiped to yet) + const pagerOnStart = () => !pager || !pager.cur || pager.cur.getAttribute('data-k') === pager.start; + // Dismissing a pager opened from the feed after moving through it lands + // the feed on the post of the picture last looked at; otherwise (the + // media grid's pager, or nothing moved) it simply closes. + function dismissPager() { + if (pager && pager.fromFeed && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId); + else closeMediaPager(); + } + function closePagerTo(rowId) { + const c = feedNow(); + closeMediaPager(); + // the pop that closes it lands on a later turn: the glide waits for the feed + let tries = 0; + const go = () => { + if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; } + const list = c.winList ? c.winList() : c.notes; + const i = list.findIndex((e) => e.id === rowId); + if (i >= 0) glideToPost(c, list, i); + }; + requestAnimationFrame(go); + } // Leaving the pager for another page (a profile, sign-in, the zap amount): // only the feed's own render syncs the pager, and those pages don't paint // the feed — so the overlay goes now, not on the next feed render. @@ -11800,6 +11855,9 @@ export function messagesFeature(ctx) { setTimeout(() => pagerCounts(slide), 50); }) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' }))))); slide._ev = ev; + slide._rowId = it.row ? it.row.id : ev.id; // the feed row to land on (a repost's wrapper, not its inner post) + // pinch to zoom a picture (drag pans it zoomed; the wheel is the pager's) + if (!video && ctx.attachZoom) ctx.attachZoom(slide, media, { wheel: false }); if (video) { guardPicture(media, url); media.muted = !pagerLoud; @@ -11911,16 +11969,26 @@ export function messagesFeature(ctx) { const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' }, scroller, h('div', { class: 'mp-top' }, - h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); closeMediaPager(); } }, '×'), + 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); } }, h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)); // a tap shows (or hides) who posted it and what they said el.addEventListener('click', (e) => { if (e.target.closest('button, a, .mp-caption')) return; + if (pager && pager.fromFeed) { + // Opened from a post in the feed: a tap on the picture that opened + // it closes it, as a full-screen picture always has. Once the reader + // has moved on, the first tap shows the post's details, and the next + // closes the pager onto that post in the feed. + if (pagerOnStart()) { closeMediaPager(); return; } + if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; } + dismissPager(); + return; + } el.classList.toggle('show'); if (el.classList.contains('show')) pagerCounts(pager && pager.cur); }); - pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed }; + pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start }; pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -11953,7 +12021,7 @@ export function messagesFeature(ctx) { function growPager() { if (!pager) return; const c = feedNow(); - for (const it of mediaItems(c, pager.kind)) { + for (const it of (pager.fromFeed ? feedImageItems(c) : mediaItems(c, pager.kind))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); @@ -11966,8 +12034,9 @@ export function messagesFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (!pager || e.defaultPrevented) return; - if (e.key === 'Escape') { e.preventDefault(); closeMediaPager(); return; } - const step = e.key === 'ArrowDown' || e.key === 'j' || e.key === 'PageDown' ? 1 : e.key === 'ArrowUp' || e.key === 'k' || e.key === 'PageUp' ? -1 : 0; + 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; } if (e.key === ' ' && pager.cur) { const v = pager.cur.querySelector('video.mp-media'); diff --git a/src/style.css b/src/style.css index 5ce0211..0a1562d 100644 --- a/src/style.css +++ b/src/style.css @@ -1480,6 +1480,8 @@ body::after { display: flex; align-items: center; justify-content: center; overflow: hidden; } .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; } /* 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; } |
