aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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 /src/features
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 'src/features')
-rw-r--r--src/features/messages.js79
1 files changed, 78 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);