aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 22:48:11 -0700
committerAdam Soltys <[email protected]>2026-10-01 22:48:11 -0700
commit23f18ff13e0a8c612d04727de2ccfadf2d13ff7c (patch)
tree578915726c8b4285d6e0dcaedb6d24049996d773
parentc6b279abd5e502ce05c1418797714782356b7707 (diff)
downloadcoinosv3-23f18ff13e0a8c612d04727de2ccfadf2d13ff7c.tar.gz
coinosv3-23f18ff13e0a8c612d04727de2ccfadf2d13ff7c.zip
Shared note links open on the post, with names and faces
The server renders /nevent1 and /note1 pages with the thread inlined (post, root, replies, kind 0s) and Open Graph tags; the client seeds its thread cache from it and opens the thread on the first paint. Without it, a pending frame holds the screen instead of the front door. Profile lookups ask our relays and the profile indexes at once, streamed, instead of one after the other behind five-second waits. A thread opened straight from a link has no Back button. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--build.js6
-rw-r--r--feed/note-page.js180
-rw-r--r--feed/server.js33
-rw-r--r--src/app.js5
-rw-r--r--src/features/messages.js93
-rw-r--r--tools/note-page-test.js119
6 files changed, 398 insertions, 38 deletions
diff --git a/build.js b/build.js
index 1fdd889..c90656f 100644
--- a/build.js
+++ b/build.js
@@ -88,9 +88,13 @@ self.addEventListener('fetch', (e) => {
if (u.pathname === '/api/feed') return;
if (req.mode === 'navigate') {
+ // A note page comes back with its thread rendered into it (the server's
+ // note-page); that copy must not become the offline shell for every path.
+ const notePage = /^\\/(note|nevent)1/i.test(u.pathname);
e.respondWith(
fetch(req)
.then((res) => {
+ if (notePage) return res;
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('./', copy));
return res;
@@ -369,7 +373,7 @@ function bootShell(logo, staging) {
: '<span class="beta-badge">v3 beta</span>';
return `<div class="col" style="gap:16px"><div class="row between"><div class="brand ${staging ? 'staging' : 'beta'}">${badge}<div class="logo-full" aria-label="coinos" role="img">${logo}</div></div></div><div class="card col" id="boot-shell" style="gap:12px;display:none"><div class="row gap6" style="align-items:center"><div class="chat-avatar profile-avatar fallback loading"></div><div class="chat-title" id="boot-shell-name"></div></div></div></div>`;
}
-const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`;
+const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m&&!/^(note|nevent|naddr)1/i.test(m[1])){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`;
// HAL_NO_SW=1: the page registers no service worker. The Android bundled
// build serves this page from its own assets, where a worker would only
diff --git a/feed/note-page.js b/feed/note-page.js
new file mode 100644
index 0000000..f4dc08e
--- /dev/null
+++ b/feed/note-page.js
@@ -0,0 +1,180 @@
+// /nevent1… and /note1… rendered with the thread already in the page.
+//
+// A shared post opened in a fresh browser used to show the front door, then
+// the post under a punk and an npub, and the author's real name seconds
+// later — every piece waited on relays the browser had only just dialled.
+// This server dials them once, keeps the answers, and hands the browser the
+// app's own index.html with the thread inlined as JSON (the client puts it
+// in its thread cache and opens from there) plus Open Graph tags, so a link
+// pasted into a chat previews as the post rather than as the wallet.
+//
+// It never makes the page slower than the plain one by much: a thread that
+// isn't ready inside BUDGET_MS goes out without it (the client fetches as
+// before) while the fetch carries on into the cache for the next visitor.
+import { decode } from 'nostr-tools/nip19';
+
+export const NOTE_PATH = /^\/((?:note|nevent)1[a-z0-9]+)\/?$/i;
+export const NOTE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.coinos.io',
+ 'wss://relay.ditto.pub', 'wss://nostr.mom'];
+// where kind 0s live when the conversation relays lack them
+const PROFILE_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es'];
+const BUDGET_MS = 500;
+const FRESH_MS = 60_000; // a thread younger than this is served as is
+const KEEP_MS = 6 * 3600_000; // older, it's still served while a refresh runs
+const MAX_THREADS = 500;
+const MAX_REPLIES = 60;
+const MAX_PROFILES = 40;
+const MAX_EVENT = 32_000;
+
+const okEvent = (e) => e && typeof e.id === 'string' && JSON.stringify(e).length <= MAX_EVENT;
+function rootIdOf(ev) {
+ if (ev.kind === 1111) return (ev.tags.find((t) => (t[0] === 'E' || t[0] === 'e') && t[1]) || [])[1] || ev.id;
+ const es = ev.tags.filter((t) => t[0] === 'e' && t[1]);
+ return ((es.find((t) => t[3] === 'root') || es[0]) || [])[1] || ev.id;
+}
+
+export function parseNoteRef(path) {
+ const m = NOTE_PATH.exec(path);
+ if (!m) return null;
+ try {
+ const d = decode(m[1].toLowerCase());
+ if (d.type === 'note') return { id: d.data, relays: [], author: null };
+ if (d.type === 'nevent') {
+ const relays = (d.data.relays || []).filter((u) => /^wss:\/\/[^\s]{1,200}$/.test(u)).slice(0, 3);
+ return { id: d.data.id, relays, author: d.data.author || null };
+ }
+ } catch {}
+ return null;
+}
+
+// Everything the thread page shows first: the post, the root it hangs off,
+// the replies to that root, and the faces of whoever wrote them.
+export async function buildThread(ref, query) {
+ const relays = [...new Set([...ref.relays, ...NOTE_RELAYS])];
+ const [hit, direct] = await Promise.all([
+ query(relays, { ids: [ref.id] }, 2500),
+ query(relays, { kinds: [1, 1111], '#e': [ref.id], limit: MAX_REPLIES }, 2500),
+ ]);
+ const focus = hit.find((e) => e.id === ref.id);
+ if (!focus || !okEvent(focus)) return null;
+ const rootId = rootIdOf(focus);
+ let root = focus, replies = direct;
+ if (rootId !== focus.id) {
+ const [roots, more] = await Promise.all([
+ query(relays, { ids: [rootId] }, 2500),
+ query(relays, { kinds: [1, 1111], '#e': [rootId], limit: MAX_REPLIES }, 2500),
+ ]);
+ root = roots.find((e) => e.id === rootId) || null;
+ replies = [...more, ...direct, focus];
+ }
+ const seen = new Set(root ? [root.id] : []);
+ replies = replies.filter((e) => okEvent(e) && !seen.has(e.id) && seen.add(e.id))
+ .sort((a, b) => a.created_at - b.created_at).slice(0, MAX_REPLIES);
+ // no root found: left to the client, which climbs reply chains to find one
+ if (!root) return null;
+ const people = [...new Set([root.pubkey, focus.pubkey, ...replies.map((e) => e.pubkey)])].slice(0, MAX_PROFILES);
+ return { v: 1, root, replies, profiles: await profilesFor(people, query) };
+}
+
+async function profilesFor(pks, query) {
+ const evs = await query([...NOTE_RELAYS, ...PROFILE_RELAYS], { kinds: [0], authors: pks }, 2000);
+ const newest = new Map();
+ for (const e of evs) {
+ if (e.kind !== 0 || !pks.includes(e.pubkey) || !okEvent(e)) continue;
+ if (!newest.has(e.pubkey) || newest.get(e.pubkey).created_at < e.created_at) newest.set(e.pubkey, e);
+ }
+ return [...newest.values()];
+}
+
+// Single flight per thread, stale-while-refresh, oldest evicted first.
+export function createThreadCache({ build, now = Date.now }) {
+ const entries = new Map(); // id -> { data, at, pending }
+ function refresh(ref) {
+ let e = entries.get(ref.id);
+ if (!e) {
+ e = { data: null, at: 0, pending: null };
+ entries.set(ref.id, e);
+ while (entries.size > MAX_THREADS) entries.delete(entries.keys().next().value);
+ }
+ if (!e.pending) {
+ e.pending = Promise.resolve().then(() => build(ref)).then((data) => {
+ if (data) { e.data = data; e.at = now(); }
+ return e.data;
+ }).catch(() => e.data).finally(() => { e.pending = null; });
+ }
+ return e.pending;
+ }
+ async function get(ref, budget = BUDGET_MS) {
+ const e = entries.get(ref.id);
+ if (e && e.data && now() - e.at < KEEP_MS) {
+ if (now() - e.at >= FRESH_MS) void refresh(ref);
+ return e.data;
+ }
+ let timer;
+ const late = new Promise((r) => { timer = setTimeout(() => r(null), budget); });
+ try { return await Promise.race([refresh(ref), late]); } finally { clearTimeout(timer); }
+ }
+ return { get, refresh };
+}
+
+const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+// JSON inside a script tag: nothing in it may close the tag or open a comment
+const scriptJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
+
+function nameOf(data, pk) {
+ const p = (data.profiles || []).find((e) => e.pubkey === pk);
+ try { const m = JSON.parse(p.content); return { name: m.display_name || m.name || null, picture: m.picture || null }; } catch { return {}; }
+}
+
+// The app's index.html with the thread and its preview tags in the head.
+export function renderNotePage(html, data, ref, origin) {
+ const focus = [data.root, ...data.replies].find((e) => e.id === ref.id) || data.root;
+ const who = nameOf(data, focus.pubkey);
+ const text = String(focus.content || '').replace(/nostr:[a-z0-9]+/gi, '').replace(/\s+/g, ' ').trim();
+ const image = (String(focus.content || '').match(/https?:\/\/\S+\.(?:jpe?g|png|gif|webp)(?:\?\S*)?/i) || [])[0] || who.picture;
+ const title = (who.name || 'A post') + ' on Coinos';
+ const desc = text.length > 200 ? text.slice(0, 199) + '…' : text;
+ const head = [
+ `<meta property="og:type" content="article">`,
+ `<meta property="og:site_name" content="Coinos">`,
+ `<meta property="og:title" content="${esc(title)}">`,
+ desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
+ desc ? `<meta name="description" content="${esc(desc)}">` : '',
+ origin ? `<meta property="og:url" content="${esc(origin + '/' + (ref.path || ''))}">` : '',
+ image ? `<meta property="og:image" content="${esc(image)}">` : '',
+ `<meta name="twitter:card" content="${image && image !== who.picture ? 'summary_large_image' : 'summary'}">`,
+ // the author's face starts downloading with the page, not after the app boots
+ who.picture && /^https:\/\//.test(who.picture) ? `<link rel="preload" as="image" href="${esc(who.picture)}">` : '',
+ `<script type="application/json" id="boot-thread">${scriptJson(data)}</script>`,
+ ].filter(Boolean).join('\n');
+ let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
+ return out;
+}
+
+// The page itself: the app's index.html, refetched every few seconds so a
+// deploy is picked up without a restart.
+export function createIndexSource(url, { ttl = 5000, now = Date.now } = {}) {
+ let html = null, at = 0, pending = null;
+ return async () => {
+ if (html && now() - at < ttl) return html;
+ pending ||= fetch(url, { signal: AbortSignal.timeout(1500) })
+ .then((r) => (r.ok ? r.text() : Promise.reject(new Error('index ' + r.status))))
+ .then((t) => { html = t; at = now(); return t; })
+ .finally(() => { pending = null; });
+ try { return await pending; } catch (e) { if (html) return html; throw e; }
+ };
+}
+
+export async function notePageResponse(req, { index, threads }) {
+ const u = new URL(req.url);
+ const ref = parseNoteRef(u.pathname);
+ const html = await index();
+ const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, must-revalidate' };
+ if (!ref) return new Response(html, { headers });
+ ref.path = u.pathname.slice(1);
+ const data = await threads.get(ref).catch(() => null);
+ const proto = req.headers.get('x-forwarded-proto') || 'https';
+ const host = req.headers.get('x-forwarded-host') || req.headers.get('host');
+ return new Response(data ? renderNotePage(html, data, ref, host ? proto + '://' + host : '') : html, { headers });
+}
diff --git a/feed/server.js b/feed/server.js
index 2cf26a1..12ff850 100644
--- a/feed/server.js
+++ b/feed/server.js
@@ -1,12 +1,39 @@
+import { SimplePool } from 'nostr-tools/pool';
import { startFeedCache } from './service.js';
import { snapshotResponse } from './cache.js';
+import { NOTE_PATH, buildThread, createThreadCache, createIndexSource, notePageResponse } from './note-page.js';
const cache = await startFeedCache();
+// SimplePool verifies signatures and matches filters before accepting events.
+const pool = new SimplePool();
+// Every relay's answer, or maxWait — but an ask by id is done the moment
+// every id has turned up, rather than waiting out the slowest relay.
+const query = (relays, filter, maxWait) => new Promise((resolve) => {
+ const got = new Map();
+ let done = false, sub = null;
+ const finish = () => { if (done) return; done = true; try { sub && sub.close(); } catch {} resolve([...got.values()]); };
+ sub = pool.subscribeManyEose(relays, filter, {
+ maxWait,
+ onevent(e) {
+ got.set(e.id, e);
+ if (filter.ids && filter.ids.every((id) => got.has(id))) finish();
+ },
+ onclose: finish,
+ });
+ if (done) try { sub.close(); } catch {}
+});
+const threads = createThreadCache({ build: (ref) => buildThread(ref, query) });
+const index = createIndexSource(process.env.INDEX_URL || 'http://bitcoin-wallet/index.html');
const server = Bun.serve({
hostname: process.env.HOST || '127.0.0.1',
port: Number(process.env.PORT || 8792),
- fetch(req) {
- if (new URL(req.url).pathname !== '/api/feed') return new Response('Not found', { status: 404 });
- return snapshotResponse(cache, req);
+ idleTimeout: 30,
+ async fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return snapshotResponse(cache, req);
+ if (NOTE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) {
+ try { return await notePageResponse(req, { index, threads }); } catch { return new Response('Unavailable', { status: 502 }); }
+ }
+ return new Response('Not found', { status: 404 });
},
});
console.log(`Public feed cache listening on ${server.url}`);
diff --git a/src/app.js b/src/app.js
index 7131476..64564be 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1014,6 +1014,9 @@ function goBack(toParent) {
if (navIndex > 0) history.back();
else { toParent(); render(); }
}
+// Whether an in-app Back has anywhere of ours to go. A page opened straight
+// from a link has nothing under it; its Back would invent a parent.
+const canGoBack = () => navIndex > 0;
// ---------------------------------------------------------------- utilities
let toastTimer;
@@ -5710,7 +5713,7 @@ async function importSnapshotFile(e) {
// sits just before boot so every helper it captures is defined; the hooks are
// only invoked at runtime (first render happens after loadLocale below).
const ctx = {
- h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, showSend,
+ h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, canGoBack, goHome, openExternal, showSend,
fmtAmount, unitLabel, unitTag, getUnit: () => unit, toggleUnit, download,
// in fiat the typed figure is money, so it needs today's price to become sats
parseAmount: (v, u) => parseAmount(v, u, rateNow()),
diff --git a/src/features/messages.js b/src/features/messages.js
index a7f6820..45098df 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -911,36 +911,25 @@ export function messagesFeature(ctx) {
for (const pk of pks) profInFlight.add(pk);
try {
const found = new Set();
- for (let i = 0; i < pks.length; i += PROF_BATCH) {
- const slice = pks.slice(i, i + PROF_BATCH);
- const evs = await queryOn(zapRelays(), { kinds: [0], authors: slice }, 5000).catch(() => []);
- const newest = new Map();
- for (const ev of evs || []) {
- const c = newest.get(ev.pubkey);
- if (!c || ev.created_at > c.created_at) newest.set(ev.pubkey, ev);
- }
- for (const [pk, ev] of newest) { if (applyProfile(pk, ev)) found.add(pk); }
- // paint what this batch found before going after the stragglers —
- // the outbox fallback below can take seconds, and there's no reason
- // for a face we already have to wait behind one we don't
- if (newest.size) scheduleRepaint();
- }
- // Whoever our relays have never heard of: ask the relays they publish
- // to. Same outbox plan the feed uses, so it costs the lists we already
- // have rather than a round trip each — plus the relays whose whole job
- // is holding kind 0s, which we deliberately don't read notes from and
- // so never ask in the batch above.
- const left = pks.filter((pk) => !found.has(pk));
- if (left.length) {
- const idx = await queryOn(PROFILE_INDEX_RELAYS, { kinds: [0], authors: left }, 5000).catch(() => []);
- const idxNewest = new Map();
- for (const ev of idx || []) {
- const c = idxNewest.get(ev.pubkey);
- if (!c || ev.created_at > c.created_at) idxNewest.set(ev.pubkey, ev);
+ // Our relays and the profile indexes at once, each answer applied as
+ // it lands. Asking them one after the other, each waiting out its
+ // slowest relay, put a five-second floor under anyone whose kind 0
+ // only an index held: a visitor opening a shared post saw a punk and
+ // an npub for seven and a half seconds.
+ let painted = 0;
+ const asked = new Set(pks);
+ const take = (ev) => {
+ if (ev && ev.kind === 0 && asked.has(ev.pubkey) && applyProfile(ev.pubkey, ev)) {
+ found.add(ev.pubkey);
+ if (!painted) painted = setTimeout(() => { painted = 0; scheduleRepaint(); }, 30);
}
- for (const [pk, ev] of idxNewest) { if (applyProfile(pk, ev)) found.add(pk); }
- if (idxNewest.size) scheduleRepaint();
+ };
+ const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS])];
+ const asks = [];
+ for (let i = 0; i < pks.length; i += PROF_BATCH) {
+ asks.push(queryStreamOn(relays, { kinds: [0], authors: pks.slice(i, i + PROF_BATCH) }, take, 5000).catch(() => {}));
}
+ await Promise.all(asks);
const stillLeft = pks.filter((pk) => !found.has(pk));
if (stillLeft.length) {
const left = stillLeft;
@@ -1929,11 +1918,42 @@ export function messagesFeature(ctx) {
const restoringUrlThread = (() => {
try { return !!urlNote && history.state?.nav?.noteThread?.focusId === urlNote.id; } catch { return false; }
})();
+ // The server renders /nevent1… and /note1… with the thread already in the
+ // page (feed/note-page.js): the post, its root and replies, and their
+ // authors' kind 0s, in a JSON script tag. Into the thread cache it goes,
+ // in the shape a reload leaves there, so the thread opens from it with
+ // names and faces on the first paint instead of after the relays answer.
+ // No signature check: this data arrives in the same document as the code
+ // that reads it (the server verified it off the relays).
+ if (urlNote && !restoringUrlThread && typeof document !== 'undefined') {
+ try {
+ const el = document.getElementById('boot-thread');
+ const d = el && JSON.parse(el.textContent);
+ if (d && d.v === 1 && d.root && Array.isArray(d.replies)
+ && [d.root, ...d.replies].some((e) => e && e.id === urlNote.id)) {
+ const faces = {};
+ for (const ev of d.profiles || []) {
+ let m = null;
+ try { m = JSON.parse(ev.content); } catch {}
+ if (m && typeof m === 'object' && !Array.isArray(m) && ev.pubkey) {
+ faces[ev.pubkey] = { name: m.display_name || m.name || null, picture: m.picture || null, eventAt: ev.created_at, t: Date.now() };
+ }
+ }
+ createThreadStore().save({ root: d.root, replies: d.replies }, urlNote.id, faces, {}, d.quotes || []);
+ }
+ } catch {}
+ }
if (urlNote && !restoringUrlThread) {
try { history.replaceState(null, '', '/'); } catch {}
ui.pubProf = true;
+ // holds the frame (screenView) until the thread opens, so the front door
+ // never flashes up while the note is being found
+ ui.pubNotePending = urlNote.id;
// a reload of a thread the history restores by itself needs no fetch
- setTimeout(() => { if (ui.noteThread && ui.noteThread.focusId === urlNote.id) return; openNoteRef(urlNote).catch(() => {}); }, 0);
+ setTimeout(() => {
+ if (ui.noteThread && ui.noteThread.focusId === urlNote.id) { ui.pubNotePending = null; return; }
+ openNoteRef(urlNote).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); });
+ }, 0);
}
// Resolve and open right away — not in init(), which only runs once a
@@ -7275,7 +7295,7 @@ export function messagesFeature(ctx) {
const saved = threadStore.find(ref.id);
const cached = saved && (saved.root.id === ref.id ? saved.root : saved.replies.find((e) => e.id === ref.id));
if (cached) { openNoteThread(cached); return; }
- toast(t('noteRefLoading'));
+ if (!ui.pubNotePending) toast(t('noteRefLoading')); // the pending frame says so itself
const ev = quoted.get(ref.id)?.ev || await findNote(ref).catch(() => null);
if (ev) openNoteThread(ev);
else toast(t('noteRefNotFound'));
@@ -7557,9 +7577,11 @@ export function messagesFeature(ctx) {
ctx.brandHeader(!ui.pubProf && wallet.loaded),
h('div', { class: 'card col thread-card', style: 'gap:0;padding:2px 14px' }, ...kids),
// a real Back: the history entry under the thread restores the feed
- // (or profile) and its scroll place; ui-only only when the thread was
- // opened straight from a link and there is nothing under it
- h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')),
+ // (or profile) and its scroll place. A thread opened straight from a
+ // link has nothing of ours under it, so no button that invents one.
+ !ctx.canGoBack || ctx.canGoBack()
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back'))
+ : null,
...noteOverlays());
}
@@ -9938,6 +9960,11 @@ export function messagesFeature(ctx) {
// — an auto-restored wallet would otherwise flash its home page for the
// beat the registrar lookup takes. The shell shows the name straight
// off the URL; no spinner, the empty beat reads calmer.
+ // Same for a note deep link while its thread is being found.
+ if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' },
+ ctx.brandHeader(false),
+ h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' },
+ h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading'))));
if (ui.pubProfPending) return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
diff --git a/tools/note-page-test.js b/tools/note-page-test.js
new file mode 100644
index 0000000..915d892
--- /dev/null
+++ b/tools/note-page-test.js
@@ -0,0 +1,119 @@
+// A shared /nevent1… link: the server renders the thread into the page, and
+// the app paints it — author's name and face included — on its first frame,
+// with no relay answering at all. No flash of the front door on the way, and
+// no Back button inventing a parent the visitor never came from.
+// Run: bun tools/note-page-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { neventEncode } from 'nostr-tools/nip19';
+import { parseNoteRef, buildThread, createThreadCache, renderNotePage } from '../feed/note-page.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const check = (name, fn) => Promise.resolve(fn()).then(() => console.log('✓ ' + name));
+
+const A = generateSecretKey(), B = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const root = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'Where do you mostly use Nostr? </script><b>not markup</b>' }, A);
+const reply = finalizeEvent({ kind: 1, created_at: now - 300, tags: [['e', root.id, '', 'root'], ['p', getPublicKey(A)]], content: 'Mostly away from home' }, B);
+const kind0 = (sk, name) => finalizeEvent({ kind: 0, created_at: now - 9000, tags: [], content: JSON.stringify({ name, picture: 'https://example.invalid/' + name + '.png' }) }, sk);
+const events = [root, reply, kind0(A, 'Josh'), kind0(B, 'Rae')];
+// a relay pool's answer to one filter
+const match = (f) => events.filter((e) => (!f.ids || f.ids.includes(e.id)) && (!f.kinds || f.kinds.includes(e.kind))
+ && (!f.authors || f.authors.includes(e.pubkey)) && (!f['#e'] || e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))));
+const fakeQuery = async (relays, f) => match(f);
+
+const path = neventEncode({ id: reply.id, author: getPublicKey(B), relays: ['wss://relay.example.invalid'] });
+const ref = parseNoteRef('/' + path);
+await check('nevent paths parse; other paths do not', () => {
+ assert.equal(ref.id, reply.id);
+ assert.deepEqual(ref.relays, ['wss://relay.example.invalid']);
+ assert.equal(parseNoteRef('/asoltys'), null);
+ assert.equal(parseNoteRef('/nevent1garbage'), null);
+});
+const data = await buildThread(ref, fakeQuery);
+await check('a reply is served with its root, the replies and both faces', () => {
+ assert.equal(data.root.id, root.id);
+ assert.deepEqual(data.replies.map((e) => e.id), [reply.id]);
+ assert.deepEqual(data.profiles.map((e) => JSON.parse(e.content).name).sort(), ['Josh', 'Rae']);
+});
+await check('a reply whose root is nowhere is left to the client', async () => {
+ const orphan = finalizeEvent({ kind: 1, created_at: now, tags: [['e', 'f'.repeat(64), '', 'root']], content: 'lost' }, B);
+ assert.equal(await buildThread({ id: orphan.id, relays: [] }, async (r, f) => (f.ids?.includes(orphan.id) ? [orphan] : [])), null);
+});
+await check('a slow thread misses the budget, then serves the next visitor', async () => {
+ let release;
+ const gate = new Promise((r) => { release = r; });
+ const cache = createThreadCache({ build: async () => { await gate; return data; } });
+ assert.equal(await cache.get(ref, 50), null);
+ release();
+ await sleep(10);
+ assert.equal(await cache.get(ref, 50), data);
+});
+const html = await buildHtml({ minify: true, pwa: false });
+const page1 = renderNotePage(html, data, { ...ref, path }, 'https://v3.coinos.io');
+await check('preview tags name the author and escape the post', () => {
+ assert.match(page1, /<title>Rae on Coinos<\/title>/);
+ assert.match(page1, /<meta property="og:description" content="Mostly away from home">/);
+ // the root's text sits inside the JSON script; it can't close the tag
+ const json = page1.slice(page1.indexOf('id="boot-thread">'));
+ assert(!json.slice(0, json.indexOf('</script>')).includes('</script'), 'no closing tag inside the JSON');
+});
+
+// The browser: relays are blackholed, so whatever paints came from the page.
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url), p = u.pathname;
+ if (p.startsWith('/punks') || p === '/verify-worker.js') return new Response(Bun.file('dist' + p));
+ if (p === '/' + path && !u.searchParams.has('plain')) return new Response(page1, { headers: { 'content-type': 'text/html' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const open = async (url) => {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(() => {
+ window.WebSocket = function () { const ws = new EventTarget(); ws.readyState = 0; ws.send = () => {}; ws.close = () => {}; return ws; };
+ window.__frames = [];
+ const tick = () => { try { if (document.body) window.__frames.push(document.body.innerText.replace(/\s+/g, ' ').slice(0, 200)); } catch {} };
+ setInterval(tick, 16);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ return { page, errs };
+ };
+ const { page, errs } = await open(server.url.href + path);
+ await page.waitForFunction(() => /Mostly away from home/.test(document.body.innerText), { timeout: 8000 });
+ await sleep(600);
+ const frames = await page.evaluate(() => window.__frames);
+ await check('the thread opens from the page alone, names and all', async () => {
+ const text = await page.evaluate(() => document.body.innerText);
+ assert.match(text, /Rae/);
+ assert.match(text, /Josh/);
+ assert.match(text, /Where do you mostly use Nostr\? <\/script><b>not markup<\/b>/);
+ });
+ await check('never the front door on the way', () => {
+ const door = frames.find((f) => /Create a new wallet|Welcome to coinos/i.test(f));
+ assert(!door, 'front door painted: ' + door);
+ });
+ await check('opened straight from a link: no Back button', async () => {
+ assert.equal(await page.evaluate(() => [...document.querySelectorAll('.thread-page button')].some((b) => b.textContent.trim() === 'Back')), false);
+ });
+ await check('no page errors', () => assert.deepEqual(errs, []));
+ await page.close();
+
+ // Without the server's help (plain index.html): a pending frame, not the
+ // front door, while the relays (here: none) are asked.
+ const plain = await open(server.url.href + path + '?plain');
+ await sleep(1500);
+ const pf = await plain.page.evaluate(() => window.__frames);
+ await check('a plain page holds a "Fetching note" frame instead of the front door', () => {
+ const before = pf.slice(0, pf.findIndex((f) => /Fetching note/i.test(f)) + 1);
+ assert(pf.some((f) => /Fetching note/i.test(f)), 'no pending frame: ' + pf.at(-1));
+ assert(!before.some((f) => /Create a new wallet|Welcome to coinos/i.test(f)), 'front door before the pending frame');
+ });
+ await plain.page.close();
+} finally { await browser.close(); server.stop(true); }
+console.log('\n✅ note pages');
+process.exit(0);