diff options
| author | Adam Soltys <[email protected]> | 2026-09-13 09:06:24 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-13 09:06:24 -0700 |
| commit | 848c3edd115360739b5680fec45ef6b71b3b9c1c (patch) | |
| tree | 8e278613b08b8e616c4a90cdf439d60525078ca8 /src/features | |
| parent | 9fb2154236a3f9d7ede5344cfcc4fa56ff3ada0c (diff) | |
| download | coinosv3-848c3edd115360739b5680fec45ef6b71b3b9c1c.tar.gz coinosv3-848c3edd115360739b5680fec45ef6b71b3b9c1c.zip | |
Bigger faces in the feed.
Measured on a Pixel 9: Amethyst gives a feed avatar 55dp, we gave it 30 —
the same circle the inbox and chat rows use, where a face is a label on a
line. In a feed the face is the point. Feed rows now get 44px; everywhere
else is untouched.
The catch, and the reason this isn't a one-line change: the cached
thumbnail was 96px square, sized for a 30px circle at 3x. A 44px circle
no longer fits inside it, and the fallback is to layer the ORIGINAL over
the thumbnail — which for a feed of twenty rows means twenty full-size
downloads, one of which in this wallet's own cache was a 5MB JPEG. That
is exactly the slow-avatar problem we already fixed once.
So the thumbnail grows with the circle: 144px, which covers 44 at 3x with
room to spare. Faces cached at the old size are remade once (thumbPx says
which size a thumbnail was made for), and the old one keeps painting
meanwhile, so nobody watches a grey circle while it happens. The per-face
cap goes 9000 → 16000 chars and the shared budget 180k → 280k, both
scaled to the new size: the busiest picture on hand measures 12827 chars
at 144px, and at the old cap it would have been rejected and left
fetching its original forever.
avatar-thumb-test gains the guard that would have caught all of this:
the thumbnail must cover the biggest circle in the app at 3x. It failed
at 128px (132 needed) — which is why THUMB_PX is 144 and not 128. Its
redirect assertion was also order-dependent — the seed writes the profile
into every wallet's cache but only the painted one makes a thumbnail, so
it asked the first row and got a coin toss. It takes the best copy now.
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 | 36 |
1 files changed, 27 insertions, 9 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index ae310a6..254e5ca 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -310,7 +310,8 @@ export function messagesFeature(ctx) { 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(), - ...(p.thumbFor === p.picture && p.thumb ? { thumb: p.thumb, thumbFor: p.thumbFor } : {}), + ...(p.thumbFor === p.picture && p.thumb + ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; // Thumbnails are the bulk of this blob, so they live on a budget: the @@ -353,7 +354,11 @@ export function messagesFeature(ctx) { 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.thumb && prev.thumbFor === entry.picture) { + // thumbPx rides along: without it a refreshed profile looks like a + // thumbnail of unknown size and gets remade on every relay refresh + entry.thumb = prev.thumb; entry.thumbFor = prev.thumbFor; entry.thumbPx = prev.thumbPx || 0; + } if (prev.thumbFail === entry.picture) { entry.thumbFail = prev.thumbFail; entry.thumbFailAt = prev.thumbFailAt || 0; @@ -379,10 +384,21 @@ export function messagesFeature(ctx) { // 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 THUMB_PX = 144; // the 44px feed circle at 3x, with room to spare; + // the 64px profile avatar layers the original + // over it anyway + // Chars of data URL for one face. Scaled with THUMB_PX: the busiest + // picture on hand fit 9000 at 96px and needs 12827 at 144px — measured in + // the browser, not estimated, because its own webp encoder is nothing + // like as kind to noise as an offline one. A face over the cap keeps + // painting from its original, which is the old behaviour, so the cap only + // decides who gets the fast path. + const THUMB_MAX = 16_000; + // ...and for all of them together. Raised with THUMB_PX: a 144px face + // costs about 5.4KB against 96px's 3.2KB, and the budget is what decides + // how many faces paint instantly rather than fetching their original — + // this holds about fifty, which covers a feed and an inbox at once. + const THUMB_BUDGET = 280_000; const THUMB_SLOW = 20_000; // a host that won't answer must not hold a slot const THUMB_RETRY = 6 * 3600_000; // ...and must not be written off for good const THUMB_RETRY_MAX = 14 * 24 * 3600_000; // a host that never works, backed off @@ -392,7 +408,9 @@ export function messagesFeature(ctx) { const thumbing = new Set(); function makeThumb(pk, p) { if (!p || !p.picture || typeof document === 'undefined') return; - if (p.thumbFor === p.picture) return; + // thumbPx: a thumbnail made when the circles were smaller is too soft for + // today's, so it's remade once. The old one keeps painting until then. + if (p.thumbFor === p.picture && p.thumbPx === THUMB_PX) return; if (localPunk(p.picture)) return; // our own art, already the right size on disk // A failed attempt is usually the network, not the host: these images sit // on CDNs that answer in 700ms one minute and time out the next. So every @@ -445,7 +463,7 @@ export function messagesFeature(ctx) { if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder if (data.length > THUMB_MAX) throw new Error('too big'); - done({ thumb: data, thumbFor: url }); + done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }); } catch { failed(); } finally { try { bmp && bmp.close(); } catch {} } })(); } @@ -3238,7 +3256,7 @@ export function messagesFeature(ctx) { }, // the avatar is its own tap-target (profile), even inside an openable // row — its handler stops propagation, so the row still opens the thread - avatar(pk), + avatar(pk, 'chat-avatar note-avatar'), h('div', { class: 'col grow', style: 'min-width:0;gap:3px' }, h('div', { class: 'row between', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, |
