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 +++++++++++++++--- tools/profile-actions-test.js | 28 +++++++++++ tools/send-avatar-loading-test.js | 103 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 tools/send-avatar-loading-test.js 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. diff --git a/tools/profile-actions-test.js b/tools/profile-actions-test.js index e015b6d..9e98e27 100644 --- a/tools/profile-actions-test.js +++ b/tools/profile-actions-test.js @@ -8,6 +8,7 @@ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { npubEncode } from 'nostr-tools/nip19'; import { cacheKeyFor } from '../src/wallet.js'; const peer = getPublicKey(generateSecretKey()), nextPeer = getPublicKey(generateSecretKey()); +const searchPeer = getPublicKey(generateSecretKey()); const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, plugins: [{ name: 'profile-actions-test', setup(build) { build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` @@ -20,6 +21,7 @@ window.profileActions = { ui, wallet, render, showSend, enterWallet, assert(bundle.success, bundle.logs.join('\n')); const html = `
`; const server = Bun.serve({ port: 0, fetch(req) { + if (new URL(req.url).pathname === '/recipient-portrait.svg') return new Response('', { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' } }); if (/^\/punks\/\d+\.webp$/.test(new URL(req.url).pathname)) return new Response(Bun.file('static' + new URL(req.url).pathname)); return new URL(req.url).pathname === '/' || new URL(req.url).pathname.startsWith('/npub1') ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }); @@ -36,6 +38,9 @@ try { await page.setRequestInterception(true); page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()); await page.evaluateOnNewDocument(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-ark-provider:regtest', 'off'); }); + await page.evaluateOnNewDocument((pk) => localStorage.setItem('btc-wallet-search-queries-v2', JSON.stringify({ + carol: { t: Date.now(), rows: [{ pk, name: 'Carol', picture: '/recipient-portrait.svg', pri: 1 }] }, + })), searchPeer); await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.profileActions?.ready); const mnemonic = generateMnemonic(wordlist); @@ -50,6 +55,29 @@ try { profileActions.render(); }, mnemonic); await page.waitForSelector('.app-bottom-nav'); + // Search owns a separate profile cache. A cold portrait cache must inherit + // the suggestion's metadata and fetch the large image before selection. + await page.evaluate(() => { profileActions.showSend({ fresh: true }); profileActions.render(); }); + const portraitResponse = page.waitForResponse(r => r.url().endsWith('/recipient-portrait.svg')); + await page.type('input.mono-input', 'carol'); + await page.waitForSelector('.send-suggest .ava-img'); + await portraitResponse; + await page.evaluate(() => { + window.recipientFrames = []; + const record = () => { + const a = document.querySelector('.send-avatar'); + if (a) recipientFrames.push({ punk: !!a.querySelector('.punk'), hidden: getComputedStyle(a).visibility === 'hidden', picture: a.style.backgroundImage }); + }; + window.recipientObserver = new MutationObserver(record); + recipientObserver.observe(document.querySelector('#app'), { subtree: true, childList: true, attributes: true }); + }); + await page.click('.send-suggest .chat-thread-row'); + await page.waitForSelector('.send-avatar'); + assert.equal(await page.$eval('input.mono-input', e => e.value), npubEncode(searchPeer)); + assert(await page.$eval('.send-avatar', e => e.classList.contains('ava-img') && e.style.backgroundImage.includes('/recipient-portrait.svg') && getComputedStyle(e).visibility !== 'hidden'), 'selection immediately uses the already-fetched suggestion portrait'); + assert(await page.evaluate(() => recipientFrames.length > 0 && recipientFrames.every(f => !f.punk && !f.hidden && f.picture.includes('/recipient-portrait.svg'))), 'every selected-recipient frame has the real avatar, without fallback or placeholder'); + await page.evaluate(() => recipientObserver.disconnect()); + console.log('✓ Autosuggest warms the large recipient picture before selection and displays it immediately'); const openProfile = async (pk) => { await page.evaluate((pk) => profileActions.openProfile(pk), pk); await page.waitForSelector('.prof-actions'); diff --git a/tools/send-avatar-loading-test.js b/tools/send-avatar-loading-test.js new file mode 100644 index 0000000..daedb7c --- /dev/null +++ b/tools/send-avatar-loading-test.js @@ -0,0 +1,103 @@ +// Real recipient portraits and profile fetches with controlled relay answers. +// Run: bun tools/send-avatar-loading-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${domHelper} +const plans = new Map(), trace = []; +let feature, recipient; +const wallet = { nostr: { pk: 'f'.repeat(64) }, loadFeatureState: () => ({}), saveFeatureState() {}, registerCacheExtension() {} }; +window.avatarLookup = async (filter) => { + if (!filter.kinds.includes(0)) return []; + return (await Promise.all((filter.authors || []).map(pk => plans.get(pk)?.answer || []))).flat(); +}; +function render() { + if (!recipient) return; + const portrait = feature.profileChip(recipient, 'lg'); + document.querySelector('#recipient').replaceChildren(portrait); + trace.push({ punk: !!portrait.querySelector('.punk'), loading: !!portrait.querySelector('.loading') }); +} +feature = messagesFeature({ h, wallet, ui: { screen: 'wallet' }, render, hook: () => false }); +window.test = { + start(pk, loadingName = false, expiredWait = false) { + let finish; + plans.set(pk, { answer: new Promise(resolve => finish = resolve), finish: result => finish(result) }); + if (loadingName) feature.testSeed(pk, { name: 'Recipient', t: 0, loading: true }); + if (expiredWait) feature.testExpiredWait(pk); + recipient = pk; trace.length = 0; render(); + }, + complete(pk, profile) { + plans.get(pk).finish(profile === null ? [] : [{ pubkey: pk, created_at: Math.floor(Date.now()/1000), content: JSON.stringify(profile) }]); + }, + cached(pk, profile) { feature.testSeed(pk, { ...profile, t: Date.now() }); recipient = pk; render(); }, + get trace() { return trace; }, render, + small(pk) { document.querySelector('#small').replaceChildren(feature.testAvatar(pk, 'chat-avatar mini', false)); }, +}; +`; +const bundle = await Bun.build({ entrypoints: ['avatar-test-entry'], target: 'browser', plugins: [{ name: 'avatar-test', setup(build) { + build.onResolve({ filter: /^avatar-test-entry$/ }, () => ({ path: 'entry', namespace: 'avatar-test' })); + build.onLoad({ filter: /.*/, namespace: 'avatar-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', + contents: (await Bun.file(path).text()).replace("id: 'messages',", `id: 'messages', + testSeed: (pk, p) => profiles.set(pk, p), + testExpiredWait: (pk) => faceAsked.set(pk, Date.now() - 10000), testAvatar: avatar,`), + })); + 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.avatarLookup(filter);'), + })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `
`; +const server = Bun.serve({ port: 0, fetch(req) { + const path = new URL(req.url).pathname; + if (/^\/punks\/\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); + const state = () => page.$eval('.send-avatar', e => ({ loading: e.classList.contains('loading'), punk: !!e.querySelector('.punk'), picture: e.style.backgroundImage, hidden: getComputedStyle(e).visibility === 'hidden' })); + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); + for (const [i, loadingName, expiredWait] of [[1, false, false], [2, true, false], [3, true, true]]) { + const pk = String(i).repeat(64); + await page.evaluate(([pk, name, expired]) => test.start(pk, name, expired), [pk, loadingName, expiredWait]); + assert.deepEqual(await state(), { loading: true, punk: false, picture: '', hidden: true }, 'cold lookup paints no visible placeholder'); + await pause(1350); + await page.evaluate(() => test.render()); + assert.deepEqual(await state(), { loading: true, punk: false, picture: '', hidden: true }, 'slow metadata lookup never times out into punk art'); + await page.evaluate(pk => test.complete(pk, { name: 'Recipient', picture: '/punks/18.webp' }), pk); + await page.waitForSelector('.send-avatar.ava-img'); + assert((await state()).picture.includes('/punks/18.webp')); + assert(await page.evaluate(() => test.trace.every(frame => !frame.punk)), 'no fallback punk appeared before the real picture'); + } + console.log('✓ Cold, name-only and previously timed-out recipients show no placeholder before their picture arrives'); + + await page.evaluate(() => test.cached('4'.repeat(64), { name: 'Cached', picture: '/punks/54.webp' })); + assert((await state()).picture.includes('/punks/54.webp')); + assert.equal((await state()).loading, false); + console.log('✓ Cached recipient pictures appear immediately'); + + for (const [i, loadingName, result] of [[5, false, { name: 'No picture' }], [6, false, null], [7, true, null]]) { + const pk = String(i).repeat(64); + await page.evaluate(([pk, name]) => test.start(pk, name), [pk, loadingName]); + await page.evaluate(([pk, result]) => test.complete(pk, result), [pk, result]); + await page.waitForSelector('.send-avatar .punk'); + assert.equal((await state()).loading, false, 'finished or missing profile uses its actual fallback'); + } + console.log('✓ Profiles without pictures and failed lookups settle into their fallback avatar'); + + await page.evaluate(() => test.small('8'.repeat(64))); + assert(await page.$('#small .punk'), 'compact avatars retain their immediate fallback'); + assert.deepEqual(errors, []); + console.log('✓ Compact avatars keep existing behavior; no browser errors'); +} finally { await browser.close(); server.stop(true); } -- cgit v1.2.3