diff options
| -rw-r--r-- | src/features/messages.js | 47 | ||||
| -rw-r--r-- | tools/room-faces-test.js | 55 |
2 files changed, 100 insertions, 2 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 89fe55a..38bc899 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2907,7 +2907,7 @@ export function messagesFeature(ctx) { ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) : p && p.loading && !p.picture ? h('div', { class: cls + ' fallback loading' }) - : p === null && ui.noteThread + : p === null && (ui.noteThread || (ui.chatOpen && ui.msgView === 'room')) ? h('div', { class: cls + ' fallback loading' }) : p === null ? fallbackAvatar(h, pk, null, cls) @@ -8627,6 +8627,9 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate')))); const allRooms = communities(); const shownRooms = ui.msgAllRooms ? allRooms : allRooms.slice(0, LIST_PREVIEW); + // the rooms on the list get their faces while it sits there, so even a + // tap with no hover before it finds them ready + setTimeout(() => { for (const jm of shownRooms) if (rooms.has(jm.community_id)) warmRoom(jm); }, 300); kids.push(h('div', { class: 'list' }, shownRooms.map((jm) => { const room = rooms.get(jm.community_id); const name = room?.folded?.metadata?.name || jm.name; @@ -8641,7 +8644,9 @@ export function messagesFeature(ctx) { const unread = room ? roomUnread(room) : false; return h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), - onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); }, + onMouseenter: () => warmRoom(jm), + onPointerdown: () => warmRoom(jm), + onClick: () => openRoomWarm(jm, () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; }), }, h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, @@ -8692,6 +8697,44 @@ export function messagesFeature(ctx) { // ---- room --------------------------------------------------------------- + // ---- a room's faces, before it opens ------------------------------------- + // Opening a community painted everyone as a punk for a beat: their + // profiles weren't in memory yet. As SvelteKit preloads on hover, a + // pointer over (or pressing) a room's row starts the faces of its latest + // messages — profile, then the picture decoded — and the tap waits for + // them, a moment at most, before the room replaces the list. + const ROOM_READY_MS = 1500; + const roomWarm = new Map(); // cid -> { at, promise } + let roomOpenSeq = 0; + function roomFaces(jm) { + const room = ensureRoom(jm); + const ch = roomChannels(room)[0]; + if (!ch) return []; + // everyone the channel paints, newest speakers first + const msgs = [...(room.byChannel.get(ch.id)?.values() || [])] + .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor)); + return [...new Set(msgs.map((m) => m.author))].slice(0, 150); + } + function warmRoom(jm) { + const w = roomWarm.get(jm.community_id); + if (w && Date.now() - w.at < 30_000) return w.promise; + const deadline = Date.now() + 8000; + let promise; + try { promise = Promise.all(roomFaces(jm).map((pk) => warmAvatar(pk, deadline).catch(() => {}))); } + catch { promise = Promise.resolve(); } + roomWarm.set(jm.community_id, { at: Date.now(), promise }); + return promise; + } + async function openRoomWarm(jm, go) { + const seq = ++roomOpenSeq; + const from = ui.msgView; + await Promise.race([warmRoom(jm), new Promise((r) => setTimeout(r, ROOM_READY_MS))]); + // another tap, or the reader went elsewhere, while the faces loaded + if (seq !== roomOpenSeq || ui.msgView !== from || !ui.chatOpen) return; + go(); + render(); + } + function messageRows(room, chId) { const my = myPubkeys(); const msgs = [...(room.byChannel.get(chId)?.values() || [])] diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js new file mode 100644 index 0000000..8c39e59 --- /dev/null +++ b/tools/room-faces-test.js @@ -0,0 +1,55 @@ +// Opening a community must not paint everyone as a punk and then swap in +// their real pictures: faces are warmed on hover/press (and while the list +// sits there) and the tap waits a moment for them. A fresh wallet has no +// profiles cached — the worst case — against the real coinos community. +// +// Run: bun tools/room-faces-test.js [--no-hover] +import puppeteer from 'puppeteer-core'; +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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5277, fetch: () => 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:5277/', { 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'); + await click('skip verification'); + await waitText('receive', 20000); + await page.evaluate(() => [...document.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click()); + await waitText('members', 20000); + // let the community's history arrive (the list shows before it has) + await sleep(process.argv.includes('--no-hover') ? 1500 : 6000); + const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')] + .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'); + await page.evaluate((fs) => { + const face = new Function('return ' + fs)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } }); + mo.observe(document.body, { childList: true, subtree: true }); + }, face.toString()); + const row = await page.evaluateHandle(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => /members/.test(r.innerText))); + const t0 = await page.evaluate(() => performance.now()); + if (!process.argv.includes('--no-hover')) await row.hover(); + await row.click(); + for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(6000); + const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]); + const last = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length; + console.log(' first paint after', Math.round(at - t0), 'ms:', JSON.stringify(tally(first)), ' settled:', JSON.stringify(tally(last))); + check('the room opens with its messages', (first || []).length > 0); + check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped'); + check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms'); +} finally { await browser.close(); server.stop(true); } +function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; } +console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
