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 | |
| 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
| -rw-r--r-- | src/app.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 289 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/locales/ar.json | 7 | ||||
| -rw-r--r-- | src/locales/bn.json | 7 | ||||
| -rw-r--r-- | src/locales/de.json | 7 | ||||
| -rw-r--r-- | src/locales/es.json | 7 | ||||
| -rw-r--r-- | src/locales/fr.json | 7 | ||||
| -rw-r--r-- | src/locales/hi.json | 7 | ||||
| -rw-r--r-- | src/locales/id.json | 7 | ||||
| -rw-r--r-- | src/locales/ja.json | 7 | ||||
| -rw-r--r-- | src/locales/ko.json | 7 | ||||
| -rw-r--r-- | src/locales/mr.json | 7 | ||||
| -rw-r--r-- | src/locales/pt.json | 7 | ||||
| -rw-r--r-- | src/locales/ru.json | 7 | ||||
| -rw-r--r-- | src/locales/sw.json | 7 | ||||
| -rw-r--r-- | src/locales/ta.json | 7 | ||||
| -rw-r--r-- | src/locales/te.json | 7 | ||||
| -rw-r--r-- | src/locales/tr.json | 7 | ||||
| -rw-r--r-- | src/locales/ur.json | 7 | ||||
| -rw-r--r-- | src/locales/vi.json | 7 | ||||
| -rw-r--r-- | src/locales/zh.json | 7 | ||||
| -rw-r--r-- | src/style.css | 79 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 167 |
24 files changed, 656 insertions, 22 deletions
@@ -817,7 +817,7 @@ function renderInner() { const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null; morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(ui.lightbox ? [imageViewer()] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page - try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {} + try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {} if (fpath) { const el = nodeAtPath(fpath); if (el && el !== a && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(el.tagName)) { @@ -871,7 +871,7 @@ wallet.subscribe(scheduleRender); // lightbox: a full-screen photo is a place too — the phone's Back must close // it, not pop the screen under it (which once walked a viewer straight back // to the start page, so the photo's × then looked like a logout). -const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'feedEdit', 'settingsView']; +const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView']; function navSnapshot() { const s = {}; for (const f of NAV_FIELDS) s[f] = ui[f] ?? null; @@ -976,6 +976,7 @@ function restoreNavFromHistory(renderNow = true) { if (['unlock', 'vault'].includes(nav.screen) && activeAccount()) nav.screen = 'wallet'; for (const f of NAV_FIELDS) if (f in nav) ui[f] = (f === 'profEdit' || f === 'feedEdit') && nav[f] ? structuredClone(nav[f]) : nav[f]; ui.lightbox = null; nav.lightbox = null; // an object URL doesn't survive a reload + ui.mediaPager = null; nav.mediaPager = null; // nor does the feed under a media pager, not yet navStack = [nav]; navIndex = 0; // A reload is a continuation of the same page, not a navigation. Do not 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 diff --git a/src/i18n.js b/src/i18n.js index cb0e752..932b552 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -299,6 +299,11 @@ const STR = { askSignInCreate: 'Create a new account', askNotNow: 'Not now', feedLangLabel: 'Language', + mediaAll: "All", + mediaImages: "Images", + mediaVideos: "Videos", + mediaNoImages: "No pictures in this feed yet.", + mediaNoVideos: "No videos in this feed yet.", feedRelays: 'Relays', feedRelaysFormHelp: 'Read this feed from these relays only. Leave empty for the usual ones.', feedAllToggle: 'Every post on these relays (a firehose)', diff --git a/src/locales/ar.json b/src/locales/ar.json index 57324ca..cf1de50 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -903,5 +903,10 @@ "relaysSaved": "حُفظت قوائم المرحّلات", "relaysSave": "حفظ", "relaysSaving": "جارٍ الحفظ…", - "relaysReload": "إعادة تحميل" + "relaysReload": "إعادة تحميل", + "mediaAll": "الكل", + "mediaImages": "الصور", + "mediaVideos": "الفيديوهات", + "mediaNoImages": "لا صور في هذه الخلاصة بعد.", + "mediaNoVideos": "لا فيديوهات في هذه الخلاصة بعد." } diff --git a/src/locales/bn.json b/src/locales/bn.json index cf1a830..6ec0e94 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -903,5 +903,10 @@ "relaysSaved": "রিলে তালিকা সংরক্ষিত", "relaysSave": "সংরক্ষণ", "relaysSaving": "সংরক্ষণ হচ্ছে…", - "relaysReload": "আবার লোড" + "relaysReload": "আবার লোড", + "mediaAll": "সব", + "mediaImages": "ছবি", + "mediaVideos": "ভিডিও", + "mediaNoImages": "এই ফিডে এখনও কোনো ছবি নেই।", + "mediaNoVideos": "এই ফিডে এখনও কোনো ভিডিও নেই।" } diff --git a/src/locales/de.json b/src/locales/de.json index d5f3d70..df72786 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -903,5 +903,10 @@ "relaysSaved": "Relay-Listen gespeichert", "relaysSave": "Speichern", "relaysSaving": "Speichern…", - "relaysReload": "Neu laden" + "relaysReload": "Neu laden", + "mediaAll": "Alle", + "mediaImages": "Bilder", + "mediaVideos": "Videos", + "mediaNoImages": "Noch keine Bilder in diesem Feed.", + "mediaNoVideos": "Noch keine Videos in diesem Feed." } diff --git a/src/locales/es.json b/src/locales/es.json index 604a528..1f1c170 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -903,5 +903,10 @@ "relaysSaved": "Listas de relés guardadas", "relaysSave": "Guardar", "relaysSaving": "Guardando…", - "relaysReload": "Recargar" + "relaysReload": "Recargar", + "mediaAll": "Todo", + "mediaImages": "Imágenes", + "mediaVideos": "Vídeos", + "mediaNoImages": "Aún no hay imágenes en este feed.", + "mediaNoVideos": "Aún no hay vídeos en este feed." } diff --git a/src/locales/fr.json b/src/locales/fr.json index 22b41ed..1d7c5f4 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -903,5 +903,10 @@ "relaysSaved": "Listes de relais enregistrées", "relaysSave": "Enregistrer", "relaysSaving": "Enregistrement…", - "relaysReload": "Recharger" + "relaysReload": "Recharger", + "mediaAll": "Tout", + "mediaImages": "Images", + "mediaVideos": "Vidéos", + "mediaNoImages": "Pas encore d’images dans ce fil.", + "mediaNoVideos": "Pas encore de vidéos dans ce fil." } diff --git a/src/locales/hi.json b/src/locales/hi.json index bc7f7a8..7772bc1 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -903,5 +903,10 @@ "relaysSaved": "रिले सूचियाँ सहेजी गईं", "relaysSave": "सहेजें", "relaysSaving": "सहेजा जा रहा है…", - "relaysReload": "फिर से लोड करें" + "relaysReload": "फिर से लोड करें", + "mediaAll": "सभी", + "mediaImages": "तस्वीरें", + "mediaVideos": "वीडियो", + "mediaNoImages": "इस फ़ीड में अभी कोई तस्वीर नहीं।", + "mediaNoVideos": "इस फ़ीड में अभी कोई वीडियो नहीं।" } diff --git a/src/locales/id.json b/src/locales/id.json index 9318205..a4fe07f 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -239,5 +239,10 @@ "relaysSaved": "Daftar relay disimpan", "relaysSave": "Simpan", "relaysSaving": "Menyimpan…", - "relaysReload": "Muat ulang" + "relaysReload": "Muat ulang", + "mediaAll": "Semua", + "mediaImages": "Gambar", + "mediaVideos": "Video", + "mediaNoImages": "Belum ada gambar di feed ini.", + "mediaNoVideos": "Belum ada video di feed ini." } diff --git a/src/locales/ja.json b/src/locales/ja.json index 9e0e66a..b376d3b 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -903,5 +903,10 @@ "relaysSaved": "リレーリストを保存しました", "relaysSave": "保存", "relaysSaving": "保存中…", - "relaysReload": "再読み込み" + "relaysReload": "再読み込み", + "mediaAll": "すべて", + "mediaImages": "画像", + "mediaVideos": "動画", + "mediaNoImages": "このフィードにはまだ画像がありません。", + "mediaNoVideos": "このフィードにはまだ動画がありません。" } diff --git a/src/locales/ko.json b/src/locales/ko.json index cd7606b..7d6bb2a 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -902,5 +902,10 @@ "relaysSaved": "릴레이 목록 저장됨", "relaysSave": "저장", "relaysSaving": "저장 중…", - "relaysReload": "새로고침" + "relaysReload": "새로고침", + "mediaAll": "전체", + "mediaImages": "이미지", + "mediaVideos": "동영상", + "mediaNoImages": "이 피드에는 아직 이미지가 없습니다.", + "mediaNoVideos": "이 피드에는 아직 동영상이 없습니다." } diff --git a/src/locales/mr.json b/src/locales/mr.json index 38a9b86..a5080f2 100644 --- a/src/locales/mr.json +++ b/src/locales/mr.json @@ -239,5 +239,10 @@ "relaysSaved": "रिले याद्या जतन केल्या", "relaysSave": "जतन करा", "relaysSaving": "जतन होत आहे…", - "relaysReload": "पुन्हा लोड करा" + "relaysReload": "पुन्हा लोड करा", + "mediaAll": "सर्व", + "mediaImages": "चित्रे", + "mediaVideos": "व्हिडिओ", + "mediaNoImages": "या फीडमध्ये अजून चित्रे नाहीत.", + "mediaNoVideos": "या फीडमध्ये अजून व्हिडिओ नाहीत." } diff --git a/src/locales/pt.json b/src/locales/pt.json index aaa25a5..142daf9 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -903,5 +903,10 @@ "relaysSaved": "Listas de relays salvas", "relaysSave": "Salvar", "relaysSaving": "Salvando…", - "relaysReload": "Recarregar" + "relaysReload": "Recarregar", + "mediaAll": "Tudo", + "mediaImages": "Imagens", + "mediaVideos": "Vídeos", + "mediaNoImages": "Ainda não há imagens neste feed.", + "mediaNoVideos": "Ainda não há vídeos neste feed." } diff --git a/src/locales/ru.json b/src/locales/ru.json index c8af847..c94fb56 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -903,5 +903,10 @@ "relaysSaved": "Списки реле сохранены", "relaysSave": "Сохранить", "relaysSaving": "Сохранение…", - "relaysReload": "Обновить" + "relaysReload": "Обновить", + "mediaAll": "Все", + "mediaImages": "Фото", + "mediaVideos": "Видео", + "mediaNoImages": "В этой ленте пока нет фото.", + "mediaNoVideos": "В этой ленте пока нет видео." } diff --git a/src/locales/sw.json b/src/locales/sw.json index 4a5267c..770551d 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -239,5 +239,10 @@ "relaysSaved": "Orodha za relei zimehifadhiwa", "relaysSave": "Hifadhi", "relaysSaving": "Inahifadhi…", - "relaysReload": "Pakia upya" + "relaysReload": "Pakia upya", + "mediaAll": "Zote", + "mediaImages": "Picha", + "mediaVideos": "Video", + "mediaNoImages": "Bado hakuna picha kwenye mlisho huu.", + "mediaNoVideos": "Bado hakuna video kwenye mlisho huu." } diff --git a/src/locales/ta.json b/src/locales/ta.json index cf032ac..856841e 100644 --- a/src/locales/ta.json +++ b/src/locales/ta.json @@ -239,5 +239,10 @@ "relaysSaved": "ரிலே பட்டியல்கள் சேமிக்கப்பட்டன", "relaysSave": "சேமி", "relaysSaving": "சேமிக்கிறது…", - "relaysReload": "மீண்டும் ஏற்று" + "relaysReload": "மீண்டும் ஏற்று", + "mediaAll": "அனைத்தும்", + "mediaImages": "படங்கள்", + "mediaVideos": "வீடியோக்கள்", + "mediaNoImages": "இந்த ஊட்டத்தில் இன்னும் படங்கள் இல்லை.", + "mediaNoVideos": "இந்த ஊட்டத்தில் இன்னும் வீடியோக்கள் இல்லை." } diff --git a/src/locales/te.json b/src/locales/te.json index f45ad3e..2e7deff 100644 --- a/src/locales/te.json +++ b/src/locales/te.json @@ -237,5 +237,10 @@ "relaysSaved": "రిలే జాబితాలు సేవ్ అయ్యాయి", "relaysSave": "సేవ్", "relaysSaving": "సేవ్ అవుతోంది…", - "relaysReload": "మళ్లీ లోడ్" + "relaysReload": "మళ్లీ లోడ్", + "mediaAll": "అన్నీ", + "mediaImages": "చిత్రాలు", + "mediaVideos": "వీడియోలు", + "mediaNoImages": "ఈ ఫీడ్లో ఇంకా చిత్రాలు లేవు.", + "mediaNoVideos": "ఈ ఫీడ్లో ఇంకా వీడియోలు లేవు." } diff --git a/src/locales/tr.json b/src/locales/tr.json index b779157..9b5dacf 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -902,5 +902,10 @@ "relaysSaved": "Röle listeleri kaydedildi", "relaysSave": "Kaydet", "relaysSaving": "Kaydediliyor…", - "relaysReload": "Yenile" + "relaysReload": "Yenile", + "mediaAll": "Tümü", + "mediaImages": "Görseller", + "mediaVideos": "Videolar", + "mediaNoImages": "Bu akışta henüz görsel yok.", + "mediaNoVideos": "Bu akışta henüz video yok." } diff --git a/src/locales/ur.json b/src/locales/ur.json index ff7f461..3a04f22 100644 --- a/src/locales/ur.json +++ b/src/locales/ur.json @@ -239,5 +239,10 @@ "relaysSaved": "ریلے فہرستیں محفوظ ہو گئیں", "relaysSave": "محفوظ کریں", "relaysSaving": "محفوظ ہو رہا ہے…", - "relaysReload": "دوبارہ لوڈ کریں" + "relaysReload": "دوبارہ لوڈ کریں", + "mediaAll": "سب", + "mediaImages": "تصاویر", + "mediaVideos": "ویڈیوز", + "mediaNoImages": "اس فیڈ میں ابھی کوئی تصویر نہیں۔", + "mediaNoVideos": "اس فیڈ میں ابھی کوئی ویڈیو نہیں۔" } diff --git a/src/locales/vi.json b/src/locales/vi.json index 162d73f..7393d1d 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -239,5 +239,10 @@ "relaysSaved": "Đã lưu danh sách relay", "relaysSave": "Lưu", "relaysSaving": "Đang lưu…", - "relaysReload": "Tải lại" + "relaysReload": "Tải lại", + "mediaAll": "Tất cả", + "mediaImages": "Ảnh", + "mediaVideos": "Video", + "mediaNoImages": "Bảng tin này chưa có ảnh.", + "mediaNoVideos": "Bảng tin này chưa có video." } diff --git a/src/locales/zh.json b/src/locales/zh.json index 995575e..f576f42 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -903,5 +903,10 @@ "relaysSaved": "中继列表已保存", "relaysSave": "保存", "relaysSaving": "正在保存…", - "relaysReload": "重新加载" + "relaysReload": "重新加载", + "mediaAll": "全部", + "mediaImages": "图片", + "mediaVideos": "视频", + "mediaNoImages": "此动态中还没有图片。", + "mediaNoVideos": "此动态中还没有视频。" } diff --git a/src/style.css b/src/style.css index 592ca7f..f8987f2 100644 --- a/src/style.css +++ b/src/style.css @@ -393,7 +393,7 @@ textarea.send-dest::placeholder { font-family: var(--sans); } .toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); background: var(--toast-bg); color: var(--toast-fg); padding: 10px 16px; border-radius: 10px; - font-size: 14px; box-shadow: var(--shadow); z-index: 50; opacity: 0; pointer-events: none; + font-size: 14px; box-shadow: var(--shadow); z-index: 300; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } @@ -1366,3 +1366,80 @@ body::after { .social-count b { color: var(--ink); font-weight: 650; } .social-count:hover { background: none; color: var(--ink); } .social-count.on { color: var(--ink); border-bottom-color: var(--accent); } + +/* ---- feed media views: All / Images / Videos, the grid, the pager ---- */ +.media-modes { gap: 6px; } +.media-modes .feed-chip { padding: 4px 12px; font-size: 13px; } +.media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; border-radius: 12px; overflow: hidden; } +.media-tile { + position: relative; display: block; aspect-ratio: 1; padding: 0; margin: 0; border: 0; border-radius: 0; + background: var(--surface2); overflow: hidden; cursor: pointer; +} +.media-tile.video { aspect-ratio: 9 / 16; } +.media-tile img, .media-tile video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } +.media-tile.broken { display: none; } +.media-tile-play { + position: absolute; right: 6px; bottom: 6px; color: #fff; font-size: 13px; line-height: 1; + text-shadow: 0 1px 3px rgba(0, 0, 0, .7); pointer-events: none; +} +.media-pager { + position: fixed; inset: 0; z-index: 200; background: #000; color: #fff; + animation: lightbox-in .12s ease-out; +} +.mp-scroll { + position: absolute; inset: 0; overflow-y: auto; overscroll-behavior: contain; + scroll-snap-type: y mandatory; scrollbar-width: none; +} +.mp-scroll::-webkit-scrollbar { display: none; } +.mp-slide { + position: relative; height: 100vh; height: 100dvh; width: 100%; + scroll-snap-align: start; scroll-snap-stop: always; + 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; } +/* 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; } +.mp-meta { + position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: flex-end; gap: 12px; + padding: 48px 14px max(16px, env(safe-area-inset-bottom)); + background: linear-gradient(to top, rgba(0, 0, 0, .72), rgba(0, 0, 0, 0)); +} +.mp-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } +.mp-author { + display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; max-width: 100%; + padding: 0; margin: 0; border: 0; background: none; color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; + text-shadow: 0 1px 3px rgba(0, 0, 0, .6); +} +.mp-author span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mp-caption { + font-size: 14px; line-height: 1.35; color: rgba(255, 255, 255, .92); text-shadow: 0 1px 3px rgba(0, 0, 0, .6); + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; overflow-wrap: anywhere; +} +.mp-caption.open { -webkit-line-clamp: unset; max-height: 40vh; overflow-y: auto; } +.mp-counts { display: flex; flex-direction: column; align-items: center; gap: 14px; flex: 0 0 auto; } +.mp-counts button { + display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0; margin: 0; border: 0; + background: none; color: #fff; font-size: 13px; cursor: pointer; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); +} +.mp-counts .i { font-size: 24px; line-height: 1; display: flex; } +.mp-counts .i svg { width: 28px; height: 28px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } +.mp-like.on { color: #ff4d6d; } +.mp-zap.on .i { color: #f7b500; } +.mp-top { + position: absolute; top: max(10px, env(safe-area-inset-top)); left: 10px; right: 10px; + display: flex; justify-content: space-between; align-items: center; +} +.mp-x, .mp-sound { + height: 40px; min-width: 40px; padding: 0 12px; font-size: 22px; line-height: 1; border-radius: 999px; border: 0; + color: #fff; background: rgba(255, 255, 255, .16); +} +.mp-sound { font-size: 15px; } +.mp-sound .off { display: none; } +.media-pager.quiet .mp-sound .on { display: none; } +.media-pager.quiet .mp-sound .off { display: inline; } +/* muted against our will: the button stays up so the way to sound is plain */ +.media-pager.quiet .mp-top { opacity: 1; } +.media-pager.quiet .mp-top .mp-x { opacity: 0; pointer-events: none; } +.media-pager.quiet .mp-top .mp-sound { pointer-events: auto; } +.media-pager.quiet.show .mp-top .mp-x { opacity: 1; pointer-events: auto; } diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js new file mode 100644 index 0000000..792052a --- /dev/null +++ b/tools/feed-media-views-test.js @@ -0,0 +1,167 @@ +// Feed media views: All / Images / Videos under the feed chips. Images is a +// grid of the feed's pictures; a tile opens the full-screen pager. Videos +// opens straight into the pager: one clip per screen, the one on screen +// playing, the next a flick (or ↓) away. The pager shows only the media +// until tapped: then the author, the words as a caption, likes and sats. +// Back and Escape close it. +// Run: bun tools/feed-media-views-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const dir = mkdtempSync(tmpdir() + '/media-'); +const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); }; +for (const n of ['a', 'b']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]); +for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/v/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'video/mp4' } }); + if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const base = server.url.origin; +const SK = generateSecretKey(), PK = getPublicKey(SK); +const FAN = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const post = (i, content) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags: [], content }, SK); +const notes = [ + post(0, 'first clip, look at this ' + base + '/v/a.mp4'), + post(1, 'a plain post with no media at all'), + post(2, 'sunset over the bay ' + base + '/i/p1.jpg'), + post(3, base + '/v/b.mp4'), + post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'), +]; +const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN); + +const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((like) => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const f = m[2] || {}; + const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : []; + setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'EVENT') return; + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, LIKE); +const errs = []; +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, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m); +const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); }; +// which slide fills the screen, and what's playing +const pagerState = () => page.evaluate(() => { + const p = document.querySelector('.media-pager'); + if (!p) return null; + const sc = p.querySelector('.mp-scroll'); + const slides = [...sc.children]; + const i = Math.round(sc.scrollTop / sc.clientHeight); + 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'), + meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity }; +}); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const key = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Shutterbug', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [key, PK, notes]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click()); + await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 }); + + console.log('\n[the switch]'); + check('All / Images / Videos sit under the chips, All on', await page.evaluate(() => + [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos')); + + console.log('\n[Images: a grid]'); + await mode('images'); + check('the feed becomes a grid of its pictures, one tile each', await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 3, 8000), + String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length))); + check('...and the posts themselves are gone', await page.evaluate(() => !document.querySelector('.notes-feed'))); + await shot('grid'); + await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile')[1].click()); + check('a tile opens the pager', await waitFor(() => !!document.querySelector('.media-pager'), 4000)); + await sleep(400); + let st = await pagerState(); + check('...on that picture, among all three', st && st.n === 3 && st.src === 'p2.jpg', JSON.stringify(st)); + check('...with nothing over it', st && !st.show && st.meta === '0'); + await shot('pager-bare'); + 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 }; }); + 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)); + 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'))); + check('...and the page scrolls again', await page.evaluate(() => !document.documentElement.classList.contains('no-scroll'))); + + console.log('\n[Videos: flip through]'); + await mode('videos'); + check('Videos opens straight into the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), 4000)); + check('...the first clip playing', await waitFor(() => { const v = document.querySelector('.media-pager video'); return v && !v.paused && v.currentTime > 0; }, 8000), JSON.stringify(await pagerState())); + st = await pagerState(); + check('...two clips in all, nothing over them, sound on', st.n === 2 && st.src === 'a.mp4' && !st.show && st.meta === '0' + && await page.evaluate(() => !document.querySelector('.media-pager video').muted), JSON.stringify(st)); + await page.keyboard.press('ArrowDown'); await sleep(1200); + st = await pagerState(); + check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st)); + await shot('videos'); + await page.keyboard.press('Escape'); await sleep(600); + check('Escape closes it, leaving a grid of the clips', await page.evaluate(() => !document.querySelector('.media-pager') && document.querySelectorAll('.media-grid .media-tile.video').length === 2)); + check('no clip goes on playing behind', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused))); + + console.log('\n[remembered]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click()); + check('the feed opens on the view last chosen', await waitFor(() => document.querySelector('.media-mode.on')?.dataset.mode === 'videos' && !!document.querySelector('.media-grid'), 15000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ media views: a grid of pictures, videos to flip through' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
