From 4164d22809e49f1a5c55571f4f37eed8c2a70ba7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 08:43:05 -0700 Subject: A community opens with its members' faces, not a beat of punks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faces (profile, then the picture decoded) are warmed when the pointer is over or presses a room's row, and while the chat list sits there; the tap waits for them, 1.5 s at most, before the room replaces the list. A face still loading in a room is the quiet circle, not a punk. room-faces-test (fresh wallet, real coinos community): 57 faces flipped punk → picture before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- tools/room-faces-test.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tools/room-faces-test.js (limited to 'tools/room-faces-test.js') diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js new file mode 100644 index 0000000..8c39e59 --- /dev/null +++ b/tools/room-faces-test.js @@ -0,0 +1,55 @@ +// Opening a community must not paint everyone as a punk and then swap in +// their real pictures: faces are warmed on hover/press (and while the list +// sits there) and the tap waits a moment for them. A fresh wallet has no +// profiles cached — the worst case — against the real coinos community. +// +// Run: bun tools/room-faces-test.js [--no-hover] +import puppeteer from 'puppeteer-core'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5277, 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(); +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:5277/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + await click('skip verification'); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click()); + await waitText('members', 20000); + // let the community's history arrive (the list shows before it has) + await sleep(process.argv.includes('--no-hover') ? 1500 : 6000); + const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')] + .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'); + await page.evaluate((fs) => { + const face = new Function('return ' + fs)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } }); + mo.observe(document.body, { childList: true, subtree: true }); + }, face.toString()); + const row = await page.evaluateHandle(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => /members/.test(r.innerText))); + const t0 = await page.evaluate(() => performance.now()); + if (!process.argv.includes('--no-hover')) await row.hover(); + await row.click(); + for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(6000); + const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]); + const last = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length; + console.log(' first paint after', Math.round(at - t0), 'ms:', JSON.stringify(tally(first)), ' settled:', JSON.stringify(tally(last))); + check('the room opens with its messages', (first || []).length > 0); + check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped'); + check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms'); +} finally { await browser.close(); server.stop(true); } +function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; } +console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3 From d2b3c05995b0d54cfe586128fd2e958a563ac861 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 09:03:13 -0700 Subject: Someone with no profile picture paints as their punk from the first frame after a reload, not a blank circle that turns into one. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faceless answers were never persisted (they'd evict real faces from the 150-row profile blob), so every reload re-asked and showed the loading circle until the relays said "nothing". A small pubkey->time list now remembers them: no kind 0 on two asks running (a cold relay isn't enough), or a kind 0 naming nothing; finding a face takes them off it. room-faces-test --refresh: reload inside the coinos room, faces keyed by author — 2 changed (wait→punk) before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 36 ++++++++++++++++++++++++++++++++---- tools/room-faces-test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) (limited to 'tools/room-faces-test.js') diff --git a/src/features/messages.js b/src/features/messages.js index 38bc899..a86d0a9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -555,6 +555,29 @@ export function messagesFeature(ctx) { profilesWarmed = true; const cached = profilesDisk(); for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p); + // someone known to have no face paints their punk from the first frame, + // instead of a blank circle that turns into one when the relays answer + for (const [pk, t] of Object.entries(facelessDisk())) { + if (!profiles.has(pk)) profiles.set(pk, { name: null, picture: null, t, miss: 1 }); + } + } + // People with no face: no kind 0 on two asks running, or one naming + // nothing. Kept apart from the profile blob (whose 150 rows are for real + // faces) as a small pubkey -> time map; one found with a face leaves it. + const FACELESS_MAX = 1000; + let facelessBlob = null, facelessFlush = null; + const facelessDisk = () => (facelessBlob ||= wallet.loadFeatureState('profilesFaceless', {}) || {}); + function markFaceless(pk, on) { + const s = facelessDisk(); + if (on ? s[pk] : !s[pk]) return; + if (on) s[pk] = Date.now(); else delete s[pk]; + if (facelessFlush) return; + facelessFlush = setTimeout(() => { + facelessFlush = null; + const keys = Object.keys(s); + if (keys.length > FACELESS_MAX) for (const k of keys.sort((a, b) => s[a] - s[b]).slice(0, keys.length - FACELESS_MAX)) delete s[k]; + wallet.saveFeatureState('profilesFaceless', s); + }, 500); } // The persisted profile blob (names, faces, thumbnails) lives parsed in // memory and is written back once per burst: a batch of thirty profiles @@ -592,9 +615,10 @@ export function messagesFeature(ctx) { wallet.saveFeatureState('profiles', s); } function persistProfile(pk, p) { - // A faceless answer is not a fact worth writing down — persisting - // {name:null, picture:null} rows only evicts real faces from the cap and - // spreads a cold-relay miss across sessions. + // A faceless answer is not a fact worth writing down here — persisting + // {name:null, picture:null} rows only evicts real faces from the cap. + // (It goes on the faceless list instead.) + if (p && p.eventAt) markFaceless(pk, !p.name && !p.picture); if (!p || (!p.name && !p.picture)) return; const s = profilesDisk(); s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, @@ -927,7 +951,11 @@ export function messagesFeature(ctx) { 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 }); + const miss = ((prev && prev.miss) || 0) + 1; + profiles.set(pk, { ...(prev || {}), t: Date.now(), miss }); + // twice running: not a cold relay, they have none (never someone + // whose face we do know) + if (miss >= 2 && !(prev && (prev.name || prev.picture))) markFaceless(pk, true); } scheduleRepaint(); } finally { diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js index 8c39e59..706691b 100644 --- a/tools/room-faces-test.js +++ b/tools/room-faces-test.js @@ -31,6 +31,10 @@ try { await sleep(process.argv.includes('--no-hover') ? 1500 : 6000); const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')] .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'); + // name -> face state, per author (rows stream in, so positions shift) + const keyed = () => { const o = {}; for (const r of document.querySelectorAll('.chat-log .chat-row')) { + const a = r.querySelector(':scope > .chat-avatar:not(.spacer), :scope > .hat-wrap > .chat-avatar'); const n = r.querySelector('.chat-meta')?.firstElementChild?.textContent; + if (a && n) o[n] = a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'; } return o; }; await page.evaluate((fs) => { const face = new Function('return ' + fs)(); const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } }); @@ -49,6 +53,32 @@ try { check('the room opens with its messages', (first || []).length > 0); check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped'); check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms'); + + if (process.argv.includes('--refresh')) { + // a reload inside the room: every face as it will stay, from the first + // frame — a known-faceless member is their punk at once, not a blank + // circle that turns into one. (Wait out the second ask that confirms + // who has no profile.) + await sleep(25000); + await page.evaluateOnNewDocument((fs, ks) => { + const face = new Function('return ' + fs)(); + const keyed = new Function('return ' + ks)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstBy = keyed(); } }); + addEventListener('DOMContentLoaded', () => mo.observe(document.body, { childList: true, subtree: true })); + }, face.toString(), keyed.toString()); + await page.reload({ waitUntil: 'domcontentloaded' }); + for (let i = 0; i < 100 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(6000); + const f2 = await page.evaluate(() => window.__first); + const l2 = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const byName = await page.evaluate(new Function('return (' + keyed.toString() + ')()')); + const firstBy = await page.evaluate(() => window.__firstBy); + const changedWho = Object.keys(firstBy || {}).filter((k) => byName[k] && firstBy[k] !== byName[k] && !(firstBy[k] === 'punk' && byName[k] === 'other')); + if (changedWho.length) console.log(' changed:', changedWho.map((k) => k + ' ' + firstBy[k] + '→' + byName[k]).join(', ')); + const changed = changedWho.length; + console.log(' after reload, first paint:', JSON.stringify(tally(f2)), ' settled:', JSON.stringify(tally(l2))); + check('after a reload in the room, no face changes once painted', !!f2 && changed === 0, changed + ' changed'); + } } finally { await browser.close(); server.stop(true); } function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; } console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed'); -- cgit v1.2.3 From 9763e3f0b2b9de23231a45274bd534889a7d478c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 09:08:24 -0700 Subject: A face still being looked up shows its punk after a second at most; a picture found later replaces it. The quiet loading circle is capped at 1 s per face (a timer repaints when it runs out); one miss is enough to remember someone as faceless; and a room's warm-up adds members whose messages arrived after it first ran, instead of reusing a warm-up that covered only the early history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp --- src/features/messages.js | 42 +++++++++++++++++++++++++++++------------- tools/room-faces-test.js | 7 +++++-- 2 files changed, 34 insertions(+), 15 deletions(-) (limited to 'tools/room-faces-test.js') diff --git a/src/features/messages.js b/src/features/messages.js index a86d0a9..00f66b9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -953,9 +953,9 @@ export function messagesFeature(ctx) { const prev = profiles.get(pk); const miss = ((prev && prev.miss) || 0) + 1; profiles.set(pk, { ...(prev || {}), t: Date.now(), miss }); - // twice running: not a cold relay, they have none (never someone - // whose face we do know) - if (miss >= 2 && !(prev && (prev.name || prev.picture))) markFaceless(pk, true); + // remembered, so the next load paints their punk at once (never + // someone whose face we do know; a face found later replaces it) + if (!(prev && (prev.name || prev.picture))) markFaceless(pk, true); } scheduleRepaint(); } finally { @@ -2918,6 +2918,18 @@ export function messagesFeature(ctx) { return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`; } + const FACE_WAIT_MS = 1000; + const faceAsked = new Map(); // pk -> when its face was first wanted this session + let faceTimer = null; + function faceWaiting(pk) { + let at = faceAsked.get(pk); + if (at == null) { at = Date.now(); faceAsked.set(pk, at); } + const left = at + FACE_WAIT_MS - Date.now(); + if (left <= 0) return false; + // repaint when the wait runs out, so the punk shows without an event + if (!faceTimer) faceTimer = setTimeout(() => { faceTimer = null; scheduleRepaint(); }, left + 20); + return true; + } const avatar = (pk, cls = 'chat-avatar', clickable = true) => { const p = profileOf(pk); // Someone we've never cached used to get an empty circle until a relay @@ -2931,12 +2943,15 @@ export function messagesFeature(ctx) { // `loading` is different and still gets the quiet circle: a name lookup // is in flight for that specific person, so a picture is expected and // punk art must not flash in front of it. + // A face still being looked up gets a quiet circle for a second at + // most, then its punk — a picture found later replaces it. + const waiting = (p === null || (p && p.loading && !p.picture)) && faceWaiting(pk); const node = feedPaint && !p.picture ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) - : p && p.loading && !p.picture - ? h('div', { class: cls + ' fallback loading' }) - : p === null && (ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) + : waiting && (p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) ? h('div', { class: cls + ' fallback loading' }) + : p && p.loading && !p.picture + ? fallbackAvatar(h, pk, p.name, cls) : p === null ? fallbackAvatar(h, pk, null, cls) : p.picture @@ -8743,15 +8758,16 @@ export function messagesFeature(ctx) { .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor)); return [...new Set(msgs.map((m) => m.author))].slice(0, 150); } + // Each face is warmed once per half minute; a later call (the history + // still streaming in when the list first warmed) adds whoever is new. function warmRoom(jm) { - const w = roomWarm.get(jm.community_id); - if (w && Date.now() - w.at < 30_000) return w.promise; + let w = roomWarm.get(jm.community_id); + if (!w || Date.now() - w.at > 30_000) { w = { at: Date.now(), faces: new Map() }; roomWarm.set(jm.community_id, w); } const deadline = Date.now() + 8000; - let promise; - try { promise = Promise.all(roomFaces(jm).map((pk) => warmAvatar(pk, deadline).catch(() => {}))); } - catch { promise = Promise.resolve(); } - roomWarm.set(jm.community_id, { at: Date.now(), promise }); - return promise; + try { + for (const pk of roomFaces(jm)) if (!w.faces.has(pk)) w.faces.set(pk, warmAvatar(pk, deadline).catch(() => {})); + } catch {} + return Promise.all(w.faces.values()); } async function openRoomWarm(jm, go) { const seq = ++roomOpenSeq; diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js index 706691b..ab673ff 100644 --- a/tools/room-faces-test.js +++ b/tools/room-faces-test.js @@ -45,7 +45,10 @@ try { if (!process.argv.includes('--no-hover')) await row.hover(); await row.click(); for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); - await sleep(6000); + await sleep(1200); + const at1s = await page.evaluate(new Function('return (' + face.toString() + ')()')); + check('no face is still a blank circle a second after the room shows', !at1s.includes('wait'), JSON.stringify(tally(at1s))); + await sleep(4800); const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]); const last = await page.evaluate(new Function('return (' + face.toString() + ')()')); const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length; @@ -59,7 +62,7 @@ try { // frame — a known-faceless member is their punk at once, not a blank // circle that turns into one. (Wait out the second ask that confirms // who has no profile.) - await sleep(25000); + await sleep(8000); await page.evaluateOnNewDocument((fs, ks) => { const face = new Function('return ' + fs)(); const keyed = new Function('return ' + ks)(); -- cgit v1.2.3