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 /src/features | |
| 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
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 97 |
1 files changed, 94 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); |
