diff options
| -rw-r--r-- | src/features/messages.js | 154 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/nostr.js | 149 | ||||
| -rw-r--r-- | src/style.css | 2 | ||||
| -rw-r--r-- | tools/relay-budget-test.js | 167 | ||||
| -rw-r--r-- | tools/resume-wake-test.js | 111 | ||||
| -rw-r--r-- | tools/web-comment-test.js | 123 |
7 files changed, 647 insertions, 60 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 4e90646..b7982c6 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ import { mediaServers } from '../media-servers.js'; // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, + subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.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(); @@ -4923,25 +4926,38 @@ export function messagesFeature(ctx) { const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } // NIP-65: where this author actually writes. Their notes and threads live // there first — our default relays are just the common ground. + // Asked one at a time — a quoted note's author, a face tapped, a reply's + // target — these went out as one REQ per author per relay: a feed with + // six quotes on screen sent eighteen. Asks now wait a beat and go out + // together, one REQ with every author wanted, through the feed's own + // batched fetch, which also remembers who has no list so nobody is asked + // twice. const relayListCache = new Map(); // pk -> Promise<string[]> + const RELAY_ASK_MS = 80; + let relayAskQ = new Map(), relayAskTimer = null; // pk -> [resolve] function relaysOf(pk) { let p = relayListCache.get(pk); if (p) return p; // the feed's batched fetch has probably already asked for this one const known = relayListsNow().get(pk); if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; } - p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500) - .then((evs) => { - const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; - if (!newest) return []; - return [...new Set(newest.tags - .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1])) - .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4); - }) - .catch(() => []); + p = new Promise((resolve) => { + if (!relayAskQ.has(pk)) relayAskQ.set(pk, []); + relayAskQ.get(pk).push(resolve); + if (!relayAskTimer) relayAskTimer = setTimeout(flushRelayAsks, RELAY_ASK_MS); + }); relayListCache.set(pk, p); return p; } + async function flushRelayAsks() { + relayAskTimer = null; + const batch = relayAskQ; relayAskQ = new Map(); + try { await fetchRelayLists([...batch.keys()], [...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])]); } catch {} + for (const [pk, waiters] of batch) { + const r = ((relayListsNow().get(pk) || {}).r || []).slice(0, 4); + for (const w of waiters) w(r); + } + } // Plus relays that keep history: the big ones prune within days, and an // author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows // only whatever primal still holds — 2 posts of 22. @@ -4998,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. @@ -5385,12 +5412,12 @@ export function messagesFeature(ctx) { // small, widely mirrored, and worth having before anything else is asked. // Authors with no list at all are remembered as such, so we don't ask again // every time the feed refreshes. - async function fetchRelayLists(pks) { + async function fetchRelayLists(pks, relays = NOTE_RELAYS) { const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk)); if (!want.length) return; for (let i = 0; i < want.length; i += 100) { const batch = want.slice(i, i + 100); - const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []); + const evs = await queryOn(relays, { kinds: [10002], authors: batch }, 4500).catch(() => []); const newest = new Map(); for (const ev of evs || []) { const cur = newest.get(ev.pubkey); @@ -6227,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'; @@ -7770,22 +7801,33 @@ export function messagesFeature(ctx) { } // While the feed is what's on screen, new posts arrive by themselves — on // the same relays the pass above reads, one subscription each. + // The live subscriptions stay up across refreshes. Every refresh pass + // used to end by closing and reopening all of them — seventeen REQs over + // eight relays, twice per opening of Following — for subscriptions whose + // relays and authors hadn't changed; only the head they count fresh posts + // against moves, and that is read at each event. + let feedWatchKey = '', feedWatchHead = { t: Infinity }; function watchFeed() { - stopFeedWatch(); const c = feedNow(); const def = feedDef(c.id); // the Conversations view has its own - if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; + if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') { stopFeedWatch(); return; } const newerThan = c.notes.length ? c.notes[0].created_at : Infinity; const since = Math.floor(Date.now() / 1000) - 60; const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; const kinds = feedKinds(def); - const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); + const orT = orTopicsOf(def); + const plan = def.curated || !authors.length ? [] : feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); + const key = [c.id, !!def.curated, !!def.all, !!def.replies, kinds.join(), topics.join(), orT.join(), (feedRelays(def) || []).join(), + ...plan.map(({ relays, authors: a }) => relays.join() + '>' + a.join())].join('\n'); + if (feedUnsubs.length && key === feedWatchKey) { feedWatchHead.t = newerThan; return; } + stopFeedWatch(); + feedWatchKey = key; + const head = feedWatchHead = { t: newerThan }; + const on = (ev) => { mergeFeed([ev], { live: true, newerThan: head.t }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; } - const orT = orTopicsOf(def); if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on)); - const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); @@ -7793,6 +7835,7 @@ export function messagesFeature(ctx) { function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } feedUnsubs = []; + feedWatchKey = ''; } // Prepare older pages several screens ahead of the reader. A scroll while // preparation is underway shares the same work instead of exposing it early. @@ -8541,20 +8584,35 @@ export function messagesFeature(ctx) { } } } - // All at once, first answer wins: asked in turn, a note on a far relay - // took longer than the feed waits for a row. + // The near relays first — the reference's hints, ours, the author's own + // — and the wide net only once those have come back empty, or after a + // moment if they are slow about it (asked in turn, a note on a far relay + // took longer than the feed waits for a row; asked all at once, every + // quoted note and every reply's parent went to a dozen relays, and the + // Conversations view spent half its REQs — three hundred in twenty + // seconds — on notes our own relays answered in the first beat). + const WIDE_AFTER_MS = 1200; function findNote(ref) { const ask = (relays) => askNote(relays, ref.id); const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))]; - const asks = [ - askNote(near, ref.id, ref.relays || []), - ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), - ask(WIDE_RELAYS.filter((r) => !near.includes(r))), - ]; return new Promise((resolve) => { - let left = asks.length; - for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); }); + let done = false; + const found = (ev) => { if (!done && ev) { done = true; resolve(ev); } }; + const nearAsks = [ + askNote(near, ref.id, ref.relays || []), + ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), + ]; + for (const a of nearAsks) a.then(found); + const nearDone = Promise.all(nearAsks); + let wide = null; + const goWide = () => { + if (done || wide) return; + wide = ask(WIDE_RELAYS.filter((r) => !near.includes(r))); + wide.then(found); + }; + const timer = setTimeout(goWide, WIDE_AFTER_MS); + nearDone.then(() => { clearTimeout(timer); if (done) return; goWide(); wide.then((ev) => { if (!done) { done = true; resolve(ev || null); } }); }); }); } function quotedNote(ref) { @@ -11253,10 +11311,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]); } @@ -11270,10 +11332,16 @@ export function messagesFeature(ctx) { } } const REPORTS_MS = 30 * 60_000; + // The half hour counts from the last sync the device SAVED, so a reload + // (every open of the installed app is one) doesn't ask the outbox relays + // again — some forty REQs with every follow's reports — and a sync after + // one asks only for what came since. (The index is kept.) async function syncReports({ force = false } = {}) { if (!modOn('reports') || !myPubkeys().length) return; - if (!force && Date.now() - reportsAt < REPORTS_MS) return; + const last = Math.max(reportsAt, reportsNow().at || 0); + if (!force && Date.now() - last < REPORTS_MS) return; reportsAt = Date.now(); + const since = last ? { since: Math.floor(last / 1000) - 3600 } : {}; const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX); const from = new Set(authors); try { @@ -11282,7 +11350,7 @@ export function messagesFeature(ctx) { const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); - return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => [])); + return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500, ...since }, 5000).catch(() => [])); })); for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev); reportsNow().at = Date.now(); @@ -12071,6 +12139,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 @@ -12113,6 +12186,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', { @@ -12122,7 +12198,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 @@ -18511,10 +18587,14 @@ export function messagesFeature(ctx) { resumed(awayMs) { // a blink between apps didn't kill anything if (awayMs && awayMs < 5_000) return; - resubscribeStreams(); // DMs and rooms, whether or not chat is on screen - if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true; - if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); - else refreshFeed({ force: true }).catch(() => {}); + // the sockets first: after a freeze they read open and are dead, and + // a refresh sent on them finds nothing (wakeRelays) + wakeRelays().catch(() => {}).then(() => { + resubscribeStreams(); // DMs and rooms, whether or not chat is on screen + if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return; + if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); + else refreshFeed({ force: true }).catch(() => {}); + }); return true; }, // A zap flow reporting how its payment went, so the chip that appeared 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/nostr.js b/src/nostr.js index c77fc27..731c076 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -186,7 +186,12 @@ pool.ensureRelay = async (url, params) => { relay._coinosRL = true; const notice = relay.onnotice; relay.onnotice = (msg) => { - if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); + // Primal's "too many concurrent REQs" is advice, not a refusal: every + // REQ past its twenty is answered all the same (measured: 60 at once, + // 60 EOSEs, 12 notices). Cooling it off for 30 s at every boot — when + // the relay lists, profiles and the feed all ask at once — only lost + // us its answers. A CLOSED with that reason still counts (below). + if (RATE_LIMITED.test(String(msg)) && !/concurrent/i.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); try { notice?.call(relay, msg); } catch {} }; const subscribe = relay.subscribe.bind(relay); @@ -284,6 +289,54 @@ export function resetRelay(url) { try { r.close(); } catch {} pool.relays.delete(n); } +// Back from a freeze (a phone's backgrounded tab: timers stopped, the +// network's idea of every socket long gone), the pool's sockets still read +// OPEN, and anything sent on them vanishes. Left to itself the library +// notices one in up to 29 s (its ping) + 20 s (the ping's timeout), then +// waits 10 s more before redialing — a minute of a feed that won't catch up +// ("no new notes since 11:35?", Adam, 2026-11-01). So on resume every open +// socket is asked one tiny REQ; one that answers within the deadline is +// fine, one that doesn't is closed and redialed now, its subscriptions +// refired by the library with `since` set to the last event each saw. A +// relay already waiting out a reconnect backoff is dialed now too. +const WAKE_PING_MS = 2500; +const DUMMY_ID = 'a'.repeat(64); +function pingRelay(r, ms) { + return new Promise((resolve) => { + let done = false; + const fin = (ok) => { if (done) return; done = true; clearTimeout(t); resolve(ok); }; + const t = setTimeout(() => fin(false), ms); + try { + const sub = r.subscribe([{ ids: [DUMMY_ID], limit: 0 }], { + label: '<wake-ping>', + oneose: () => { fin(true); try { sub.close(); } catch {} }, + onclose: () => fin(true), // a relay refusing the REQ is a relay that answers + eoseTimeout: ms + 1000, // ours fires first: the library's would count as an EOSE + }); + } catch { fin(false); } + }); +} +function redialRelay(r, why) { + console.warn('nostr: ' + r.url + ' ' + why + ' — redialing'); + // the dead socket's close would reach the library long after (a closing + // handshake nobody answers), and undo the new connection: detached first + const ws = r.ws; + try { if (ws) { ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); } } catch {} + try { r.handleHardClose(why); } catch {} // the library's own path: subscriptions kept, a reconnect queued + if (r.reconnectTimeoutHandle) { clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; } + r.connect().catch(() => {}); // now, not after the backoff +} +export async function wakeRelays(ms = WAKE_PING_MS) { + const checks = []; + for (const r of pool.relays.values()) { + if (r.connected && r.ws && r.ws.readyState === 1) checks.push(pingRelay(r, ms).then((ok) => { if (!ok) redialRelay(r, 'silent after resume'); })); + else if (!r.connectionPromise && r.reconnectTimeoutHandle) { + clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; + r.connect().catch(() => {}); + } + } + await Promise.all(checks); +} // the relays a subscription on this list actually dials (sick ones skipped) export const liveRelayList = (list) => liveRelays(list); // ...and the ones a one-shot read goes to (rate-limited ones skipped too) @@ -354,12 +407,77 @@ async function authRelay(relay, onauth) { } return relay.auth(onauth); } +// ---- one-shot reads: per relay, closed on its own EOSE, a few at a time -- +// The pool's own querySync keeps every relay's REQ open until the SLOWEST +// relay has answered or the wait runs out: asked beside a relay still +// dialing, our own relay — done in fifty milliseconds — carried each REQ +// for five seconds, and a feed's opening asks stacked up on it by the +// dozen. Each relay's REQ now closes on that relay's EOSE. And no relay +// carries more than REQ_SLOTS of them at once: the rest wait their turn, +// which is a beat on a relay that answers and no longer than the wait on +// one that doesn't. (A Following feed opened from the laptop sent some 560 +// REQs in twenty seconds across 57 relays, over a hundred each to damus +// and primal; damus answered the home IP "banned: too many rate-limit +// violations" on 2026-11-02, and primal notices past twenty at once.) +const REQ_SLOTS = 10; +const slots = new Map(); // normalized url -> { n, q: [resolve] } +function takeSlot(url) { + const k = relayKey(url); + let s = slots.get(k); + if (!s) slots.set(k, (s = { n: 0, q: [] })); + if (s.n < REQ_SLOTS) { s.n++; return Promise.resolve(false); } + const at = Date.now(); + return new Promise((resolve) => s.q.push(() => resolve(Date.now() - at))); // how long it waited +} +function freeSlot(url) { + const s = slots.get(relayKey(url)); + if (!s) return; + const next = s.q.shift(); + if (next) next(); // the slot passes straight to the next in line + else s.n--; +} +export const reqSlotsOf = (url) => { const s = slots.get(relayKey(url)); return s ? { busy: s.n, waiting: s.q.length } : { busy: 0, waiting: 0 }; }; +// One REQ carrying `filters` on one relay. Settles at the relay's EOSE, its +// CLOSED, a failed dial, or maxWait — whichever is first — and sends CLOSE. +function askRelay(url, filters, maxWait, onEvent) { + return new Promise((resolve) => { + let finished = false, sub = null, timer = 0; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + if (sub) { try { sub.close('done'); } catch {} } + freeSlot(url); + resolve(); + }; + takeSlot(url).then((waited) => { + // a turn that came only after the ask's own wait had run out: the + // caller has moved on, and a storm of late REQs helps nobody + if (waited > maxWait) { finish(); return; } + timer = setTimeout(finish, maxWait + 1000); + try { + sub = pool.subscribeMap(filters.map((filter) => ({ url, filter })), { maxWait, onevent: onEvent, oneose: finish, onclose: finish }); + if (finished) { try { sub.close('done'); } catch {} } + } catch { finish(); } + }); + }); +} +// All relays asked at once; an event is counted once however many hold it. +async function askRelays(relays, filters, maxWait, onEvent) { + const seen = new Set(); + await Promise.all(relays.map((url) => askRelay(url, filters, maxWait, (ev) => { + if (!ev || seen.has(ev.id)) return; + seen.add(ev.id); + onEvent(ev); + }))); +} export async function queryOn(relays, filter, maxWait = 1500) { probeRelays(relays); try { const live = readRelays(relays); if (!live.length) return []; - const evs = await pool.querySync(live, filter, { maxWait }); + const evs = []; + await askRelays(live, [filter], maxWait, (ev) => evs.push(ev)); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -464,17 +582,8 @@ export async function queryManyOn(relays, filters, maxWait = 1500) { try { const live = readRelays(relays); if (!live.length || !filters.length) return []; - const evs = await new Promise((resolve) => { - const got = []; - let sub = null, done = false; - const finish = () => { if (!done) { done = true; resolve(got); } }; - sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), { - maxWait, - onevent: (e) => got.push(e), - oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); }, - onclose: finish, - }); - }); + const evs = []; + await askRelays(live, filters, maxWait, (ev) => evs.push(ev)); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -487,14 +596,8 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) { try { const live = readRelays(relays); if (!live.length) return; - await new Promise((resolve) => { - pool.subscribeManyEose(live, filter, { - maxWait, - onevent(event) { - pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); - }, - onclose: resolve, - }); + await askRelays(live, [filter], maxWait, (event) => { + pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); }); } catch {} finally { await Promise.all(pending); } } @@ -604,7 +707,7 @@ export const PROFILE_RELAYS = HOME_RELAYS; // Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { let events; - try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; } + try { events = await queryOn(relays, { kinds: [0], authors: [pubkeyHex] }, 5000); } catch { return null; } const best = events.sort((a, b) => b.created_at - a.created_at)[0]; if (!best) return null; try { @@ -626,7 +729,7 @@ export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { // 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back). export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { let events; - try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; } + try { events = await queryOn(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, 5000); } catch { return []; } const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; const dm = newest(10050); if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; } 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/relay-budget-test.js b/tools/relay-budget-test.js new file mode 100644 index 0000000..bb1dd11 --- /dev/null +++ b/tools/relay-budget-test.js @@ -0,0 +1,167 @@ +// What a Following feed asks of a relay, and how. Measured from the laptop +// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds: +// a relay list asked per quoted note's author (one REQ per author per +// relay), the live subscriptions closed and reopened at the end of every +// refresh pass, the follows' reports fetched afresh at every reload, and +// every one-shot REQ held open on our fast relay until the slowest relay +// in the ask had answered. damus answered the home IP "banned: too many +// rate-limit violations". Now: relay lists go out in one REQ per batch, +// unchanged live subscriptions stay up, the reports sync honours the saved +// clock, each relay's REQ closes on its own EOSE, and no relay carries more +// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { neventEncode } 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 html = await buildHtml({ minify: true, pwa: false }); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = () => Math.floor(Date.now() / 1000); +const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk); +// six people the follow quotes, each with one note on the relay +const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }); +const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i)); +const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)]; +const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]])); +stored.push(...quotePosts); +// one quoted note only a far relay holds (the wide net's job) +const farQ = generateSecretKey(); +const farNote = finalizeEvent({ kind: 1, created_at: now() - 9000, tags: [], content: 'kept only on the far relay' }, farQ); +const farPost = post(SK, `and this nostr:${neventEncode({ id: farNote.id, author: farNote.pubkey })}`, 1100, [['q', farNote.id, '', farNote.pubkey]]); +stored.push(farPost); +const wideStore = [farNote]; +const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at); +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); + +// two relays on one server: /fast answers at once, /slow holds its EOSE +const SLOW_MS = 3000; +const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters } +const sockets = new Set(); +let maxOpen = { fast: 0, slow: 0 }; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/fast' || path === '/slow' || path === '/wide') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? 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: { + open(ws) { sockets.add(ws); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + const relay = ws.data.relay; + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const filters = m.slice(2); + ws.data.subs.set(m[1], filters); + maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size); + log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters }); + const answer = () => { + if (!ws.data.subs.has(m[1])) return; + for (const e of (relay === 'wide' ? wideStore : stored)) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] }); + }; + if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer(); + } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); } + }, + close(ws) { sockets.delete(ws); }, +} }); +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}/fast`; + else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`; + else if (/nostr\.wine/.test(String(url))) url = `ws://localhost:${port}/wide`; + 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++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters)); +const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1)); +// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox) +const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0); + +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, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + log.length = 0; maxOpen = { fast: 0, slow: 0 }; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + const t0 = Date.now(); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000)); + await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync + + console.log('\n[relay lists for the quoted notes\' authors]'); + const qpks = new Set(quoters.map((q) => q.pk)); + const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a)))); + const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a))); + check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors'); + + console.log('\n[quoted notes]'); + const wideIds = new Set(reqs('wide', (fs) => fs.some((f) => f.ids)).flatMap((x) => x.filters.flatMap((f) => f.ids || []))); + const nearHeld = quotedNotes.filter((n) => wideIds.has(n.id)).length; + check('the ones our relay holds were never asked of the wide net', nearHeld === 0, nearHeld + ' of 6 asked on the far relay'); + check('the one only the far relay holds was found there', wideIds.has(farNote.id) && (await page.evaluate(() => document.body.innerText.includes('kept only on the far relay'))), wideIds.has(farNote.id) ? 'asked' : 'never asked'); + + console.log('\n[the live subscriptions]'); + const live = reqs('fast', isLive); + const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id)); + check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed'); + + console.log('\n[one-shot REQs]'); + const fastReqs = reqs('fast', (fs) => !isSub(fs)); + const lag = []; + for (const r of fastReqs) { + const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id); + const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t); + if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); } + } + const slowLag = lag.filter((x) => x > 1500).length; + check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)'); + const slowReqs = reqs('slow', () => true).length; + check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')'); + const totalFast = reqs('fast', () => true).length; + console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s'); + + console.log('\n[a reload within the half hour]'); + const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + log.length = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await sleep(6000); + const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload'); + 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✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js new file mode 100644 index 0000000..3b992fb --- /dev/null +++ b/tools/resume-wake-test.js @@ -0,0 +1,111 @@ +// Back from the background on a phone, the app's relay sockets read OPEN +// and are dead (the tab was frozen; the network forgot them), and the +// catch-up it sends on them finds nothing. The library noticed a dead one +// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a +// Following feed stuck at the morning's posts for a minute or more after +// opening the app in the evening ("no new notes since 11:35?", Adam, +// 2026-11-01). On resume every open socket is now asked one tiny REQ; a +// silent one is closed and redialed at once, its subscriptions refired, and +// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = () => Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now() - ago, tags: [], content }, SK); +const stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)]; +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); +// one relay; a socket marked dead keeps its connection and answers nothing, +// the way a phone's frozen sockets do once the network has forgotten them +const sockets = new Set(); let opened = 0, closed = 0; +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: { + open(ws) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; }, + message(ws, raw) { + if (ws.data.dead) return; + 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') { + const s = { id: m[1], filters: m.slice(2) }; + ws.data.subs.set(m[1], s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]); + }, + close(ws) { sockets.delete(ws); closed++; }, +} }); +const PORT = server.port; +const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]); + +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 warns = []; +page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.push(m.text()); }); +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) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; }; +const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40))); + +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, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, stored]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | ')); + check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets'); + const openedBefore = opened; + + console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]'); + for (const s of sockets) s.data.dead = true; + const fresh = post('evening news', 0); + stored.push(fresh); + await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base); + await sleep(300); + + console.log('\n[back on screen]'); + const t0 = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })(); + check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s'); + const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000); + check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | ')); + check('the dead sockets were let go', closed >= 1, closed + ' closed'); + check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs'); + 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✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed'); +process.exit(ok ? 0 : 1); 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); |
