aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 04:32:47 +0000
committerAdam Soltys <[email protected]>2026-10-13 04:32:47 +0000
commit492bc22788ba8c0e1589591d687b741021d2368e (patch)
treeb26f6cd2e40ef4b17fa72f5f16f90170ae02788c /tools
parentf8dedaec828cecb02412139a7653df6b5c94dbe8 (diff)
downloadcoinosv3-492bc22788ba8c0e1589591d687b741021d2368e.tar.gz
coinosv3-492bc22788ba8c0e1589591d687b741021d2368e.zip
Feed: a post you've read isn't boosted back to the top. Adam found Gigi's post (read several times that day) at the head of his feed under "47 reposted": a repost takes its place in the feed by when it was boosted, and the only check against showing a post twice was the feed's own window — the 80 posts in memory, the 30 cached — so once the copy he'd read was out of it (a reload, a long scroll), the next boost brought the post back as new. Now a post is remembered as read on this device (per wallet, two weeks, 5000 at most) once its row has held half itself, or half the screen for a tall one, in view for a second, or it was shown in the media pager — remembered by the row it was read in (the post, or the repost it came under). A later repost of it under any other row stays out of the feed (mergeFeed); the row that was read keeps its place, so scrolling back still finds it, and your own reposts show. tools/feed-reboost-test.js (read after a second in view, not at a glance; a live boost of the read post stays out while one of an unread post lands; after a cold reload another boost of it stays out and the post is where it was) — with the check off it fails as Adam saw, the post at the top under both boosts. feed-stability's two failures are on HEAD before this too.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
Diffstat (limited to 'tools')
-rw-r--r--tools/feed-reboost-test.js147
1 files changed, 147 insertions, 0 deletions
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);