aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 17:18:19 -0700
committerAdam Soltys <[email protected]>2026-10-01 17:18:19 -0700
commit6fa98bd4da4da341d48bf95012ad480354ecf32e (patch)
treeb0f80eccd697442ec8fde57dce35654968538b48 /tools
parent5c661b68c61d53a4684e47c2f306ce43cc76c1c0 (diff)
downloadcoinosv3-6fa98bd4da4da341d48bf95012ad480354ecf32e.tar.gz
coinosv3-6fa98bd4da4da341d48bf95012ad480354ecf32e.zip
Warm recipient portraits from Send suggestions before selection
Diffstat (limited to 'tools')
-rw-r--r--tools/profile-actions-test.js28
-rw-r--r--tools/send-avatar-loading-test.js103
2 files changed, 131 insertions, 0 deletions
diff --git a/tools/profile-actions-test.js b/tools/profile-actions-test.js
index e015b6d..9e98e27 100644
--- a/tools/profile-actions-test.js
+++ b/tools/profile-actions-test.js
@@ -8,6 +8,7 @@ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
import { cacheKeyFor } from '../src/wallet.js';
const peer = getPublicKey(generateSecretKey()), nextPeer = getPublicKey(generateSecretKey());
+const searchPeer = getPublicKey(generateSecretKey());
const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
plugins: [{ name: 'profile-actions-test', setup(build) {
build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
@@ -20,6 +21,7 @@ window.profileActions = { ui, wallet, render, showSend, enterWallet,
assert(bundle.success, bundle.logs.join('\n'));
const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
const server = Bun.serve({ port: 0, fetch(req) {
+ if (new URL(req.url).pathname === '/recipient-portrait.svg') return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" fill="#2685c8"/><circle cx="48" cy="40" r="22" fill="#ffcc77"/></svg>', { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' } });
if (/^\/punks\/\d+\.webp$/.test(new URL(req.url).pathname)) return new Response(Bun.file('static' + new URL(req.url).pathname));
return new URL(req.url).pathname === '/' || new URL(req.url).pathname.startsWith('/npub1')
? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 });
@@ -36,6 +38,9 @@ try {
await page.setRequestInterception(true);
page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
await page.evaluateOnNewDocument(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-ark-provider:regtest', 'off'); });
+ await page.evaluateOnNewDocument((pk) => localStorage.setItem('btc-wallet-search-queries-v2', JSON.stringify({
+ carol: { t: Date.now(), rows: [{ pk, name: 'Carol', picture: '/recipient-portrait.svg', pri: 1 }] },
+ })), searchPeer);
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.profileActions?.ready);
const mnemonic = generateMnemonic(wordlist);
@@ -50,6 +55,29 @@ try {
profileActions.render();
}, mnemonic);
await page.waitForSelector('.app-bottom-nav');
+ // Search owns a separate profile cache. A cold portrait cache must inherit
+ // the suggestion's metadata and fetch the large image before selection.
+ await page.evaluate(() => { profileActions.showSend({ fresh: true }); profileActions.render(); });
+ const portraitResponse = page.waitForResponse(r => r.url().endsWith('/recipient-portrait.svg'));
+ await page.type('input.mono-input', 'carol');
+ await page.waitForSelector('.send-suggest .ava-img');
+ await portraitResponse;
+ await page.evaluate(() => {
+ window.recipientFrames = [];
+ const record = () => {
+ const a = document.querySelector('.send-avatar');
+ if (a) recipientFrames.push({ punk: !!a.querySelector('.punk'), hidden: getComputedStyle(a).visibility === 'hidden', picture: a.style.backgroundImage });
+ };
+ window.recipientObserver = new MutationObserver(record);
+ recipientObserver.observe(document.querySelector('#app'), { subtree: true, childList: true, attributes: true });
+ });
+ await page.click('.send-suggest .chat-thread-row');
+ await page.waitForSelector('.send-avatar');
+ assert.equal(await page.$eval('input.mono-input', e => e.value), npubEncode(searchPeer));
+ assert(await page.$eval('.send-avatar', e => e.classList.contains('ava-img') && e.style.backgroundImage.includes('/recipient-portrait.svg') && getComputedStyle(e).visibility !== 'hidden'), 'selection immediately uses the already-fetched suggestion portrait');
+ assert(await page.evaluate(() => recipientFrames.length > 0 && recipientFrames.every(f => !f.punk && !f.hidden && f.picture.includes('/recipient-portrait.svg'))), 'every selected-recipient frame has the real avatar, without fallback or placeholder');
+ await page.evaluate(() => recipientObserver.disconnect());
+ console.log('✓ Autosuggest warms the large recipient picture before selection and displays it immediately');
const openProfile = async (pk) => {
await page.evaluate((pk) => profileActions.openProfile(pk), pk);
await page.waitForSelector('.prof-actions');
diff --git a/tools/send-avatar-loading-test.js b/tools/send-avatar-loading-test.js
new file mode 100644
index 0000000..daedb7c
--- /dev/null
+++ b/tools/send-avatar-loading-test.js
@@ -0,0 +1,103 @@
+// Real recipient portraits and profile fetches with controlled relay answers.
+// Run: bun tools/send-avatar-loading-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+
+const app = await Bun.file('src/app.js').text();
+const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import { messagesFeature } from './src/features/messages.js';
+${domHelper}
+const plans = new Map(), trace = [];
+let feature, recipient;
+const wallet = { nostr: { pk: 'f'.repeat(64) }, loadFeatureState: () => ({}), saveFeatureState() {}, registerCacheExtension() {} };
+window.avatarLookup = async (filter) => {
+ if (!filter.kinds.includes(0)) return [];
+ return (await Promise.all((filter.authors || []).map(pk => plans.get(pk)?.answer || []))).flat();
+};
+function render() {
+ if (!recipient) return;
+ const portrait = feature.profileChip(recipient, 'lg');
+ document.querySelector('#recipient').replaceChildren(portrait);
+ trace.push({ punk: !!portrait.querySelector('.punk'), loading: !!portrait.querySelector('.loading') });
+}
+feature = messagesFeature({ h, wallet, ui: { screen: 'wallet' }, render, hook: () => false });
+window.test = {
+ start(pk, loadingName = false, expiredWait = false) {
+ let finish;
+ plans.set(pk, { answer: new Promise(resolve => finish = resolve), finish: result => finish(result) });
+ if (loadingName) feature.testSeed(pk, { name: 'Recipient', t: 0, loading: true });
+ if (expiredWait) feature.testExpiredWait(pk);
+ recipient = pk; trace.length = 0; render();
+ },
+ complete(pk, profile) {
+ plans.get(pk).finish(profile === null ? [] : [{ pubkey: pk, created_at: Math.floor(Date.now()/1000), content: JSON.stringify(profile) }]);
+ },
+ cached(pk, profile) { feature.testSeed(pk, { ...profile, t: Date.now() }); recipient = pk; render(); },
+ get trace() { return trace; }, render,
+ small(pk) { document.querySelector('#small').replaceChildren(feature.testAvatar(pk, 'chat-avatar mini', false)); },
+};
+`;
+const bundle = await Bun.build({ entrypoints: ['avatar-test-entry'], target: 'browser', plugins: [{ name: 'avatar-test', setup(build) {
+ build.onResolve({ filter: /^avatar-test-entry$/ }, () => ({ path: 'entry', namespace: 'avatar-test' }));
+ build.onLoad({ filter: /.*/, namespace: 'avatar-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js',
+ contents: (await Bun.file(path).text()).replace("id: 'messages',", `id: 'messages',
+ testSeed: (pk, p) => profiles.set(pk, p),
+ testExpiredWait: (pk) => faceAsked.set(pk, Date.now() - 10000), testAvatar: avatar,`),
+ }));
+ build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js',
+ contents: (await Bun.file(path).text()).replace('export async function queryOn(relays, filter, maxWait = 1500) {',
+ 'export async function queryOn(relays, filter, maxWait = 1500) { return window.avatarLookup(filter);'),
+ }));
+} }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="recipient"></div><div id="small"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (/^\/punks\/\d+\.webp$/.test(path)) return new Response(Bun.file('static' + path));
+ return path === '/' ? new Response(html, { headers: { 'content-type': 'text/html' } }) : new Response(null, { status: 404 });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.setRequestInterception(true);
+ page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ await page.goto(server.url.href);
+ await page.waitForFunction(() => window.test);
+ const state = () => page.$eval('.send-avatar', e => ({ loading: e.classList.contains('loading'), punk: !!e.querySelector('.punk'), picture: e.style.backgroundImage, hidden: getComputedStyle(e).visibility === 'hidden' }));
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
+ for (const [i, loadingName, expiredWait] of [[1, false, false], [2, true, false], [3, true, true]]) {
+ const pk = String(i).repeat(64);
+ await page.evaluate(([pk, name, expired]) => test.start(pk, name, expired), [pk, loadingName, expiredWait]);
+ assert.deepEqual(await state(), { loading: true, punk: false, picture: '', hidden: true }, 'cold lookup paints no visible placeholder');
+ await pause(1350);
+ await page.evaluate(() => test.render());
+ assert.deepEqual(await state(), { loading: true, punk: false, picture: '', hidden: true }, 'slow metadata lookup never times out into punk art');
+ await page.evaluate(pk => test.complete(pk, { name: 'Recipient', picture: '/punks/18.webp' }), pk);
+ await page.waitForSelector('.send-avatar.ava-img');
+ assert((await state()).picture.includes('/punks/18.webp'));
+ assert(await page.evaluate(() => test.trace.every(frame => !frame.punk)), 'no fallback punk appeared before the real picture');
+ }
+ console.log('✓ Cold, name-only and previously timed-out recipients show no placeholder before their picture arrives');
+
+ await page.evaluate(() => test.cached('4'.repeat(64), { name: 'Cached', picture: '/punks/54.webp' }));
+ assert((await state()).picture.includes('/punks/54.webp'));
+ assert.equal((await state()).loading, false);
+ console.log('✓ Cached recipient pictures appear immediately');
+
+ for (const [i, loadingName, result] of [[5, false, { name: 'No picture' }], [6, false, null], [7, true, null]]) {
+ const pk = String(i).repeat(64);
+ await page.evaluate(([pk, name]) => test.start(pk, name), [pk, loadingName]);
+ await page.evaluate(([pk, result]) => test.complete(pk, result), [pk, result]);
+ await page.waitForSelector('.send-avatar .punk');
+ assert.equal((await state()).loading, false, 'finished or missing profile uses its actual fallback');
+ }
+ console.log('✓ Profiles without pictures and failed lookups settle into their fallback avatar');
+
+ await page.evaluate(() => test.small('8'.repeat(64)));
+ assert(await page.$('#small .punk'), 'compact avatars retain their immediate fallback');
+ assert.deepEqual(errors, []);
+ console.log('✓ Compact avatars keep existing behavior; no browser errors');
+} finally { await browser.close(); server.stop(true); }