aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js36
-rw-r--r--src/style.css5
-rw-r--r--tools/avatar-thumb-test.js34
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,