@rmdes/indiekit-endpoint-activitypub 1.1.14 → 1.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/reader.css CHANGED
@@ -746,6 +746,37 @@
746
746
  Notifications
747
747
  ========================================================================== */
748
748
 
749
+ /* Notifications Toolbar */
750
+ .ap-notifications__toolbar {
751
+ display: flex;
752
+ gap: var(--space-s);
753
+ margin-bottom: var(--space-m);
754
+ }
755
+
756
+ .ap-notifications__btn {
757
+ background: var(--color-offset);
758
+ border: var(--border-width-thin) solid var(--color-outline);
759
+ border-radius: var(--border-radius-small);
760
+ color: var(--color-on-background);
761
+ cursor: pointer;
762
+ font-size: var(--font-size-s);
763
+ padding: var(--space-xs) var(--space-m);
764
+ transition: all 0.2s ease;
765
+ }
766
+
767
+ .ap-notifications__btn:hover {
768
+ background: var(--color-offset-variant);
769
+ border-color: var(--color-outline-variant);
770
+ }
771
+
772
+ .ap-notifications__btn--danger {
773
+ color: var(--color-red45);
774
+ }
775
+
776
+ .ap-notifications__btn--danger:hover {
777
+ border-color: var(--color-red45);
778
+ }
779
+
749
780
  .ap-notification {
750
781
  align-items: flex-start;
751
782
  background: var(--color-offset);
@@ -754,6 +785,7 @@
754
785
  display: flex;
755
786
  gap: var(--space-s);
756
787
  padding: var(--space-m);
788
+ position: relative;
757
789
  }
758
790
 
759
791
  .ap-notification--unread {
@@ -761,9 +793,34 @@
761
793
  box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3);
762
794
  }
763
795
 
764
- .ap-notification__icon {
796
+ .ap-notification__avatar-wrap {
765
797
  flex-shrink: 0;
766
- font-size: 1.5em;
798
+ position: relative;
799
+ }
800
+
801
+ .ap-notification__avatar {
802
+ border: var(--border-width-thin) solid var(--color-outline);
803
+ border-radius: 50%;
804
+ height: 40px;
805
+ object-fit: cover;
806
+ width: 40px;
807
+ }
808
+
809
+ .ap-notification__avatar--default {
810
+ align-items: center;
811
+ background: var(--color-offset-variant);
812
+ color: var(--color-on-offset);
813
+ display: inline-flex;
814
+ font-size: 1.1em;
815
+ font-weight: 600;
816
+ justify-content: center;
817
+ }
818
+
819
+ .ap-notification__type-badge {
820
+ bottom: -2px;
821
+ font-size: 0.75em;
822
+ position: absolute;
823
+ right: -4px;
767
824
  }
768
825
 
769
826
  .ap-notification__body {
@@ -803,6 +860,29 @@
803
860
  font-size: var(--font-size-xs);
804
861
  }
805
862
 
863
+ .ap-notification__dismiss {
864
+ position: absolute;
865
+ right: var(--space-xs);
866
+ top: var(--space-xs);
867
+ }
868
+
869
+ .ap-notification__dismiss-btn {
870
+ background: transparent;
871
+ border: 0;
872
+ border-radius: var(--border-radius-small);
873
+ color: var(--color-on-offset);
874
+ cursor: pointer;
875
+ font-size: var(--font-size-m);
876
+ line-height: 1;
877
+ padding: 2px 6px;
878
+ transition: all 0.2s ease;
879
+ }
880
+
881
+ .ap-notification__dismiss-btn:hover {
882
+ background: var(--color-offset-variant);
883
+ color: var(--color-red45);
884
+ }
885
+
806
886
  /* ==========================================================================
807
887
  Remote Profile
808
888
  ========================================================================== */
package/index.js CHANGED
@@ -12,6 +12,9 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
12
12
  import {
13
13
  readerController,
14
14
  notificationsController,
15
+ markAllNotificationsReadController,
16
+ clearAllNotificationsController,
17
+ deleteNotificationController,
15
18
  composeController,
16
19
  submitComposeController,
17
20
  remoteProfileController,
@@ -79,6 +82,7 @@ const defaults = {
79
82
  parallelWorkers: 5,
80
83
  actorType: "Person",
81
84
  timelineRetention: 1000,
85
+ notificationRetentionDays: 30,
82
86
  };
83
87
 
84
88
  export default class ActivityPubEndpoint {
@@ -189,6 +193,9 @@ export default class ActivityPubEndpoint {
189
193
  router.get("/", dashboardController(mp));
190
194
  router.get("/admin/reader", readerController(mp));
191
195
  router.get("/admin/reader/notifications", notificationsController(mp));
196
+ router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
197
+ router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
198
+ router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
192
199
  router.get("/admin/reader/compose", composeController(mp, this));
193
200
  router.post("/admin/reader/compose", submitComposeController(mp, this));
194
201
  router.post("/admin/reader/like", likeController(mp, this));
@@ -835,6 +842,15 @@ export default class ActivityPubEndpoint {
835
842
  { background: true },
836
843
  );
837
844
 
845
+ // TTL index for notification cleanup
846
+ const notifRetention = this.options.notificationRetentionDays;
847
+ if (notifRetention > 0) {
848
+ this._collections.ap_notifications.createIndex(
849
+ { createdAt: 1 },
850
+ { expireAfterSeconds: notifRetention * 86_400 },
851
+ );
852
+ }
853
+
838
854
  this._collections.ap_muted.createIndex(
839
855
  { url: 1 },
840
856
  { unique: true, sparse: true, background: true },
@@ -7,8 +7,10 @@ import {
7
7
  getNotifications,
8
8
  getUnreadNotificationCount,
9
9
  markAllNotificationsRead,
10
+ clearAllNotifications,
11
+ deleteNotification,
10
12
  } from "../storage/notifications.js";
11
- import { getToken } from "../csrf.js";
13
+ import { getToken, validateToken } from "../csrf.js";
12
14
  import {
13
15
  getMutedUrls,
14
16
  getMutedKeywords,
@@ -188,19 +190,17 @@ export function notificationsController(mountPath) {
188
190
  // Get notifications
189
191
  const result = await getNotifications(collections, { before, limit });
190
192
 
191
- // Get unread count before marking as read
192
193
  const unreadCount = await getUnreadNotificationCount(collections);
193
194
 
194
- // Mark all as read when page loads
195
- if (result.items.length > 0) {
196
- await markAllNotificationsRead(collections);
197
- }
195
+ // CSRF token for action forms
196
+ const csrfToken = getToken(request.session);
198
197
 
199
198
  response.render("activitypub-notifications", {
200
199
  title: response.locals.__("activitypub.notifications.title"),
201
200
  items: result.items,
202
201
  before: result.before,
203
202
  unreadCount,
203
+ csrfToken,
204
204
  mountPath,
205
205
  });
206
206
  } catch (error) {
@@ -208,3 +208,92 @@ export function notificationsController(mountPath) {
208
208
  }
209
209
  };
210
210
  }
211
+
212
+ /**
213
+ * POST /admin/reader/notifications/mark-read — mark all notifications as read.
214
+ */
215
+ export function markAllNotificationsReadController(mountPath) {
216
+ return async (request, response, next) => {
217
+ try {
218
+ if (!validateToken(request)) {
219
+ return response.status(403).redirect(`${mountPath}/admin/reader/notifications`);
220
+ }
221
+
222
+ const { application } = request.app.locals;
223
+ const collections = {
224
+ ap_notifications: application?.collections?.get("ap_notifications"),
225
+ };
226
+
227
+ await markAllNotificationsRead(collections);
228
+
229
+ return response.redirect(`${mountPath}/admin/reader/notifications`);
230
+ } catch (error) {
231
+ next(error);
232
+ }
233
+ };
234
+ }
235
+
236
+ /**
237
+ * POST /admin/reader/notifications/clear — delete all notifications.
238
+ */
239
+ export function clearAllNotificationsController(mountPath) {
240
+ return async (request, response, next) => {
241
+ try {
242
+ if (!validateToken(request)) {
243
+ return response.status(403).redirect(`${mountPath}/admin/reader/notifications`);
244
+ }
245
+
246
+ const { application } = request.app.locals;
247
+ const collections = {
248
+ ap_notifications: application?.collections?.get("ap_notifications"),
249
+ };
250
+
251
+ await clearAllNotifications(collections);
252
+
253
+ return response.redirect(`${mountPath}/admin/reader/notifications`);
254
+ } catch (error) {
255
+ next(error);
256
+ }
257
+ };
258
+ }
259
+
260
+ /**
261
+ * POST /admin/reader/notifications/delete — delete a single notification.
262
+ */
263
+ export function deleteNotificationController(mountPath) {
264
+ return async (request, response, next) => {
265
+ try {
266
+ if (!validateToken(request)) {
267
+ return response.status(403).json({
268
+ success: false,
269
+ error: "Invalid CSRF token",
270
+ });
271
+ }
272
+
273
+ const { uid } = request.body;
274
+
275
+ if (!uid) {
276
+ return response.status(400).json({
277
+ success: false,
278
+ error: "Missing notification UID",
279
+ });
280
+ }
281
+
282
+ const { application } = request.app.locals;
283
+ const collections = {
284
+ ap_notifications: application?.collections?.get("ap_notifications"),
285
+ };
286
+
287
+ await deleteNotification(collections, uid);
288
+
289
+ // Support both JSON (fetch) and form redirect
290
+ if (request.headers.accept?.includes("application/json")) {
291
+ return response.json({ success: true, uid });
292
+ }
293
+
294
+ return response.redirect(`${mountPath}/admin/reader/notifications`);
295
+ } catch (error) {
296
+ next(error);
297
+ }
298
+ };
299
+ }
@@ -66,9 +66,10 @@ export async function getNotifications(collections, options = {}) {
66
66
  query.read = false;
67
67
  }
68
68
 
69
- // Cursor pagination
69
+ // Cursor pagination — published is stored as ISO string, so compare
70
+ // as strings (lexicographic ISO 8601 comparison is correct for dates)
70
71
  if (options.before) {
71
- query.published = { $lt: new Date(options.before) };
72
+ query.published = { $lt: options.before };
72
73
  }
73
74
 
74
75
  const rawItems = await ap_notifications
@@ -85,9 +86,9 @@ export async function getNotifications(collections, options = {}) {
85
86
  : item.published,
86
87
  }));
87
88
 
88
- // Generate cursor for next page
89
+ // Generate cursor for next page (only if full page returned = more may exist)
89
90
  const before =
90
- items.length > 0
91
+ items.length === limit
91
92
  ? items[items.length - 1].published
92
93
  : null;
93
94
 
@@ -130,3 +131,24 @@ export async function markAllNotificationsRead(collections) {
130
131
  const { ap_notifications } = collections;
131
132
  return await ap_notifications.updateMany({}, { $set: { read: true } });
132
133
  }
134
+
135
+ /**
136
+ * Delete all notifications
137
+ * @param {object} collections - MongoDB collections
138
+ * @returns {Promise<object>} Delete result
139
+ */
140
+ export async function clearAllNotifications(collections) {
141
+ const { ap_notifications } = collections;
142
+ return await ap_notifications.deleteMany({});
143
+ }
144
+
145
+ /**
146
+ * Delete a single notification by UID
147
+ * @param {object} collections - MongoDB collections
148
+ * @param {string} uid - Notification UID
149
+ * @returns {Promise<object>} Delete result
150
+ */
151
+ export async function deleteNotification(collections, uid) {
152
+ const { ap_notifications } = collections;
153
+ return await ap_notifications.deleteOne({ uid });
154
+ }
@@ -94,23 +94,20 @@ export async function getTimelineItems(collections, options = {}) {
94
94
  query["author.url"] = options.authorUrl;
95
95
  }
96
96
 
97
- // Cursor pagination — validate dates
97
+ // Cursor pagination — published is stored as ISO string, so compare
98
+ // as strings (lexicographic ISO 8601 comparison is correct for dates)
98
99
  if (options.before) {
99
- const beforeDate = new Date(options.before);
100
-
101
- if (Number.isNaN(beforeDate.getTime())) {
100
+ if (Number.isNaN(new Date(options.before).getTime())) {
102
101
  throw new Error("Invalid before cursor");
103
102
  }
104
103
 
105
- query.published = { $lt: beforeDate };
104
+ query.published = { $lt: options.before };
106
105
  } else if (options.after) {
107
- const afterDate = new Date(options.after);
108
-
109
- if (Number.isNaN(afterDate.getTime())) {
106
+ if (Number.isNaN(new Date(options.after).getTime())) {
110
107
  throw new Error("Invalid after cursor");
111
108
  }
112
109
 
113
- query.published = { $gt: afterDate };
110
+ query.published = { $gt: options.after };
114
111
  }
115
112
 
116
113
  const rawItems = await ap_timeline
@@ -128,13 +125,16 @@ export async function getTimelineItems(collections, options = {}) {
128
125
  }));
129
126
 
130
127
  // Generate cursors for pagination
128
+ // Items are sorted newest-first, so:
129
+ // - "before" cursor (for "Older" link) = oldest item's date (last in array)
130
+ // - "after" cursor (for "Newer" link) = newest item's date (first in array)
131
131
  const before =
132
- items.length > 0
133
- ? items[0].published
132
+ items.length === limit
133
+ ? items[items.length - 1].published
134
134
  : null;
135
135
  const after =
136
- items.length > 0
137
- ? items[items.length - 1].published
136
+ items.length > 0 && (options.before || options.after)
137
+ ? items[0].published
138
138
  : null;
139
139
 
140
140
  return {
@@ -190,7 +190,9 @@ export async function updateTimelineItem(collections, uid, updates) {
190
190
  */
191
191
  export async function deleteOldTimelineItems(collections, cutoffDate) {
192
192
  const { ap_timeline } = collections;
193
- const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } });
193
+ // published is stored as ISO string convert cutoff to string for comparison
194
+ const cutoff = cutoffDate instanceof Date ? cutoffDate.toISOString() : cutoffDate;
195
+ const result = await ap_timeline.deleteMany({ published: { $lt: cutoff } });
194
196
  return result.deletedCount;
195
197
  }
196
198
 
package/locales/en.json CHANGED
@@ -141,7 +141,11 @@
141
141
  "boostedPost": "boosted your post",
142
142
  "followedYou": "followed you",
143
143
  "repliedTo": "replied to your post",
144
- "mentionedYou": "mentioned you"
144
+ "mentionedYou": "mentioned you",
145
+ "markAllRead": "Mark all read",
146
+ "clearAll": "Clear all",
147
+ "clearConfirm": "Delete all notifications? This cannot be undone.",
148
+ "dismiss": "Dismiss"
145
149
  },
146
150
  "reader": {
147
151
  "title": "Reader",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -7,7 +7,7 @@
7
7
  {% from "pagination/macro.njk" import pagination with context %}
8
8
 
9
9
  {% block content %}
10
- {{ heading({ text: __("activitypub.activities"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
10
+ {{ heading({ text: __("activitypub.activities"), level: 1 }) }}
11
11
 
12
12
  {% if activities.length > 0 %}
13
13
  {% for activity in activities %}
@@ -3,11 +3,7 @@
3
3
  {% from "heading/macro.njk" import heading with context %}
4
4
 
5
5
  {% block readercontent %}
6
- {{ heading({
7
- text: title,
8
- level: 1,
9
- parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
10
- }) }}
6
+ {{ heading({ text: title, level: 1 }) }}
11
7
 
12
8
  {# Reply context — show the post being replied to #}
13
9
  {% if replyContext %}
@@ -4,7 +4,7 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block content %}
7
- {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
7
+ {{ heading({ text: title, level: 1 }) }}
8
8
 
9
9
  {% if tags.length > 0 %}
10
10
  <table class="table">
@@ -4,7 +4,7 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block content %}
7
- {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
7
+ {{ heading({ text: title, level: 1 }) }}
8
8
 
9
9
  {% if pinned.length > 0 %}
10
10
  <table class="table">
@@ -6,7 +6,7 @@
6
6
  {% from "pagination/macro.njk" import pagination with context %}
7
7
 
8
8
  {% block content %}
9
- {{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
9
+ {{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1 }) }}
10
10
 
11
11
  {% if followers.length > 0 %}
12
12
  {% for follower in followers %}
@@ -7,7 +7,7 @@
7
7
  {% from "pagination/macro.njk" import pagination with context %}
8
8
 
9
9
  {% block content %}
10
- {{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
10
+ {{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1 }) }}
11
11
 
12
12
  {% if following.length > 0 %}
13
13
  {% for account in following %}
@@ -8,7 +8,7 @@
8
8
  {% from "prose/macro.njk" import prose with context %}
9
9
 
10
10
  {% block content %}
11
- {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
11
+ {{ heading({ text: title, level: 1 }) }}
12
12
 
13
13
  {% if result %}
14
14
  {{ notificationBanner({ type: result.type, text: result.text }) }}
@@ -4,11 +4,7 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({
8
- text: title,
9
- level: 1,
10
- parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
11
- }) }}
7
+ {{ heading({ text: title, level: 1 }) }}
12
8
 
13
9
  {# Blocked actors #}
14
10
  <section class="ap-moderation__section">
@@ -4,13 +4,23 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({
8
- text: __("activitypub.notifications.title"),
9
- level: 1,
10
- parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
11
- }) }}
7
+ {{ heading({ text: __("activitypub.notifications.title"), level: 1 }) }}
12
8
 
13
9
  {% if items.length > 0 %}
10
+ <div class="ap-notifications__toolbar">
11
+ {% if unreadCount > 0 %}
12
+ <form method="post" action="{{ mountPath }}/admin/reader/notifications/mark-read">
13
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
14
+ <button type="submit" class="ap-notifications__btn">{{ __("activitypub.notifications.markAllRead") }}</button>
15
+ </form>
16
+ {% endif %}
17
+ <form method="post" action="{{ mountPath }}/admin/reader/notifications/clear"
18
+ onsubmit="return confirm('{{ __("activitypub.notifications.clearConfirm") }}')">
19
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
20
+ <button type="submit" class="ap-notifications__btn ap-notifications__btn--danger">{{ __("activitypub.notifications.clearAll") }}</button>
21
+ </form>
22
+ </div>
23
+
14
24
  <div class="ap-timeline">
15
25
  {% for item in items %}
16
26
  {% include "partials/ap-notification-card.njk" %}
@@ -3,11 +3,7 @@
3
3
  {% from "heading/macro.njk" import heading with context %}
4
4
 
5
5
  {% block readercontent %}
6
- {{ heading({
7
- text: title,
8
- level: 1,
9
- parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
10
- }) }}
6
+ {{ heading({ text: title, level: 1 }) }}
11
7
 
12
8
  <div class="ap-post-detail" data-mount-path="{{ mountPath }}">
13
9
  {# Back button #}
@@ -10,7 +10,7 @@
10
10
  {% from "prose/macro.njk" import prose with context %}
11
11
 
12
12
  {% block content %}
13
- {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
13
+ {{ heading({ text: title, level: 1 }) }}
14
14
 
15
15
  {% if result %}
16
16
  {{ notificationBanner({ type: result.type, text: result.text }) }}
@@ -4,17 +4,10 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({
8
- text: __("activitypub.reader.title"),
9
- level: 1,
10
- parent: { text: __("activitypub.title"), href: mountPath }
11
- }) }}
7
+ {{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
12
8
 
13
9
  {# Tab navigation #}
14
10
  <nav class="ap-tabs" role="tablist">
15
- <a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
16
- {{ __("activitypub.reader.tabs.all") }}
17
- </a>
18
11
  <a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
19
12
  {{ __("activitypub.reader.tabs.notes") }}
20
13
  </a>
@@ -30,6 +23,9 @@
30
23
  <a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
31
24
  {{ __("activitypub.reader.tabs.media") }}
32
25
  </a>
26
+ <a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
27
+ {{ __("activitypub.reader.tabs.all") }}
28
+ </a>
33
29
  </nav>
34
30
 
35
31
  {# Timeline items #}
@@ -4,11 +4,7 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({
8
- text: title,
9
- level: 1,
10
- parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
11
- }) }}
7
+ {{ heading({ text: title, level: 1 }) }}
12
8
 
13
9
  <div class="ap-profile"
14
10
  x-data="{
@@ -24,8 +24,9 @@
24
24
  {# Author header #}
25
25
  <header class="ap-card__author">
26
26
  {% if item.author.photo %}
27
- <img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar"
28
- onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'ap-card__avatar ap-card__avatar--default',textContent:'{{ item.author.name[0] | upper if item.author.name else "?" }}'}))">
27
+ <img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous"
28
+ onerror="this.style.display='none';this.nextElementSibling.style.display=''">
29
+ <span class="ap-card__avatar ap-card__avatar--default" style="display:none" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
29
30
  {% else %}
30
31
  <span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
31
32
  {% endif %}
@@ -1,19 +1,25 @@
1
1
  {# Notification card partial #}
2
2
 
3
3
  <div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}">
4
- {# Type icon #}
5
- <div class="ap-notification__icon">
6
- {% if item.type == "like" %}
7
-
8
- {% elif item.type == "boost" %}
9
- 🔁
10
- {% elif item.type == "follow" %}
11
- 👤
12
- {% elif item.type == "reply" %}
13
- 💬
14
- {% elif item.type == "mention" %}
15
- @
4
+ {# Dismiss button #}
5
+ <form method="post" action="{{ mountPath }}/admin/reader/notifications/delete" class="ap-notification__dismiss">
6
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
7
+ <input type="hidden" name="uid" value="{{ item.uid }}">
8
+ <button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">&times;</button>
9
+ </form>
10
+
11
+ {# Actor avatar with type badge #}
12
+ <div class="ap-notification__avatar-wrap">
13
+ {% if item.actorPhoto %}
14
+ <img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"
15
+ onerror="this.style.display='none';this.nextElementSibling.style.display=''">
16
+ <span class="ap-notification__avatar ap-notification__avatar--default" style="display:none" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
17
+ {% else %}
18
+ <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
16
19
  {% endif %}
20
+ <span class="ap-notification__type-badge">
21
+ {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% endif %}
22
+ </span>
17
23
  </div>
18
24
 
19
25
  {# Notification body #}