diff options
| author | Adam Soltys <[email protected]> | 2026-09-10 22:47:05 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-10 22:47:05 -0700 |
| commit | ac675185e362930f2086e01ab578fdd3423e4c79 (patch) | |
| tree | 78928f4a0431de24c3797fa18e90edf85ad3b766 | |
| parent | 4260b7469bd487e574f97bdf99db31abf2caaa67 (diff) | |
| download | coinosv3-ac675185e362930f2086e01ab578fdd3423e4c79.tar.gz coinosv3-ac675185e362930f2086e01ab578fdd3423e4c79.zip | |
Keep a thumbnail of every face, so they're on screen in the first frame.
A profile picture is whatever its owner uploaded, and that is very often the
full-size original. Measured against the faces this wallet had cached: one
avatar is a 4,988,208-byte JPEG, another 3,901,966 — megabytes fetched and
decoded to paint a 30px circle. That is the second of blank circles on the
messages page after a refresh, and it repeats every time the upstream cache
header expires.
So the first time we see a picture we downscale it once (96px, webp) and keep
the thumbnail beside the profile in localStorage, which is read synchronously
at boot. Small avatars then paint from it alone and never ask the network for
the original again; the 64px profile avatar layers them, so the thumbnail
shows instantly while the full-size original loads over the top.
Reading the pixels needs CORS — most avatar hosts allow it (6 of the 9 here),
and the ones that don't are remembered as failures rather than retried every
boot. A refresh of the profile carries the thumbnail across when the picture
hasn't changed. Thumbnails live on a 180k budget, oldest face first, and Data
Saver opts out of making them at all.
tools/avatar-thumb-test.js watches a wallet meet a large cross-origin picture
and checks the next boot fetches no picture at all.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
| -rw-r--r-- | src/features/messages.js | 97 | ||||
| -rw-r--r-- | tools/avatar-thumb-test.js | 107 |
2 files changed, 201 insertions, 3 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 58a1948..18703ab 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -308,7 +308,19 @@ export function messagesFeature(ctx) { // spreads a cold-relay miss across sessions. if (!p || (!p.name && !p.picture)) return; const s = wallet.loadFeatureState('profiles', {}); - s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, t: Date.now() }; + s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, t: Date.now(), + ...(p.thumbFor === p.picture && p.thumb ? { thumb: p.thumb, thumbFor: p.thumbFor } : {}), + ...(p.thumbFail ? { thumbFail: p.thumbFail } : {}) }; + // Thumbnails are the bulk of this blob, so they live on a budget: the + // least recently seen faces give theirs up first. The row itself stays — + // that face just paints the way it used to. + if (JSON.stringify(s).length > THUMB_BUDGET) { + const oldestFirst = Object.keys(s).filter((k) => s[k].thumb).sort((a, b) => (s[a].t || 0) - (s[b].t || 0)); + for (const k of oldestFirst) { + delete s[k].thumb; delete s[k].thumbFor; + if (JSON.stringify(s).length <= THUMB_BUDGET) break; + } + } const keys = Object.keys(s); if (keys.length > 150) { // the user's own faces never churn out — their avatar going punk over @@ -320,11 +332,76 @@ export function messagesFeature(ctx) { } wallet.saveFeatureState('profiles', s); } + // A refreshed profile whose picture hasn't changed keeps the thumbnail we + // already made of it — otherwise every relay refresh throws the local copy + // away and the original gets downloaded all over again. + const keepThumb = (pk, entry) => { + const prev = profiles.get(pk); + if (!prev || !entry || !entry.picture) return entry; + if (prev.thumb && prev.thumbFor === entry.picture) { entry.thumb = prev.thumb; entry.thumbFor = prev.thumbFor; } + if (prev.thumbFail === entry.picture) entry.thumbFail = prev.thumbFail; + return entry; + }; + // 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 && p.picture) new Image().src = p.picture; } 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 + // 5MB JPEG and another 3.9MB — megabytes fetched and decoded to paint a + // 30px circle, which is the second of blank circles you see after a + // refresh. So the first time we see a picture we downscale it ONCE and keep + // the thumbnail beside the profile. Every later boot paints the face from + // localStorage in the first frame, with no network at all. + // + // Reading the pixels needs CORS (a tainted canvas can't be exported). Most + // avatar hosts allow it; the ones that don't are remembered as failures and + // keep today's behaviour rather than being retried every boot. + const THUMB_PX = 96; // 30px circles at 3x; the 64px profile avatar + // layers the original over it anyway + const THUMB_MAX = 9000; // chars of data URL for one face + const THUMB_BUDGET = 180_000; // ...and for all of them together + const thumbing = new Set(); + function makeThumb(pk, p) { + if (!p || !p.picture || typeof document === 'undefined') return; + if (p.thumbFor === p.picture || p.thumbFail === p.picture) return; + if (thumbing.has(pk) || thumbing.size >= 3) return; // a few at a time + // Making the thumbnail costs one more fetch of the original today to + // save every fetch after it — but not on a connection someone is + // nursing. Data Saver keeps today's behaviour. + try { if (navigator.connection && navigator.connection.saveData) return; } catch {} + thumbing.add(pk); + const url = p.picture; + const done = (patch) => { + thumbing.delete(pk); + const entry = { ...(profiles.get(pk) || p), ...patch }; + profiles.set(pk, entry); + persistProfile(pk, entry); + if (patch.thumb) scheduleRepaint(); + }; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + img.onload = () => { + try { + // centre-crop to a square, the way background-size:cover paints it + const side = Math.min(img.naturalWidth, img.naturalHeight); + if (!side) return done({ thumbFail: url }); + const c = document.createElement('canvas'); + c.width = c.height = THUMB_PX; + c.getContext('2d').drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, + side, side, 0, 0, THUMB_PX, THUMB_PX); + let data = c.toDataURL('image/webp', 0.75); + if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); + done(data.length <= THUMB_MAX ? { thumb: data, thumbFor: url } : { thumbFail: url }); + } catch { done({ thumbFail: url }); } // tainted canvas — this host sends no CORS header + }; + img.onerror = () => done({ thumbFail: url }); + img.src = url; + } + // A profile that came back EMPTY is usually a cold boot's 5s query racing // relays that were still dialing — not proof there's no kind 0. Trusting it // for the full TTL once dressed a real account in a punk and an npub for a @@ -342,7 +419,7 @@ export function messagesFeature(ctx) { // punk — keep the stale fields and just refresh the clock, so the // known avatar holds while the full-profile fetch does its rounds. const prev = profiles.get(pk); - const entry = p ? { ...p, t: Date.now() } : { ...(prev || {}), t: Date.now() }; + const entry = keepThumb(pk, p ? { ...p, t: Date.now() } : { ...(prev || {}), t: Date.now() }); profiles.set(pk, entry); persistProfile(pk, entry); preloadPicture(entry); @@ -1665,8 +1742,21 @@ export function messagesFeature(ctx) { : p.picture // A background paints synchronously from cache; a fresh <img> decodes // async, so recreating one per render made avatars visibly flash. - ? h('div', { class: cls + ' ava-img', style: `background-image:url(${JSON.stringify(p.picture)})` }) + // + // The thumbnail we keep locally is the whole picture at this size, so + // a 30px circle never asks the network for the original again. The + // 64px profile avatar layers them: CSS paints the first layer that + // has arrived, so the thumbnail shows instantly and the full-size + // original takes over on top when it lands. + ? h('div', { + class: cls + ' ava-img', + style: 'background-image:' + (p.thumb && p.thumbFor === p.picture + ? (cls.includes('profile-avatar') ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(p.thumb)})` + : `url(${JSON.stringify(p.picture)})`), + }) : fallbackAvatar(h, pk, p.name, cls); + // A face we keep painting is one worth keeping a thumbnail of. + if (p && p.picture) makeThumb(pk, p); if (clickable) { node.classList.add('clickable'); node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); @@ -1745,6 +1835,7 @@ export function messagesFeature(ctx) { nip05: m.nip05 || null, lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null, t: Date.now(), }; + keepThumb(pk, entry); profiles.set(pk, entry); persistProfile(pk, entry); preloadPicture(entry); diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js new file mode 100644 index 0000000..48830be --- /dev/null +++ b/tools/avatar-thumb-test.js @@ -0,0 +1,107 @@ +// Avatar pictures are whatever their owner uploaded — often the full-size +// original (megabytes, to paint a 30px circle). We downscale each one once +// and keep the thumbnail beside the profile, so later boots paint the face +// from localStorage with no network at all. +// +// Here: a wallet is given a profile whose picture is a large cross-origin +// image, and we check that a thumbnail is made and persisted, that the small +// avatar then paints from it alone (the original is never requested again), +// and that a refresh of the profile doesn't throw the thumbnail away. +// +// Run: bun tools/avatar-thumb-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { seedPubkey } from '../src/nostr.js'; +import { buildHtml } from '../build.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +// a big-ish PNG (1024x1024 of noise) served with CORS, and a request counter +const big = await (async () => { + const px = 512, head = Buffer.alloc(0); + // a real image is needed (the canvas has to decode it): build a PNG via a data URI in the page instead + return { px, head }; +})(); +const html = await buildHtml({ minify: true, pwa: false }); +let originalHits = 0; +const server = Bun.serve({ + port: 5235, + async fetch(req) { + const u = new URL(req.url); + if (u.pathname === '/big.png') { + originalHits++; + const bytes = await Bun.file('/tmp/claude-1000/-home-adam-coinosv3/61dea255-d26d-411d-be8b-92dfb0a75887/scratchpad/big.png').arrayBuffer(); + return new Response(bytes, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*', 'cache-control': 'no-store' } }); + } + return new Response(html, { headers: { 'content-type': 'text/html' } }); + }, +}); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('get started'); await sleep(500); + await click('import existing'); await sleep(400); + await page.waitForSelector('textarea'); + const mnemonic = generateMnemonic(wordlist); + await page.type('textarea', mnemonic); + await click('open wallet'); + await waitText('receive', 20000); + + // the face in the header is the seed's own nostr identity + const pk = seedPubkey(mnemonic, '', 0); + check('found the wallet identity', !!pk, pk ? pk.slice(0, 12) + '…' : 'none'); + + // give it a picture: a large cross-origin image + // the seed-keyed slot and the watch-only xpub mirror both look like + // `btc-wallet-cache:<hash>` — seed the row under every one of them + const bases = await page.evaluate((pkHex) => { + const ks = Object.keys(localStorage).filter((k) => /^btc-wallet-cache:[0-9a-f]+$/.test(k)); + for (const b of ks) localStorage.setItem(b + ':profiles', JSON.stringify({ + [pkHex]: { name: 'Face Test', picture: 'http://localhost:5235/big.png', t: Date.now() }, + })); + return ks.length; + }, pk); + check('seeded the profile cache', bases > 0, bases + ' cache slot(s)'); + originalHits = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await sleep(2500); + + const row = await page.evaluate(() => { + const rows = Object.keys(localStorage).filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k)) + .flatMap((k) => Object.values(JSON.parse(localStorage.getItem(k) || '{}'))); + const r = rows.find((x) => x && x.thumb) || rows[0] || {}; + return { hasThumb: typeof r.thumb === 'string', kind: (r.thumb || '').slice(0, 20), len: (r.thumb || '').length, thumbFor: r.thumbFor === r.picture }; + }); + check('a thumbnail was made and persisted', row.hasThumb, row.kind + ' ' + row.len + ' chars'); + check('it is pinned to the picture it came from', row.thumbFor); + check('it is small', row.len > 0 && row.len < 12000, row.len + ' chars'); + + // the small avatar paints from the thumbnail alone + const style = await page.evaluate(() => { + const a = document.querySelector('.header-avatar .ava-img') || document.querySelector('.ava-img'); + return a ? a.getAttribute('style') : 'NONE header=' + (document.querySelector('.header-avatar') || {}).innerHTML; + }); + console.log(' header avatar:', String(style).slice(0, 160)); + check('the header avatar paints from the local copy', /data:image/.test(style)); + check('...and never asks for the original', !/localhost:5235\/big\.png/.test(style), style.slice(0, 40)); + + // and a reload makes no request for the original at all + originalHits = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await sleep(2000); + check('a later boot fetches no picture at all', originalHits === 0, originalHits + ' request(s)'); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ faces come back instantly' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
