aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js40
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css2
-rw-r--r--tools/web-comment-test.js123
4 files changed, 161 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 836966f..79526b8 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -587,7 +587,10 @@ export function messagesFeature(ctx) {
let repostBy = null; // set while noteRow draws a repost's note
// A profile shows its articles (NIP-23) among the notes, as Amethyst does.
// Their boosts too, under "<name> reposted", as in a feed.
- const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST];
+ // ...and their NIP-22 comments (kind 1111): the Replies tab is where
+ // those go, and a comment scoped to a web address (DaddyTube's, on a
+ // film's IMDb page) was never asked for at all
+ const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST, 1111];
// An edited article is a new event at the same address: keep the newest.
const newestArticles = (evs) => {
const best = new Map();
@@ -5011,6 +5014,17 @@ export function messagesFeature(ctx) {
if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3);
return ref;
}
+ // The web page a NIP-22 comment is on (its I tag, an http(s) URL), with
+ // the label its alt tag gives it — DaddyTube: "Comment on Sheep
+ // Detectives (2025) in DaddyTube" — or else the address, host and path.
+ function webScopeOf(ev) {
+ const url = ((ev.tags || []).find((x) => x[0] === 'I' && /^https?:\/\/\S+$/i.test(x[1] || '')) || [])[1];
+ if (!url) return null;
+ let where = url;
+ try { const u = new URL(url); where = (u.host + u.pathname).replace(/^www\./, '').replace(/\/$/, ''); } catch {}
+ const alt = ((ev.tags || []).find((x) => x[0] === 'alt' && String(x[1] || '').trim()) || [])[1];
+ return { url, label: alt ? String(alt).trim().slice(0, 140) : t('noteCommentOn', { where }) };
+ }
// A note already in hand — in a feed, a thread, someone's page — before
// asking any relay for it: the post a reply answers is usually right
// there in the Following feed.
@@ -6240,13 +6254,17 @@ export function messagesFeature(ctx) {
}
try {
const orT = orTopicsOf(def);
+ // the feed's own kinds: a Conversations view asks for replies and
+ // NIP-22 comments (asked for posts, it painted no comment at all
+ // until a refresh half a minute on; the pass below holds for them)
+ const kinds = feedKinds(def);
await Promise.all([
// people's reposts with their posts (a topic or firehose ask: posts only);
// every author, a chunk of them per ask
...(authors.length ? authorChunks(authors) : [null]).map((chunk) =>
- queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length ? [...FEED_KINDS, REPOST] : FEED_KINDS, limit: FEED_PAGE,
+ queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length && !def.replies ? [...kinds, REPOST] : kinds, limit: FEED_PAGE,
...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000)),
- orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]);
+ orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]);
} catch {} finally {
if (!c.stopped) {
c.status = 'ready';
@@ -11278,10 +11296,14 @@ export function messagesFeature(ctx) {
function noteReport(ev) { indexReport(reportsNow(), ev, myPubkeys().includes(ev.pubkey)); }
function indexReport(r, ev, mine) {
const hex = (v) => /^[0-9a-f]{64}$/.test(v || '');
+ // every post named counts toward es, the 'other' ones too: a report
+ // of a post as 'other' with its author's p tag beside it used to have
+ // es = 0 and land on the AUTHOR as a person report
let es = 0;
for (const x of ev.tags || []) {
- if (x[0] !== 'e' || !hex(x[1]) || x[2] === 'other') continue;
+ if (x[0] !== 'e' || !hex(x[1])) continue;
es++;
+ if (x[2] === 'other') continue;
(r.ev.get(x[1]) || r.ev.set(x[1], new Set()).get(x[1])).add(ev.pubkey);
if (mine) r.mine.add(x[1]);
}
@@ -12102,6 +12124,11 @@ export function messagesFeature(ctx) {
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e'));
const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null;
+ // A NIP-22 comment on something that isn't an event — a web page (I
+ // tag: DaddyTube comments on a film's IMDb page) — has no post to show
+ // above it; the page it is on goes there instead, named by the
+ // comment's own alt tag when it carries one, else by the address.
+ const scope = ev.kind === COMMENT && !parent && !(ev.tags || []).some((x) => (x[0] === 'e' || x[0] === 'E') && x[1]) ? webScopeOf(ev) : null;
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
@@ -12144,6 +12171,9 @@ export function messagesFeature(ctx) {
parent ? h('div', { class: 'note-context', 'data-context-of': parent.id },
h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')),
quoteCard(parent, 0)) : null,
+ scope ? h('div', { class: 'note-context note-scope' },
+ h('a', { class: 'small faint note-context-label', href: scope.url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() },
+ '\u{1F4AC} ' + scope.label)) : null,
h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' },
h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
h('span', {
@@ -12153,7 +12183,7 @@ export function messagesFeature(ctx) {
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
- (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (isReply && !parent && !scope ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (edited ? ' · ' + t('postEditedTag') : ''))),
pending ? null : h('button', {
// the overflow stays up here; reply, boost, quote, react and zap
diff --git a/src/i18n.js b/src/i18n.js
index fdcd1e1..b944f96 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -329,6 +329,7 @@ const STR = {
feedTitle: 'Feed',
feedFollowingName: 'Following',
noteReplyingTo: 'Replying to',
+ noteCommentOn: 'Comment on {where}',
feedNew: 'New feed',
feedNewName: 'New feed',
feedEdit: 'Edit feed',
diff --git a/src/style.css b/src/style.css
index cef3663..b65f632 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1949,6 +1949,8 @@ body::after {
/* a reply shown with the post it answers above it (Conversations, a profile's Replies) */
.note-context { margin-bottom: 6px; }
.note-context-label { margin-bottom: 2px; }
+.note-scope .note-context-label { display: inline-block; text-decoration: none; }
+.note-scope .note-context-label:hover { text-decoration: underline; }
.note-context .quote-card { margin-top: 0; }
.note-context .quote-card .note-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden; }
/* attach + GIF inside the composer's field, bottom right; the field keeps
diff --git a/tools/web-comment-test.js b/tools/web-comment-test.js
new file mode 100644
index 0000000..7a6258f
--- /dev/null
+++ b/tools/web-comment-test.js
@@ -0,0 +1,123 @@
+// Comments scoped to a web address (NIP-22 kind 1111 with an I tag:
+// DaddyTube's, on a film's IMDb page) belong with the person's replies.
+// Adam's own ("Charming and wholesome", 2026-11-02, on relay.primal.net)
+// showed on neither his profile's Replies tab — the profile page never
+// asked for kind 1111 — nor said what it was a comment on. Now the page
+// asks for comments, and a web-scoped comment carries the page it is on
+// where "Replying to" would go, named by its alt tag when it has one.
+// Also here: a report naming a post as 'other', with the author's bare p
+// tag beside it, counted as a report of the PERSON (es stayed 0 for an
+// 'other' e tag) — two such reports from follows folded the author's every
+// post. Run: bun tools/web-comment-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+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 html = await buildHtml({ minify: true, pwa: false });
+const keys = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const B = keys(), C = keys(), D = keys(); // B comments; C and D report
+const now = () => Math.floor(Date.now() / 1000);
+const ev = (sk, kind, content, ago, tags = []) => finalizeEvent({ kind, created_at: now() - ago, tags, content }, sk);
+const IMDB = 'https://www.imdb.com/title/tt32565993/';
+const post = ev(B.sk, 1, 'a plain post about sheep', 3600);
+const comment = ev(B.sk, 1111, 'Charming and wholesome', 1800, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web']]);
+const named = ev(B.sk, 1111, 'The dog steals every scene', 1700, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web'], ['alt', 'Comment on Sheep Detectives (2025) in DaddyTube']]);
+const reports = [C, D].map((r) => ev(r.sk, 1984, '', 900, [['e', post.id, 'other'], ['p', B.pk]]));
+const stored = [post, comment, named, ...reports];
+const reqLog = [], sentLog = [];
+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.since || e.created_at >= f.since);
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ reqLog.push(m.slice(2));
+ for (const e of stored) if (m.slice(2).some((f) => match(f, e))) { sentLog.push(e.content.slice(0, 20)); ws.send(JSON.stringify(['EVENT', m[1], e])); }
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ }
+ },
+} });
+const PORT = server.port;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+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++) { const r = await page.evaluate(fn, arg); if (r) return r; await sleep(100); } return null; };
+const rowWith = (text) => page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return r ? { folded: !!r.querySelector('.note-folded') || r.classList.contains('note-folded'), scope: r.querySelector('.note-scope a') ? { href: r.querySelector('.note-scope a').href, text: r.querySelector('.note-scope a').textContent.trim() } : null, text: r.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) } : null; }, text);
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { 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, pks, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pks[0]]: { name: 'Shaun', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, [B.pk, C.pk, D.pk], [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the post is in Following', !!(await waitFor((x) => [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes(x)), 'plain post', 20000)));
+ await sleep(5000); // the follows' reports arrive and the feed repaints
+ const plain = await rowWith('plain post');
+ check('two "other" reports of the post, each with the author\'s bare p tag, do not fold the author', plain && !plain.folded, plain ? plain.text : 'row gone');
+
+ console.log('\n[Conversations]');
+ await click('conversations');
+ const row = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 15000);
+ check('the web-scoped comment is listed', !!row);
+ if (process.env.DEBUG) {
+ console.log(' rows:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 50)))));
+ console.log(' chips:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')))));
+ console.log(' REQs with 1111:', JSON.stringify(reqLog.filter((fs) => fs.some((f) => (f.kinds || []).includes(1111))).map((fs) => fs.map((f) => ({ kinds: f.kinds, authors: (f.authors || []).length, limit: f.limit, since: !!f.since })))));
+ console.log(' sent:', JSON.stringify(sentLog));
+ }
+ const c1 = await rowWith('Charming and wholesome');
+ check('…under the page it is on, linked', !!c1?.scope && c1.scope.href === IMDB && c1.scope.text.includes('Comment on imdb.com/title/tt32565993'), c1 ? JSON.stringify(c1.scope) : '');
+ check('…not tagged as a plain reply', !!c1 && !/↩\s*reply/i.test(c1.text), c1 ? c1.text : '');
+ const c2 = await rowWith('dog steals');
+ check('a comment with an alt tag is labelled by it', !!c2?.scope && c2.scope.text.includes('Comment on Sheep Detectives (2025) in DaddyTube'), c2 ? JSON.stringify(c2.scope) : 'row missing');
+
+ console.log('\n[the profile\'s Replies tab]');
+ await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-avatar').click(); }, 'Charming and wholesome');
+ check('the profile opens', await waitText('replies', 10000));
+ await click('replies');
+ const onProfile = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 12000);
+ check('the comment is on the Replies tab', !!onProfile);
+ const p1 = await rowWith('Charming and wholesome');
+ check('…with its page above it', !!p1?.scope && p1.scope.href === IMDB, p1 ? JSON.stringify(p1.scope) : '');
+ console.log('\n[the comment opened]');
+ await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-content').click(); }, 'Charming and wholesome');
+ const opened = await waitFor((id) => location.pathname.startsWith('/nevent1') && !!document.querySelector('.note-post[data-zap-post="' + id + '"] .note-scope a'), comment.id, 10000);
+ check('the thread opens on it, its page above it', !!opened);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ web-scoped comments show with their page, and an "other" report stays off the author' : '\n❌ failed');
+process.exit(ok ? 0 : 1);