diff options
| author | Adam Soltys <[email protected]> | 2026-09-26 07:38:51 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-26 07:38:51 -0700 |
| commit | 1054c86e45c8ab8671dd46ec8e244daceaa70482 (patch) | |
| tree | 7767521aee9908422f467c31d70c794b9093f10f | |
| parent | 9b49a11710dfb9ded1cf3297696c32ffc64bf7e8 (diff) | |
| download | coinosv3-1054c86e45c8ab8671dd46ec8e244daceaa70482.tar.gz coinosv3-1054c86e45c8ab8671dd46ec8e244daceaa70482.zip | |
Notifications: the faces the page will paint are warmed before it opens.
The list was already cached locally and refreshed from the chat home, but
each row only asked for its author's profile when painted, so the page
opened as quiet circles filling in one by one. Now the actors' names are
fetched in one batch and each picture decoded into a thumbnail the moment
the list is read from disk or grows — which the chat home does before the
page is ever opened.
Test: notif-faces (stub relay answers the kind-0 batch; the pictures are
fetched and cached from the chat home; every row has its face on the
first frame). Note for tests: serve /verify-worker.js from dist, or every
relay answer waits four seconds for the inline fallback.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/features/messages.js | 17 | ||||
| -rw-r--r-- | tools/notif-faces-test.js | 100 |
2 files changed, 116 insertions, 1 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 65dec9d..1910546 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7993,10 +7993,24 @@ export function messagesFeature(ctx) { const s = st(); const stored = (!s.notifsPk || s.notifsPk === identityPk()) ? (s.notifs || []) : []; notif = { status: stored.length ? 'ready' : 'loading', items: stored }; + warmNotifFaces(stored); refreshNotifs(); } return notif; } + // The faces the notifications page will paint, fetched and decoded ahead + // of it: the names in one batch, then each picture through the cache and + // into a thumbnail. Runs when the list is read from disk or grows (the + // chat home does both before the page is ever opened), so opening it + // paints faces rather than quiet circles that fill in one by one. + const notifWarmed = new Set(); + function warmNotifFaces(items) { + const pks = [...new Set((items || []).map((x) => x && x.actor).filter((pk) => pk && !notifWarmed.has(pk)))].slice(0, 80); + if (!pks.length || typeof Image === 'undefined') return; + for (const pk of pks) { notifWarmed.add(pk); liveProfileOf(pk); } + const deadline = Date.now() + READY_MS; + for (const pk of pks) warmAvatar(pk, deadline).catch(() => {}); + } // What an event says happened, or null if it isn't about you after all. function notifItem(ev) { const my = myPubkeys(); @@ -8031,6 +8045,7 @@ export function messagesFeature(ctx) { const add = (evs || []).map((ev) => (ev.kind === 1 && hidden(ev) ? null : notifItem(ev))) .filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id)); if (!add.length) return false; + warmNotifFaces(add); // One zap, two receipts: a coinos zap publishes its own (9737) beside the // LNURL server's (9735). The same person, post and amount within a few // minutes is one row. @@ -8164,7 +8179,7 @@ export function messagesFeature(ctx) { notifSeenAtOpen = notifSeen(); ui.chatOpen = true; ui.msgView = 'notifs'; - notifNow(); + warmNotifFaces(notifNow().items); refreshNotifs(true).catch(() => {}); watchNotifs(); markNotifsSeen(); diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js new file mode 100644 index 0000000..7d1c740 --- /dev/null +++ b/tools/notif-faces-test.js @@ -0,0 +1,100 @@ +// Notifications: the list is cached locally, and the faces it will paint are +// fetched and decoded from the chat home — before the page is opened — so it +// opens with faces, not circles filling in. The stub relay answers the kind-0 +// batch for the actors with pictures served by this test. +// Run: bun tools/notif-faces-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +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; }; +const ACTORS = Array.from({ length: 5 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }); +const now = Math.floor(Date.now() / 1000); +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); +const PROFILES = ACTORS.map((a, i) => finalizeEvent({ kind: 0, created_at: now - 100, tags: [], content: JSON.stringify({ name: 'Actor ' + i, picture: 'http://localhost:5306/face-' + i + '.png' }) }, a.sk)); + +const html = await buildHtml({ minify: true, pwa: false }); +const faceHits = []; +const server = Bun.serve({ port: 5306, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/face-')) { faceHits.push(path); return new Response(PNG, { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=3600' } }); } + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + 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(); +await page.evaluateOnNewDocument((profiles) => { + window.__profileReqs = 0; + const byPk = new Map(profiles.map((p) => [p.pubkey, p])); + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const f = m[2] || {}; + const evs = (f.kinds || []).includes(0) ? (f.authors || []).map((pk) => byPk.get(pk)).filter(Boolean) : []; + if (evs.length) { window.__profileReqs++; (window.__log ||= []).push([f.authors.length, evs.length, a[0]]); } + setTimeout(() => { + for (const e of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'EVENT') return; + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, PROFILES); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +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:5306/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + const ME = getPublicKey(privateKeyFromSeedWords(mn)); + // a cached notifications list: five people reacted to my post + const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) })); + await page.evaluate(([k, me, items]) => { + const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); + st.notifs = items; st.notifsPk = me; + localStorage.setItem(k + ':messages', JSON.stringify(st)); + }, [base, ME, items]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(3000); + console.log('\n[on the chat home, before opening Notifications]'); + check('the cached list is there (the row counts it)', await page.evaluate(() => !!document.querySelector('.notifs-row, .chat-thread-row') && /Notifications/.test(document.body.innerText))); + check('the actors\' profiles were asked for', (await page.evaluate(() => window.__profileReqs)) > 0); + check('...and their pictures fetched', faceHits.length >= 5, faceHits.length + ' of 5'); + const profs = await page.evaluate((k) => Object.keys(JSON.parse(localStorage.getItem(k + ':profiles') || '{}')).length, base); + check('...and remembered in the profile cache', profs >= 5, profs + ' profiles'); + + console.log('\n[opening the page]'); + await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /Notifications/.test(n.textContent)); if (e) e.click(); }); + await sleep(150); // the first paint, not a settled one + const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url'))); + check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
