aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js62
-rw-r--r--tools/comments-test.js99
2 files changed, 152 insertions, 9 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 11204f5..3d9b1e9 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6719,9 +6719,9 @@ export function messagesFeature(ctx) {
// An event of a kind this app has no renderer for (a chess game, kind
// 64, reached through a reaction to it) is shown as what its author's
// client said it is (NIP-31 alt), never as raw content dressed as a post.
- const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL;
+ const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL && ev.kind !== COMMENT;
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
- const isReply = !foreign && ev.tags.some((x) => x[0] === 'e');
+ const isReply = ev.kind === COMMENT || (!foreign && ev.tags.some((x) => x[0] === 'e'));
const canZap = canZapPk(pk);
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
@@ -6834,7 +6834,16 @@ export function messagesFeature(ctx) {
settle();
requestAnimationFrame(settle);
}
+ // ---- NIP-22 comments (kind 1111) ----
+ // The reply format for everything that isn't a kind 1 note — a poll, an
+ // article, a picture. Uppercase tags name the ROOT (E/A + K + P),
+ // lowercase the PARENT it answers (e/a + k + p). A kind 1 note is never
+ // answered with one (NIP-22), and never answers anything but a kind 1.
+ const COMMENT = 1111;
+ const tagVal = (ev, name) => (ev.tags.find((x) => x[0] === name && x[1]) || [])[1] || null;
+ const addrOf = (ev) => ev.kind >= 30000 && ev.kind < 40000 ? ev.kind + ':' + ev.pubkey + ':' + (tagVal(ev, 'd') || '') : null;
function rootIdOf(ev) {
+ if (ev.kind === COMMENT) return tagVal(ev, 'E') || tagVal(ev, 'e') || ev.id;
const es = ev.tags.filter((x) => x[0] === 'e');
const marked = es.find((x) => x[3] === 'root');
return (marked || es[0] || [])[1] || ev.id;
@@ -6893,7 +6902,11 @@ export function messagesFeature(ctx) {
let relays = await relaysFor(c.root || seed);
const [roots, replies] = await Promise.all([
c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll
- queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500),
+ // kind 1 replies, and NIP-22 comments naming it as their root
+ Promise.all([
+ queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []),
+ queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []),
+ ]).then(([a, b]) => [...(a || []), ...(b || [])]),
]);
if (!c.root) c.root = (roots || [])[0] || null;
let all = [...c.replies, ...(replies || [])];
@@ -6907,7 +6920,7 @@ export function messagesFeature(ctx) {
relays = [...new Set([...relays, ...(await relaysFor(top))])].slice(0, 14);
const [ups, more] = await Promise.all([
queryOn(relays, { ids: [up] }, 4000),
- queryOn(relays, { kinds: [1], '#e': [up], limit: 80 }, 4500),
+ queryOn(relays, { kinds: [1, COMMENT], '#e': [up], limit: 80 }, 4500),
]);
const upNote = (ups || [])[0];
if (!upNote) break;
@@ -6919,6 +6932,9 @@ export function messagesFeature(ctx) {
threadCache.set(topId, c);
if (ui.noteThread && ui.noteThread.rootId === rootId) ui.noteThread.rootId = topId;
}
+ // an article's comments may name it only by address
+ const addr = c.root && addrOf(c.root);
+ if (addr) all = [...all, ...(await queryOn(relays, { kinds: [COMMENT], '#A': [addr], limit: 120 }, 4500).catch(() => []) || [])];
for (const e of all) noteForSpam(e);
const seen = new Set([c.rootId]);
c.replies = all
@@ -7020,7 +7036,28 @@ export function messagesFeature(ctx) {
])].filter((pk) => pk !== id.pubkey).slice(0, 8);
const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url))
.map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]);
- const partial = {
+ const root = c.root || (target.id === rootId ? target : null);
+ // the root's scope: from the root itself, or — not loaded yet — copied
+ // off the comment being answered, which carries it
+ const scope = root
+ ? [...(addrOf(root) ? [['A', addrOf(root), '']] : []), ['E', root.id, '', root.pubkey], ['K', String(root.kind)], ['P', root.pubkey]]
+ : target.kind === COMMENT ? target.tags.filter((x) => ['A', 'E', 'I', 'K', 'P'].includes(x[0])) : [];
+ const partial = target.kind === 1 || !scope.length ? null : {
+ // NIP-22: a comment on a poll, an article, or on another comment
+ kind: COMMENT,
+ content: text,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ...scope,
+ ...(root && target.id === root.id
+ ? [...(addrOf(root) ? [['a', addrOf(root), '']] : []), ['e', root.id, '', root.pubkey], ['k', String(root.kind)], ['p', root.pubkey]]
+ : [['e', target.id, '', target.pubkey], ['k', String(target.kind)], ['p', target.pubkey]]),
+ ...pTags.filter((pk) => pk !== target.pubkey && (!root || pk !== root.pubkey)).map((pk) => ['p', pk]),
+ ...imeta,
+ CLIENT_TAG,
+ ],
+ };
+ const note = partial || {
kind: 1,
content: text,
created_at: Math.floor(Date.now() / 1000),
@@ -7032,7 +7069,7 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
- const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
if (!ok) throw new Error(t('msgSendFailed'));
@@ -7163,6 +7200,9 @@ export function messagesFeature(ctx) {
// 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;
@@ -9174,7 +9214,7 @@ export function messagesFeature(ctx) {
// kept as a short list of what-happened rows rather than the raw events —
// a zap receipt drags its whole request along, and the list rides in the
// feature state. Painted from the last visit while the relays are asked.
- const NOTIF_KINDS = [1, 6, 7, ...ZAP_KINDS];
+ const NOTIF_KINDS = [1, COMMENT, 6, 7, ...ZAP_KINDS];
const NOTIF_KEEP = 150;
let notif = null; // { status, items }
let notifAt = 0, notifUnsub = null;
@@ -9289,6 +9329,10 @@ export function messagesFeature(ctx) {
return { id: ev.id, what: 'react', actor: ev.pubkey, target: lastE, hint, emoji, ts: ev.created_at };
}
if (ev.kind === 6) return lastE ? { id: ev.id, what: 'boost', actor: ev.pubkey, target: lastE, hint, ts: ev.created_at } : null;
+ if (ev.kind === COMMENT) {
+ const parent = tagVal(ev, 'e') || tagVal(ev, 'E');
+ return { id: ev.id, what: parent ? 'reply' : 'mention', actor: ev.pubkey, target: parent, text: String(ev.content || '').slice(0, 300), ts: ev.created_at, pubkey: ev.pubkey };
+ }
if (ev.kind === 1) {
const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]);
const replyTo = (es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es.at(-1) || [])[1] || null;
@@ -9300,7 +9344,7 @@ export function messagesFeature(ctx) {
const c = notifNow();
const known = new Set(c.items.map((x) => x.id));
for (const ev of evs || []) noteForSpam(ev);
- const add = (evs || []).map((ev) => (ev.kind === 1 && hidden(ev) ? null : notifItem(ev)))
+ const add = (evs || []).map((ev) => ((ev.kind === 1 || ev.kind === COMMENT) && hidden(ev) ? null : notifItem(ev)))
.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id));
if (!add.length) return false;
warmNotifFaces(add);
@@ -9317,7 +9361,7 @@ export function messagesFeature(ctx) {
c.items = kept.slice(0, NOTIF_KEEP);
// Keep the small notification rows and one deduplicated copy of each note
// they show. A reply is also a note we can open straight away.
- for (const ev of evs) if (ev.kind === 1) notifNotes.set(ev.id, ev);
+ for (const ev of evs) if (ev.kind === 1 || ev.kind === COMMENT) notifNotes.set(ev.id, ev);
cacheNotifNotes(evs);
warmNotifNotes(c.items);
persistNotifs();
diff --git a/tools/comments-test.js b/tools/comments-test.js
new file mode 100644
index 0000000..01d47d5
--- /dev/null
+++ b/tools/comments-test.js
@@ -0,0 +1,99 @@
+// NIP-22 comments (kind 1111): a thread rooted in something other than a
+// kind 1 note (here a real long-form article, kind 30023) shows its
+// comments, and a reply to it — or to one of its comments — goes out as a
+// kind 1111 with the root's E/K/P and the parent's e/k/p. Outgoing events
+// are caught at the socket; nothing is published.
+//
+// Run: bun tools/comments-test.js
+import puppeteer from 'puppeteer-core';
+import { SimplePool } from 'nostr-tools/pool';
+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 ROOT = 'b615961519481cffc69af3e6ba9ae45fc236c251f21d327d243804dc94a77026';
+const NEVENT = 'nevent1qvzqqqr4gupzq0mhp4ja8fmy48zuk5p6uy37vtk8tx9dqdwcxm32sy8nsaa8gkeyqqstv9vkz5v5s88lc6d08e46ntj9ls3kcfgly8fj05jrspxujjnhqfsede0ss';
+const pool = new SimplePool();
+const R = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.nostr.band', 'wss://nostr.wine'];
+let root = null, comments = [];
+for (let i = 0; i < 3 && !(root && comments.length); i++) {
+ root ||= (await pool.querySync(R, { ids: [ROOT] }, { maxWait: 8000 }))[0];
+ if (!comments.length) comments = await pool.querySync(R, { kinds: [1111], '#E': [ROOT] }, { maxWait: 5000 });
+}
+if (!root || !comments.length) { console.log(' ✗ the article or its comments are gone from the relays', !!root, comments.length); process.exit(1); }
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5283, 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(() => {
+ window.__sent = [];
+ window.__all = [];
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ window.__all.push(this.url + ' ' + String(d).slice(0, 60));
+ if (typeof d === 'string' && d.startsWith('["EVENT"')) { // every event: nothing leaves the test
+ const ev = JSON.parse(d)[1];
+ window.__sent.push(ev);
+ // answer as a relay would, so the app counts it published
+ setTimeout(() => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', ev.id, true, '']) })), 50);
+ return;
+ }
+ return send.call(this, d);
+ };
+});
+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, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const reply = async (rowFind, text) => {
+ await page.evaluate((f) => { const row = new Function('return ' + f)()(); row.querySelector('button[aria-label="Reply"]').click(); }, rowFind.toString());
+ await waitFor(() => !!document.querySelector('.thread-reply-input'), 5000);
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT + text.length + '.png' });
+ await page.focus('.thread-reply-input');
+ await page.keyboard.type(text);
+ if (process.env.DBG) console.log('typed:', await page.evaluate(() => ({ boxes: document.querySelectorAll('.thread-reply-input').length, v: document.querySelector('.thread-reply-input')?.value, sendDisabled: document.querySelector('.thread-reply-send')?.disabled, active: document.activeElement?.className })));
+ await page.evaluate(() => document.querySelector('.thread-reply-send').click());
+ await sleep(300);
+ if (process.env.DBG && text.startsWith('a reply')) console.log('ALL', await page.evaluate(() => window.__all.slice(-25)));
+ if (process.env.DBG) console.log('after send:', await page.evaluate(() => ({ boxes: document.querySelectorAll('.thread-reply-input').length, toast: document.querySelector('.toast.show')?.textContent })));
+ // one event per relay it goes to: pick the one carrying this text
+ const mine = 'return window.__sent.find((e) => e.content.endsWith(' + JSON.stringify(text) + '))';
+ await waitFor(new Function('return !!(() => { ' + mine + ' })()'), 20000);
+ return page.evaluate(new Function(mine));
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5283/', { 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');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ await page.goto('http://localhost:5283/' + NEVENT, { waitUntil: 'domcontentloaded' });
+ const first = comments.sort((a, b) => a.created_at - b.created_at)[0];
+ const shown = await waitFor(new Function('return document.querySelector(".thread-page")?.innerText.includes(' + JSON.stringify(first.content.slice(0, 20)) + ')'), 25000);
+ check('the article opens with its NIP-22 comments', shown, JSON.stringify(first.content.slice(0, 40)));
+ const rows = await page.evaluate(() => document.querySelectorAll('.thread-page [data-zap-post]').length);
+ check('...each as a row of the thread', rows >= 1 + Math.min(comments.length, 2), rows + ' rows for ' + comments.length + ' comments');
+
+ const ev = await reply(() => document.querySelector('.thread-page [data-zap-post]'), 'a comment on the article');
+ const tag = (e, n) => (e?.tags || []).filter((x) => x[0] === n).map((x) => x[1]);
+ check('a reply to the article is a kind 1111 comment', ev?.kind === 1111, 'kind ' + ev?.kind);
+ check('...with the root as E/K/P', tag(ev, 'E')[0] === ROOT && tag(ev, 'K')[0] === '30023' && tag(ev, 'P')[0] === root.pubkey, JSON.stringify(ev?.tags));
+ check('...and its address as A', tag(ev, 'A')[0] === '30023:' + root.pubkey + ':' + tag(root, 'd')[0]);
+ check('...and the article as the parent (e/k/p)', tag(ev, 'e')[0] === ROOT && tag(ev, 'k')[0] === '30023' && tag(ev, 'p')[0] === root.pubkey);
+
+ await page.evaluate(() => document.querySelector('.thread-reply-cancel')?.click());
+ const onScreen = await page.evaluate(() => [...document.querySelectorAll('.thread-page [data-zap-post]')].filter((r) => r.querySelector('button[aria-label="Reply"]')).map((r) => r.getAttribute('data-zap-post')));
+ const target = comments.find((c) => onScreen.includes(c.id));
+ if (process.env.DBG) console.log('rows with Reply', onScreen.length, 'comments', comments.map((c) => c.id.slice(0, 8)), 'shown', onScreen.map((x) => x.slice(0, 8)));
+ const cid = target?.id;
+ const ev2 = await reply(new Function('return () => document.querySelector(\'.thread-page [data-zap-post="' + cid + '"]\')')(), 'a reply to a comment');
+ if (process.env.DBG) console.log(await page.evaluate((cid) => ({ sent: window.__sent.map((e) => e.kind + ':' + e.content.slice(0, 30)), row: !!document.querySelector('.thread-page [data-zap-post="' + cid + '"]'), box: document.querySelector('.thread-reply-input')?.value, toast: document.querySelector('.toast')?.textContent, sending: !!document.querySelector('.thread-reply-send .spinner') }), cid));
+ if (process.env.DBG) console.log('EV2', JSON.stringify(ev2)?.slice(0, 200), await page.evaluate(() => window.__sent.filter((e) => e.kind === 1111).map((e) => JSON.stringify(e.content))));
+ check('a reply to a comment is a kind 1111 too', ev2?.kind === 1111 && tag(ev2, 'E')[0] === ROOT, 'kind ' + ev2?.kind);
+ check('...its parent the comment (e/k/p)', tag(ev2, 'e')[0] === cid && tag(ev2, 'k')[0] === '1111' && tag(ev2, 'p')[0] === target?.pubkey, JSON.stringify(ev2?.tags.filter((x) => /^[ekp]$/.test(x[0]))));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ comments read and write as NIP-22' : '\n❌ failed');
+process.exit(ok ? 0 : 1);