From c82e042aa592e4e619d0c280b02486eeb859d358 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 7 Oct 2026 13:10:43 -0700 Subject: Profile links paint whole on the first frame: the server renders /npub1…, /nprofile1… and / with the kind 0, latest posts, follow counts and mentioned faces inlined (stale-while-refresh cache, 2 s budget), the app seeds its caches from it, and the big avatar never flashes punk art while a lookup runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold visit to a profile link showed a punk, '–' counts and no posts for 9 s. Server-side relay answers settle once events stop arriving (idle 400 ms) instead of waiting out a relay that never sends EOSE: a cold build is ~1.9 s. Co-Authored-By: Claude Opus 5.5 --- feed/note-page.js | 4 +- feed/profile-page.js | 214 +++++++++++++++++++++++++++++++++++++++++++++ feed/server.js | 19 +++- src/features/messages.js | 37 +++++++- tools/profile-page-test.js | 126 ++++++++++++++++++++++++++ 5 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 feed/profile-page.js create mode 100644 tools/profile-page-test.js diff --git a/feed/note-page.js b/feed/note-page.js index f4dc08e..3145f14 100644 --- a/feed/note-page.js +++ b/feed/note-page.js @@ -117,9 +117,9 @@ export function createThreadCache({ build, now = Date.now }) { return { get, refresh }; } -const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +export 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(/ JSON.stringify(v).replace(/ e.pubkey === pk); diff --git a/feed/profile-page.js b/feed/profile-page.js new file mode 100644 index 0000000..a30c2ab --- /dev/null +++ b/feed/profile-page.js @@ -0,0 +1,214 @@ +// /npub1…, /nprofile1… and / rendered with the profile already +// in the page — the same trick note-page.js plays for shared posts. +// +// A profile link opened in a fresh browser showed a punk, "–" for the follow +// counts, and no posts for nine seconds: the page looked up the author's +// relay list, waited on every relay, then held the first screen for its +// media. This server does that once, keeps the answer, and inlines it — +// kind 0, latest posts, follow counts, and the faces of anyone the posts +// mention — so the page paints whole on its first frame. Stale answers are +// served while a refresh runs; a cold one gets BUDGET_MS before the plain +// page goes out (and the build carries on into the cache). +import { decode } from 'nostr-tools/nip19'; +import { verifyEvent } from 'nostr-tools/pure'; +import { NOTE_RELAYS, esc, scriptJson } from './note-page.js'; + +export const PROFILE_PATH = /^\/((?:npub1|nprofile1)[a-z0-9]+|[A-Za-z0-9._-]{1,30})\/?$/; +// single-segment app routes and files that are never a person +const RESERVED = new Set(['chat', 'pos', 'feed', 'flash', 'index.html', 'sw.js', 'manifest.webmanifest', 'favicon.ico', 'robots.txt']); +const INDEX_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es']; +// relays that keep history (the big ones prune within days) +const ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr21.com', 'wss://offchain.pub']; +const PROFILE_KINDS = [1, 1068, 30023]; +const MAX_NOTES = 30; +const MAX_FACES = 30; +const MAX_EVENT = 32_000; +export const PROFILE_BUDGET_MS = 2000; +const IDLE_MS = 400; + +const okEvent = (e) => e && typeof e.id === 'string' && JSON.stringify(e).length <= MAX_EVENT; +const newest = (evs, kind, pk) => (evs || []).filter((e) => e.kind === kind && e.pubkey === pk && okEvent(e)) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + +// What the path names: a key outright, or a coinos name for the registrar. +export function parseProfileRef(path) { + const m = PROFILE_PATH.exec(path); + if (!m || RESERVED.has(m[1].toLowerCase()) || /^(note|nevent|naddr)1/i.test(m[1])) return null; + const seg = m[1]; + if (/^(npub1|nprofile1)/i.test(seg)) { + try { + const d = decode(seg.toLowerCase()); + if (d.type === 'npub') return { pk: d.data, relays: [], path: seg }; + if (d.type === 'nprofile') { + const relays = (d.data.relays || []).filter((u) => /^wss:\/\/[^\s]{1,200}$/.test(u)).slice(0, 3); + return { pk: d.data.pubkey, relays, path: seg }; + } + } catch {} + return null; + } + // a dotted segment is a file the server didn't have, not a name + if (seg.includes('.')) return null; + return { name: seg.toLowerCase(), relays: [], path: seg }; +} + +// Everything the profile page shows first. +export async function buildProfile(ref, { query: ask, primal = async () => null }) { + const pk = ref.pk; + // answers settle once they stop coming (see server.js's query) + const query = (relays, filter, wait) => ask(relays, filter, wait, { idle: IDLE_MS }); + const home = [...new Set([...ref.relays, ...NOTE_RELAYS])]; + const notesFrom = (relays, wait) => query(relays, { kinds: PROFILE_KINDS, authors: [pk], limit: MAX_NOTES }, wait).catch(() => []); + // their own write relays join in as soon as their list turns up + const theirs = query([...home, ...INDEX_RELAYS], { kinds: [10002], authors: [pk] }, 1500).catch(() => []).then((evs) => { + const list = newest(evs, 10002, pk); + const own = list ? [...new Set(list.tags.filter((t) => t[0] === 'r' && /^wss:\/\//.test(t[1] || '') && t[2] !== 'read') + .map((t) => t[1].replace(/\/$/, '')))].filter((u) => !home.includes(u)).slice(0, 4) : []; + return own.length ? notesFrom(own, 2500) : []; + }); + // faces the posts mention, so names read right on the first paint — asked + // as soon as the first batch of posts lands, beside the author's own relays + const mentionsOf = (evs) => { + const out = new Set(); + for (const e of evs) { + if (e.pubkey !== pk) continue; + for (const t of e.tags || []) if (t[0] === 'p' && /^[0-9a-f]{64}$/.test(t[1] || '')) out.add(t[1]); + for (const m of String(e.content || '').matchAll(/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g)) { + try { const d = decode(m[1]); out.add(d.type === 'npub' ? d.data : d.data.pubkey); } catch {} + } + } + out.delete(pk); + return [...out].slice(0, MAX_FACES); + }; + const first = notesFrom([...home, ...ARCHIVE_RELAYS], 2500); + const facesP = first.then((evs) => { + const want = mentionsOf(evs); + return want.length + ? query([...NOTE_RELAYS, ...INDEX_RELAYS], { kinds: [0], authors: want }, 1500).catch(() => []) + .then((faces) => want.map((x) => newest(faces, 0, x)).filter(Boolean)) + : []; + }); + const [kind0s, stats, a, b, profiles] = await Promise.all([ + query([...home, ...INDEX_RELAYS], { kinds: [0], authors: [pk] }, 2000).catch(() => []), + primal(pk).catch(() => null), + first, + theirs, + facesP, + ]); + const profile = newest([...kind0s, ...(stats?.profile ? [stats.profile] : [])], 0, pk); + // an edited article is a new event at the same address: keep the newest + const byId = new Map(); + const articles = new Map(); + for (const e of [...a, ...b]) { + if (e.pubkey !== pk || !PROFILE_KINDS.includes(e.kind) || !okEvent(e)) continue; + if (e.kind === 30023) { + const d = (e.tags.find((t) => t[0] === 'd') || [])[1] || ''; + const cur = articles.get(d); + if (cur && cur.created_at >= e.created_at) continue; + if (cur) byId.delete(cur.id); + articles.set(d, e); + } + byId.set(e.id, e); + } + const notes = [...byId.values()].sort((x, y) => y.created_at - x.created_at).slice(0, MAX_NOTES); + if (!profile && !notes.length) return null; // nobody there: the client looks for itself + const counts = stats && (stats.follows != null || stats.followers != null) + ? { follows: stats.follows, followers: stats.followers } : null; + return { v: 1, pk, profile, notes, counts, profiles }; +} + +// Primal's cache answers a profile and its follow counts in one request +// (what the app's own social line asks). Its events are signature-checked. +export function createPrimal({ url = 'wss://cache2.primal.net/v1', timeoutMs = 2000, WS = globalThis.WebSocket } = {}) { + let n = 0; + return (pk) => new Promise((resolve) => { + const sid = 'pp' + ++n; + const evs = []; + let ws; + const done = () => { + clearTimeout(timer); + try { ws.close(); } catch {} + const stats = (() => { try { return JSON.parse(evs.find((e) => e.kind === 10000105)?.content || 'null'); } catch { return null; } })(); + const num = (v) => (Number.isFinite(v) && v >= 0 ? v : null); + const profile = evs.find((e) => e.kind === 0 && e.pubkey === pk && verifyEvent(e)) || null; + resolve(stats || profile ? { profile, follows: num(stats?.follows_count), followers: num(stats?.followers_count) } : null); + }; + const timer = setTimeout(done, timeoutMs); + try { + ws = new WS(url); + ws.onopen = () => ws.send(JSON.stringify(['REQ', sid, { cache: ['user_profile', { pubkey: pk }] }])); + ws.onmessage = (m) => { + try { + const d = JSON.parse(m.data); + if (d[1] !== sid) return; + if (d[0] === 'EVENT' && d[2]) evs.push(d[2]); + if (d[0] === 'EOSE' || d[0] === 'CLOSED') done(); + } catch {} + }; + ws.onerror = done; + } catch { done(); } + }); +} + +// coinos names resolve through the registrar, remembered for a while +export function createNameResolver({ url = 'https://names.coinos.io', domain = 'coinos.io', ttl = 600_000, now = Date.now } = {}) { + const known = new Map(); // name -> { pk, at } + return async (name) => { + const k = known.get(name); + if (k && now() - k.at < ttl) return k.pk; + let pk = null; + try { + const j = await fetch(`${url}/.well-known/nostr.json?name=${encodeURIComponent(name)}&domain=${domain}`, + { signal: AbortSignal.timeout(1000) }).then((r) => r.json()); + const v = (j.names || {})[name]; + pk = /^[0-9a-f]{64}$/.test(v || '') ? v : null; + } catch { return k ? k.pk : null; } + known.set(name, { pk, at: now() }); + if (known.size > 2000) known.delete(known.keys().next().value); + return pk; + }; +} + +const fields = (ev) => { try { const m = JSON.parse(ev.content); return m && typeof m === 'object' ? m : {}; } catch { return {}; } }; + +export function renderProfilePage(html, data, ref, origin) { + const m = data.profile ? fields(data.profile) : {}; + const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null); + const name = str(m.display_name, 80) || str(m.name, 80) || ref.name || null; + const picture = /^https:\/\//.test(m.picture || '') ? m.picture : null; + const banner = /^https:\/\//.test(m.banner || '') ? m.banner : null; + const about = (str(m.about, 2000) || '').replace(/\s+/g, ' '); + const desc = about.length > 200 ? about.slice(0, 199) + '…' : about; + const title = (name || 'A profile') + ' on Coinos'; + const head = [ + ``, + ``, + ``, + desc ? `` : '', + desc ? `` : '', + origin ? `` : '', + picture ? `` : '', + ``, + // the face and the cover start downloading with the page + picture ? `` : '', + banner ? `` : '', + ``, + ].filter(Boolean).join('\n'); + let out = html.replace(/[^<]*<\/title>/i, `<title>${esc(title)}`); + out = out.includes('') ? out.replace('', head + '\n') : head + out; + return out; +} + +export async function profilePageResponse(req, { index, profiles, resolveName }) { + const u = new URL(req.url); + const ref = parseProfileRef(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 }); + if (!ref.pk) ref.pk = await resolveName(ref.name); + if (!ref.pk) return new Response(html, { headers }); + ref.id = ref.pk; // the cache's key + const data = await profiles.get(ref, PROFILE_BUDGET_MS).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 ? renderProfilePage(html, data, ref, host ? proto + '://' + host : '') : html, { headers }); +} diff --git a/feed/server.js b/feed/server.js index 12ff850..9566447 100644 --- a/feed/server.js +++ b/feed/server.js @@ -2,26 +2,34 @@ 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'; +import { PROFILE_PATH, buildProfile, createPrimal, createNameResolver, profilePageResponse } from './profile-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) => { +// every id has turned up, rather than waiting out the slowest relay. With +// `idle`, the answer is also done once events have been arriving and then +// stop for that long: one relay that never says EOSE no longer holds a +// profile page for the whole maxWait. +const query = (relays, filter, maxWait, { idle = 0 } = {}) => 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()]); }; + let done = false, sub = null, quiet = null; + const finish = () => { if (done) return; done = true; clearTimeout(quiet); 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(); + if (idle) { clearTimeout(quiet); quiet = setTimeout(finish, idle); } }, onclose: finish, }); if (done) try { sub.close(); } catch {} }); const threads = createThreadCache({ build: (ref) => buildThread(ref, query) }); +const primal = createPrimal(); +const profiles = createThreadCache({ build: (ref) => buildProfile(ref, { query, primal }) }); +const resolveName = createNameResolver({ url: process.env.NAMES_URL || 'https://names.coinos.io' }); const index = createIndexSource(process.env.INDEX_URL || 'http://bitcoin-wallet/index.html'); const server = Bun.serve({ hostname: process.env.HOST || '127.0.0.1', @@ -33,6 +41,9 @@ const server = Bun.serve({ if (NOTE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) { try { return await notePageResponse(req, { index, threads }); } catch { return new Response('Unavailable', { status: 502 }); } } + if (PROFILE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) { + try { return await profilePageResponse(req, { index, profiles, resolveName }); } catch { return new Response('Unavailable', { status: 502 }); } + } return new Response('Not found', { status: 404 }); }, }); diff --git a/src/features/messages.js b/src/features/messages.js index 3ab64d9..39cc7b1 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2164,6 +2164,29 @@ export function messagesFeature(ctx) { }, 0); } + // (runs after construction: the caches it fills are declared further down) + function seedBootProfile(path) { + try { + const el = typeof document !== 'undefined' && document.getElementById('boot-profile'); + const d = el && JSON.parse(el.textContent); + if (!d || d.v !== 1 || !/^[0-9a-f]{64}$/.test(d.pk || '') || String(d.path || '').toLowerCase() !== path.toLowerCase()) return null; + const pk = d.pk; + for (const ev of [...(d.profiles || []), d.profile]) { + if (ev && ev.kind === 0 && /^[0-9a-f]{64}$/.test(ev.pubkey || '')) applyProfile(ev.pubkey, ev); + } + // the page's own copy (not marked fetched: the relays still freshen it) + if (d.profile && d.profile.pubkey === pk && !fullProfiles.has(pk)) { + try { const m = JSON.parse(d.profile.content); if (m && typeof m === 'object' && !Array.isArray(m)) fullProfiles.set(pk, m); } catch {} + } + const notes = (d.notes || []).filter((e) => e && e.pubkey === pk && PROFILE_KINDS.includes(e.kind)); + if (notes.length && !notesCache.has(pk)) notesCache.set(pk, { status: 'ready', notes, fetchedAt: 0 }); + if (d.counts && !socialCounts.get(pk)?.at) { + const num = (n) => (Number.isFinite(n) && n >= 0 ? n : null); + socialCounts.set(pk, { follows: num(d.counts.follows), followers: num(d.counts.followers), at: Date.now() }); + } + return pk; + } catch { return null; } + } if (urlProfile) { // Claim the first paint synchronously: these are set before the boot // render, so the visitor sees a profile shell from the first frame — @@ -2172,7 +2195,12 @@ export function messagesFeature(ctx) { ui.pubProfPending = urlProfile; (async () => { await new Promise((r) => setTimeout(r, 0)); // never render mid-boot - let pk = parseNostrPubkey(urlProfile); + // The server renders profile links with the page already in them + // (feed/profile-page.js): kind 0, latest posts, follow counts and the + // faces the posts mention. Into the caches the page reads they go, so + // it paints whole on its first frame; the usual fetches still freshen + // it behind. A coinos name arrives already resolved. + let pk = seedBootProfile(urlProfile) || parseNostrPubkey(urlProfile); if (!pk && /^[a-z0-9._-]{1,30}$/i.test(urlProfile)) { // same registrar + domain rule the names feature uses const domain = getNetwork() === 'mutinynet' ? 'staging.coinos.io' : 'coinos.io'; @@ -3571,11 +3599,14 @@ export function messagesFeature(ctx) { // after a miss; the request state lets // it fall back once we've finished looking. const sendFace = cls.includes('send-avatar'); + // A profile page's own face is the same: the one big picture on the + // page, never punk art while that person's lookup is still running. + const pageFace = cls.includes('profile-avatar'); const waiting = (p === null || (p && p.loading && !p.picture)) - && (sendFace ? p === null || profQueue.has(pk) || profInFlight.has(pk) : faceWaiting(pk)); + && (sendFace || pageFace ? p === null || profQueue.has(pk) || profInFlight.has(pk) || fullInFlight.has(pk) : faceWaiting(pk)); const node = feedPaint && !p.picture ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) - : waiting && (sendFace || p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) + : waiting && (sendFace || pageFace || p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) ? h('div', { class: cls + ' fallback loading', style: sendFace ? 'visibility:hidden' : undefined }) : p && p.loading && !p.picture ? fallbackAvatar(h, pk, p.name, cls) diff --git a/tools/profile-page-test.js b/tools/profile-page-test.js new file mode 100644 index 0000000..19b8b0b --- /dev/null +++ b/tools/profile-page-test.js @@ -0,0 +1,126 @@ +// A shared profile link (/npub1…, /): the server renders the +// person's kind 0, latest posts and follow counts into the page, and the app +// paints all of it on its first frame — no punk, no "–" counts, no empty +// column while relays answer. Relays are blackholed here, so whatever shows +// came from the page. +// Run: bun tools/profile-page-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +import { parseProfileRef, buildProfile, renderProfilePage } from '../feed/profile-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 pkA = getPublicKey(A), pkB = getPublicKey(B); +const now = Math.floor(Date.now() / 1000); +const kind0 = (sk, m, at = now - 9000) => finalizeEvent({ kind: 0, created_at: at, tags: [], content: JSON.stringify(m) }, sk); +const posts = [ + finalizeEvent({ kind: 1, created_at: now - 60, tags: [], content: 'Sunrise over the ridge not markup' }, A), + finalizeEvent({ kind: 1, created_at: now - 120, tags: [['p', pkB]], content: 'Thanks nostr:' + npubEncode(pkB) + ' for the tip' }, A), +]; +const events = [kind0(A, { name: 'vesper', display_name: 'Vesper', picture: '/punks/18.webp', about: 'Climber & coder' }), + kind0(A, { name: 'old' }, now - 99999), kind0(B, { name: 'Rae' }), ...posts]; +const match = (f) => events.filter((e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))); +const query = async (relays, f) => match(f); +const primal = async () => ({ profile: null, follows: 901, followers: 589 }); + +const npub = npubEncode(pkA); +await check('profile paths parse; files, app routes and notes do not', () => { + assert.equal(parseProfileRef('/' + npub).pk, pkA); + assert.equal(parseProfileRef('/Asoltys').name, 'asoltys'); + for (const p of ['/sw.js', '/chat', '/feed', '/note1abc', '/a/b', '/', '/npub1garbage']) assert.equal(parseProfileRef(p), null, p); +}); +const ref = { ...parseProfileRef('/' + npub) }; +const data = await buildProfile(ref, { query, primal }); +await check('the newest kind 0, the posts, the counts and the mentioned faces', () => { + assert.equal(JSON.parse(data.profile.content).display_name, 'Vesper'); + assert.deepEqual(data.notes.map((e) => e.id), posts.map((e) => e.id)); + assert.deepEqual(data.counts, { follows: 901, followers: 589 }); + assert.deepEqual(data.profiles.map((e) => e.pubkey), [pkB]); +}); +await check('nobody there is left to the client', async () => { + assert.equal(await buildProfile({ pk: 'e'.repeat(64), relays: [] }, { query, primal: async () => null }), null); +}); + +const html = await buildHtml({ minify: true, pwa: false }); +const page1 = renderProfilePage(html, data, { ...ref, path: npub }, 'https://v3.coinos.io'); +await check('preview tags name the person; the JSON cannot close its tag', () => { + assert.match(page1, /Vesper on Coinos<\/title>/); + assert.match(page1, /<meta property="og:description" content="Climber & coder">/); + const json = page1.slice(page1.indexOf('id="boot-profile">')); + assert(!json.slice(0, json.indexOf('</script>')).includes('</script'), 'no closing tag inside the JSON'); +}); + +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 === '/' + npub && !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 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 { + const a = document.querySelector('.profile-avatar'); + if (!a) return; + window.__frames.push({ + punk: !!a.querySelector('.punk'), img: a.classList.contains('ava-img'), + social: (document.querySelector('.social-line')?.textContent || '').replace(/\s+/g, ' '), + posts: document.querySelectorAll('.notes-feed > *').length, + }); + } catch {} + }; + setInterval(tick, 16); + }); + const errs = []; + page.on('pageerror', (e) => errs.push(String(e))); + await page.goto(server.url.href + npub, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__frames.length > 5, { timeout: 8000 }); + await sleep(300); + const frames = await page.evaluate(() => window.__frames); + await check('the first frame with anything in it is complete: face, counts, posts', () => { + const i = frames.findIndex((f) => f.img || f.social || f.posts); + // before it, only the blank boot shell (a frame or so): no placeholder face, counts or posts + assert(frames.slice(0, i).every((f) => !f.img && !f.social && !f.posts && !f.punk)); + const f = frames[i]; + assert.equal(f.img, true, 'picture'); + assert.match(f.social, /901 Following/); + assert.match(f.social, /589 Followers/); + assert(f.posts >= 2, 'posts: ' + f.posts); + }); + await check('no punk at any point', () => assert(frames.every((f) => !f.punk))); + await check('mentions read as names', async () => { + const text = await page.evaluate(() => document.body.innerText); + assert.match(text, /Vesper/); + assert.match(text, /Rae/); + assert.match(text, /Sunrise over the ridge <\/script><b>not markup<\/b>/); + }); + await check('no page errors', () => assert.deepEqual(errs, [])); + await page.close(); + + // the plain page (server down or slow): a quiet circle, never the punk, + // while the (here: silent) relays are asked + const plain = await browser.newPage(); + await plain.evaluateOnNewDocument(() => { + window.WebSocket = function () { const ws = new EventTarget(); ws.readyState = 0; ws.send = () => {}; ws.close = () => {}; return ws; }; + window.__punk = false; + setInterval(() => { if (document.querySelector('.profile-avatar .punk')) window.__punk = true; }, 16); + }); + await plain.goto(server.url.href + npub + '?plain', { waitUntil: 'domcontentloaded' }); + await plain.waitForSelector('.profile-avatar', { timeout: 8000 }); + await sleep(1200); + await check('without the server: no punk while the lookup runs', async () => { + assert.equal(await plain.evaluate(() => window.__punk), false); + }); + await plain.close(); +} finally { await browser.close(); server.stop(true); } +console.log('\n✅ profile pages'); +process.exit(0); -- cgit v1.2.3