aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js97
-rw-r--r--tools/avatar-thumb-test.js107
2 files changed, 201 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);
diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js
new file mode 100644
index 0000000..48830be
--- /dev/null
+++ b/tools/avatar-thumb-test.js
@@ -0,0 +1,107 @@
+// Avatar pictures are whatever their owner uploaded — often the full-size
+// original (megabytes, to paint a 30px circle). We downscale each one once
+// and keep the thumbnail beside the profile, so later boots paint the face
+// from localStorage with no network at all.
+//
+// Here: a wallet is given a profile whose picture is a large cross-origin
+// image, and we check that a thumbnail is made and persisted, that the small
+// avatar then paints from it alone (the original is never requested again),
+// and that a refresh of the profile doesn't throw the thumbnail away.
+//
+// Run: bun tools/avatar-thumb-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { seedPubkey } from '../src/nostr.js';
+import { buildHtml } from '../build.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// a big-ish PNG (1024x1024 of noise) served with CORS, and a request counter
+const big = await (async () => {
+ const px = 512, head = Buffer.alloc(0);
+ // a real image is needed (the canvas has to decode it): build a PNG via a data URI in the page instead
+ return { px, head };
+})();
+const html = await buildHtml({ minify: true, pwa: false });
+let originalHits = 0;
+const server = Bun.serve({
+ port: 5235,
+ async fetch(req) {
+ const u = new URL(req.url);
+ if (u.pathname === '/big.png') {
+ originalHits++;
+ const bytes = await Bun.file('/tmp/claude-1000/-home-adam-coinosv3/61dea255-d26d-411d-be8b-92dfb0a75887/scratchpad/big.png').arrayBuffer();
+ return new Response(bytes, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*', 'cache-control': 'no-store' } });
+ }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('get started'); await sleep(500);
+ await click('import existing'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mnemonic = generateMnemonic(wordlist);
+ await page.type('textarea', mnemonic);
+ await click('open wallet');
+ await waitText('receive', 20000);
+
+ // the face in the header is the seed's own nostr identity
+ const pk = seedPubkey(mnemonic, '', 0);
+ check('found the wallet identity', !!pk, pk ? pk.slice(0, 12) + '…' : 'none');
+
+ // give it a picture: a large cross-origin image
+ // the seed-keyed slot and the watch-only xpub mirror both look like
+ // `btc-wallet-cache:<hash>` — seed the row under every one of them
+ const bases = await page.evaluate((pkHex) => {
+ const ks = Object.keys(localStorage).filter((k) => /^btc-wallet-cache:[0-9a-f]+$/.test(k));
+ for (const b of ks) localStorage.setItem(b + ':profiles', JSON.stringify({
+ [pkHex]: { name: 'Face Test', picture: 'http://localhost:5235/big.png', t: Date.now() },
+ }));
+ return ks.length;
+ }, pk);
+ check('seeded the profile cache', bases > 0, bases + ' cache slot(s)');
+ originalHits = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(2500);
+
+ const row = 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 && x.thumb) || rows[0] || {};
+ return { hasThumb: typeof r.thumb === 'string', kind: (r.thumb || '').slice(0, 20), len: (r.thumb || '').length, thumbFor: r.thumbFor === r.picture };
+ });
+ 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');
+
+ // the small avatar paints from the thumbnail alone
+ const style = await page.evaluate(() => {
+ const a = document.querySelector('.header-avatar .ava-img') || document.querySelector('.ava-img');
+ return a ? a.getAttribute('style') : 'NONE header=' + (document.querySelector('.header-avatar') || {}).innerHTML;
+ });
+ console.log(' header avatar:', String(style).slice(0, 160));
+ check('the header avatar paints from the local copy', /data:image/.test(style));
+ check('...and never asks for the original', !/localhost:5235\/big\.png/.test(style), style.slice(0, 40));
+
+ // and a reload makes no request for the original at all
+ originalHits = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(2000);
+ check('a later boot fetches no picture at all', originalHits === 0, originalHits + ' request(s)');
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ faces come back instantly' : '\n❌ failed');
+process.exit(ok ? 0 : 1);