diff options
| author | Adam Soltys <[email protected]> | 2026-10-03 13:12:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-03 13:12:30 -0700 |
| commit | 543a66ca70323921acf4da9b8e4fca8128cf0b58 (patch) | |
| tree | d3aef3cd1a703188bae15b95bf06d1911f549334 /src/features | |
| parent | 4444c9ce12cc9c1ed3eca9470e0c1080f80d8027 (diff) | |
| download | coinosv3-543a66ca70323921acf4da9b8e4fca8128cf0b58.tar.gz coinosv3-543a66ca70323921acf4da9b8e4fca8128cf0b58.zip | |
Feed media views: a grid of pictures, videos to flip through
All / Images / Videos sits under the feed chips (remembered).
- Images lays the feed's pictures out as a three-column grid.
- Videos opens straight into a full-screen pager: one clip per screen,
snapping, the one on screen playing with sound (muted with an Unmute
button when the browser refuses), the next a flick or ↓ away. Closing
it leaves a grid of the clips.
- The pager shows only the media until tapped; a tap shows the author
(tap for their profile), the post's words as a caption without the
media links (tap to expand), and likes and sats in the bottom right,
which also like and zap.
- It is a place: Back or Escape closes it. Built outside the morph, so a
repaint never restarts a clip; off-screen clips let go of their media.
- Near the end of the grid or the pager the feed fetches older posts and
new slides are appended.
- Toasts now sit above full-screen viewers.
tools/feed-media-views-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ULQZS9MQa6iAsXnrJUD9Du
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 7165dfc..10980ee 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6117,6 +6117,13 @@ export function messagesFeature(ctx) { feedAheadTimer = setTimeout(() => { feedAheadTimer = null; if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return; + if (mediaMode !== 'all') { + // a grid holds only the posts with media: keep fetching while its + // foot is near (within reason — a firehose has no end) + const g = document.querySelector('.media-grid'); + if (c.notes.length < 3000 && (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3)) loadOlderFeed().catch(() => {}); + return; + } if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging const rows = document.querySelectorAll('.notes-feed > .row[data-key]'); const last = rows[rows.length - 1]; @@ -9354,6 +9361,285 @@ export function messagesFeature(ctx) { render(); window.scrollTo({ top: 0 }); })); } + // ---- media views: a feed's pictures as a grid, its videos to flip through -- + // All / Images / Videos sits under the feed chips. Images lays the feed's + // pictures out as a grid; Videos opens straight into a full-screen pager, + // one clip per screen, the next a flick away. The pager shows nothing but + // the picture or clip until it's tapped: then the author, the post's words + // as a caption, and its likes and sats. The pager is a place (Back closes + // it), built outside the morph so a repaint never restarts a playing clip. + const MEDIA_MODE_KEY = 'coinos-feed-media'; + let mediaMode = 'all'; + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos') mediaMode = m; } catch {} + const mediaKey = (it) => it.ev.id + '|' + it.url; + // the picture an imeta tag names for a clip (NIP-92 `image`), if any + const posterOf = (ev, url) => { + for (const x of ev.tags || []) { + if (x[0] !== 'imeta' || !x.includes('url ' + url)) continue; + const img = x.find((p) => typeof p === 'string' && p.startsWith('image ')); + if (img) return img.slice(6); + } + return null; + }; + function mediaItems(c, kind) { + const out = [], seen = new Set(); + for (const ev of c.notes) { + if (hidden(ev)) continue; + const urls = kind === 'videos' ? noteVideoUrls(ev.content) + : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); + for (const url of urls) { + const it = { ev, url }; + if (seen.has(mediaKey(it))) continue; + seen.add(mediaKey(it)); + out.push(it); + } + } + return out; + } + // The words of the post, without the links to the media it carries. + function mediaCaption(ev) { + let s = String(ev.content || ''); + s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m)); + for (const u of [...noteMediaUrls(ev.content), ...noteVideoUrls(ev.content)]) s = s.split(u).join(''); + return plainExcerpt(s); + } + function setMediaMode(m) { + mediaMode = m; + try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {} + if (m === 'videos') { + const items = mediaItems(feedNow(), 'videos'); + if (items.length) { openMediaPager('videos', mediaKey(items[0])); return; } + } + render(); + try { window.scrollTo({ top: 0 }); } catch {} + } + function mediaModes() { + const b = (m, label) => h('button', { + class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m, + onClick: () => setMediaMode(m), + }, label); + return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, + b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos'))); + } + function mediaGrid(c) { + const items = mediaItems(c, mediaMode); + if (!items.length) { + return c.booting || c.status === 'loading' || c.loadingMore || !c.end + ? h('div', { class: 'row gap6', 'data-key': 'media-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) + : h('div', { class: 'small faint', 'data-key': 'media-empty', style: 'text-align:center;padding:12px 0' }, + t(mediaMode === 'videos' ? 'mediaNoVideos' : 'mediaNoImages')); + } + return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, + ...items.map((it) => { + const k = mediaKey(it); + const poster = mediaMode === 'videos' ? posterOf(it.ev, it.url) : null; + return h('button', { + class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, + 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), + }, + mediaMode === 'images' || poster + ? h('img', { src: poster || mediaSrc(it.url), loading: 'lazy', alt: '', draggable: 'false', + onError: (e) => { const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken'); } }) + // no still to show: the clip's first frame, fetched once the tile is near + : h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }), + mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null); + })); + } + // Video tiles get their src once they come near the screen; the tile is + // then left alone by the morph, which would otherwise strip the src. + let tileObs = null; + function watchMediaTiles() { + if (typeof IntersectionObserver === 'undefined') return; + tileObs = tileObs || new IntersectionObserver((entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + const v = e.target; + tileObs.unobserve(v); + v.muted = true; + v.src = v.getAttribute('data-src'); + const tile = v.closest('.media-tile'); + if (tile) tile._skipMorph = true; + } + }, { rootMargin: '400px 0px' }); + for (const v of document.querySelectorAll('.media-tile video[data-src]:not([data-obs])')) { + v.setAttribute('data-obs', '1'); + tileObs.observe(v); + } + } + + function openMediaPager(kind, start) { + ui.mediaPager = { kind, start, feed: curFeedId }; + render(); + } + const closeMediaPager = () => ctx.goBack(() => { ui.mediaPager = null; }); + let pager = null; // the live pager: { el, scroller, kind, keys, cur, loud, io, near, tick } + let pagerLoud = true; // sound on, unless the browser said no without a tap + function syncMediaPager() { + const want = ui.mediaPager && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread ? ui.mediaPager : null; + if (!want) { if (pager) dropPager(); return; } + if (pager && pager.kind !== want.kind) dropPager(); + if (!pager) buildPager(want); + else growPager(); + } + function dropPager() { + const p = pager; + pager = null; + try { p.io.disconnect(); p.near.disconnect(); } catch {} + clearInterval(p.tick); + for (const v of p.el.querySelectorAll('video')) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } + p.el.remove(); + document.documentElement.classList.remove('no-scroll'); + } + function pagerCounts(slide) { + const ev = slide && slide._ev; + if (!ev) return; + const s = noteCountSnapshot(ev.id); + const zp = pendingOf(ev.id); + const sats = s.sats + (zp ? zp.sats : 0); + const like = slide.querySelector('.mp-like'), zap = slide.querySelector('.mp-zap'); + like.classList.toggle('on', !!myReactOn(ev.id)); + like.querySelector('.i').innerHTML = I_HEART(!!myReactOn(ev.id)); + like.querySelector('.n').textContent = s.likes ? String(s.likes) : ''; + zap.classList.toggle('on', sats > 0); + zap.querySelector('.n').textContent = sats ? fmtSats(sats) : ''; + } + function pagerSlide(it) { + const { ev, url } = it; + const video = pager.kind === 'videos'; + const media = video + ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'auto', poster: posterOf(ev, url) || undefined }) + : h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' }); + const caption = mediaCaption(ev); + const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; + const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, + media, + h('div', { class: 'mp-meta' }, + h('div', { class: 'mp-left' }, + h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { ui.mediaPager = null; openProfile(ev.pubkey); }) }, + avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))), + caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null), + h('div', { class: 'mp-counts' }, + h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => { + if (signinAsk()) { ui.mediaPager = null; render(); return; } + (myReactOn(ev.id) ? unreact(ev) : reactTo(ev, '❤️')).catch(() => {}).finally(() => pagerCounts(slide)); + setTimeout(() => pagerCounts(slide), 50); + }) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })), + h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => { + if (signinAsk()) { ui.mediaPager = null; render(); return; } + // the first zap ever asks for an amount on a page of its own + if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) ui.mediaPager = null; + zapNote(ev.pubkey, ev, e.currentTarget.getBoundingClientRect()); + recheckZap(ev.id); + setTimeout(() => pagerCounts(slide), 50); + }) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' }))))); + slide._ev = ev; + if (video) { + media.muted = !pagerLoud; + media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's + } + return slide; + } + function pagerAttach(slide) { + const m = slide.querySelector('.mp-media'); + if (m && !m.getAttribute('src')) m.setAttribute('src', m.getAttribute('data-src')); + } + function pagerRelease(slide) { + const m = slide.querySelector('video.mp-media'); + if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} } + } + function playSlide(slide) { + const v = slide.querySelector('video.mp-media'); + if (!v) return; + pagerAttach(slide); + v.muted = !pagerLoud; + v.play().catch((err) => { + // sound needs a tap the browser counted: play quietly, the button says so + if (err && err.name === 'NotAllowedError' && !v.muted) { + pagerLoud = false; v.muted = true; + pager?.el.classList.add('quiet'); + v.play().catch(() => {}); + } + }); + } + function pagerSound(on) { + pagerLoud = on; + if (!pager) return; + pager.el.classList.toggle('quiet', !on); + for (const v of pager.el.querySelectorAll('video.mp-media')) v.muted = !on; + const cur = pager.cur && pager.cur.querySelector('video.mp-media'); + if (cur && cur.paused) cur.play().catch(() => {}); + } + 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-top' }, + h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); closeMediaPager(); } }, '×'), + 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; + 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.near = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting) { pagerAttach(e.target); watchZaps([e.target._ev.id]); } + else pagerRelease(e.target); + } + }, { root: scroller, rootMargin: '150% 0px' }); + pager.io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue; + if (pager.cur && pager.cur !== e.target) { const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); } + pager.cur = e.target; + playSlide(e.target); + pagerCounts(e.target); + // near the end: ask the feed for more, the pager picks them up + const slides = scroller.children; + if ([...slides].indexOf(e.target) >= slides.length - 3) loadOlderFeed().catch(() => {}); + } + }, { root: scroller, threshold: [0.6] }); + growPager(); + document.body.append(el); + document.documentElement.classList.add('no-scroll'); + const first = [...scroller.children].find((s) => s.getAttribute('data-k') === want.start) || scroller.firstChild; + if (first) scroller.scrollTop = first.offsetTop; + // new posts land in the feed while the pager is open; counts move + pager.tick = setInterval(() => { if (pager) { growPager(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); + } + // Slides for feed items the pager doesn't have yet, appended (never + // inserted above the one on screen). + function growPager() { + if (!pager) return; + const c = feedNow(); + for (const it of mediaItems(c, pager.kind)) { + const k = mediaKey(it); + if (pager.keys.has(k)) continue; + pager.keys.add(k); + const s = pagerSlide(it); + pager.scroller.append(s); + pager.near.observe(s); + pager.io.observe(s); + } + } + 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 (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'); + if (v) { e.preventDefault(); if (v.paused) v.play().catch(() => {}); else v.pause(); } + } + }); + window.addEventListener('popstate', () => setTimeout(syncMediaPager, 0)); + } + function feedView() { syncFollowSets().catch(() => {}); // throttled inside syncReports().catch(() => {}); // likewise @@ -9364,6 +9650,7 @@ export function messagesFeature(ctx) { c.opened = true; const def = feedDef(); const visitor = isVisitor(); + queueMicrotask(() => { syncMediaPager(); if (mediaMode !== 'all') watchMediaTiles(); }); const authors = feedAuthors(def); const hasQuery = feedHasQuery(def); prepareFeedAhead(c); @@ -9424,6 +9711,7 @@ export function messagesFeature(ctx) { ), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), + mediaModes(), // floating bottom right, wherever the reader is in the feed: the // composer opens at the top and takes the focus visitor || ui.profCompose != null ? null : h('button', { @@ -9454,6 +9742,7 @@ export function messagesFeature(ctx) { ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty')) + : mediaMode !== 'all' ? mediaGrid(c) : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows), // The foot of the feed says what is happening down there: older // posts on their way, or nothing older left to fetch. (Older pages |
