From 508ac753637c0f3e6ce8169870f1ab13c6d5da9b Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 2 Mar 2026 10:54:11 +0100 Subject: [PATCH] feat: new posts banner, mark-as-read on scroll, unread filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Poll every 30s for new items, show sticky "N new posts — Load" banner - IntersectionObserver marks cards as read at 50% visibility, batches to server every 5s - Read cards fade to 70% opacity, full opacity on hover - "Unread" toggle in tab bar filters to unread-only items - New API: GET /api/timeline/count-new, POST /api/timeline/mark-read Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06 --- assets/reader-infinite-scroll.js | 150 +++++++++++++++++++++++++++++++ assets/reader.css | 57 ++++++++++++ index.js | 4 +- lib/controllers/api-timeline.js | 70 ++++++++++++++- lib/controllers/reader.js | 16 +++- lib/storage/timeline.js | 58 ++++++++++++ views/activitypub-reader.njk | 41 +++++++-- views/partials/ap-item-card.njk | 2 +- 8 files changed, 381 insertions(+), 17 deletions(-) diff --git a/assets/reader-infinite-scroll.js b/assets/reader-infinite-scroll.js index ae230ec..5f616e6 100644 --- a/assets/reader-infinite-scroll.js +++ b/assets/reader-infinite-scroll.js @@ -186,4 +186,154 @@ document.addEventListener("alpine:init", () => { if (this.observer) this.observer.disconnect(); }, })); + + /** + * New posts banner — polls for new items every 30s, shows "N new posts" banner. + */ + // eslint-disable-next-line no-undef + Alpine.data("apNewPostsBanner", () => ({ + count: 0, + newest: null, + tab: "", + mountPath: "", + _interval: null, + + init() { + const el = this.$el; + this.newest = el.dataset.newest || null; + this.tab = el.dataset.tab || "notes"; + this.mountPath = el.dataset.mountPath || ""; + + if (!this.newest) return; + + this._interval = setInterval(() => this.poll(), 30000); + }, + + async poll() { + if (!this.newest) return; + try { + const params = new URLSearchParams({ after: this.newest, tab: this.tab }); + const res = await fetch( + `${this.mountPath}/admin/reader/api/timeline/count-new?${params}`, + { headers: { Accept: "application/json" } }, + ); + if (!res.ok) return; + const data = await res.json(); + this.count = data.count || 0; + } catch { + // Silently ignore polling errors + } + }, + + async loadNew() { + if (!this.newest || this.count === 0) return; + try { + const params = new URLSearchParams({ after: this.newest, tab: this.tab }); + const res = await fetch( + `${this.mountPath}/admin/reader/api/timeline?${params}`, + { headers: { Accept: "application/json" } }, + ); + if (!res.ok) return; + const data = await res.json(); + + const timeline = document.getElementById("ap-timeline"); + if (data.html && timeline) { + timeline.insertAdjacentHTML("afterbegin", data.html); + // Update newest cursor to the first item's published date + const firstCard = timeline.querySelector(".ap-card"); + if (firstCard) { + const timeEl = firstCard.querySelector("time[datetime]"); + if (timeEl) this.newest = timeEl.getAttribute("datetime"); + } + } + + this.count = 0; + } catch { + // Silently ignore load errors + } + }, + + destroy() { + if (this._interval) clearInterval(this._interval); + }, + })); + + /** + * Read tracking — IntersectionObserver marks cards as read on 50% visibility. + * Batches UIDs and flushes to server every 5 seconds. + */ + // eslint-disable-next-line no-undef + Alpine.data("apReadTracker", () => ({ + _observer: null, + _batch: [], + _flushTimer: null, + _mountPath: "", + _csrfToken: "", + + init() { + const el = this.$el; + this._mountPath = el.dataset.mountPath || ""; + this._csrfToken = el.dataset.csrfToken || ""; + + this._observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const card = entry.target; + const uid = card.dataset.uid; + if (uid && !card.classList.contains("ap-card--read")) { + card.classList.add("ap-card--read"); + this._batch.push(uid); + } + this._observer.unobserve(card); + } + } + }, + { threshold: 0.5 }, + ); + + // Observe all existing cards + this._observeCards(); + + // Watch for new cards added by infinite scroll + this._mutationObserver = new MutationObserver(() => this._observeCards()); + this._mutationObserver.observe(el, { childList: true, subtree: true }); + + // Flush batch every 5 seconds + this._flushTimer = setInterval(() => this._flush(), 5000); + }, + + _observeCards() { + const cards = this.$el.querySelectorAll(".ap-card[data-uid]:not(.ap-card--read)"); + for (const card of cards) { + this._observer.observe(card); + } + }, + + async _flush() { + if (this._batch.length === 0) return; + const uids = [...this._batch]; + this._batch = []; + + try { + await fetch(`${this._mountPath}/admin/reader/api/timeline/mark-read`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this._csrfToken, + }, + body: JSON.stringify({ uids }), + }); + } catch { + // Non-critical — items will be re-marked on next view + } + }, + + destroy() { + if (this._observer) this._observer.disconnect(); + if (this._mutationObserver) this._mutationObserver.disconnect(); + if (this._flushTimer) clearInterval(this._flushTimer); + this._flush(); // Final flush on teardown + }, + })); }); diff --git a/assets/reader.css b/assets/reader.css index da42682..dfe19db 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -2343,6 +2343,63 @@ visibility: hidden; } +/* ========================================================================== + New Posts Banner + ========================================================================== */ + +.ap-new-posts-banner { + left: 0; + position: sticky; + right: 0; + top: 0; + z-index: 10; +} + +.ap-new-posts-banner__btn { + background: var(--color-primary); + border: none; + border-radius: var(--border-radius-small); + color: var(--color-on-primary); + cursor: pointer; + display: block; + font-family: inherit; + font-size: var(--font-size-s); + margin: 0 auto var(--space-s); + padding: var(--space-xs) var(--space-m); + text-align: center; + width: auto; +} + +.ap-new-posts-banner__btn:hover { + opacity: 0.9; +} + +/* ========================================================================== + Read State + ========================================================================== */ + +.ap-card--read { + opacity: 0.7; + transition: opacity 0.3s ease; +} + +.ap-card--read:hover { + opacity: 1; +} + +/* ========================================================================== + Unread Toggle + ========================================================================== */ + +.ap-unread-toggle { + margin-left: auto; +} + +.ap-unread-toggle--active { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + font-weight: var(--font-weight-bold); +} + /* ========================================================================== Quote Embeds ========================================================================== */ diff --git a/index.js b/index.js index de974f8..559f65f 100644 --- a/index.js +++ b/index.js @@ -61,7 +61,7 @@ import { } from "./lib/controllers/featured-tags.js"; import { resolveController } from "./lib/controllers/resolve.js"; import { tagTimelineController } from "./lib/controllers/tag-timeline.js"; -import { apiTimelineController } from "./lib/controllers/api-timeline.js"; +import { apiTimelineController, countNewController, markReadController } from "./lib/controllers/api-timeline.js"; import { exploreController, exploreApiController, @@ -239,6 +239,8 @@ export default class ActivityPubEndpoint { router.get("/admin/reader", readerController(mp)); router.get("/admin/reader/tag", tagTimelineController(mp)); router.get("/admin/reader/api/timeline", apiTimelineController(mp)); + router.get("/admin/reader/api/timeline/count-new", countNewController()); + router.post("/admin/reader/api/timeline/mark-read", markReadController()); router.get("/admin/reader/explore", exploreController(mp)); router.get("/admin/reader/api/explore", exploreApiController(mp)); router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp)); diff --git a/lib/controllers/api-timeline.js b/lib/controllers/api-timeline.js index 23b3d24..ffc5e07 100644 --- a/lib/controllers/api-timeline.js +++ b/lib/controllers/api-timeline.js @@ -2,8 +2,8 @@ * JSON API timeline endpoint — returns pre-rendered HTML cards for infinite scroll AJAX loads. */ -import { getTimelineItems } from "../storage/timeline.js"; -import { getToken } from "../csrf.js"; +import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js"; +import { getToken, validateToken } from "../csrf.js"; import { getMutedUrls, getMutedKeywords, @@ -26,7 +26,8 @@ export function apiTimelineController(mountPath) { const limit = 20; // Build storage query options (same logic as readerController) - const options = { before, limit }; + const unread = request.query.unread === "1"; + const options = { before, limit, unread }; if (tag) { options.tag = tag; @@ -168,3 +169,66 @@ export function apiTimelineController(mountPath) { } }; } + +/** + * GET /admin/reader/api/timeline/count-new — count items newer than a given date. + */ +export function countNewController() { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collections = { + ap_timeline: application?.collections?.get("ap_timeline"), + }; + + const after = request.query.after; + const tab = request.query.tab || "notes"; + + const options = {}; + if (tab === "notes") { + options.type = "note"; + options.excludeReplies = true; + } else if (tab === "articles") { + options.type = "article"; + } else if (tab === "boosts") { + options.type = "boost"; + } + + const count = await countNewItems(collections, after, options); + response.json({ count }); + } catch (error) { + next(error); + } + }; +} + +/** + * POST /admin/reader/api/timeline/mark-read — mark items as read by UID array. + */ +export function markReadController() { + return async (request, response, next) => { + try { + if (!validateToken(request)) { + return response.status(403).json({ success: false, error: "Invalid CSRF token" }); + } + + const { uids } = request.body; + if (!Array.isArray(uids) || uids.length === 0) { + return response.status(400).json({ success: false, error: "Missing uids array" }); + } + + // Cap batch size to prevent abuse + const batch = uids.slice(0, 100).filter((uid) => typeof uid === "string"); + + const { application } = request.app.locals; + const collections = { + ap_timeline: application?.collections?.get("ap_timeline"), + }; + + const updated = await markItemsRead(collections, batch); + response.json({ success: true, updated }); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index 6fd15de..fed54f2 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -2,7 +2,7 @@ * Reader controller — shows timeline of posts from followed accounts. */ -import { getTimelineItems } from "../storage/timeline.js"; +import { getTimelineItems, countUnreadItems } from "../storage/timeline.js"; import { getNotifications, getUnreadNotificationCount, @@ -48,8 +48,11 @@ export function readerController(mountPath) { const after = request.query.after; const limit = Number.parseInt(request.query.limit || "20", 10); + // Unread filter + const unread = request.query.unread === "1"; + // Build query options - const options = { before, after, limit }; + const options = { before, after, limit, unread }; // Tab filtering if (tab === "notes") { @@ -141,8 +144,11 @@ export function readerController(mountPath) { }); } - // Get unread notification count for badge - const unreadCount = await getUnreadNotificationCount(collections); + // Get unread notification count for badge + unread timeline count for toggle + const [unreadCount, unreadTimelineCount] = await Promise.all([ + getUnreadNotificationCount(collections), + countUnreadItems(collections), + ]); // Get interaction state for liked/boosted indicators // Interactions are keyed by canonical AP uid (new) or display url (legacy). @@ -206,9 +212,11 @@ export function readerController(mountPath) { readerParent: { href: mountPath, text: response.locals.__("activitypub.title") }, items, tab, + unread, before: result.before, after: result.after, unreadCount, + unreadTimelineCount, interactionMap, csrfToken, mountPath, diff --git a/lib/storage/timeline.js b/lib/storage/timeline.js index 751473f..515f6c9 100644 --- a/lib/storage/timeline.js +++ b/lib/storage/timeline.js @@ -107,6 +107,11 @@ export async function getTimelineItems(collections, options = {}) { query.category = { $regex: new RegExp(`^${escapedTag}$`, "i") }; } + // Unread-only filter + if (options.unread) { + query.read = { $ne: true }; + } + // Cursor pagination — published is stored as ISO string, so compare // as strings (lexicographic ISO 8601 comparison is correct for dates) if (options.before) { @@ -233,3 +238,56 @@ export async function cleanupTimelineByCount(collections, keepCount) { const cutoffDate = items[0].published; return await deleteOldTimelineItems(collections, cutoffDate); } + +/** + * Count timeline items newer than a given date + * @param {object} collections - MongoDB collections + * @param {string} after - ISO date string — count items published after this + * @param {object} [options] - Filter options + * @param {string} [options.type] - Filter by type + * @param {boolean} [options.excludeReplies] - Exclude replies + * @returns {Promise} Count of new items + */ +export async function countNewItems(collections, after, options = {}) { + const { ap_timeline } = collections; + if (!after || Number.isNaN(new Date(after).getTime())) return 0; + + const query = { published: { $gt: after } }; + if (options.type) query.type = options.type; + if (options.excludeReplies) { + query.$or = [ + { inReplyTo: null }, + { inReplyTo: "" }, + { inReplyTo: { $exists: false } }, + ]; + } + + return await ap_timeline.countDocuments(query); +} + +/** + * Mark timeline items as read + * @param {object} collections - MongoDB collections + * @param {string[]} uids - Array of item UIDs to mark as read + * @returns {Promise} Number of items updated + */ +export async function markItemsRead(collections, uids) { + const { ap_timeline } = collections; + if (!uids || uids.length === 0) return 0; + + const result = await ap_timeline.updateMany( + { uid: { $in: uids }, read: { $ne: true } }, + { $set: { read: true } }, + ); + return result.modifiedCount; +} + +/** + * Count unread timeline items + * @param {object} collections - MongoDB collections + * @returns {Promise} + */ +export async function countUnreadItems(collections) { + const { ap_timeline } = collections; + return await ap_timeline.countDocuments({ read: { $ne: true } }); +} diff --git a/views/activitypub-reader.njk b/views/activitypub-reader.njk index dfb5bb2..14c4ac6 100644 --- a/views/activitypub-reader.njk +++ b/views/activitypub-reader.njk @@ -64,32 +64,57 @@ {# Tab navigation #} - {# Timeline items #} + {# New posts banner — polls every 30s, shows count of new items #} + {% if items.length > 0 %} +
+ +
+ {% endif %} + + {# Timeline items with read tracking #} {% if items.length > 0 %}
+ data-before="{{ before if before else '' }}" + data-csrf-token="{{ csrfToken }}" + x-data="apReadTracker()" + x-init="init()"> {% for item in items %} {% include "partials/ap-item-card.njk" %} {% endfor %} diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index 50705a9..7e92006 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -6,7 +6,7 @@ {% set hasCardMedia = (item.photo and item.photo.length > 0) or (item.video and item.video.length > 0) or (item.audio and item.audio.length > 0) %} {% if hasCardContent or hasCardTitle or hasCardMedia %} -
+
{# Moderation content warning wrapper #} {% if item._moderated %} {% if item._moderationReason == "muted_account" %}