aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 12:49:17 -0700
committerAdam Soltys <[email protected]>2026-10-07 12:49:17 -0700
commit7c76b28b3e52622f8a8f734df7478ec8ba5428cf (patch)
tree296cd4bdf21e12e30c21c8a63a6fc246bf7cdd4e
parent86d9818abfc160dac031bb292abe28d6f28fc624 (diff)
downloadcoinosv3-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.js15
-rw-r--r--tools/search-avatar-loading-test.js87
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); }