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:
@@ -746,6 +746,37 @@
|
|||||||
Notifications
|
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 {
|
.ap-notification {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
background: var(--color-offset);
|
background: var(--color-offset);
|
||||||
@@ -754,6 +785,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-notification--unread {
|
.ap-notification--unread {
|
||||||
@@ -803,6 +835,29 @@
|
|||||||
font-size: var(--font-size-xs);
|
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
|
Remote Profile
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
|
|||||||
import {
|
import {
|
||||||
readerController,
|
readerController,
|
||||||
notificationsController,
|
notificationsController,
|
||||||
|
markAllNotificationsReadController,
|
||||||
|
clearAllNotificationsController,
|
||||||
|
deleteNotificationController,
|
||||||
composeController,
|
composeController,
|
||||||
submitComposeController,
|
submitComposeController,
|
||||||
remoteProfileController,
|
remoteProfileController,
|
||||||
@@ -79,6 +82,7 @@ const defaults = {
|
|||||||
parallelWorkers: 5,
|
parallelWorkers: 5,
|
||||||
actorType: "Person",
|
actorType: "Person",
|
||||||
timelineRetention: 1000,
|
timelineRetention: 1000,
|
||||||
|
notificationRetentionDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ActivityPubEndpoint {
|
export default class ActivityPubEndpoint {
|
||||||
@@ -189,6 +193,9 @@ export default class ActivityPubEndpoint {
|
|||||||
router.get("/", dashboardController(mp));
|
router.get("/", dashboardController(mp));
|
||||||
router.get("/admin/reader", readerController(mp));
|
router.get("/admin/reader", readerController(mp));
|
||||||
router.get("/admin/reader/notifications", notificationsController(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.get("/admin/reader/compose", composeController(mp, this));
|
||||||
router.post("/admin/reader/compose", submitComposeController(mp, this));
|
router.post("/admin/reader/compose", submitComposeController(mp, this));
|
||||||
router.post("/admin/reader/like", likeController(mp, this));
|
router.post("/admin/reader/like", likeController(mp, this));
|
||||||
@@ -835,6 +842,15 @@ export default class ActivityPubEndpoint {
|
|||||||
{ background: true },
|
{ 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(
|
this._collections.ap_muted.createIndex(
|
||||||
{ url: 1 },
|
{ url: 1 },
|
||||||
{ unique: true, sparse: true, background: true },
|
{ unique: true, sparse: true, background: true },
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
getNotifications,
|
getNotifications,
|
||||||
getUnreadNotificationCount,
|
getUnreadNotificationCount,
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
|
clearAllNotifications,
|
||||||
|
deleteNotification,
|
||||||
} from "../storage/notifications.js";
|
} from "../storage/notifications.js";
|
||||||
import { getToken } from "../csrf.js";
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
import {
|
import {
|
||||||
getMutedUrls,
|
getMutedUrls,
|
||||||
getMutedKeywords,
|
getMutedKeywords,
|
||||||
@@ -188,19 +190,17 @@ export function notificationsController(mountPath) {
|
|||||||
// Get notifications
|
// Get notifications
|
||||||
const result = await getNotifications(collections, { before, limit });
|
const result = await getNotifications(collections, { before, limit });
|
||||||
|
|
||||||
// Get unread count before marking as read
|
|
||||||
const unreadCount = await getUnreadNotificationCount(collections);
|
const unreadCount = await getUnreadNotificationCount(collections);
|
||||||
|
|
||||||
// Mark all as read when page loads
|
// CSRF token for action forms
|
||||||
if (result.items.length > 0) {
|
const csrfToken = getToken(request.session);
|
||||||
await markAllNotificationsRead(collections);
|
|
||||||
}
|
|
||||||
|
|
||||||
response.render("activitypub-notifications", {
|
response.render("activitypub-notifications", {
|
||||||
title: response.locals.__("activitypub.notifications.title"),
|
title: response.locals.__("activitypub.notifications.title"),
|
||||||
items: result.items,
|
items: result.items,
|
||||||
before: result.before,
|
before: result.before,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
csrfToken,
|
||||||
mountPath,
|
mountPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,3 +130,24 @@ export async function markAllNotificationsRead(collections) {
|
|||||||
const { ap_notifications } = collections;
|
const { ap_notifications } = collections;
|
||||||
return await ap_notifications.updateMany({}, { $set: { read: true } });
|
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
@@ -141,7 +141,11 @@
|
|||||||
"boostedPost": "boosted your post",
|
"boostedPost": "boosted your post",
|
||||||
"followedYou": "followed you",
|
"followedYou": "followed you",
|
||||||
"repliedTo": "replied to your post",
|
"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": {
|
"reader": {
|
||||||
"title": "Reader",
|
"title": "Reader",
|
||||||
|
|||||||
@@ -11,6 +11,20 @@
|
|||||||
}) }}
|
}) }}
|
||||||
|
|
||||||
{% if items.length > 0 %}
|
{% 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">
|
<div class="ap-timeline">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
{% include "partials/ap-notification-card.njk" %}
|
{% include "partials/ap-notification-card.njk" %}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
{# Notification card partial #}
|
{# Notification card partial #}
|
||||||
|
|
||||||
<div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}">
|
<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') }}">×</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{# Type icon #}
|
{# Type icon #}
|
||||||
<div class="ap-notification__icon">
|
<div class="ap-notification__icon">
|
||||||
{% if item.type == "like" %}
|
{% if item.type == "like" %}
|
||||||
|
|||||||
Reference in New Issue
Block a user