aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--src/features/messages.js79
-rw-r--r--tools/feed-reboost-test.js147
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);