diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 12:49:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 12:49:17 -0700 |
| commit | 7c76b28b3e52622f8a8f734df7478ec8ba5428cf (patch) | |
| tree | 296cd4bdf21e12e30c21c8a63a6fc246bf7cdd4e | |
| parent | 86d9818abfc160dac031bb292abe28d6f28fc624 (diff) | |
| download | coinosv3-7c76b28b3e52622f8a8f734df7478ec8ba5428cf.tar.gz coinosv3-7c76b28b3e52622f8a8f734df7478ec8ba5428cf.zip | |
Send suggestions: a name lookup's avatar is a quiet circle until its profile arrives — the default punk flashed in front of a new account's chosen picture
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | src/recipient-search.js | 15 | ||||
| -rw-r--r-- | tools/search-avatar-loading-test.js | 87 |
2 files changed, 99 insertions, 3 deletions
diff --git a/src/recipient-search.js b/src/recipient-search.js index 5909078..4853c66 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -132,7 +132,8 @@ onCacheReady(loadSearchProfiles); try { const now = Date.now(); for (const [q, e] of Object.entries(JSON.parse(localStorage.getItem(QUERY_KEY) || '{}'))) - if (e && Array.isArray(e.rows) && now - (e.t || 0) < QUERY_TTL) { queryCache.set(q, e.rows); queryAt.set(q, e.t); } + // cached rows are final: their profile lookup already finished + if (e && Array.isArray(e.rows) && now - (e.t || 0) < QUERY_TTL) { queryCache.set(q, e.rows.map((r) => ({ ...r, known: true }))); queryAt.set(q, e.t); } } catch {} let persistTimer = null; function persistCaches() { @@ -199,11 +200,13 @@ export async function searchRecipients(qRaw, onPartial = null) { // Within a source, a name that actually contains the query outranks the // search relay's looser relevance matches. const score = (r) => ((r.name || '').toLowerCase().replace(/\s+/g, ' ').includes(q) || (r.address || r.nip05 || '').toLowerCase().includes(q)) ? 0 : 1; + // A row is `known` once its profile has been looked up (or arrived with the + // search itself): only then does a missing picture mean "has none". const snapshot = () => { const rows = [...out.values()].sort((a, b) => a.pri - b.pri || score(a) - score(b)).slice(0, MAX_RESULTS); for (const r of rows) { const p = profileCache.get(r.pk); - if (p) { r.picture ||= p.picture; r.name ||= p.name; } + if (p) { r.picture ||= p.picture; r.name ||= p.name; r.known = true; } } return rows; }; @@ -239,6 +242,7 @@ export async function searchRecipients(qRaw, onPartial = null) { const rows = snapshot(); await fillProfiles(rows).catch(() => {}); + for (const r of rows) r.known = true; // looked: no picture now means none if (queryCache.size > 200) { queryCache.clear(); queryAt.clear(); } queryCache.set(q, rows); queryAt.set(q, Date.now()); @@ -281,7 +285,12 @@ export function resultRows(h, rows, onPick, wrapAvatar) { // background, not <img>: paints synchronously from cache, so rows // re-rendered per keystroke don't flash their avatars ? h('div', { class: 'chat-avatar ava-img', style: `background-image:url(${JSON.stringify(r.picture)})` }) - : fallbackAvatar(h, r.pk, r.name, 'chat-avatar'); + // A registrar hit lands before its profile does. Punk art means "has + // no picture", never "picture not here yet" — a new account that + // picked another punk or uploaded a photo would flash the wrong face. + : !r.known + ? h('div', { class: 'chat-avatar fallback loading' }) + : fallbackAvatar(h, r.pk, r.name, 'chat-avatar'); return h('div', { class: 'item chat-thread-row', onClick: () => onPick(r) }, (wrapAvatar && wrapAvatar(r.pk, ava)) || ava, h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, diff --git a/tools/search-avatar-loading-test.js b/tools/search-avatar-loading-test.js new file mode 100644 index 0000000..34d68a5 --- /dev/null +++ b/tools/search-avatar-loading-test.js @@ -0,0 +1,87 @@ +// Recipient-search suggestions: a registrar hit lands before its profile, and +// its avatar must stay a quiet circle until the profile lookup settles — no +// pubkey punk flashing in front of the picture the account actually chose. +// Run: bun tools/search-avatar-loading-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { searchRecipients, resultRows } from './src/recipient-search.js'; +${domHelper} +const lookups = new Map(), frames = []; +// kind-0 fetches answer when the test says so; full-text search finds nothing +window.searchLookup = (filter) => { + if (filter.search || !filter.authors) return Promise.resolve([]); + return Promise.all(filter.authors.map((pk) => new Promise((resolve) => lookups.set(pk, resolve)))).then((a) => a.flat()); +}; +window.fetch = async (url) => { + const q = new URL(url).searchParams.get('q'); + return { json: async () => ({ results: [{ pubkey: window.registrar[q], name: q, address: q + '@coinos.io' }] }) }; +}; +const paint = (rows) => { + const el = document.querySelector('#rows'); + el.replaceChildren(...resultRows(h, rows, () => {})); + const a = el.querySelector('.chat-avatar'); + frames.push({ punk: !!a.querySelector('.punk'), loading: a.classList.contains('loading'), picture: a.style.backgroundImage }); +}; +window.test = { + search(q, pk) { window.registrar = { [q]: pk }; frames.length = 0; return searchRecipients(q, paint).then(paint); }, + answer(pk, profile) { + lookups.get(pk)(profile ? [{ pubkey: pk, created_at: 1, kind: 0, content: JSON.stringify(profile) }] : []); + }, + pending: (pk) => lookups.has(pk), + get frames() { return frames; }, +}; +`; +const bundle = await Bun.build({ entrypoints: ['search-test-entry'], target: 'browser', plugins: [{ name: 'search-test', setup(build) { + build.onResolve({ filter: /^search-test-entry$/ }, () => ({ path: 'entry', namespace: 'search-test' })); + build.onLoad({ filter: /.*/, namespace: 'search-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + build.onLoad({ filter: /src\/recipient-search\.js$/ }, async ({ path }) => ({ loader: 'js', + // Primal's socket stays out of it: the registrar and kind-0 fetch are the race + contents: (await Bun.file(path).text()).replace('const primalSearch = (q', 'const primalSearch = () => Promise.resolve([]);\nconst _primalSearch = (q'), + })); + build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', + contents: (await Bun.file(path).text()).replace('export async function queryOn(relays, filter, maxWait = 1500) {', + 'export async function queryOn(relays, filter, maxWait = 1500) { return window.searchLookup(filter);'), + })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="rows" class="list"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch(req) { + const path = new URL(req.url).pathname; + if (/^\/punks(-sm)?\/\d+\.webp$/.test(path)) return new Response(Bun.file('static' + path)); + return path === '/' ? new Response(html, { headers: { 'content-type': 'text/html' } }) : new Response(null, { status: 404 }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(), errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + await page.goto(server.url.href); + await page.waitForFunction(() => window.test); + + // a new account that picked a punk other than its pubkey's default + const pk = 'a'.repeat(64); + const done = page.evaluate((pk) => test.search('newbie', pk), pk); + await page.waitForFunction((pk) => test.pending(pk) && test.frames.length, {}, pk); + assert.deepEqual(await page.evaluate(() => test.frames.at(-1)), { punk: false, loading: true, picture: '' }, 'registrar hit paints a quiet circle while its profile loads'); + await page.evaluate((pk) => test.answer(pk, { name: 'newbie', picture: '/punks/18.webp' }), pk); + await done; + const frames = await page.evaluate(() => test.frames); + assert(frames.at(-1).picture.includes('/punks/18.webp'), 'the chosen picture lands'); + assert(frames.every((f) => !f.punk), 'no default punk before the real picture'); + console.log('✓ A new account\'s suggestion shows no default punk before its real picture'); + + // no profile at all: once the lookup settles, the default punk is the right answer + const bare = 'b'.repeat(64); + const done2 = page.evaluate((pk) => test.search('bare', pk), bare); + await page.waitForFunction((pk) => test.pending(pk), {}, bare); + await page.evaluate((pk) => test.answer(pk, null), bare); + await done2; + assert.equal((await page.evaluate(() => test.frames.at(-1))).punk, true, 'settled lookup without a picture shows the punk'); + console.log('✓ Accounts without a picture settle into their default punk'); + assert.deepEqual(errors, []); +} finally { await browser.close(); server.stop(true); } |
