feat: new posts banner, mark-as-read on scroll, unread filter

- 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
This commit is contained in:
Ricardo
2026-03-02 10:54:11 +01:00
parent 68aadb6ff2
commit 508ac75363
8 changed files with 381 additions and 17 deletions

View File

@@ -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
},
}));
});

View File

@@ -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
========================================================================== */

View File

@@ -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));

View File

@@ -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);
}
};
}

View File

@@ -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,

View File

@@ -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<number>} 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>} 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<number>}
*/
export async function countUnreadItems(collections) {
const { ap_timeline } = collections;
return await ap_timeline.countDocuments({ read: { $ne: true } });
}

View File

@@ -64,32 +64,57 @@
{# Tab navigation #}
<nav class="ap-tabs" role="tablist">
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=notes{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.notes") }}
</a>
<a href="?tab=articles" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=articles{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.articles") }}
</a>
<a href="?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=replies{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.replies") }}
</a>
<a href="?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=boosts{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.boosts") }}
</a>
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=media{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.media") }}
</a>
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=all{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.all") }}
</a>
<a href="?tab={{ tab }}{% if not unread %}&unread=1{% endif %}" class="ap-tab ap-unread-toggle{% if unread %} ap-unread-toggle--active{% endif %}" title="{% if unread %}Show all posts{% else %}Show unread only{% endif %}">
{% if unread %}
All posts
{% else %}
Unread{% if unreadTimelineCount %} ({{ unreadTimelineCount }}){% endif %}
{% endif %}
</a>
</nav>
{# Timeline items #}
{# New posts banner — polls every 30s, shows count of new items #}
{% if items.length > 0 %}
<div class="ap-new-posts-banner"
x-data="apNewPostsBanner()"
data-newest="{{ items[0].published }}"
data-tab="{{ tab }}"
data-mount-path="{{ mountPath }}"
x-show="count > 0"
x-cloak>
<button class="ap-new-posts-banner__btn" @click="loadNew()">
<span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load
</button>
</div>
{% endif %}
{# Timeline items with read tracking #}
{% if items.length > 0 %}
<div class="ap-timeline"
id="ap-timeline"
data-mount-path="{{ mountPath }}"
data-before="{{ before if before else '' }}">
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 %}

View File

@@ -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 %}
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}">
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}{% if item.read %} ap-card--read{% endif %}" data-uid="{{ item.uid }}">
{# Moderation content warning wrapper #}
{% if item._moderated %}
{% if item._moderationReason == "muted_account" %}