From 6fa98bd4da4da341d48bf95012ad480354ecf32e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 1 Oct 2026 17:18:19 -0700 Subject: Warm recipient portraits from Send suggestions before selection --- src/app.js | 4 ++++ src/features/messages.js | 48 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index b2584c2..d3222f9 100644 --- a/src/app.js +++ b/src/app.js @@ -5012,6 +5012,9 @@ function parkSendField() { // input for a fresh node, which resets Android's keyboard session and cancels // key auto-repeat — holding backspace deleted one character per press. const sendSearcher = makeSearcher((q, rows) => { + // Suggestions already know the person's picture. Share that profile and + // warm the large portrait before a selection can paint the Send form. + for (const cand of rows || []) featureHook('warmRecipient', cand); sendSearch.rows = rows; if (sendSearch.sync) sendSearch.sync(); else render(); if (rows && rows.length) setTimeout(parkSendField, 50); @@ -5091,6 +5094,7 @@ function recipientRow(s, r, i) { }, }); const pickRecipient = (cand) => { + featureHook('warmRecipient', cand); const v = cand.address || npubOf(cand.pk); // State first: clear() re-renders, and that render must already see the // picked address — the old order repainted the half-typed query and left diff --git a/src/features/messages.js b/src/features/messages.js index 38fd1f6..6d33cc3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -666,7 +666,15 @@ export function messagesFeature(ctx) { // Pull the picture bytes into the HTTP cache the moment we learn the URL — // an avatar div then paints instantly instead of holding its quiet circle // while the image downloads. - const preloadPicture = (p) => { try { if (p?.picture) new Image().src = localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture; } catch {} }; + const preloadPicture = (p, big = false) => { + try { + if (!p?.picture) return; + const image = new Image(); + image.src = big ? localPunk(p.picture, true) || p.picture + : localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture; + if (big) image.decode().catch(() => {}); + } catch {} + }; // A profile picture is whatever its owner uploaded, and that is very often // the full-size original: among the faces this wallet had cached, one was a @@ -2947,13 +2955,18 @@ 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); + // Small faces wait at most a second. An unknown payment portrait stays + // hidden until its lookup settles; suggestions warm the real photo + // before selection. A name-only search placeholder can retain `loading` + // after a miss; the request state lets + // it fall back once we've finished looking. + const sendFace = cls.includes('send-avatar'); + const waiting = (p === null || (p && p.loading && !p.picture)) + && (sendFace ? p === null || profQueue.has(pk) || profInFlight.has(pk) : faceWaiting(pk)); const node = feedPaint && !p.picture ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) - : waiting && (p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) - ? h('div', { class: cls + ' fallback loading' }) + : waiting && (sendFace || p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) + ? h('div', { class: cls + ' fallback loading', style: sendFace ? 'visibility:hidden' : undefined }) : p && p.loading && !p.picture ? fallbackAvatar(h, pk, p.name, cls) : p === null @@ -9942,6 +9955,29 @@ export function messagesFeature(ctx) { // The light profile cache, read-only — lets the onboarding wizard skip // asks (like the avatar picker) that a loaded identity already answered. cachedProfile(pk) { return profileOf(pk); }, + // Search results have their own profile cache. Hand the known name and + // photo to the portrait cache before drawing the suggestions, instead + // of throwing them away and waiting for another relay lookup on pick. + warmRecipient(cand) { + const pk = cand?.pk; + if (!/^[0-9a-f]{64}$/.test(pk || '')) return; + warmProfiles(); + const cur = profiles.get(pk); + const entry = keepThumb(pk, { + ...(cur || {}), name: cur?.name || cand.name || null, + picture: cur?.picture || cand.picture || null, + t: cur?.t || 0, + }); + if (entry.picture) delete entry.loading; + else if (!cur || cur.loading) entry.loading = true; + profiles.set(pk, entry); + persistProfile(pk, entry); + preloadPicture(entry, true); + // Search data is partial: fetch payment metadata in the background + // while the already-known picture is immediately available. + profileOf(pk); + return true; + }, // Start fetching a profile (and its picture bytes) NOW — the login flow // calls this the moment it knows the identity, so the avatar is already // in cache by the time the home screen first paints. -- cgit v1.2.3