diff options
| -rw-r--r-- | src/features/messages.js | 36 | ||||
| -rw-r--r-- | src/style.css | 5 | ||||
| -rw-r--r-- | tools/avatar-thumb-test.js | 34 |
3 files changed, 64 insertions, 11 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' }, diff --git a/src/style.css b/src/style.css index 0f0ce3d..a16101c 100644 --- a/src/style.css +++ b/src/style.css @@ -700,6 +700,11 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } } .zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; } .chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; } +/* A feed row is the one place a face is the point, not a label on a line — + Amethyst gives it 55dp; at our 30px it read as a bullet beside the post. + The thumbnail we cache is sized to cover this (see THUMB_PX), so a bigger + circle costs no extra network. */ +.chat-avatar.note-avatar { width: 44px; height: 44px; font-size: 15px; } .chat-avatar.fallback { position: relative; overflow: hidden; } .chat-avatar.fallback .punk { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .header-avatar { padding: 0; border: none; background: none; cursor: pointer; display: flex; } diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js index b532982..727c49a 100644 --- a/tools/avatar-thumb-test.js +++ b/tools/avatar-thumb-test.js @@ -109,7 +109,32 @@ try { }); 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'); + check('it is small', row.len > 0 && row.len < 16000, row.len + ' chars'); + + // The whole point of the thumbnail is that no circle in the app ever has + // to fetch the original again. The feed's face is the biggest of them, so + // the thumbnail has to cover IT at phone pixel density — if someone grows + // that circle without growing THUMB_PX, every feed row quietly starts + // pulling down full-size originals again. + const covers = await page.evaluate(() => { + const el = document.createElement('div'); + el.className = 'chat-avatar note-avatar'; + el.style.position = 'absolute'; el.style.visibility = 'hidden'; + document.body.appendChild(el); + const css = parseFloat(getComputedStyle(el).width); + el.remove(); + 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 || '').length) || {}; + return new Promise((done) => { + const i = new Image(); + i.onload = () => done({ css, thumb: i.naturalWidth }); + i.onerror = () => done({ css, thumb: 0 }); + i.src = r.thumb || ''; + }); + }); + check('the thumbnail covers the biggest circle at 3x', + covers.thumb >= covers.css * 3, `${covers.thumb}px thumbnail for a ${covers.css}px circle`); // the small avatar paints from the thumbnail alone const style = await page.evaluate(() => { @@ -187,7 +212,12 @@ try { const redirThumb = 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 && /redir/.test(x.picture || '')) || {}; + // The seed above wrote this profile into EVERY wallet's cache, but only + // the one actually on screen paints the avatar and so makes the + // thumbnail. Asking the first matching row was a coin toss on + // localStorage key order — take the best copy, not an arbitrary one. + const all = rows.filter((x) => x && /redir/.test(x.picture || '')); + const r = all.find((x) => (x.thumb || '').length) || all[0] || {}; return { thumb: (r.thumb || '').length, fail: !!r.thumbFail }; }); check('an old thumbnail failure retries immediately and clears on success', redirThumb.thumb > 0 && !redirThumb.fail, |
