aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 22:47:05 -0700
committerAdam Soltys <[email protected]>2026-09-10 22:47:05 -0700
commitac675185e362930f2086e01ab578fdd3423e4c79 (patch)
tree78928f4a0431de24c3797fa18e90edf85ad3b766 /tools
parent4260b7469bd487e574f97bdf99db31abf2caaa67 (diff)
downloadcoinosv3-ac675185e362930f2086e01ab578fdd3423e4c79.tar.gz
coinosv3-ac675185e362930f2086e01ab578fdd3423e4c79.zip
Keep a thumbnail of every face, so they're on screen in the first frame.
A profile picture is whatever its owner uploaded, and that is very often the full-size original. Measured against the faces this wallet had cached: one avatar is a 4,988,208-byte JPEG, another 3,901,966 — megabytes fetched and decoded to paint a 30px circle. That is the second of blank circles on the messages page after a refresh, and it repeats every time the upstream cache header expires. So the first time we see a picture we downscale it once (96px, webp) and keep the thumbnail beside the profile in localStorage, which is read synchronously at boot. Small avatars then paint from it alone and never ask the network for the original again; the 64px profile avatar layers them, so the thumbnail shows instantly while the full-size original loads over the top. Reading the pixels needs CORS — most avatar hosts allow it (6 of the 9 here), and the ones that don't are remembered as failures rather than retried every boot. A refresh of the profile carries the thumbnail across when the picture hasn't changed. Thumbnails live on a 180k budget, oldest face first, and Data Saver opts out of making them at all. tools/avatar-thumb-test.js watches a wallet meet a large cross-origin picture and checks the next boot fetches no picture at all. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'tools')
-rw-r--r--tools/avatar-thumb-test.js107
1 files changed, 107 insertions, 0 deletions
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);