aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-03 13:12:30 -0700
committerAdam Soltys <[email protected]>2026-10-03 13:12:30 -0700
commit543a66ca70323921acf4da9b8e4fca8128cf0b58 (patch)
treed3aef3cd1a703188bae15b95bf06d1911f549334
parent4444c9ce12cc9c1ed3eca9470e0c1080f80d8027 (diff)
downloadcoinosv3-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.js5
-rw-r--r--src/features/messages.js289
-rw-r--r--src/i18n.js5
-rw-r--r--src/locales/ar.json7
-rw-r--r--src/locales/bn.json7
-rw-r--r--src/locales/de.json7
-rw-r--r--src/locales/es.json7
-rw-r--r--src/locales/fr.json7
-rw-r--r--src/locales/hi.json7
-rw-r--r--src/locales/id.json7
-rw-r--r--src/locales/ja.json7
-rw-r--r--src/locales/ko.json7
-rw-r--r--src/locales/mr.json7
-rw-r--r--src/locales/pt.json7
-rw-r--r--src/locales/ru.json7
-rw-r--r--src/locales/sw.json7
-rw-r--r--src/locales/ta.json7
-rw-r--r--src/locales/te.json7
-rw-r--r--src/locales/tr.json7
-rw-r--r--src/locales/ur.json7
-rw-r--r--src/locales/vi.json7
-rw-r--r--src/locales/zh.json7
-rw-r--r--src/style.css79
-rw-r--r--tools/feed-media-views-test.js167
24 files changed, 656 insertions, 22 deletions
diff --git a/src/app.js b/src/app.js
index 43c5308..7b37ead 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);