diff options
| author | Adam Soltys <[email protected]> | 2026-10-15 08:35:59 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-15 08:35:59 -0700 |
| commit | 849e3d172354c87c14991392bd075334dc5f68af (patch) | |
| tree | b6ae24bdf7f8abd13f1b44f4915336d6cb60f9b6 | |
| parent | b540d3fab2579da40e0bcdb59581f83b0b742947 (diff) | |
| download | coinosv3-849e3d172354c87c14991392bd075334dc5f68af.tar.gz coinosv3-849e3d172354c87c14991392bd075334dc5f68af.zip | |
Thread pages: a picture opens in the media pager, over the thread's pictures; a tap brings up the caption and controls wherever the pager was opened from. A picture tapped on a thread page opened alone, full screen, and a tap on it closed it — no caption, no controls, nothing to swipe to (Adam, 2026-10-16: "clicking again dismisses it instead of showing me the caption and controls… let's make it consistent no matter where media is opened from"). Now a thread is a pager source like the feed and a profile page (pageRowOf/pagerSource `thread`, keyed by the thread's root): its slides are the root's pictures and then its replies', in the order the page draws them (threadTree/threadOrder, the tree walk factored out of threadScreen), with nothing more to fetch; closing after moving on brings that post on screen. And in any pager opened from a page, the first tap shows the post's details and the next closes it — the tap on the picture that opened it no longer closes it outright, so the feed and a profile behave the same as the thread. A face or a chat's picture still opens alone. tools/thread-pager-test.js (the thread's pictures only, root first then the reply's; counter 1/2; a tap shows the author and the words; right and down; Escape leaves the thread with the post on screen; a reply's picture; tap, then tap closes); feed-picture-pager and profile-pager tests updated for the tap rule; image-tap, thread-back-scroll pass; thread-reply-test times out on clean HEAD too.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
| -rw-r--r-- | src/features/messages.js | 122 | ||||
| -rw-r--r-- | tools/feed-picture-pager-test.js | 6 | ||||
| -rw-r--r-- | tools/profile-pager-test.js | 4 | ||||
| -rw-r--r-- | tools/thread-pager-test.js | 134 |
4 files changed, 227 insertions, 39 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 7fcce9e..d18f6df 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -10374,9 +10374,41 @@ export function messagesFeature(ctx) { return evt; } + // The thread's replies by parent, each note's answers in the order the + // page draws them (yours first, then oldest first); an orphan hangs off + // the root. + function threadTree(c) { + const parentOf = (ev) => { + // a comment's parent is its lowercase e; one answering the root by + // address (an article) has only an a — the root + if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId; + const es = ev.tags.filter((x) => x[0] === 'e'); + const marked = es.find((x) => x[3] === 'reply'); + return (marked || es[es.length - 1] || [])[1] || c.rootId; + }; + const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]); + const children = new Map(); + for (const ev of c.replies) { + const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; + if (!children.has(pid)) children.set(pid, []); + children.get(pid).push(ev); + } + for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at)); + return children; + } + // The thread's posts in the order the page shows them: the root, then + // the replies down the tree (a hidden one skipped, its answers kept). + function threadOrder(c) { + const children = threadTree(c), out = []; + if (c.root) out.push(c.root); + const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } }; + walk(c.rootId); + return out; + } function threadScreen() { const s = ui.noteThread; const c = threadFor(s.seed); + queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures // the conversation on screen is the best count of its own replies for (const e of c.replies) noteReply(e); const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)); @@ -10515,23 +10547,9 @@ export function messagesFeature(ctx) { // under it, a little indented, oldest first — except your own, which go // first among a note's answers, so what you just wrote shows right // under the post instead of at the bottom of a long thread. - const parentOf = (ev) => { - // a comment's parent is its lowercase e; one answering the root by - // address (an article) has only an a — the root - if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId; - const es = ev.tags.filter((x) => x[0] === 'e'); - const marked = es.find((x) => x[3] === 'reply'); - return (marked || es[es.length - 1] || [])[1] || c.rootId; - }; - const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]); - const children = new Map(); - for (const ev of c.replies) { - const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; // an orphan hangs off the root - if (!children.has(pid)) children.set(pid, []); - children.get(pid).push(ev); - } + const children = threadTree(c); const walk = (pid, depth) => { - const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at)); + const list = children.get(pid) || []; for (const ev of list) { if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place const indent = Math.min(depth, 3) * 14; @@ -11890,11 +11908,22 @@ export function messagesFeature(ctx) { const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList; return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null; } - // the page the row is on: the feed, or a person's timeline (their - // profile page) — the pager runs over that page's pictures (or clips) + // the page the row is on: the feed, a person's timeline (their profile + // page) or a thread — the pager runs over that page's pictures (or clips) const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread); + const onThreadPage = () => !!ui.noteThread && !onProfilePage(); + // the thread on screen, by its root — the key a thread pager is tied to + const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null; + // a thread as a list the pager can read (feedMediaItems), in the page's order + const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 }); function pageRowOf(el) { - const rowId = el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null; + if (!el || !el.closest) return null; + if (onThreadPage()) { + const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null; + const tid = threadPageId(); + return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null; + } + const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null; if (!rowId) return null; if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; } return feedRowOf(el) ? { c: feed, rowId, profile: null } : null; @@ -11904,6 +11933,13 @@ export function messagesFeature(ctx) { // as the reader nears the end. The media grid's pager has no source here. function pagerSource(p = pager) { if (!p) return null; + if (p.thread) { + // a thread's pictures: the root's and its replies', as the page lists + // them — all here already, nothing more to fetch + const c = threadCache.get(p.thread); + return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true, + live: () => threadPageId() === p.thread } : null; + } if (p.profile) { const c = notesCache.get(p.profile); return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore, @@ -11914,14 +11950,17 @@ export function messagesFeature(ctx) { } // A picture tapped in the feed opens in the media pager, on that picture, // with the feed's other pictures a swipe (or an arrow) away, the way the - // media grid's are. On any other page — a thread, a profile, a chat — the - // picture opens alone, as before. + // media grid's are; on a profile page over that person's pictures, and on + // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever + // a picture is opened from — a thread's opened alone and a tap on it + // closed it, with no caption or controls). Elsewhere — a chat, a face — + // the picture opens alone. function openPicture(url, e) { try { const at = pageRowOf(e && e.target); if (at) { - const it = feedMediaItems(at.c, 'images').find((x) => x.row.id === at.rowId && x.url === url); - if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile, profile: at.profile }); return; } + const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url); + if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; } } } catch {} ctx.openImage && ctx.openImage(url); @@ -11935,12 +11974,12 @@ export function messagesFeature(ctx) { try { const on = pageRowOf(v); if (!on) return false; - const it = feedMediaItems(on.c, 'videos').find((x) => x.row.id === on.rowId && x.url === url); + const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); if (!it) return false; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); - openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile, profile: on.profile, at }); + openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at }); pagerFullscreen(); return true; } catch { return false; } @@ -12098,13 +12137,13 @@ export function messagesFeature(ctx) { // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there - function openMediaPager(kind, start, { fromFeed = false, profile = null, at = 0 } = {}) { + function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) { // the page's own players stop: the pager's is the one to hear for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} } for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) { try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {} } - ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, at }; + ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at }; render(); } const closeMediaPager = () => ctx.closeLayer('mediaPager'); @@ -12123,6 +12162,15 @@ export function messagesFeature(ctx) { // the pop that closes it lands on a later turn: the glide waits for the feed let tries = 0; const go = () => { + if (src.thread) { + // a thread's post is drawn already (no window): just bring it on screen if it isn't + if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; } + if (!src.live()) return; + const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]'); + const r = el && el.getBoundingClientRect(); + if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8)); + return; + } if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; } landOnPost(src, rowId); }; @@ -12176,10 +12224,11 @@ export function messagesFeature(ctx) { let pagerLoud = true; // sound on, unless the browser said no without a tap function syncMediaPager() { const m = ui.mediaPager; - const want = m && (m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile) + const want = m && (m.thread ? threadPageId() === m.thread + : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile) : ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null; if (!want) { if (pager) dropPager(); return; } - if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null))) dropPager(); + if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager(); if (!pager) buildPager(want); else growPager(); } @@ -12788,11 +12837,12 @@ export function messagesFeature(ctx) { return; } if (pager && pagerSource()) { - // Opened from a post in the feed (or on a profile): a tap on the picture that opened - // it closes it, as a full-screen picture always has. Once the reader - // has moved on, the first tap shows the post's details, and the next - // closes the pager onto that post in the feed. - if (pagerOnStart()) { closeMediaPager(); return; } + // Opened from a post on a page (the feed, a profile, a thread): the + // first tap shows the post's details — its caption and controls — + // and the next closes the pager onto that post. (A tap on the + // picture that opened it used to close it outright, as a full-screen + // picture had; Adam, 2026-10-16: a tap should bring the details up + // wherever the picture was opened from.) if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; } dismissPager(); return; @@ -12800,7 +12850,7 @@ export function messagesFeature(ctx) { 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, fromFeed: !!want.fromFeed, profile: want.profile || null, start: want.start, at: want.at || 0 }; + pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 }; pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -12826,7 +12876,7 @@ export function messagesFeature(ctx) { pagerArrows(slide); pagerPosition(); if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); } - else if (pager.profile) markSeen(slide._ev); + else if (pager.profile || pager.thread) markSeen(slide._ev); playSlide(slide); pagerCounts(slide); pagerLoadAhead(); diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js index 32625c9..ff69626 100644 --- a/tools/feed-picture-pager-test.js +++ b/tools/feed-picture-pager-test.js @@ -82,12 +82,14 @@ try { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); - console.log('\n[a tap on the picture that opened it closes it]'); + console.log('\n[a tap on the picture that opened it shows its details; the next closes it]'); const y0 = await openPic('two pictures'); check('the picture opens in the pager, on itself', await resting('a1.png'), await curPic()); check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show'))); + await tapPager(); await sleep(400); + check('one tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); await tapPager(); await sleep(600); - check('one tap closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); check('the feed is where it was', Math.abs((await page.evaluate(() => window.scrollY)) - y0) <= 2); console.log('\n[left and right: along a post\'s pictures; up and down: post to post]'); diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js index 85aee86..29341e7 100644 --- a/tools/profile-pager-test.js +++ b/tools/profile-pager-test.js @@ -125,8 +125,10 @@ try { check('…landed on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture'))); await openPic('one picture'); check('opened again on the picture it closed on', await resting('b.png'), await curPic()); + await tapPager(); await sleep(400); + check('a tap on the picture that opened it shows its details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); await tapPager(); await sleep(600); - check('a tap on the picture that opened it closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…and the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ a profile\'s pictures page through the pager' : '\n❌ failed'); diff --git a/tools/thread-pager-test.js b/tools/thread-pager-test.js new file mode 100644 index 0000000..0f99742 --- /dev/null +++ b/tools/thread-pager-test.js @@ -0,0 +1,134 @@ +// A picture tapped on a thread page opens in the media pager over the +// THREAD's pictures — the root's and its replies', in the page's order, +// nobody else's — with the caption and controls a tap away, and closing it +// leaves the thread on screen. (It opened alone, in the lightbox, before: +// a tap on it closed it, with no caption or controls — Adam, 2026-10-16.) +// Run: bun tools/thread-pager-test.js +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +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 png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } }); + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SK, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const root = post('two pictures in the root ' + U('a1') + ' ' + U('a2'), 600); +const replyWords = post('a reply with only words', 500, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]); +const replyPic = post('a reply with a picture ' + U('b'), 400, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]); +const feedNotes = [ + post('someone else, newer, with a picture ' + U('x'), 100, SK2), + post('another post with a picture ' + U('d'), 200), + root, + ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 700 + i * 60)), +]; +const replies = [replyWords, replyPic]; +const profiles = [finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Picture Poster' }) }, SK), + finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Reply Writer' }) }, SK2)]; +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes: feedNotes, profiles, popUntil: now - 3600 }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const openPicIn = async (scope, word, nth = 0) => { + await page.evaluate(([sc, w]) => { const r = [...document.querySelectorAll(sc)].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, [scope, word]); + await sleep(400); + const at = await page.evaluate(([sc, w, n]) => { const r = [...document.querySelectorAll(sc)].find((x) => x.textContent.includes(w)); const i = r.querySelectorAll('img.note-img')[n].getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, [scope, word, nth]); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager, .lightbox'), null, 4000); +}; +const threadRowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.thread-page [data-zap-post]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(([notes, replies, profiles]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + let evs = []; + const all = [...notes, ...replies]; + if (f['#e']) evs = replies.filter((e) => e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))); + else if (f.ids) evs = all.filter((e) => f.ids.includes(e.id)); + else if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) && (!f.authors || f.authors.includes(e.pubkey))).slice(0, f.limit || 100); + if (f.kinds?.includes(0)) evs = profiles.filter((p) => !f.authors || f.authors.includes(p.pubkey)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [feedNotes, replies, profiles]); + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + check('the feed shows the posts', await waitFor(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, null, 20000)); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); r.scrollIntoView({ block: 'center' }); }); + await sleep(300); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); (r.querySelector('.note-text') || r).click(); }); + check('the thread opens', await waitFor(() => !!document.querySelector('.thread-page'), null, 5000)); + check('…with its replies', await waitFor(() => document.body.innerText.includes('a reply with a picture'), null, 8000)); + await sleep(500); + + console.log('\n[a picture on a thread page]'); + await openPicIn('.thread-page [data-zap-post]', 'two pictures in the root'); + check('it opens in the media pager, not the lightbox', await page.evaluate(() => !!document.querySelector('.media-pager') && !document.querySelector('.lightbox'))); + check('…on itself', await resting('a1.png'), await curPic()); + const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop())); + check('the pager holds the thread\'s pictures only, root first then the replies', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png']), JSON.stringify(keys)); + check('counter: first of two posts', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 2'); + check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show'))); + await tapPager(); await sleep(400); + check('a tap shows the caption and controls', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Picture Poster/.test(s.textContent) && /two pictures in the root/.test(s.textContent); })); + await page.keyboard.press('ArrowRight'); + check('right: the root\'s second picture', await resting('a2.png'), await curPic()); + await page.keyboard.press('ArrowDown'); + check('down: the reply\'s picture', await resting('b.png'), await curPic()); + check('counter: second post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 2'); + check('no loading slide (nothing more to fetch)', await page.$('.mp-tail') === null); + await page.keyboard.press('Escape'); + check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + await sleep(600); + check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a reply with only words'))); + check('…with the reply\'s post on screen', (await threadRowTop('a reply with a picture')) !== null && (await threadRowTop('a reply with a picture')) >= -10 && (await threadRowTop('a reply with a picture')) < 844, 'row top ' + (await threadRowTop('a reply with a picture'))); + + console.log('\n[a reply\'s picture, and a tap then another]'); + await openPicIn('.thread-page [data-zap-post]', 'a reply with a picture'); + check('opens on the reply\'s picture', await resting('b.png'), await curPic()); + await tapPager(); await sleep(400); + check('a tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + await tapPager(); await sleep(600); + check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a thread\'s pictures page through the pager' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
