diff options
| author | Adam Soltys <[email protected]> | 2026-10-13 06:06:40 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-13 06:06:40 +0000 |
| commit | b330cc707fab62365c29b57d414c46aa198febef (patch) | |
| tree | 40706a4e2035e63f9217e428cc6b6733f7ea4e57 | |
| parent | 72705992c3d40f87052ade605c50f60a18f80453 (diff) | |
| download | coinosv3-b330cc707fab62365c29b57d414c46aa198febef.tar.gz coinosv3-b330cc707fab62365c29b57d414c46aa198febef.zip | |
Feed pager: pictures (and clips) in a quoted post are in it too. Adam full-screened the screenshots in a quoted post and couldn't swipe on: a tap on a picture in the feed opens the pager on it only if it is among the pager's slides, and those were each feed post's own pictures — a quoted post's are its own, drawn inside the one quoting it, so the tap fell back to the picture alone (the lightbox), with nothing to swipe to. Now the pager takes, after each post's own pictures (or clips), those of the posts it quotes, as far as they've been fetched (and not hidden or folded as sensitive): swipes walk through them and on to the next post's, the slide's details name the quoted post's author and words, and closing lands on the post that quoted it. tools/quote-pager-test.js (a post with its own picture quoting one with two, served only by a relay: the tap opens the pager on the quoted picture, down through the quote and on, up to the quoting post's own, the quoted author in the details, Escape onto the quoting post) — with the quotes left out it fails as Adam saw, the picture opening alone.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
| -rw-r--r-- | src/features/messages.js | 27 | ||||
| -rw-r--r-- | tools/quote-pager-test.js | 126 |
2 files changed, 148 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 598ff39..a3c7cd5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -11789,16 +11789,33 @@ export function messagesFeature(ctx) { // before the next post's — for the pager opened from one in the feed: the // feed's own list (its order, its moderation), not the media index. Clips // of every shape, not only the tall ones the Videos view flips through. + // The posts a post quotes (drawn inside it), as far as they've been fetched. + function quotedIn(ev) { + const out = []; + for (const m of String(ev.content || '').matchAll(/nostr:((?:note|nevent)1[a-z0-9]+)/gi)) { + const ref = parseNostrRef(m[1].toLowerCase()); + const q = ref && ref.type === 'event' && quoted.get(ref.id); + if (q && q.ev && FEED_KINDS.includes(q.ev.kind) && !hidden(q.ev) && !out.includes(q.ev)) out.push(q.ev); + } + return out; + } function feedMediaItems(c, kind) { const out = []; const list = c.winList ? c.winList() : c.notes; + const sensitive = (e) => modOn('sensitive') && sensitiveWhy(e) !== null && !isMe(e.pubkey); + const add = (e, row) => { + if (kind === 'videos') learnVideoMeta(e); + const urls = kind === 'videos' ? clipUrlsOf(e) + : noteMediaUrls(e.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); + for (const url of urls) out.push({ ev: e, url, row }); + }; for (const row of list) { const ev = row.kind === REPOST ? repostInner(row) || row : row; - if (modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; - if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev) - : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); - for (const url of urls) out.push({ ev, url, row }); + if (sensitive(ev)) continue; + add(ev, row); + // then the pictures (clips) of the posts it quotes, which are drawn + // inside it: tapped there, one opened alone, with nothing to swipe to + for (const q of quotedIn(ev)) if (!sensitive(q)) add(q, row); } return out; } diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js new file mode 100644 index 0000000..2a0e33b --- /dev/null +++ b/tools/quote-pager-test.js @@ -0,0 +1,126 @@ +// Pictures in a quoted post are in the pager too. A post quoting another +// draws the quoted one inside it; a tap on a picture there opened it alone +// (the pager's list held only the feed posts' own pictures), with nothing to +// swipe to. Now the pager has, after each post's own pictures, those of the +// posts it quotes: swipes walk through them and on to the next post's, the +// quoted picture's details name its author, and closing lands on the post +// that quoted it. +// Run: bun tools/quote-pager-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { noteEncode } from 'nostr-tools/nip19'; +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 PIC = png('400x300'); + +const A = generateSecretKey(), Q = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (/^\/\w+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } }); + 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 U = (n) => server.url.origin + '/' + n + '.png'; +const ev = (sk, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, sk); +// the quoted post: someone not followed, two screenshots +const quotedPost = ev(Q, 'two screenshots ' + U('q1') + ' ' + U('q2'), 5000); +const FEED = [ + ev(A, 'my own picture ' + U('a') + ' and a store worth a look nostr:' + noteEncode(quotedPost.id), 100), + ev(A, 'one more after it ' + U('b'), 200), + ...Array.from({ length: 4 }, (_, i) => ev(A, 'filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 300 + i * 60)), +]; +const ALL = [...FEED, quotedPost]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((evs) => { + const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#')); + class FakeWS { + constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); } + send(raw) { + const m = JSON.parse(raw); + const reply = (x) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5); + if (m[0] === 'REQ') { + for (const f of m.slice(2)) { + if (f.cache) continue; + const hits = evs.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e]); + } + reply(['EOSE', m[1]]); + } else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']); + else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]); + } + close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); } + addEventListener(t, fn) { this['on' + t] = fn; } + removeEventListener() {} + } + FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3; + window.WebSocket = FakeWS; +}, ALL); +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, 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 AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : 'between'; }; +const at = () => page.evaluate(AT); +const resting = (n) => waitFor(([f, x]) => eval(f)() === x, ['(' + AT + ')', n], 4000); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { 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 base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pa, pq]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pa]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pa]: { name: 'Quoter', t: Date.now() }, [pq]: { name: 'Store Maker', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, getPublicKey(A), getPublicKey(Q)]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the quoted post\'s pictures are drawn inside the post', await waitFor(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); return r && [...r.querySelectorAll('img')].some((i) => /\/q1\.png$/.test(i.src) && i.complete && i.naturalWidth); }, null, 20000)); + await sleep(800); + + console.log('\n[a tap on a quoted picture]'); + const pos = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); const i = [...r.querySelectorAll('img')].find((x) => /\/q1\.png$/.test(x.src)); i.scrollIntoView({ block: 'center' }); const b = i.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }); + await sleep(500); + const pos2 = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); const i = [...r.querySelectorAll('img')].find((x) => /\/q1\.png$/.test(x.src)); const b = i.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }); + await page.mouse.click(pos2.x, pos2.y); + check('it opens in the pager (not alone)', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && !(await page.evaluate(() => !!document.querySelector('.lightbox')))); + check('…on the quoted picture', await resting('q1'), await at()); + await page.keyboard.press('ArrowDown'); + check('down: the quoted post\'s second picture', await resting('q2'), await at()); + await page.keyboard.press('ArrowDown'); + check('down: on to the next post\'s', await resting('b'), await at()); + await page.keyboard.press('ArrowUp'); await resting('q2'); + await page.keyboard.press('ArrowUp'); await resting('q1'); + await page.keyboard.press('ArrowUp'); + check('up past the quote: the quoting post\'s own picture, before it', await resting('a'), await at()); + await page.keyboard.press('ArrowDown'); await resting('q1'); + await page.keyboard.press('ArrowDown'); await resting('q2'); + // details: a tap (moved on from the start, so the first tap shows them) + await page.mouse.click(195, 300); await sleep(400); + check('the quoted picture\'s details name its author', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return /Store Maker/.test(s.textContent) && /two screenshots/.test(s.textContent); })); + await page.keyboard.press('Escape'); + check('closing it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + await sleep(1500); + const top = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); return r ? Math.round(r.getBoundingClientRect().top) : null; }); + check('…lands on the post that quoted it', top !== null && top >= -10 && top < 120, 'row top ' + top); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ quoted pictures page with the rest' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
