From 7c76b28b3e52622f8a8f734df7478ec8ba5428cf Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 7 Oct 2026 12:49:17 -0700 Subject: 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/recipient-search.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) (limited to 'src') 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 : 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' }, -- cgit v1.2.3