From 492bc22788ba8c0e1589591d687b741021d2368e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 13 Oct 2026 04:32:47 +0000 Subject: 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt --- src/features/messages.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) (limited to 'src') 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); -- cgit v1.2.3