From 848c3edd115360739b5680fec45ef6b71b3b9c1c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 13 Sep 2026 09:06:24 -0700 Subject: Bigger faces in the feed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/features/messages.js | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) (limited to 'src/features') 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' }, -- cgit v1.2.3