feat: notification management — clear, mark read, dismiss, TTL retention

- Add "Mark all read" and "Clear all" toolbar buttons on notifications page
- Add per-notification dismiss (×) button
- Remove auto-mark-all-as-read on page load (explicit action only)
- Add 30-day TTL index on createdAt for automatic notification cleanup
- New config option: notificationRetentionDays (default 30)
This commit is contained in:
Ricardo
2026-02-21 20:00:05 +01:00
parent 5ff3197493
commit d20dea2dc8
7 changed files with 213 additions and 7 deletions
+55
View File
@@ -746,6 +746,37 @@
Notifications
========================================================================== */
/* Notifications Toolbar */
.ap-notifications__toolbar {
display: flex;
gap: var(--space-s);
margin-bottom: var(--space-m);
}
.ap-notifications__btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
transition: all 0.2s ease;
}
.ap-notifications__btn:hover {
background: var(--color-offset-variant);
border-color: var(--color-outline-variant);
}
.ap-notifications__btn--danger {
color: var(--color-red45);
}
.ap-notifications__btn--danger:hover {
border-color: var(--color-red45);
}
.ap-notification {
align-items: flex-start;
background: var(--color-offset);
@@ -754,6 +785,7 @@
display: flex;
gap: var(--space-s);
padding: var(--space-m);
position: relative;
}
.ap-notification--unread {
@@ -803,6 +835,29 @@
font-size: var(--font-size-xs);
}
.ap-notification__dismiss {
position: absolute;
right: var(--space-xs);
top: var(--space-xs);
}
.ap-notification__dismiss-btn {
background: transparent;
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
font-size: var(--font-size-m);
line-height: 1;
padding: 2px 6px;
transition: all 0.2s ease;
}
.ap-notification__dismiss-btn:hover {
background: var(--color-offset-variant);
color: var(--color-red45);
}
/* ==========================================================================
Remote Profile
========================================================================== */
+16
View File
@@ -12,6 +12,9 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
import {
readerController,
notificationsController,
markAllNotificationsReadController,
clearAllNotificationsController,
deleteNotificationController,
composeController,
submitComposeController,
remoteProfileController,
@@ -79,6 +82,7 @@ const defaults = {
parallelWorkers: 5,
actorType: "Person",
timelineRetention: 1000,
notificationRetentionDays: 30,
};
export default class ActivityPubEndpoint {
@@ -189,6 +193,9 @@ export default class ActivityPubEndpoint {
router.get("/", dashboardController(mp));
router.get("/admin/reader", readerController(mp));
router.get("/admin/reader/notifications", notificationsController(mp));
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
router.get("/admin/reader/compose", composeController(mp, this));
router.post("/admin/reader/compose", submitComposeController(mp, this));
router.post("/admin/reader/like", likeController(mp, this));
@@ -835,6 +842,15 @@ export default class ActivityPubEndpoint {
{ background: true },
);
// TTL index for notification cleanup
const notifRetention = this.options.notificationRetentionDays;
if (notifRetention > 0) {
this._collections.ap_notifications.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
this._collections.ap_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
+95 -6
View File
@@ -7,8 +7,10 @@ import {
getNotifications,
getUnreadNotificationCount,
markAllNotificationsRead,
clearAllNotifications,
deleteNotification,
} from "../storage/notifications.js";
import { getToken } from "../csrf.js";
import { getToken, validateToken } from "../csrf.js";
import {
getMutedUrls,
getMutedKeywords,
@@ -188,19 +190,17 @@ export function notificationsController(mountPath) {
// Get notifications
const result = await getNotifications(collections, { before, limit });
// Get unread count before marking as read
const unreadCount = await getUnreadNotificationCount(collections);
// Mark all as read when page loads
if (result.items.length > 0) {
await markAllNotificationsRead(collections);
}
// CSRF token for action forms
const csrfToken = getToken(request.session);
response.render("activitypub-notifications", {
title: response.locals.__("activitypub.notifications.title"),
items: result.items,
before: result.before,
unreadCount,
csrfToken,
mountPath,
});
} catch (error) {
@@ -208,3 +208,92 @@ export function notificationsController(mountPath) {
}
};
}
/**
* POST /admin/reader/notifications/mark-read mark all notifications as read.
*/
export function markAllNotificationsReadController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).redirect(`${mountPath}/admin/reader/notifications`);
}
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
await markAllNotificationsRead(collections);
return response.redirect(`${mountPath}/admin/reader/notifications`);
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/notifications/clear delete all notifications.
*/
export function clearAllNotificationsController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).redirect(`${mountPath}/admin/reader/notifications`);
}
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
await clearAllNotifications(collections);
return response.redirect(`${mountPath}/admin/reader/notifications`);
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/notifications/delete delete a single notification.
*/
export function deleteNotificationController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { uid } = request.body;
if (!uid) {
return response.status(400).json({
success: false,
error: "Missing notification UID",
});
}
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
await deleteNotification(collections, uid);
// Support both JSON (fetch) and form redirect
if (request.headers.accept?.includes("application/json")) {
return response.json({ success: true, uid });
}
return response.redirect(`${mountPath}/admin/reader/notifications`);
} catch (error) {
next(error);
}
};
}
+21
View File
@@ -130,3 +130,24 @@ export async function markAllNotificationsRead(collections) {
const { ap_notifications } = collections;
return await ap_notifications.updateMany({}, { $set: { read: true } });
}
/**
* Delete all notifications
* @param {object} collections - MongoDB collections
* @returns {Promise<object>} Delete result
*/
export async function clearAllNotifications(collections) {
const { ap_notifications } = collections;
return await ap_notifications.deleteMany({});
}
/**
* Delete a single notification by UID
* @param {object} collections - MongoDB collections
* @param {string} uid - Notification UID
* @returns {Promise<object>} Delete result
*/
export async function deleteNotification(collections, uid) {
const { ap_notifications } = collections;
return await ap_notifications.deleteOne({ uid });
}
+5 -1
View File
@@ -141,7 +141,11 @@
"boostedPost": "boosted your post",
"followedYou": "followed you",
"repliedTo": "replied to your post",
"mentionedYou": "mentioned you"
"mentionedYou": "mentioned you",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
"clearConfirm": "Delete all notifications? This cannot be undone.",
"dismiss": "Dismiss"
},
"reader": {
"title": "Reader",
+14
View File
@@ -11,6 +11,20 @@
}) }}
{% if items.length > 0 %}
<div class="ap-notifications__toolbar">
{% if unreadCount > 0 %}
<form method="post" action="{{ mountPath }}/admin/reader/notifications/mark-read">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<button type="submit" class="ap-notifications__btn">{{ __("activitypub.notifications.markAllRead") }}</button>
</form>
{% endif %}
<form method="post" action="{{ mountPath }}/admin/reader/notifications/clear"
onsubmit="return confirm('{{ __("activitypub.notifications.clearConfirm") }}')">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<button type="submit" class="ap-notifications__btn ap-notifications__btn--danger">{{ __("activitypub.notifications.clearAll") }}</button>
</form>
</div>
<div class="ap-timeline">
{% for item in items %}
{% include "partials/ap-notification-card.njk" %}
+7
View File
@@ -1,6 +1,13 @@
{# Notification card partial #}
<div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}">
{# Dismiss button #}
<form method="post" action="{{ mountPath }}/admin/reader/notifications/delete" class="ap-notification__dismiss">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="uid" value="{{ item.uid }}">
<button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">&times;</button>
</form>
{# Type icon #}
<div class="ap-notification__icon">
{% if item.type == "like" %}