diff options
| -rw-r--r-- | src/features/messages.js | 79 | ||||
| -rw-r--r-- | tools/feed-reboost-test.js | 147 |
2 files changed, 225 insertions, 1 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index eb55d88..f24e935 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6670,6 +6670,81 @@ export function messagesFeature(ctx) { finally { feedPaint = prev; } } + // ---- read posts: a boost doesn't bring them back ----------------------- + // A post you've read (its row held on screen for a moment, or shown in the + // media pager) is remembered on this device, by the row it was read in: + // the post itself, or the repost it came under. A later repost of it — + // someone boosting it again, hours on — would put it back at the top of + // the feed as if it were new; it stays out. The row you read keeps its + // place, so scrolling back down still finds it. Your own reposts show. + const SEEN_LS = 'coinos-seen'; + const SEEN_MAX = 5000, SEEN_DAYS = 14; + const seenLsKey = () => SEEN_LS + ':' + (wallet._cacheKey ? wallet._cacheKey() : ''); + let seenNotes = null, seenKey = null, seenSave = 0; // note id -> [row id, seen at (s)] + function seenMap() { + const k = seenLsKey(); + if (seenNotes && seenKey === k) return seenNotes; + seenKey = k; seenNotes = new Map(); + try { + const cut = Date.now() / 1000 - SEEN_DAYS * 86400; + for (const [id, row, t] of JSON.parse(localStorage.getItem(k) || '[]')) if (t > cut) seenNotes.set(id, [row, t]); + } catch {} + return seenNotes; + } + function markSeen(row) { + const note = row && (row.kind === REPOST ? repostInner(row) : row); + if (!note) return; + const m = seenMap(); + if (m.has(note.id)) return; + m.set(note.id, [row.id, Math.floor(Date.now() / 1000)]); + if (m.size > SEEN_MAX) for (const id of [...m.keys()].slice(0, m.size - SEEN_MAX)) m.delete(id); + const k = seenKey; + clearTimeout(seenSave); + seenSave = setTimeout(() => { + try { localStorage.setItem(k, JSON.stringify([...seenMap()].map(([id, [r, t]]) => [id, r, t]))); } catch {} + }, 1000); + } + // a repost of a post already read under another row (the post itself, or + // someone else's repost of it) + function reboosted(e) { + if (!e || e.kind !== REPOST || isMe(e.pubkey)) return false; + const note = repostInner(e); + const was = note && seenMap().get(note.id); + return !!was && was[0] !== e.id; + } + // Read: at least half the row (or half the screen, for a tall one) in view + // for a second without a break. + const SEEN_MS = 1000; + let seenObserver = null; + const seenTimers = new Map(); // row element -> timer + const seenWatched = new WeakSet(); + function watchSeen() { + if (typeof IntersectionObserver === 'undefined') return; + seenObserver ||= new IntersectionObserver((entries) => { + for (const en of entries) { + const el = en.target; + const enough = en.isIntersecting && en.intersectionRect.height >= Math.min(en.boundingClientRect.height, window.innerHeight) * 0.5; + if (!enough) { clearTimeout(seenTimers.get(el)); seenTimers.delete(el); if (!el.isConnected) seenObserver.unobserve(el); continue; } + if (seenTimers.has(el)) continue; + seenTimers.set(el, setTimeout(() => { + seenTimers.delete(el); + if (!el.isConnected) return; + const id = el.getAttribute('data-key'); + const ev = feed && feed.notes.find((x) => x.id === id); + if (ev) markSeen(ev); + seenObserver.unobserve(el); + }, SEEN_MS)); + } + }, { threshold: [0, 0.25, 0.5, 0.75, 1] }); + requestAnimationFrame(() => { + for (const el of document.querySelectorAll('.notes-feed > .row[data-key]')) { + if (seenWatched.has(el)) continue; + seenWatched.add(el); + seenObserver.observe(el); + } + }); + } + // A new post wears its tint until any of it reaches the screen; from that // moment it fades out (the row's 3s background transition, in the CSS) // whether you stay or scroll on. It used to wait for half the row to sit @@ -6709,7 +6784,7 @@ export function messagesFeature(ctx) { const notesIn = new Set([...c.notes, ...(c.deferred || [])].map((e) => noteOf(e)?.id).filter(Boolean)); const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); }; const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) staged.delete(n.id); }; - let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e) + let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); if (!add.length) return false; const def = feedDef(c.id); @@ -12140,6 +12215,7 @@ export function messagesFeature(ctx) { if (pager.fromFeed && video) el.classList.remove('show'); } pager.cur = e.target; + if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === e.target._rowId); markSeen(row || e.target._ev); } playSlide(e.target); pagerCounts(e.target); // near the end: ask the feed for more, the pager picks them up @@ -12229,6 +12305,7 @@ export function messagesFeature(ctx) { const visitor = isVisitor(); queueMicrotask(() => { syncMediaPager(); + if (mediaMode === 'all') watchSeen(); if (mediaMode !== 'all') watchMediaTiles(); }); const authors = feedAuthors(def); diff --git a/tools/feed-reboost-test.js b/tools/feed-reboost-test.js new file mode 100644 index 0000000..e22df91 --- /dev/null +++ b/tools/feed-reboost-test.js @@ -0,0 +1,147 @@ +// A post you've read doesn't come back to the top of the feed when someone +// boosts it again: a row held on screen for a second counts as read (kept on +// this device), and a later repost of that post — live, or found after a +// reload — stays out. A repost of a post you haven't read still lands, and +// the row you read keeps its place. +// Run: bun tools/feed-reboost-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +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 A = generateSecretKey(), B = generateSecretKey(), C = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const note = (sk, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, sk); +const repost = (sk, inner, ago) => finalizeEvent({ kind: 6, created_at: now - ago, tags: [['e', inner.id], ['p', inner.pubkey]], content: JSON.stringify(inner) }, sk); +const READ = note(A, 'the one already read, about weekend projects', 3000); +const UNREAD = note(C, 'never seen before, a fresh find', 5000); +const seeded = [ + ...Array.from({ length: 3 }, (_, i) => note(A, 'newer filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(4), 600 + i * 60)), + READ, + ...Array.from({ length: 8 }, (_, i) => note(B, 'older filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(4), 3600 + i * 60)), +]; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) 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(); +// The relays, in the page: they hold the seeded posts (plus whatever the +// test adds to localStorage's __relayExtra, for after a reload), answer a REQ +// by kinds/authors/until/limit, and __push() delivers a new event live to +// every open subscription it matches. +await page.evaluateOnNewDocument((seed) => { + let extra = []; + try { extra = JSON.parse(localStorage.getItem('__relayExtra') || '[]'); } catch {} + const evs = [...seed, ...extra]; + const match = (f, e, live) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && (live || ((!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since))) && !Object.keys(f).some((k) => k.startsWith('#')); + const subs = new Set(); + class FakeWS { + constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); } + reply(x) { setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5); } + send(raw) { + const m = JSON.parse(raw); + if (m[0] === 'REQ') { + const filters = m.slice(2).filter((f) => !f.cache); + subs.add({ ws: this, id: m[1], filters }); + for (const f of filters) { + const hits = evs.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hits.slice(0, f.limit) : hits) this.reply(['EVENT', m[1], e]); + } + this.reply(['EOSE', m[1]]); + } else if (m[0] === 'CLOSE') { for (const s of subs) if (s.ws === this && s.id === m[1]) subs.delete(s); } + else if (m[0] === 'EVENT') this.reply(['OK', m[1].id, true, '']); + else if (m[0] === 'COUNT') this.reply(['COUNT', m[1], { count: 0 }]); + } + close() { this.readyState = 3; for (const s of subs) if (s.ws === this) subs.delete(s); this.onclose && this.onclose({ code: 1000 }); } + addEventListener(t, fn) { this['on' + t] = fn; } + removeEventListener() {} + } + FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3; + window.WebSocket = FakeWS; + window.__push = (e) => { let n = 0; for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e, true))) { s.ws.reply(['EVENT', s.id, e]); n++; } return n; }; +}, seeded); +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; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const rows = (w) => page.evaluate((x) => [...document.querySelectorAll('.notes-feed > [data-key]')].filter((n) => n.textContent.includes(x)).map((n) => ({ top: Math.round(n.getBoundingClientRect().top + scrollY), reposted: !!n.querySelector('.note-reposted') })), w); +const seenIds = (base) => page.evaluate((k) => { try { return JSON.parse(localStorage.getItem('coinos-seen:' + k) || '[]').map((x) => x[0]); } catch { return []; } }, base); +// take in whatever is waiting above (the new-posts pill), and go to the top +const takeNew = async () => { + await sleep(1500); + await page.evaluate(() => document.querySelector('.feed-new-pill')?.click()); + await sleep(800); + await page.evaluate(() => window.scrollTo(0, 0)); + await sleep(500); +}; +const openFeed = async () => { + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + return waitFor(() => document.querySelectorAll('.notes-feed > [data-key]').length >= 6, null, 20000); +}; + +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'); localStorage.removeItem('__relayExtra'); }); + 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'); + await page.evaluate(([k, pks]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, [getPublicKey(A), getPublicKey(B)]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + check('the feed opens', await openFeed()); + check('the post to read is in it', await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('the one already read')), null, 15000)); + + console.log('\n[a row held on screen for a second is read]'); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('the one already read')).scrollIntoView({ block: 'center' })); + await sleep(400); + check('not read at a glance', !(await seenIds(base)).includes(READ.id)); + await sleep(2200); + check('read after a second in view', (await seenIds(base)).includes(READ.id), (await seenIds(base)).length + ' remembered'); + const beforeTop = (await rows('the one already read'))[0]?.top; + + console.log('\n[a boost, live: the read post stays put, an unread one lands]'); + await page.evaluate(() => window.scrollTo(0, 0)); await sleep(400); + const r1 = repost(B, READ, 30), r2 = repost(B, UNREAD, 20); + const pushed = await page.evaluate(([a, b]) => window.__push(a) + window.__push(b), [r1, r2]); + check('the boosts reach the feed\'s subscriptions', pushed > 0, pushed + ' deliveries'); + await takeNew(); + check('the boost of an unread post lands', await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('never seen before') && n.querySelector('.note-reposted')), null, 6000)); + let r = await rows('the one already read'); + check('the read post is shown once', r.length === 1, JSON.stringify(r)); + check('…as itself, not under the boost', r.length === 1 && !r[0].reposted); + + console.log('\n[after a reload, a different boost of it stays out too]'); + const r3 = repost(B, READ, 10); + await page.evaluate((x) => localStorage.setItem('__relayExtra', JSON.stringify(x)), [r1, r2, r3]); + await page.evaluate(() => { localStorage.removeItem(Object.keys(localStorage).find((k) => k.endsWith(':feedNotes')) || '_'); }); // a cold start: nothing cached + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + check('the feed opens again', await openFeed()); + await takeNew(); + await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('never seen before')), null, 8000); + r = await rows('the one already read'); + check('the read post is not boosted back to the top', r.every((x) => !x.reposted), JSON.stringify(r)); + check('…and it is still in the feed, in its place', r.length === 1 && Math.abs(r[0].top - beforeTop) < 400, JSON.stringify(r) + ' was ' + beforeTop); + check('the unread post\'s boost is still there', (await rows('never seen before')).length === 1); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a read post isn\'t boosted back to the top' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
