diff options
| -rw-r--r-- | src/features/messages.js | 127 | ||||
| -rw-r--r-- | tools/profile-batch-test.js | 135 | ||||
| -rw-r--r-- | tools/profile-feed-test.js | 8 |
3 files changed, 245 insertions, 25 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index cd601d9..715400e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -13,7 +13,7 @@ // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays, + subscribeOn, publishOn, queryOn, fetchInboxRelays, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, } from '../nostr.js'; @@ -450,34 +450,109 @@ export function messagesFeature(ctx) { })(); } - // A profile that came back EMPTY is usually a cold boot's 5s query racing - // relays that were still dialing — not proof there's no kind 0. Trusting it - // for the full TTL once dressed a real account in a punk and an npub for a - // whole day; an empty answer is retried in minutes instead. + // A profile that came back EMPTY is usually not proof there's no kind 0. + // A feed holds a couple of dozen relay subscriptions open, relays cap what + // one connection may ask at once, and a batch of a dozen pubkeys routinely + // comes back with seven — the rest aren't missing, they're crowded out. + // Trusting that silence for ten minutes is what left real accounts wearing + // a punk and an npub. So a miss is retried soon and backs off only if it + // keeps missing: fifteen seconds, then half a minute, then a minute, up to + // the ten minutes that's right for someone who genuinely has no profile. const EMPTY_RETRY = 10 * 60_000; + const emptyRetry = (p) => Math.min(15_000 * Math.pow(2, Math.max(0, (p && p.miss) || 1) - 1), EMPTY_RETRY); function profileOf(pk) { warmProfiles(); const cur = profiles.get(pk); if (cur !== undefined && (cur === null - || Date.now() - (cur.t || 0) < (!cur.name && !cur.picture ? EMPTY_RETRY : PROFILE_TTL))) return cur; + || Date.now() - (cur.t || 0) < (!cur.name && !cur.picture ? emptyRetry(cur) : PROFILE_TTL))) return cur; profiles.set(pk, cur || null); // null = loading, no fallback art yet - fetchNostrProfile(pk).then((p) => { - // An empty answer on a cold boot (relays still dialing, the 5s query - // came back with nothing) must never clobber a remembered face with a - // punk — keep the stale fields and just refresh the clock, so the - // known avatar holds while the full-profile fetch does its rounds. - const prev = profiles.get(pk); - const entry = keepThumb(pk, p ? { ...p, t: Date.now() } : { ...(prev || {}), t: Date.now() }); - profiles.set(pk, entry); - persistProfile(pk, entry); - preloadPicture(entry); - scheduleRepaint(); - }).catch(() => { - const prev = profiles.get(pk); - profiles.set(pk, { ...(prev || {}), t: Date.now() }); - }); + wantProfile(pk); return cur || null; } + + // Profiles are asked for in BATCHES. + // + // One REQ per pubkey was the old shape, and it looked fine until a feed + // painted eighty rows at once: eighty REQs in the same breath, against + // relays that cap how many a connection may have open. Most were refused, + // the five-second timeout expired, and an empty answer was remembered for + // ten minutes — which is the punk-and-a-shortened-npub row. Measured + // against this wallet's own feed, 131 of the 133 people on screen had a + // kind 0 sitting on relays we were already connected to. + const PROF_BATCH = 200; + let profQueue = new Set(), profTimer = null, profInFlight = new Set(); + function wantProfile(pk) { + if (!pk || profQueue.has(pk) || profInFlight.has(pk)) return; + profQueue.add(pk); + if (profTimer) return; + // a short beat, so one paint's worth of rows becomes one request + profTimer = setTimeout(() => { profTimer = null; const b = [...profQueue]; profQueue = new Set(); fetchProfiles(b).catch(() => {}); }, 250); + } + + function applyProfile(pk, ev) { + let m = null; + try { m = JSON.parse(ev.content); } catch { m = null; } + const p = m ? { + name: m.display_name || m.name || null, + picture: m.picture || null, + nip05: m.nip05 || null, + lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null, + } : null; + // An empty answer must never clobber a remembered face with a punk: keep + // what we had and just refresh the clock. + const prev = profiles.get(pk); + const entry = keepThumb(pk, p ? { ...p, t: Date.now(), miss: 0 } : { ...(prev || {}), t: Date.now() }); + profiles.set(pk, entry); + persistProfile(pk, entry); + preloadPicture(entry); + } + + async function fetchProfiles(pks) { + 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) { 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. + const left = pks.filter((pk) => !found.has(pk)); + if (left.length) { + await fetchRelayLists(left); + await Promise.all(outboxPlan(left).map(async ({ relays, authors }) => { + const evs = await queryOn(relays, { kinds: [0], authors }, 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) { applyProfile(pk, ev); found.add(pk); } + })); + } + // nobody has one: mark the clock so it's retried in minutes, not asked + // again on every paint + for (const pk of pks) { + if (found.has(pk)) continue; + const prev = profiles.get(pk); + profiles.set(pk, { ...(prev || {}), t: Date.now(), miss: ((prev && prev.miss) || 0) + 1 }); + } + scheduleRepaint(); + } finally { + for (const pk of pks) profInFlight.delete(pk); + } + } const displayName = (pk) => { const p = profileOf(pk); if (p && p.name) return p.name; @@ -3137,7 +3212,13 @@ export function messagesFeature(ctx) { } function noteRow(pk, ev, name, { open = true, focus = false } = {}) { - prefetchProfilePage(pk); // rows are tap-targets: have the page warm + // NOT prefetchProfilePage: that fetches the author's whole page — their + // kind 0, their relay list, their last thirty notes — and a feed row is + // not a profile tap. Twenty rows meant sixty requests, which is how the + // batched profile fetch below ended up starved and the rows settled as + // punks. The name and face come from the batch; the page loads when the + // profile is actually opened. + profileOf(pk); const isReply = ev.tags.some((x) => x[0] === 'e'); const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); // an optimistic post mid-publish: visible but not yet a real event — @@ -4137,7 +4218,7 @@ export function messagesFeature(ctx) { syncInbox().catch(() => {}); for (const jm of communities()) ensureRoom(jm); - for (const peer of threads.keys()) prefetchProfilePage(peer); + for (const peer of threads.keys()) profileOf(peer); // names and faces, in one batch const dmRows = [...threads.entries()] .map(([peer, m]) => { const last = [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at).at(-1); diff --git a/tools/profile-batch-test.js b/tools/profile-batch-test.js new file mode 100644 index 0000000..335bfbc --- /dev/null +++ b/tools/profile-batch-test.js @@ -0,0 +1,135 @@ +// A feed paints eighty rows at once, and each row wants a face. Asked one +// pubkey per REQ, relays cap the concurrency, most are refused, and the rows +// settle as a punk and a shortened npub — which is what a real feed looked +// like. They go out in batches now, so the whole screen is one request. +// +// This counts the REQs the app actually sends for kind 0, against real +// relays and real pubkeys from that feed. +// +// Run: bun tools/profile-batch-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.js'; +import { buildHtml } from '../build.js'; +import { SimplePool } from 'nostr-tools/pool'; +import * as nip19 from 'nostr-tools/nip19'; +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; }; + +// thirty people who really post, so the feed has thirty faces to find +const pool = new SimplePool(); +const seeds = ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', + '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2', + '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9']; +const recent = await pool.querySync(['wss://relay.primal.net', 'wss://nos.lol'], + { kinds: [1], limit: 120, since: Math.floor(Date.now() / 1000) - 3600 }, { maxWait: 8000 }).catch(() => []); +const authors = [...new Set([...seeds, ...recent.map((e) => e.pubkey)])].slice(0, 30); +const notes = recent.filter((e) => authors.includes(e.pubkey)).slice(0, 30); +if (notes.length < 10) { console.log(' ✗ relays too quiet to test with'); process.exit(1); } + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5273, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +page.on('console', (m) => { const t = m.text(); if (/profiles asked/.test(t)) console.log(' [page]', t); }); +// count the kind-0 requests the app sends, and how many pubkeys each carries +await page.evaluateOnNewDocument(() => { + window.__k0 = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ' && m[2] && Array.isArray(m[2].kinds) && m[2].kinds.includes(0)) { + const a = m[2].authors || []; + window.__k0.push(a.length); + // the same fetch goes to every relay, so count distinct ASKS (by + // what was asked for) rather than sockets written to + window.__k0asks = window.__k0asks || new Set(); + window.__k0asks.add(a.join(',')); + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}); +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; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5273/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('get started'); await sleep(500); + await click('import existing'); await sleep(400); + await page.waitForSelector('textarea'); + const mn = generateMnemonic(wordlist); + await page.type('textarea', mn); + await click('open wallet'); + 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)); + }, [base, authors, notes.map((e) => ({ id: e.id, pubkey: e.pubkey, kind: 1, created_at: e.created_at, content: e.content.slice(0, 200), tags: [] }))]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(1000); + await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + await sleep(12000); + + const reqs = await page.evaluate(() => window.__k0); + const rowCount = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length); + // A screenful of faces must cost a handful of asks, not one per row. (A few + // singles are expected and fine: our own profile, which onboarding reads + // before it writes a name, and the outbox fallback chasing one straggler to + // the relay only they publish to.) + const asks = await page.evaluate(() => [...(window.__k0asks || [])].length); + check('a screenful of faces costs a handful of asks, not one per row', + reqs.length > 0 && asks <= Math.max(4, rowCount / 4), + `${asks} asks for ${rowCount} rows, batches of ${[...new Set(reqs)].filter((n) => n > 1).join(',')}`); + check('...one request covers the screen', Math.max(0, ...reqs) >= 8, 'biggest carried ' + Math.max(0, ...reqs) + ' pubkeys'); + + // The honest measure, and the exact complaint: a row wearing a punk and a + // shortened npub. For each of those, ask the relays directly whether that + // person has ever published a name. If nobody has, the rows are telling the + // truth — plenty of posters have no profile at all, and that isn't ours to + // fix. Any row whose person DOES have a kind 0 is a face we failed to fetch. + const shown = await page.evaluate(() => { + const rows = [...document.querySelectorAll('.notes-feed > .row')]; + return rows.map((r) => { + const n = r.querySelector('.col > .row span'); + const t = n ? n.textContent.trim() : ''; + return { name: t, npub: /^npub1/.test(t) ? t : null }; + }); + }); + const anon = [...new Set(shown.map((r) => r.npub).filter(Boolean))]; + // the row shows a truncated npub, so match it back to the people we seeded + const nameless = []; + for (const short of anon) { + const pk = [...new Set(notes.map((e) => e.pubkey))].find((p) => nip19.npubEncode(p).startsWith(short)); + if (pk) nameless.push(pk); + } + const reallyNamed = new Set(); + if (nameless.length) { + for (const e of await pool.querySync(['wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io', 'wss://purplepag.es'], + { kinds: [0], authors: nameless }, { maxWait: 8000 }).catch(() => [])) { + try { const m = JSON.parse(e.content); if (m.display_name || m.name) reallyNamed.add(e.pubkey); } catch {} + } + } + const named = shown.filter((r) => !r.npub).length; + check('nobody is shown as an npub who has published a name', + reallyNamed.size === 0, + `${named} of ${shown.length} rows named; of the ${nameless.length} shown as an npub, ${reallyNamed.size} have a kind 0 we missed`); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the feed knows who it is showing' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/profile-feed-test.js b/tools/profile-feed-test.js index e499e5a..1e7283b 100644 --- a/tools/profile-feed-test.js +++ b/tools/profile-feed-test.js @@ -25,7 +25,7 @@ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { try { await page.goto('http://localhost:5234/', { waitUntil: 'domcontentloaded' }); await sleep(400); - await click('button', 'I already have a wallet'); + await click('button', 'Get started'); await sleep(300); await click('button', 'Import existing'); await sleep(300); @@ -34,7 +34,11 @@ try { await click('button', 'Open wallet'); check('wallet opens', await waitText('receive', 20000)); - await page.evaluate(() => document.querySelector('.header-msgs')?.click()); + // .header-msgs is the shared header-button class — settings, lock and + // search wear it too, and the first one is no longer this. Ask for the + // messages button by name. + await page.evaluate(() => [...document.querySelectorAll('button')] + .find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); await sleep(800); check('chat home opens', await waitText('coinos', 10000)); await click('.item, .chat-thread-row', 'coinos'); |
