diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 16:04:46 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 16:04:46 -0700 |
| commit | 22653c35ab372f7649711a743fd88b71265ae4e5 (patch) | |
| tree | e96aecb2d926ded1292c35bc81abc740df54cf2a /tools/profile-replies-test.js | |
| parent | b6cca5ba5196d3b89df4708c0b1aff31ec624df0 (diff) | |
| download | coinosv3-22653c35ab372f7649711a743fd88b71265ae4e5.tar.gz coinosv3-22653c35ab372f7649711a743fd88b71265ae4e5.zip | |
Profiles: Posts / Replies tabs. Replies were mixed into a profile's list, tagged "↩ reply" — now Posts (the default) is what they posted and Replies is their side of other people's threads (NIP-10 replies, NIP-22 comments). Profiles load thirty mixed notes a page and older pages only on scroll, so a tab that holds few of its kind (someone who mostly replies, or never does) fetches older pages by itself — five at most per tab, so a person who never replies doesn't have their whole history paged through. tools/profile-replies-test.js; profile-feed-test waits for rows (the tabs come before them; the old "Latest posts" heading only appeared with notes in hand).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'tools/profile-replies-test.js')
| -rw-r--r-- | tools/profile-replies-test.js | 93 |
1 files changed, 93 insertions, 0 deletions
diff --git a/tools/profile-replies-test.js b/tools/profile-replies-test.js new file mode 100644 index 0000000..b50999f --- /dev/null +++ b/tools/profile-replies-test.js @@ -0,0 +1,93 @@ +// A profile's Posts / Replies tabs: Posts (the default) is what they posted, +// Replies their answers in other people's threads. A tab with few of its +// kind among the pages fetched so far asks for older pages by itself. +// Run: bun tools/profile-replies-test.js +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { npubEncode } 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 key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; +const AUTHOR = key(), OTHER = key(); +const now = Math.floor(Date.now() / 1000); +const theirs = finalizeEvent({ kind: 1, created_at: now - 99999, tags: [], content: 'a post by someone else' }, OTHER.sk); +const evs = []; +// newest first: 25 replies with 3 posts among them, then 10 older posts +let t = now - 60; +for (let i = 0; i < 28; i++) { + const post = i % 9 === 4; // three posts in the first page + evs.push(finalizeEvent(post + ? { kind: 1, created_at: t, tags: [], content: 'recent post number ' + i } + : { kind: 1, created_at: t, tags: [['e', theirs.id, '', 'root'], ['p', OTHER.pk]], content: 'my reply number ' + i }, AUTHOR.sk)); + t -= 60; +} +for (let i = 0; i < 10; i++) { evs.push(finalizeEvent({ kind: 1, created_at: t, tags: [], content: 'older post number ' + i }, AUTHOR.sk)); t -= 60; } +const PROFILE = finalizeEvent({ kind: 0, created_at: now - 10, tags: [], content: JSON.stringify({ name: 'Replier' }) }, AUTHOR.sk); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(([pk, evs, prof]) => { + window.__pages = 0; + 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') { + let out = []; + for (const f of m.slice(2)) { + if (!(f.authors || []).includes(pk)) continue; + if ((f.kinds || []).includes(0)) out.push(prof); + if ((f.kinds || []).includes(1) && !f['#e'] && !f['#p']) { + const page = evs.filter((e) => !f.until || e.created_at <= f.until).slice(0, f.limit || 30); + if (f.until) window.__pages++; + out.push(...page); + } + } + setTimeout(() => { + for (const e of out) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); + 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); +}, [AUTHOR.pk, evs, PROFILE]); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const rows = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-zap-post]')].map((r) => r.innerText)); +const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href + npubEncode(AUTHOR.pk), { waitUntil: 'domcontentloaded' }); + check('the profile opens on its Posts tab', await waitFor(() => document.querySelector('.prof-tabs button.active')?.dataset.tab === 'posts', 20000)); + check('older pages are fetched until Posts has something to show', await waitFor(() => [...document.querySelectorAll('.notes-feed [data-zap-post]')].length >= 12, 15000), + (await rows()).length + ' rows, ' + (await page.evaluate(() => window.__pages)) + ' older page(s)'); + let r = await rows(); + check('Posts holds only their posts', r.length > 0 && r.every((x) => /post number/.test(x) && !/my reply/.test(x)), r.length + ' rows'); + check('...the three recent ones and the older ones', r.filter((x) => /recent post/.test(x)).length === 3 && r.some((x) => /older post/.test(x))); + await page.evaluate(() => document.querySelector('.prof-tabs button[data-tab="replies"]').click()); + await sleep(600); + r = await rows(); + check('Replies holds only their replies', r.length >= 12 && r.every((x) => /my reply/.test(x)), r.length + ' rows'); + check('...each marked as a reply', r.every((x) => /reply/i.test(x.split('\n').slice(0, 3).join(' ')))); + await page.evaluate(() => document.querySelector('.prof-tabs button[data-tab="posts"]').click()); + await sleep(400); + if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.prof-tabs').scrollIntoView({ block: 'start' })); await page.evaluate(() => window.scrollBy(0, -80)); await sleep(300); await page.screenshot({ path: process.env.SHOT }); } + check('back to Posts', (await rows()).every((x) => /post number/.test(x))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ profiles split posts and replies' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
