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 | |
| 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
| -rw-r--r-- | src/features/messages.js | 42 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | tools/profile-feed-test.js | 9 | ||||
| -rw-r--r-- | tools/profile-replies-test.js | 93 |
4 files changed, 138 insertions, 9 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 6572e27..abbbf74 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4551,6 +4551,10 @@ export function messagesFeature(ctx) { // (a relay down, the phone just back from sleep) must not freeze a // profile at two posts for the rest of the session. const NOTES_REFRESH_MS = 60_000; + // A profile's Posts / Replies tab (Posts unless chosen on this profile). + const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts'); + // what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment + const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e)); function notesFor(pk) { let c = notesCache.get(pk); if (c) { @@ -10337,18 +10341,42 @@ export function messagesFeature(ctx) { // edge, no card walls (see .notes-feed). (() => { const c = notesFor(pk); + // Posts | Replies: the replies are their side of other people's + // conversations, apart from what they posted themselves + const tab = profTab(pk); + const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); + // a tab that holds only a few of the pages fetched so far (someone + // who mostly replies, or mostly posts) asks for older ones by itself: + // too short to scroll means the scroll that loads more never comes + // (five pages at most per tab: someone who never replies must not + // have their whole history paged through looking for one) + const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 }); + if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) { + fill.n++; + queueMicrotask(() => loadOlderNotes(pk).catch(() => {})); + } + const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' }, + ...['posts', 'replies'].map((k) => h('button', { + type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k, + onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); }, + }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies')))); // while loading with nothing to show: an invisible copy of the // empty-state line holds the height, so the page doesn't jump when // the answer lands (no spinner). With posts already in hand — from // the feed we came from — those are shown instead of the blank. - if (c.status === 'loading' && !c.notes.length) - return h('div', { class: 'small faint', style: 'text-align:center;visibility:hidden' }, t('profNotesNone')); - if (!c.notes.length) - return h('div', { class: 'small faint', style: 'text-align:center' }, t('profNotesNone')); + const none = t(tab === 'replies' ? 'profRepliesNone' : 'profNotesNone'); + if (!shown.length) { + const waiting = c.status === 'loading' || c.loadingMore || (!c.end && c.status === 'ready' && fill.n < 5); + return h('div', { class: 'col', style: 'gap:8px' }, tabs, + waiting && c.notes.length + ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) + // nothing in hand yet: the empty line holds the height, unseen (no spinner) + : h('div', { class: 'small faint', style: 'text-align:center' + (waiting ? ';visibility:hidden' : '') }, none)); + } return h('div', { class: 'col', style: 'gap:8px' }, - h('div', { class: 'small muted', style: 'padding:0 2px' }, t('profNotesTitle')), - h('div', { class: 'card col notes-feed', style: 'gap:0' }, - ...(c.winList = () => c.notes, windowedRows(c, c.notes, (ev) => keyed(noteRow(pk, ev, name), ev.id)))), + tabs, + h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab }, + ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name), ev.id)))), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) diff --git a/src/i18n.js b/src/i18n.js index 894012e..9df0127 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -708,6 +708,9 @@ const STR = { hatMainnetOnly: 'Hats are sold on mainnet and Mutinynet only', profNotesTitle: 'Latest posts', profNotesNone: 'No recent posts found.', + profTabPosts: 'Posts', + profTabReplies: 'Replies', + profRepliesNone: 'No recent replies found.', profReplyTag: 'reply', msgSearching: 'Searching…', msgNoMatches: 'No matches', diff --git a/tools/profile-feed-test.js b/tools/profile-feed-test.js index f2ca9e6..87dfc80 100644 --- a/tools/profile-feed-test.js +++ b/tools/profile-feed-test.js @@ -49,8 +49,13 @@ try { await page.evaluate(() => document.querySelector('.chat-avatar.clickable')?.click()); check('profile screen opens', await waitText('back', 10000)); // the feed: either notes arrive or the explicit empty state shows - const fed = await waitText('Latest posts', 15000); - const empty = !fed && (await bodyText()).includes('No recent posts'); + // (the Posts / Replies tabs come first; their rows once the relays answer) + let fed = false, empty = false; + for (let i = 0; i < 60 && !fed && !empty; i++) { + fed = await page.evaluate(() => document.querySelectorAll('.notes-feed [data-zap-post]').length > 0); + empty = !fed && /No recent posts/.test(await bodyText()); + if (!fed && !empty) await sleep(250); + } check('note feed resolves', fed || empty, fed ? 'notes shown' : 'no posts for this pk'); if (fed) { // the ⚡ in every note's action bar, live when the wallet can pay 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); |
