aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js42
-rw-r--r--src/i18n.js3
-rw-r--r--tools/profile-feed-test.js9
-rw-r--r--tools/profile-replies-test.js93
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);