From d20dea2dc8d42226479f3dd06d4aa48a2e625710 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Feb 2026 20:00:05 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20notification=20management=20=E2=80=94?= =?UTF-8?q?=20clear,=20mark=20read,=20dismiss,=20TTL=20retention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- assets/reader.css | 55 +++++++++++++ index.js | 16 ++++ lib/controllers/reader.js | 101 ++++++++++++++++++++++-- lib/storage/notifications.js | 21 +++++ locales/en.json | 6 +- views/activitypub-notifications.njk | 14 ++++ views/partials/ap-notification-card.njk | 7 ++ 7 files changed, 213 insertions(+), 7 deletions(-) diff --git a/assets/reader.css b/assets/reader.css index 72b0a8c..5b0372a 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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 ========================================================================== */ diff --git a/index.js b/index.js index f71d0ce..7227baa 100644 --- a/index.js +++ b/index.js @@ -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 }, diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index 0cd624b..a8a567d 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -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); + } + }; +} diff --git a/lib/storage/notifications.js b/lib/storage/notifications.js index fcfe090..0b2a11d 100644 --- a/lib/storage/notifications.js +++ b/lib/storage/notifications.js @@ -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} 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} Delete result + */ +export async function deleteNotification(collections, uid) { + const { ap_notifications } = collections; + return await ap_notifications.deleteOne({ uid }); +} diff --git a/locales/en.json b/locales/en.json index a62dd6c..6d3fede 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/views/activitypub-notifications.njk b/views/activitypub-notifications.njk index 68ab3b8..9512835 100644 --- a/views/activitypub-notifications.njk +++ b/views/activitypub-notifications.njk @@ -11,6 +11,20 @@ }) }} {% if items.length > 0 %} +
+ {% if unreadCount > 0 %} +
+ + +
+ {% endif %} +
+ + +
+
+
{% for item in items %} {% include "partials/ap-notification-card.njk" %} diff --git a/views/partials/ap-notification-card.njk b/views/partials/ap-notification-card.njk index b10c32f..688ed32 100644 --- a/views/partials/ap-notification-card.njk +++ b/views/partials/ap-notification-card.njk @@ -1,6 +1,13 @@ {# Notification card partial #}
+ {# Dismiss button #} +
+ + + +
+ {# Type icon #}
{% if item.type == "like" %}