diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 20:48:38 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 20:48:38 -0700 |
| commit | 557831a04d71a155699124395f55d394ad5b2f4f (patch) | |
| tree | 41d1fb0523692d2db1914239ef48da94cc972c2f | |
| parent | 095caadcba5a63f5969e50777d7e482c1d32174e (diff) | |
| download | coinosv3-557831a04d71a155699124395f55d394ad5b2f4f.tar.gz coinosv3-557831a04d71a155699124395f55d394ad5b2f4f.zip | |
Messages: a tap on a conversation's face opens THAT person. The avatar's tap handlers were attached with addEventListener, holding the person it was first drawn for; the morph transplants only h()'s on* properties, so when a new message re-sorted the list and the rows were patched in place, a face kept its old handler — a tap on Bill opened someone else's profile, and once that page had opened, a fresh render hid the evidence. The avatar now sets onclick/onpointerdown claimed in _evs (transplanted like any h() handler), and conversation rows are keyed by person so a re-sort moves them. tools/dm-row-resort-test.js (on the old code, Bill's face opened Alice).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
| -rw-r--r-- | src/features/messages.js | 15 | ||||
| -rw-r--r-- | tools/dm-row-resort-test.js | 110 |
2 files changed, 123 insertions, 2 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 90b080b..b3066c0 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3699,8 +3699,15 @@ export function messagesFeature(ctx) { // and unlike prefetching every row on paint (which cost sixty requests // for twenty rows and starved the profile batch) this only ever asks // about the person actually being tapped. - node.addEventListener('pointerdown', () => { try { prefetchProfilePage(pk); } catch {} }, { passive: true }); - node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); + // As on* properties claimed in _evs, the way h() attaches handlers, so + // the morph transplants them: a list that re-sorts (a new message + // lifting a conversation to the top) patches this node into a + // DIFFERENT person's avatar, and an addEventListener'd handler stayed + // behind holding the old person — a tap on Bill's row opened someone + // else's profile, until the next fresh render. + node.onpointerdown = () => { try { prefetchProfilePage(pk); } catch {} }; + node.onclick = (e) => { e.stopPropagation(); openProfile(pk); }; + (node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick'); } return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; @@ -12407,6 +12414,9 @@ export function messagesFeature(ctx) { ? h('div', { class: 'list' }, shownDms.map((row) => row.g ? wnGroupRow(row.g) : (({ peer, last, unread }) => h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), + // keyed: a new message re-sorts the list, and the row moves + // with its person instead of being repainted as someone else + 'data-key': 'dm:' + peer, onClick: () => { ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); }, }, avatar(peer), @@ -12838,6 +12848,7 @@ export function messagesFeature(ctx) { for (const pk of wn.accounts(g).slice(0, 12)) profileOf(pk); return h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), + 'data-key': 'grp:' + g.id, onClick: () => { ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; ui.msgMembers = false; render(); }, }, h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), diff --git a/tools/dm-row-resort-test.js b/tools/dm-row-resort-test.js new file mode 100644 index 0000000..9e400bc --- /dev/null +++ b/tools/dm-row-resort-test.js @@ -0,0 +1,110 @@ +// Messages list: a new message lifts its conversation to the top. The rows +// below are patched in place, and the avatar's tap used to keep the person +// it was first drawn for — a tap on Bill's face opened someone else's +// profile. After the re-sort, each row's avatar and row open their own. +// Run: bun tools/dm-row-resort-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { makeDM } from '../src/dm.js'; +import { npubEncode } from 'nostr-tools/nip19'; +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 key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; +const ALICE = key(), BILL = key(); +const now = Math.floor(Date.now() / 1000); +const PROFILES = [ + finalizeEvent({ kind: 0, created_at: now - 9, tags: [], content: JSON.stringify({ name: 'Alice' }) }, ALICE.sk), + finalizeEvent({ kind: 0, created_at: now - 9, tags: [], content: JSON.stringify({ name: 'Bill' }) }, BILL.sk), +]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, 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(); +await page.evaluateOnNewDocument((profiles) => { + window.__subs = []; + 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 fs = m.slice(2); + if (fs.some((f) => (f.kinds || []).includes(1059))) window.__subs.push([ws, m[1]]); + const out = profiles.filter((p) => fs.some((f) => (f.kinds || []).includes(0) && (f.authors || []).includes(p.pubkey))); + setTimeout(() => { + for (const e of out) 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); + window.__push = (ev) => { let n = 0; for (const [ws, id] of window.__subs) if (ws.readyState === 1) { ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); n++; } return n; }; +}, 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)).includes(x)) return true; await sleep(250); } return false; }; +const order = () => page.evaluate(() => [...document.querySelectorAll('.chat-thread-row .chat-name')].map((n) => n.textContent).filter((x) => x === 'Alice' || x === 'Bill')); +const openMessages = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click()); await sleep(1500); }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { 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 ME = getPublicKey(privateKeyFromSeedWords(mn)); + const base = cacheKeyFor(mn + '\n'); + // Alice wrote last, Bill before her + await page.evaluate(([k, a, b]) => { + const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); + const t = Math.floor(Date.now() / 1000); + st.dms = { [a]: [{ id: 'a'.repeat(64), from: a, text: 'hi from alice', t: t - 60 }], [b]: [{ id: 'b'.repeat(64), from: b, text: 'old one from bill', t: t - 600 }] }; + localStorage.setItem(k + ':messages', JSON.stringify(st)); + }, [base, ALICE.pk, BILL.pk]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('Receive', 20000); + await openMessages(); + let ready = false; + for (let i = 0; i < 40 && !ready; i++) { ready = JSON.stringify(await order()) === '["Alice","Bill"]'; if (!ready) await sleep(250); } + check('Alice is on top, Bill below', ready, JSON.stringify(await order())); + + // Bill writes: his conversation goes to the top + const { toPeer } = await makeDM(BILL.sk, ME, 'a new one from bill'); + const n = await page.evaluate((e) => window.__push(e), toPeer); + let moved = false; + for (let i = 0; i < 40 && !moved; i++) { await sleep(250); moved = JSON.stringify(await order()) === '["Bill","Alice"]'; } + check('Bill\'s new message lifts him to the top', moved, n + ' sub(s), ' + JSON.stringify(await order())); + + // his face opens HIS profile + await page.evaluate(() => { const r = [...document.querySelectorAll('.chat-thread-row')].find((x) => x.querySelector('.chat-name')?.textContent === 'Bill'); r.querySelector('.chat-avatar').click(); }); + await sleep(800); + const path = await page.evaluate(() => location.pathname); + check('a tap on Bill\'s face opens Bill\'s profile', path.includes(npubEncode(BILL.pk)), path === '/' + npubEncode(ALICE.pk) ? 'opened ALICE' : path); + await page.goBack().catch(() => {}); await sleep(800); + if (!(await page.$('.chat-thread-row'))) await openMessages(); + // and his row opens his conversation + await page.evaluate(() => { const r = [...document.querySelectorAll('.chat-thread-row')].find((x) => x.querySelector('.chat-name')?.textContent === 'Bill'); r.querySelector('.chat-preview').click(); }); + check('a tap on Bill\'s row opens the conversation with Bill', await waitText('a new one from bill', 5000) && !(await page.$('.profile-avatar'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a re-sorted conversation list opens the right person' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
