aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js47
-rw-r--r--tools/room-faces-test.js55
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);