mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
@@ -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
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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
|
||||
========================================================================== */
|
||||
|
||||
4
index.js
4
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
Reference in New Issue
Block a user