diff options
| author | Adam Soltys <[email protected]> | 2026-10-01 22:48:11 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-01 22:48:11 -0700 |
| commit | 23f18ff13e0a8c612d04727de2ccfadf2d13ff7c (patch) | |
| tree | 578915726c8b4285d6e0dcaedb6d24049996d773 | |
| parent | c6b279abd5e502ce05c1418797714782356b7707 (diff) | |
| download | coinosv3-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.js | 6 | ||||
| -rw-r--r-- | feed/note-page.js | 180 | ||||
| -rw-r--r-- | feed/server.js | 33 | ||||
| -rw-r--r-- | src/app.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 93 | ||||
| -rw-r--r-- | tools/note-page-test.js | 119 |
6 files changed, 398 insertions, 38 deletions
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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}`); @@ -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); |
