From f3d03c642bef06b3afc8a3038136c5e0fc1e21f7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 13 Oct 2026 13:04:52 +0000 Subject: Profiles show boosts as boosts, and their saved copy keeps each post's kind A boost that reached a profile from the feed was saved as a kind 1 note, so reopened it drew its raw JSON as a reply; video events and polls lost their kind and tags the same way. The saved copy now keeps kinds (boosts whole), old copies are dropped, and profiles ask for boosts too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt --- src/features/messages.js | 18 ++++++++--- tools/profile-repost-test.js | 76 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tools/profile-repost-test.js diff --git a/src/features/messages.js b/src/features/messages.js index 1d9fd61..3cb94fd 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -566,7 +566,8 @@ export function messagesFeature(ctx) { } let repostBy = null; // set while noteRow draws a repost's note // A profile shows its articles (NIP-23) among the notes, as Amethyst does. - const PROFILE_KINDS = [...FEED_KINDS, 30023]; + // Their boosts too, under " reposted", as in a feed. + const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST]; // An edited article is a new event at the same address: keep the newest. const newestArticles = (evs) => { const best = new Map(); @@ -3825,13 +3826,16 @@ export function messagesFeature(ctx) { const pageCache = () => { try { const c = wallet.loadFeatureState(PROF_PAGE_CACHE, {}) || {}; - return { full: c.full || {}, notes: c.notes || {} }; + // (notes saved before nv 2 were all flattened to kind 1: a boost's + // JSON, a clip without its imeta — not worth keeping) + return { full: c.full || {}, notes: c.nv === 2 ? c.notes || {} : {} }; } catch { return { full: {}, notes: {} }; } }; function persistPage(kind, pk, value) { try { const c = pageCache(); c[kind][pk] = { t: Date.now(), v: value }; + c.nv = 2; for (const k of ['full', 'notes']) { const keys = Object.keys(c[k]).sort((a, b) => c[k][b].t - c[k][a].t); for (const drop of keys.slice(10)) delete c[k][drop]; @@ -3842,14 +3846,20 @@ export function messagesFeature(ctx) { // (an article keeps its kind, its card's tags and, near enough, all of // itself: a tap opens it to read) const ARTICLE_TAGS = ['d', 'title', 'summary', 'image', 'published_at']; + // (a note keeps its kind and what draws it: its clips, a poll's options) + const NOTE_TAGS = ['e', 'p', 'q', 'd', 'imeta', 'title', 'option', 'polltype', 'endsAt', 'relay', 'emoji', 'content-warning']; const slimNote = (ev) => ev.kind === 30023 ? { id: ev.id, pubkey: ev.pubkey, kind: 30023, created_at: ev.created_at, content: String(ev.content || '').slice(0, 30000), tags: (ev.tags || []).filter((x) => ARTICLE_TAGS.includes(x[0])), + } : ev.kind === REPOST ? { + // a boost whole: the note inside is checked against its signature + id: ev.id, pubkey: ev.pubkey, kind: REPOST, created_at: ev.created_at, + content: String(ev.content || ''), tags: (ev.tags || []).filter((x) => x[0] === 'e' || x[0] === 'p'), } : { - id: ev.id, pubkey: ev.pubkey, kind: 1, created_at: ev.created_at, + id: ev.id, pubkey: ev.pubkey, kind: ev.kind, created_at: ev.created_at, content: String(ev.content || '').slice(0, 3000), - tags: (ev.tags || []).filter((x) => x[0] === 'e' || x[0] === 'p'), + tags: (ev.tags || []).filter((x) => NOTE_TAGS.includes(x[0])), }; function fetchFullProfile(pk) { diff --git a/tools/profile-repost-test.js b/tools/profile-repost-test.js new file mode 100644 index 0000000..7825efe --- /dev/null +++ b/tools/profile-repost-test.js @@ -0,0 +1,76 @@ +// A profile lists the person's boosts, as a feed does: the note under +// " reposted", never the boost's raw JSON. And its saved copy keeps +// each post's kind: a boost reopened from cache (relays silent) is still a +// boost, a video event still has its clip — they were all saved as kind 1 +// notes, so a boost came back as "reply · {"content":…}". +// Run: bun tools/profile-repost-test.js +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +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 ME = key(), THEM = key(); +const now = Math.floor(Date.now() / 1000); +const THEIRS = finalizeEvent({ kind: 1, created_at: now - 900, tags: [], content: 'their offer to grow nostr' }, THEM.sk); +const EVS = [ + finalizeEvent({ kind: 1, created_at: now - 100, tags: [], content: 'my own words here' }, ME.sk), + finalizeEvent({ kind: 6, created_at: now - 200, tags: [['e', THEIRS.id, '', 'mention'], ['p', THEM.pk]], content: JSON.stringify(THEIRS) }, ME.sk), + finalizeEvent({ kind: 21, created_at: now - 300, tags: [['title', 'My clip title'], ['imeta', 'url https://example.invalid/v/clipfile', 'm video/mp4', 'dim 180x320']], content: 'about my clip' }, ME.sk), +]; +const PROFILES = [ + finalizeEvent({ kind: 0, created_at: now - 10, tags: [], content: JSON.stringify({ name: 'Booster' }) }, ME.sk), + finalizeEvent({ kind: 0, created_at: now - 10, tags: [], content: JSON.stringify({ name: 'Offerer' }) }, THEM.sk), +]; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); 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((fx) => { + const Real = window.WebSocket; + const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f.authors || f.authors.includes(ev.pubkey)) && (!f.ids || f.ids.includes(ev.id)) && !Object.keys(f).some((k) => k.startsWith('#')); + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { const fs = m.slice(2); setTimeout(() => { if (!sessionStorage.getItem('silent')) for (const ev of fx.filter((e) => fs.some((f) => fits(e, f)))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); }, 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, [...EVS, ...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.toLowerCase())) return true; await sleep(250); } return false; }; +const rows = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-zap-post]')].map((r) => ({ text: r.innerText, post: r.getAttribute('data-zap-post'), video: !!r.querySelector('video.note-video') }))); +async function look(label) { + console.log('\n[' + label + ']'); + check('the profile shows its posts', await waitText('my own words here', 20000)); + await sleep(1200); + const r = await rows(); + const boost = r.find((x) => x.text.includes('their offer to grow nostr')); + check('the boost is the note it boosts, under "Booster reposted"', !!boost && /Booster reposted/.test(boost.text) && boost.post === THEIRS.id, JSON.stringify(boost)); + check('…as Offerer\'s post, not a reply', !!boost && /Offerer/.test(boost.text) && !/\breply\b/i.test(boost.text.split('\n').slice(0, 4).join(' '))); + check('no raw JSON anywhere', !r.some((x) => /"content":|"sig":/.test(x.text)), r.map((x) => x.text.slice(0, 40)).join(' | ')); + const clip = r.find((x) => x.text.includes('My clip title')); + check('the video event is still a clip', !!clip && clip.video, JSON.stringify(clip)); +} +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'); + await click('skip verification'); + await waitText('receive', 20000); + await page.goto(server.url.href + npubEncode(ME.pk), { waitUntil: 'domcontentloaded' }); + await look('from the relays'); + await sleep(1500); // (the page cache is written behind a debounce) + const nav = await page.evaluate(() => history.state); + await page.evaluate(() => sessionStorage.setItem('silent', '1')); + // reopened in a booted app (a cold deep link opens before the saved pages are read) + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); await sleep(1500); + await page.evaluate(([st, u]) => { history.pushState(st, '', u); dispatchEvent(new PopStateEvent('popstate', { state: st })); }, [nav, '/' + npubEncode(ME.pk)]); + await look('reopened from its saved copy, the relays silent'); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a profile shows boosts as boosts, and keeps them so' : '\n❌ failed'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3