@rmdes/indiekit-endpoint-activitypub 2.8.1 → 2.9.0

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
@@ -1320,6 +1320,166 @@
1320
1320
  color: var(--color-on-background);
1321
1321
  }
1322
1322
 
1323
+ .ap-notification__handle {
1324
+ color: var(--color-on-offset);
1325
+ font-size: var(--font-size-s);
1326
+ margin-left: var(--space-xs);
1327
+ }
1328
+
1329
+ .ap-notifications__btn--primary {
1330
+ background: var(--color-primary);
1331
+ color: var(--color-on-primary, #fff);
1332
+ text-decoration: none;
1333
+ }
1334
+
1335
+ .ap-notifications__btn--primary:hover {
1336
+ opacity: 0.9;
1337
+ }
1338
+
1339
+ /* ==========================================================================
1340
+ Messages
1341
+ ========================================================================== */
1342
+
1343
+ .ap-messages__layout {
1344
+ display: grid;
1345
+ grid-template-columns: 240px 1fr;
1346
+ gap: var(--space-m);
1347
+ min-height: 300px;
1348
+ }
1349
+
1350
+ .ap-messages__sidebar {
1351
+ border-right: var(--border-width-thin) solid var(--color-outline);
1352
+ display: flex;
1353
+ flex-direction: column;
1354
+ gap: 2px;
1355
+ padding-right: var(--space-m);
1356
+ overflow-y: auto;
1357
+ max-height: 70vh;
1358
+ }
1359
+
1360
+ .ap-messages__partner {
1361
+ align-items: center;
1362
+ border-radius: var(--border-radius-small);
1363
+ color: var(--color-on-background);
1364
+ display: flex;
1365
+ gap: var(--space-s);
1366
+ padding: var(--space-s);
1367
+ text-decoration: none;
1368
+ transition: background 0.15s ease;
1369
+ }
1370
+
1371
+ .ap-messages__partner:hover {
1372
+ background: var(--color-offset);
1373
+ }
1374
+
1375
+ .ap-messages__partner--active {
1376
+ background: var(--color-offset);
1377
+ font-weight: var(--font-weight-bold);
1378
+ }
1379
+
1380
+ .ap-messages__partner-avatar {
1381
+ flex-shrink: 0;
1382
+ height: 32px;
1383
+ position: relative;
1384
+ width: 32px;
1385
+ }
1386
+
1387
+ .ap-messages__partner-avatar img {
1388
+ border-radius: 50%;
1389
+ height: 100%;
1390
+ object-fit: cover;
1391
+ width: 100%;
1392
+ }
1393
+
1394
+ .ap-messages__partner-initial {
1395
+ align-items: center;
1396
+ background: var(--color-offset);
1397
+ border-radius: 50%;
1398
+ color: var(--color-on-offset);
1399
+ display: flex;
1400
+ font-size: var(--font-size-s);
1401
+ height: 100%;
1402
+ justify-content: center;
1403
+ width: 100%;
1404
+ }
1405
+
1406
+ .ap-messages__partner-avatar img + .ap-messages__partner-initial {
1407
+ display: none;
1408
+ }
1409
+
1410
+ .ap-messages__partner-info {
1411
+ display: flex;
1412
+ flex-direction: column;
1413
+ min-width: 0;
1414
+ overflow: hidden;
1415
+ }
1416
+
1417
+ .ap-messages__partner-name {
1418
+ font-size: var(--font-size-s);
1419
+ overflow: hidden;
1420
+ text-overflow: ellipsis;
1421
+ white-space: nowrap;
1422
+ }
1423
+
1424
+ .ap-messages__partner-handle {
1425
+ color: var(--color-on-offset);
1426
+ font-size: var(--font-size-xs);
1427
+ overflow: hidden;
1428
+ text-overflow: ellipsis;
1429
+ white-space: nowrap;
1430
+ }
1431
+
1432
+ .ap-messages__content {
1433
+ min-width: 0;
1434
+ }
1435
+
1436
+ .ap-message--outbound {
1437
+ border-left-color: var(--color-primary);
1438
+ }
1439
+
1440
+ .ap-message__direction {
1441
+ color: var(--color-on-offset);
1442
+ font-size: var(--font-size-s);
1443
+ margin-right: var(--space-xs);
1444
+ }
1445
+
1446
+ .ap-message__content {
1447
+ color: var(--color-on-background);
1448
+ font-size: var(--font-size-s);
1449
+ line-height: 1.5;
1450
+ margin-top: var(--space-xs);
1451
+ }
1452
+
1453
+ .ap-message__content p {
1454
+ margin: 0 0 var(--space-xs);
1455
+ }
1456
+
1457
+ .ap-message__content p:last-child {
1458
+ margin-bottom: 0;
1459
+ }
1460
+
1461
+ @media (max-width: 640px) {
1462
+ .ap-messages__layout {
1463
+ grid-template-columns: 1fr;
1464
+ }
1465
+
1466
+ .ap-messages__sidebar {
1467
+ border-bottom: var(--border-width-thin) solid var(--color-outline);
1468
+ border-right: none;
1469
+ flex-direction: row;
1470
+ max-height: none;
1471
+ overflow-x: auto;
1472
+ padding-bottom: var(--space-s);
1473
+ padding-right: 0;
1474
+ -webkit-overflow-scrolling: touch;
1475
+ }
1476
+
1477
+ .ap-messages__partner {
1478
+ flex-shrink: 0;
1479
+ white-space: nowrap;
1480
+ }
1481
+ }
1482
+
1323
1483
  /* ==========================================================================
1324
1484
  Remote Profile
1325
1485
  ========================================================================== */
package/index.js CHANGED
@@ -78,6 +78,14 @@ import {
78
78
  } from "./lib/controllers/tabs.js";
79
79
  import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js";
80
80
  import { publicProfileController } from "./lib/controllers/public-profile.js";
81
+ import {
82
+ messagesController,
83
+ messageComposeController,
84
+ submitMessageController,
85
+ markAllMessagesReadController,
86
+ clearAllMessagesController,
87
+ deleteMessageController,
88
+ } from "./lib/controllers/messages.js";
81
89
  import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
82
90
  import { myProfileController } from "./lib/controllers/my-profile.js";
83
91
  import {
@@ -143,6 +151,11 @@ export default class ActivityPubEndpoint {
143
151
  text: "activitypub.notifications.title",
144
152
  requiresDatabase: true,
145
153
  },
154
+ {
155
+ href: `${this.options.mountPath}/admin/reader/messages`,
156
+ text: "activitypub.messages.title",
157
+ requiresDatabase: true,
158
+ },
146
159
  {
147
160
  href: `${this.options.mountPath}/admin/reader/moderation`,
148
161
  text: "activitypub.moderation.title",
@@ -252,6 +265,12 @@ export default class ActivityPubEndpoint {
252
265
  router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
253
266
  router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
254
267
  router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
268
+ router.get("/admin/reader/messages", messagesController(mp));
269
+ router.get("/admin/reader/messages/compose", messageComposeController(mp, this));
270
+ router.post("/admin/reader/messages/compose", submitMessageController(mp, this));
271
+ router.post("/admin/reader/messages/mark-read", markAllMessagesReadController(mp));
272
+ router.post("/admin/reader/messages/clear", clearAllMessagesController(mp));
273
+ router.post("/admin/reader/messages/delete", deleteMessageController(mp));
255
274
  router.get("/admin/reader/compose", composeController(mp, this));
256
275
  router.post("/admin/reader/compose", submitComposeController(mp, this));
257
276
  router.post("/admin/reader/like", likeController(mp, this));
@@ -885,6 +904,8 @@ export default class ActivityPubEndpoint {
885
904
  Indiekit.addCollection("ap_blocked");
886
905
  Indiekit.addCollection("ap_interactions");
887
906
  Indiekit.addCollection("ap_followed_tags");
907
+ // Message collections
908
+ Indiekit.addCollection("ap_messages");
888
909
  // Explore tab collections
889
910
  Indiekit.addCollection("ap_explore_tabs");
890
911
 
@@ -906,6 +927,8 @@ export default class ActivityPubEndpoint {
906
927
  ap_blocked: indiekitCollections.get("ap_blocked"),
907
928
  ap_interactions: indiekitCollections.get("ap_interactions"),
908
929
  ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
930
+ // Message collections
931
+ ap_messages: indiekitCollections.get("ap_messages"),
909
932
  // Explore tab collections
910
933
  ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
911
934
  get posts() {
@@ -993,6 +1016,35 @@ export default class ActivityPubEndpoint {
993
1016
  );
994
1017
  }
995
1018
 
1019
+ // Message indexes
1020
+ this._collections.ap_messages.createIndex(
1021
+ { uid: 1 },
1022
+ { unique: true, background: true },
1023
+ );
1024
+ this._collections.ap_messages.createIndex(
1025
+ { published: -1 },
1026
+ { background: true },
1027
+ );
1028
+ this._collections.ap_messages.createIndex(
1029
+ { read: 1 },
1030
+ { background: true },
1031
+ );
1032
+ this._collections.ap_messages.createIndex(
1033
+ { conversationId: 1, published: -1 },
1034
+ { background: true },
1035
+ );
1036
+ this._collections.ap_messages.createIndex(
1037
+ { direction: 1 },
1038
+ { background: true },
1039
+ );
1040
+ // TTL index for message cleanup (reuse notification retention)
1041
+ if (notifRetention > 0) {
1042
+ this._collections.ap_messages.createIndex(
1043
+ { createdAt: 1 },
1044
+ { expireAfterSeconds: notifRetention * 86_400 },
1045
+ );
1046
+ }
1047
+
996
1048
  // Muted collection — sparse unique indexes (allow multiple null values)
997
1049
  this._collections.ap_muted
998
1050
  .dropIndex("url_1")
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Messages controllers — DM inbox, compose, and send.
3
+ * Direct messages bypass Micropub and use Fedify ctx.sendActivity() directly.
4
+ */
5
+
6
+ import { getToken, validateToken } from "../csrf.js";
7
+ import { sanitizeContent } from "../timeline-store.js";
8
+ import {
9
+ getMessages,
10
+ getConversationPartners,
11
+ getUnreadMessageCount,
12
+ markMessagesRead,
13
+ markAllMessagesRead,
14
+ clearAllMessages,
15
+ deleteMessage,
16
+ addMessage,
17
+ } from "../storage/messages.js";
18
+
19
+ /**
20
+ * GET /admin/reader/messages — Messages inbox with conversation sidebar.
21
+ * @param {string} mountPath - Plugin mount path
22
+ */
23
+ export function messagesController(mountPath) {
24
+ return async (request, response, next) => {
25
+ try {
26
+ const { application } = request.app.locals;
27
+ const collections = {
28
+ ap_messages: application?.collections?.get("ap_messages"),
29
+ };
30
+
31
+ const partner = request.query.partner || null;
32
+ const before = request.query.before;
33
+ const limit = Number.parseInt(request.query.limit || "20", 10);
34
+
35
+ const options = { before, limit };
36
+ if (partner) {
37
+ options.partner = partner;
38
+ }
39
+
40
+ // Get messages + conversation partners + unread count in parallel
41
+ const [result, partners, unreadCount] = await Promise.all([
42
+ getMessages(collections, options),
43
+ getConversationPartners(collections),
44
+ getUnreadMessageCount(collections),
45
+ ]);
46
+
47
+ // Auto mark-read when viewing a specific conversation
48
+ if (partner) {
49
+ await markMessagesRead(collections, partner);
50
+ }
51
+
52
+ const csrfToken = getToken(request.session);
53
+
54
+ response.render("activitypub-messages", {
55
+ title: response.locals.__("activitypub.messages.title"),
56
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
57
+ items: result.items,
58
+ before: result.before,
59
+ partners,
60
+ activePartner: partner,
61
+ unreadCount,
62
+ csrfToken,
63
+ mountPath,
64
+ });
65
+ } catch (error) {
66
+ next(error);
67
+ }
68
+ };
69
+ }
70
+
71
+ /**
72
+ * GET /admin/reader/messages/compose — DM compose form.
73
+ * @param {string} mountPath - Plugin mount path
74
+ * @param {object} plugin - ActivityPub plugin instance
75
+ */
76
+ export function messageComposeController(mountPath, plugin) {
77
+ return async (request, response, next) => {
78
+ try {
79
+ const to = request.query.to || "";
80
+ const replyTo = request.query.replyTo || "";
81
+
82
+ // Load reply context if replying to a specific message
83
+ let replyContext = null;
84
+ if (replyTo) {
85
+ const { application } = request.app.locals;
86
+ const messagesCol = application?.collections?.get("ap_messages");
87
+ if (messagesCol) {
88
+ replyContext = await messagesCol.findOne({ uid: replyTo });
89
+ }
90
+ }
91
+
92
+ const csrfToken = getToken(request.session);
93
+
94
+ response.render("activitypub-message-compose", {
95
+ title: response.locals.__("activitypub.messages.compose"),
96
+ readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
97
+ to,
98
+ replyTo,
99
+ replyContext,
100
+ csrfToken,
101
+ mountPath,
102
+ });
103
+ } catch (error) {
104
+ next(error);
105
+ }
106
+ };
107
+ }
108
+
109
+ /**
110
+ * POST /admin/reader/messages/compose — Send a DM via Fedify.
111
+ * Bypasses Micropub — sends Create(Note) directly with DM addressing.
112
+ * @param {string} mountPath - Plugin mount path
113
+ * @param {object} plugin - ActivityPub plugin instance
114
+ */
115
+ export function submitMessageController(mountPath, plugin) {
116
+ return async (request, response, next) => {
117
+ try {
118
+ if (!validateToken(request)) {
119
+ return response.status(403).redirect(`${mountPath}/admin/reader/messages/compose`);
120
+ }
121
+
122
+ const { to, content, replyTo } = request.body;
123
+
124
+ if (!to || !to.trim()) {
125
+ return response.status(400).render("activitypub-message-compose", {
126
+ title: response.locals.__("activitypub.messages.compose"),
127
+ readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
128
+ to: "",
129
+ replyTo: replyTo || "",
130
+ replyContext: null,
131
+ csrfToken: getToken(request.session),
132
+ mountPath,
133
+ error: response.locals.__("activitypub.messages.errorNoRecipient"),
134
+ });
135
+ }
136
+
137
+ if (!content || !content.trim()) {
138
+ return response.status(400).render("activitypub-message-compose", {
139
+ title: response.locals.__("activitypub.messages.compose"),
140
+ readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
141
+ to,
142
+ replyTo: replyTo || "",
143
+ replyContext: null,
144
+ csrfToken: getToken(request.session),
145
+ mountPath,
146
+ error: response.locals.__("activitypub.messages.errorEmpty"),
147
+ });
148
+ }
149
+
150
+ if (!plugin._federation) {
151
+ return response.status(503).render("activitypub-message-compose", {
152
+ title: response.locals.__("activitypub.messages.compose"),
153
+ readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
154
+ to,
155
+ replyTo: replyTo || "",
156
+ replyContext: null,
157
+ csrfToken: getToken(request.session),
158
+ mountPath,
159
+ error: "Federation not initialized",
160
+ });
161
+ }
162
+
163
+ const { Create, Note, Mention } = await import("@fedify/fedify/vocab");
164
+ const { Temporal } = await import("@js-temporal/polyfill");
165
+ const handle = plugin.options.actor.handle;
166
+ const ctx = plugin._federation.createContext(
167
+ new URL(plugin._publicationUrl),
168
+ { handle, publicationUrl: plugin._publicationUrl },
169
+ );
170
+
171
+ const documentLoader = await ctx.getDocumentLoader({
172
+ identifier: handle,
173
+ });
174
+
175
+ // Resolve recipient — accept @user@domain or full URL
176
+ let recipient;
177
+ try {
178
+ const recipientInput = to.trim();
179
+ if (recipientInput.startsWith("http")) {
180
+ recipient = await ctx.lookupObject(recipientInput, { documentLoader });
181
+ } else {
182
+ // Handle @user@domain format
183
+ const handle = recipientInput.replace(/^@/, "");
184
+ recipient = await ctx.lookupObject(handle, { documentLoader });
185
+ }
186
+ } catch {
187
+ recipient = null;
188
+ }
189
+
190
+ if (!recipient?.id) {
191
+ return response.status(404).render("activitypub-message-compose", {
192
+ title: response.locals.__("activitypub.messages.compose"),
193
+ readerParent: { href: `${mountPath}/admin/reader/messages`, text: response.locals.__("activitypub.messages.title") },
194
+ to,
195
+ replyTo: replyTo || "",
196
+ replyContext: null,
197
+ csrfToken: getToken(request.session),
198
+ mountPath,
199
+ error: response.locals.__("activitypub.messages.errorRecipientNotFound"),
200
+ });
201
+ }
202
+
203
+ // Build Create(Note) with DM addressing — to: recipient only, no PUBLIC_COLLECTION
204
+ const uuid = crypto.randomUUID();
205
+ const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
206
+ const noteId = `${baseUrl}/activitypub/messages/${uuid}`;
207
+ const now = Temporal.Now.instant();
208
+
209
+ // Sanitize outbound content — basic paragraph wrapping
210
+ const htmlContent = `<p>${sanitizeContent(content.trim())}</p>`;
211
+
212
+ const note = new Note({
213
+ id: new URL(noteId),
214
+ attributedTo: ctx.getActorUri(handle),
215
+ tos: [recipient.id],
216
+ tags: [
217
+ new Mention({
218
+ href: recipient.id,
219
+ name: recipient.preferredUsername
220
+ ? `@${recipient.preferredUsername}`
221
+ : recipient.id.href,
222
+ }),
223
+ ],
224
+ content: htmlContent,
225
+ published: now,
226
+ replyTarget: replyTo ? new URL(replyTo) : null,
227
+ });
228
+
229
+ const create = new Create({
230
+ id: new URL(`${noteId}#activity`),
231
+ actor: ctx.getActorUri(handle),
232
+ tos: [recipient.id],
233
+ object: note,
234
+ published: now,
235
+ });
236
+
237
+ await ctx.sendActivity({ identifier: handle }, recipient, create, {
238
+ orderingKey: recipient.id.href,
239
+ });
240
+
241
+ // Store outbound message locally
242
+ const { application } = request.app.locals;
243
+ const collections = {
244
+ ap_messages: application?.collections?.get("ap_messages"),
245
+ };
246
+
247
+ const recipientName = recipient.name?.toString() ||
248
+ recipient.preferredUsername?.toString() ||
249
+ recipient.id.href;
250
+ const recipientHandle = recipient.preferredUsername
251
+ ? `@${recipient.preferredUsername}@${recipient.id.hostname}`
252
+ : recipient.id.href;
253
+
254
+ // Get our actor's icon for the outbound message
255
+ const profileCol = application?.collections?.get("ap_profile");
256
+ const profile = profileCol ? await profileCol.findOne({}) : null;
257
+
258
+ await addMessage(collections, {
259
+ uid: noteId,
260
+ actorUrl: recipient.id.href,
261
+ actorName: recipientName,
262
+ actorPhoto: recipient.iconUrl?.href || "",
263
+ actorHandle: recipientHandle,
264
+ content: {
265
+ text: content.trim(),
266
+ html: htmlContent,
267
+ },
268
+ inReplyTo: replyTo || null,
269
+ conversationId: recipient.id.href,
270
+ direction: "outbound",
271
+ published: new Date().toISOString(),
272
+ createdAt: new Date().toISOString(),
273
+ });
274
+
275
+ console.info(`[ActivityPub] Sent DM to ${recipientName} (${recipient.id.href})`);
276
+
277
+ return response.redirect(`${mountPath}/admin/reader/messages?partner=${encodeURIComponent(recipient.id.href)}`);
278
+ } catch (error) {
279
+ console.error("[ActivityPub] DM send failed:", error.message);
280
+ next(error);
281
+ }
282
+ };
283
+ }
284
+
285
+ /**
286
+ * POST /admin/reader/messages/mark-read — Mark all messages as read.
287
+ */
288
+ export function markAllMessagesReadController(mountPath) {
289
+ return async (request, response, next) => {
290
+ try {
291
+ if (!validateToken(request)) {
292
+ return response.status(403).redirect(`${mountPath}/admin/reader/messages`);
293
+ }
294
+
295
+ const { application } = request.app.locals;
296
+ const collections = {
297
+ ap_messages: application?.collections?.get("ap_messages"),
298
+ };
299
+
300
+ await markAllMessagesRead(collections);
301
+
302
+ return response.redirect(`${mountPath}/admin/reader/messages`);
303
+ } catch (error) {
304
+ next(error);
305
+ }
306
+ };
307
+ }
308
+
309
+ /**
310
+ * POST /admin/reader/messages/clear — Delete all messages.
311
+ */
312
+ export function clearAllMessagesController(mountPath) {
313
+ return async (request, response, next) => {
314
+ try {
315
+ if (!validateToken(request)) {
316
+ return response.status(403).redirect(`${mountPath}/admin/reader/messages`);
317
+ }
318
+
319
+ const { application } = request.app.locals;
320
+ const collections = {
321
+ ap_messages: application?.collections?.get("ap_messages"),
322
+ };
323
+
324
+ await clearAllMessages(collections);
325
+
326
+ return response.redirect(`${mountPath}/admin/reader/messages`);
327
+ } catch (error) {
328
+ next(error);
329
+ }
330
+ };
331
+ }
332
+
333
+ /**
334
+ * POST /admin/reader/messages/delete — Delete a single message.
335
+ */
336
+ export function deleteMessageController(mountPath) {
337
+ return async (request, response, next) => {
338
+ try {
339
+ if (!validateToken(request)) {
340
+ return response.status(403).json({
341
+ success: false,
342
+ error: "Invalid CSRF token",
343
+ });
344
+ }
345
+
346
+ const { uid } = request.body;
347
+
348
+ if (!uid) {
349
+ return response.status(400).json({
350
+ success: false,
351
+ error: "Missing message UID",
352
+ });
353
+ }
354
+
355
+ const { application } = request.app.locals;
356
+ const collections = {
357
+ ap_messages: application?.collections?.get("ap_messages"),
358
+ };
359
+
360
+ await deleteMessage(collections, uid);
361
+
362
+ // Support both JSON (fetch) and form redirect
363
+ if (request.headers.accept?.includes("application/json")) {
364
+ return response.json({ success: true, uid });
365
+ }
366
+
367
+ return response.redirect(`${mountPath}/admin/reader/messages`);
368
+ } catch (error) {
369
+ next(error);
370
+ }
371
+ };
372
+ }
@@ -117,7 +117,7 @@ export function readerController(mountPath) {
117
117
  }
118
118
 
119
119
  export function notificationsController(mountPath) {
120
- const validTabs = ["all", "reply", "like", "boost", "follow"];
120
+ const validTabs = ["all", "reply", "like", "boost", "follow", "dm"];
121
121
 
122
122
  return async (request, response, next) => {
123
123
  try {
@@ -27,6 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js";
27
27
  import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
28
28
  import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
29
29
  import { addNotification } from "./storage/notifications.js";
30
+ import { addMessage } from "./storage/messages.js";
30
31
  import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
31
32
  import { getFollowedTags } from "./storage/followed-tags.js";
32
33
 
@@ -39,6 +40,39 @@ import { getFollowedTags } from "./storage/followed-tags.js";
39
40
  * @param {string} options.handle - Actor handle
40
41
  * @param {boolean} options.storeRawActivities - Whether to store raw JSON
41
42
  */
43
+ /** @type {string} ActivityStreams Public Collection constant */
44
+ const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
45
+
46
+ /**
47
+ * Determine if an object is a direct message (DM).
48
+ * A DM is addressed only to specific actors — no PUBLIC_COLLECTION,
49
+ * no followers collection, and includes our actor URL.
50
+ *
51
+ * @param {object} object - Fedify object (Note, Article, etc.)
52
+ * @param {string} ourActorUrl - Our actor's URL
53
+ * @param {string} followersUrl - Our followers collection URL
54
+ * @returns {boolean}
55
+ */
56
+ function isDirectMessage(object, ourActorUrl, followersUrl) {
57
+ const allAddressed = [
58
+ ...object.toIds.map((u) => u.href),
59
+ ...object.ccIds.map((u) => u.href),
60
+ ...object.btoIds.map((u) => u.href),
61
+ ...object.bccIds.map((u) => u.href),
62
+ ];
63
+
64
+ // Must be addressed to us
65
+ if (!allAddressed.includes(ourActorUrl)) return false;
66
+
67
+ // Must NOT include public collection
68
+ if (allAddressed.some((u) => u === PUBLIC || u === "as:Public")) return false;
69
+
70
+ // Must NOT include our followers collection
71
+ if (followersUrl && allAddressed.includes(followersUrl)) return false;
72
+
73
+ return true;
74
+ }
75
+
42
76
  export function registerInboxListeners(inboxChain, options) {
43
77
  const { collections, handle, storeRawActivities } = options;
44
78
 
@@ -400,6 +434,69 @@ export function registerInboxListeners(inboxChain, options) {
400
434
  actorObj?.preferredUsername?.toString() ||
401
435
  actorUrl;
402
436
 
437
+ // --- DM detection ---
438
+ // Check if this is a direct message before processing as reply/mention/timeline.
439
+ // DMs are handled separately and stored in ap_messages instead of ap_timeline.
440
+ const ourActorUrl = ctx.getActorUri(handle).href;
441
+ const followersUrl = ctx.getFollowersUri(handle)?.href || "";
442
+
443
+ if (isDirectMessage(object, ourActorUrl, followersUrl)) {
444
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
445
+ const rawHtml = object.content?.toString() || "";
446
+ const contentHtml = sanitizeContent(rawHtml);
447
+ const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 500);
448
+ const published = object.published ? String(object.published) : new Date().toISOString();
449
+ const inReplyToDM = object.replyTargetId?.href || null;
450
+
451
+ // Store as message
452
+ await addMessage(collections, {
453
+ uid: object.id?.href || `dm:${actorUrl}:${Date.now()}`,
454
+ actorUrl: actorInfo.url,
455
+ actorName: actorInfo.name,
456
+ actorPhoto: actorInfo.photo,
457
+ actorHandle: actorInfo.handle,
458
+ content: {
459
+ text: contentText,
460
+ html: contentHtml,
461
+ },
462
+ inReplyTo: inReplyToDM,
463
+ conversationId: actorInfo.url,
464
+ direction: "inbound",
465
+ published,
466
+ createdAt: new Date().toISOString(),
467
+ });
468
+
469
+ // Also create a notification so DMs appear in the notification tab
470
+ await addNotification(collections, {
471
+ uid: `dm:${object.id?.href || `${actorUrl}:${Date.now()}`}`,
472
+ url: object.url?.href || object.id?.href || "",
473
+ type: "dm",
474
+ actorUrl: actorInfo.url,
475
+ actorName: actorInfo.name,
476
+ actorPhoto: actorInfo.photo,
477
+ actorHandle: actorInfo.handle,
478
+ content: {
479
+ text: contentText,
480
+ html: contentHtml,
481
+ },
482
+ published,
483
+ createdAt: new Date().toISOString(),
484
+ });
485
+
486
+ await logActivity(collections, storeRawActivities, {
487
+ direction: "inbound",
488
+ type: "DirectMessage",
489
+ actorUrl,
490
+ actorName,
491
+ actorAvatar: actorInfo.photo || "",
492
+ objectUrl: object.id?.href || "",
493
+ content: contentText.substring(0, 100),
494
+ summary: `${actorName} sent a direct message`,
495
+ });
496
+
497
+ return; // Don't process DMs as timeline/mention/reply
498
+ }
499
+
403
500
  // Use replyTargetId (non-fetching) for the inReplyTo URL
404
501
  const inReplyTo = object.replyTargetId?.href || null;
405
502
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Direct message storage operations
3
+ * @module storage/messages
4
+ */
5
+
6
+ /**
7
+ * Add a message (uses atomic upsert for deduplication)
8
+ * @param {object} collections - MongoDB collections
9
+ * @param {object} message - Message data
10
+ * @param {string} message.uid - Activity/object ID (dedup key)
11
+ * @param {string} message.actorUrl - Other party's actor URL
12
+ * @param {string} message.actorName - Display name
13
+ * @param {string} message.actorPhoto - Avatar URL
14
+ * @param {string} message.actorHandle - @user@instance
15
+ * @param {object} message.content - { text, html } (sanitized)
16
+ * @param {string|null} message.inReplyTo - Parent message URL for threading
17
+ * @param {string} message.conversationId - Grouping key (other party's actorUrl)
18
+ * @param {"inbound"|"outbound"} message.direction - Message direction
19
+ * @param {string} message.published - ISO 8601 timestamp
20
+ * @param {string} message.createdAt - ISO 8601 creation timestamp
21
+ * @returns {Promise<object>} Created or existing message
22
+ */
23
+ export async function addMessage(collections, message) {
24
+ const { ap_messages } = collections;
25
+
26
+ const result = await ap_messages.updateOne(
27
+ { uid: message.uid },
28
+ {
29
+ $setOnInsert: {
30
+ ...message,
31
+ read: message.direction === "outbound" ? true : false,
32
+ },
33
+ },
34
+ { upsert: true },
35
+ );
36
+
37
+ return await ap_messages.findOne({ uid: message.uid });
38
+ }
39
+
40
+ /**
41
+ * Get messages with cursor-based pagination
42
+ * @param {object} collections - MongoDB collections
43
+ * @param {object} options - Query options
44
+ * @param {string} [options.before] - Before cursor (published date ISO string)
45
+ * @param {number} [options.limit=20] - Items per page
46
+ * @param {string} [options.partner] - Filter by conversation partner (actorUrl)
47
+ * @returns {Promise<object>} { items, before }
48
+ */
49
+ export async function getMessages(collections, options = {}) {
50
+ const { ap_messages } = collections;
51
+ const parsedLimit = Number.parseInt(options.limit, 10);
52
+ const limit = Math.min(
53
+ Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
54
+ 100,
55
+ );
56
+
57
+ const query = {};
58
+
59
+ // Filter by conversation partner
60
+ if (options.partner) {
61
+ query.conversationId = options.partner;
62
+ }
63
+
64
+ // Cursor pagination — published is ISO string, lexicographic comparison works
65
+ if (options.before) {
66
+ query.published = { $lt: options.before };
67
+ }
68
+
69
+ const rawItems = await ap_messages
70
+ .find(query)
71
+ .sort({ published: -1 })
72
+ .limit(limit)
73
+ .toArray();
74
+
75
+ // Normalize published dates to ISO strings for Nunjucks | date filter
76
+ const items = rawItems.map((item) => ({
77
+ ...item,
78
+ published: item.published instanceof Date
79
+ ? item.published.toISOString()
80
+ : item.published,
81
+ }));
82
+
83
+ // Generate cursor for next page (only if full page returned = more may exist)
84
+ const before =
85
+ items.length === limit
86
+ ? items[items.length - 1].published
87
+ : null;
88
+
89
+ return {
90
+ items,
91
+ before,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Get conversation partners with last message date and unread count
97
+ * @param {object} collections - MongoDB collections
98
+ * @returns {Promise<Array>} Partners sorted by most recent message
99
+ */
100
+ export async function getConversationPartners(collections) {
101
+ const { ap_messages } = collections;
102
+
103
+ const pipeline = [
104
+ {
105
+ $group: {
106
+ _id: "$conversationId",
107
+ actorName: { $last: "$actorName" },
108
+ actorPhoto: { $last: "$actorPhoto" },
109
+ actorHandle: { $last: "$actorHandle" },
110
+ lastMessage: { $max: "$published" },
111
+ unreadCount: {
112
+ $sum: { $cond: [{ $eq: ["$read", false] }, 1, 0] },
113
+ },
114
+ },
115
+ },
116
+ { $sort: { lastMessage: -1 } },
117
+ ];
118
+
119
+ return await ap_messages.aggregate(pipeline).toArray();
120
+ }
121
+
122
+ /**
123
+ * Get count of unread messages
124
+ * @param {object} collections - MongoDB collections
125
+ * @returns {Promise<number>} Unread message count
126
+ */
127
+ export async function getUnreadMessageCount(collections) {
128
+ const { ap_messages } = collections;
129
+ return await ap_messages.countDocuments({ read: false });
130
+ }
131
+
132
+ /**
133
+ * Mark all messages from a partner as read
134
+ * @param {object} collections - MongoDB collections
135
+ * @param {string} actorUrl - Conversation partner's actor URL
136
+ * @returns {Promise<object>} Update result
137
+ */
138
+ export async function markMessagesRead(collections, actorUrl) {
139
+ const { ap_messages } = collections;
140
+ return await ap_messages.updateMany(
141
+ { conversationId: actorUrl, read: false },
142
+ { $set: { read: true } },
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Mark all messages as read
148
+ * @param {object} collections - MongoDB collections
149
+ * @returns {Promise<object>} Update result
150
+ */
151
+ export async function markAllMessagesRead(collections) {
152
+ const { ap_messages } = collections;
153
+ return await ap_messages.updateMany({}, { $set: { read: true } });
154
+ }
155
+
156
+ /**
157
+ * Delete a single message by UID
158
+ * @param {object} collections - MongoDB collections
159
+ * @param {string} uid - Message UID
160
+ * @returns {Promise<object>} Delete result
161
+ */
162
+ export async function deleteMessage(collections, uid) {
163
+ const { ap_messages } = collections;
164
+ return await ap_messages.deleteOne({ uid });
165
+ }
166
+
167
+ /**
168
+ * Delete all messages
169
+ * @param {object} collections - MongoDB collections
170
+ * @returns {Promise<object>} Delete result
171
+ */
172
+ export async function clearAllMessages(collections) {
173
+ const { ap_messages } = collections;
174
+ return await ap_messages.deleteMany({});
175
+ }
@@ -126,7 +126,7 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals
126
126
 
127
127
  const results = await ap_notifications.aggregate(pipeline).toArray();
128
128
 
129
- const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0 };
129
+ const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0, dm: 0 };
130
130
  for (const { _id, count } of results) {
131
131
  counts.all += count;
132
132
  if (_id === "reply" || _id === "mention") {
package/locales/en.json CHANGED
@@ -165,10 +165,32 @@
165
165
  "replies": "Replies",
166
166
  "likes": "Likes",
167
167
  "boosts": "Boosts",
168
- "follows": "Follows"
168
+ "follows": "Follows",
169
+ "dms": "DMs"
169
170
  },
170
171
  "emptyTab": "No %s notifications yet."
171
172
  },
173
+ "messages": {
174
+ "title": "Messages",
175
+ "empty": "No messages yet. Direct messages from other fediverse users will appear here.",
176
+ "allConversations": "All conversations",
177
+ "compose": "New message",
178
+ "send": "Send message",
179
+ "delete": "Delete",
180
+ "markAllRead": "Mark all read",
181
+ "clearAll": "Clear all",
182
+ "clearConfirm": "Delete all messages? This cannot be undone.",
183
+ "recipientLabel": "To",
184
+ "recipientPlaceholder": "@user@instance.social",
185
+ "placeholder": "Write your message...",
186
+ "sentTo": "To",
187
+ "replyingTo": "Replying to",
188
+ "sentYouDM": "sent you a direct message",
189
+ "viewMessage": "View message",
190
+ "errorEmpty": "Message content cannot be empty.",
191
+ "errorNoRecipient": "Please enter a recipient.",
192
+ "errorRecipientNotFound": "Could not find that user. Try a full @user@domain handle."
193
+ },
172
194
  "reader": {
173
195
  "title": "Reader",
174
196
  "tabs": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.8.1",
3
+ "version": "2.9.0",
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",
@@ -13,9 +13,9 @@
13
13
  </div>
14
14
  {% endif %}
15
15
  {% if replyContext.content and (replyContext.content.html or replyContext.content.text) %}
16
- <blockquote class="ap-compose__context-text">
16
+ <div class="ap-card__content ap-compose__context-text">
17
17
  {{ replyContext.content.html | safe if replyContext.content.html else replyContext.content.text | truncate(300) }}
18
- </blockquote>
18
+ </div>
19
19
  {% endif %}
20
20
  <a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
21
21
  </div>
@@ -0,0 +1,59 @@
1
+ {% extends "layouts/ap-reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+
5
+ {% block readercontent %}
6
+ {# Error message #}
7
+ {% if error %}
8
+ <div class="ap-compose__error">{{ error }}</div>
9
+ {% endif %}
10
+
11
+ {# Reply context — the message being replied to #}
12
+ {% if replyContext %}
13
+ <div class="ap-compose__context">
14
+ <div class="ap-compose__context-label">{{ __("activitypub.messages.replyingTo") }}</div>
15
+ <div class="ap-compose__context-author">
16
+ <a href="{{ replyContext.actorUrl }}">{{ replyContext.actorName }}</a>
17
+ </div>
18
+ {% if replyContext.content and (replyContext.content.html or replyContext.content.text) %}
19
+ <div class="ap-card__content ap-compose__context-text">
20
+ {{ replyContext.content.html | safe if replyContext.content.html else replyContext.content.text | truncate(300) }}
21
+ </div>
22
+ {% endif %}
23
+ </div>
24
+ {% endif %}
25
+
26
+ <form method="post" action="{{ mountPath }}/admin/reader/messages/compose" class="ap-compose__form">
27
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
28
+ {% if replyTo %}
29
+ <input type="hidden" name="replyTo" value="{{ replyTo }}">
30
+ {% endif %}
31
+
32
+ {# Recipient field #}
33
+ <div class="ap-compose__field">
34
+ <label for="dm-to" class="ap-compose__label">{{ __("activitypub.messages.recipientLabel") }}</label>
35
+ <input type="text" id="dm-to" name="to" value="{{ to }}"
36
+ class="ap-compose__input"
37
+ placeholder="{{ __('activitypub.messages.recipientPlaceholder') }}"
38
+ required
39
+ autocomplete="off">
40
+ </div>
41
+
42
+ {# Content textarea #}
43
+ <div class="ap-compose__editor">
44
+ <textarea name="content" class="ap-compose__textarea"
45
+ rows="6"
46
+ placeholder="{{ __('activitypub.messages.placeholder') }}"
47
+ required></textarea>
48
+ </div>
49
+
50
+ <div class="ap-compose__actions">
51
+ <button type="submit" class="ap-compose__submit">
52
+ {{ __("activitypub.messages.send") }}
53
+ </button>
54
+ <a href="{{ mountPath }}/admin/reader/messages" class="ap-compose__cancel">
55
+ {{ __("activitypub.compose.cancel") }}
56
+ </a>
57
+ </div>
58
+ </form>
59
+ {% endblock %}
@@ -0,0 +1,78 @@
1
+ {% extends "layouts/ap-reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block readercontent %}
7
+ {# Toolbar — compose + mark read + clear all #}
8
+ {% set msgBase = mountPath + "/admin/reader/messages" %}
9
+ <div class="ap-notifications__toolbar">
10
+ <a href="{{ msgBase }}/compose" class="ap-notifications__btn ap-notifications__btn--primary">
11
+ {{ __("activitypub.messages.compose") }}
12
+ </a>
13
+ {% if unreadCount > 0 %}
14
+ <form method="post" action="{{ msgBase }}/mark-read">
15
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
16
+ <button type="submit" class="ap-notifications__btn">{{ __("activitypub.messages.markAllRead") }}</button>
17
+ </form>
18
+ {% endif %}
19
+ <form method="post" action="{{ msgBase }}/clear"
20
+ onsubmit="return confirm('{{ __("activitypub.messages.clearConfirm") }}')">
21
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
22
+ <button type="submit" class="ap-notifications__btn ap-notifications__btn--danger">{{ __("activitypub.messages.clearAll") }}</button>
23
+ </form>
24
+ </div>
25
+
26
+ <div class="ap-messages__layout">
27
+ {# Conversation partner sidebar #}
28
+ {% if partners.length > 0 %}
29
+ <aside class="ap-messages__sidebar">
30
+ <a href="{{ msgBase }}" class="ap-messages__partner{% if not activePartner %} ap-messages__partner--active{% endif %}">
31
+ {{ __("activitypub.messages.allConversations") }}
32
+ </a>
33
+ {% for p in partners %}
34
+ <a href="{{ msgBase }}?partner={{ p._id | urlencode }}"
35
+ class="ap-messages__partner{% if activePartner == p._id %} ap-messages__partner--active{% endif %}">
36
+ <span class="ap-messages__partner-avatar" data-avatar-fallback>
37
+ {% if p.actorPhoto %}
38
+ <img src="{{ p.actorPhoto }}" alt="{{ p.actorName }}" loading="lazy" crossorigin="anonymous">
39
+ {% endif %}
40
+ <span class="ap-messages__partner-initial" aria-hidden="true">{{ p.actorName[0] | upper if p.actorName else "?" }}</span>
41
+ </span>
42
+ <span class="ap-messages__partner-info">
43
+ <span class="ap-messages__partner-name">{{ p.actorName }}</span>
44
+ {% if p.actorHandle %}
45
+ <span class="ap-messages__partner-handle">{{ p.actorHandle }}</span>
46
+ {% endif %}
47
+ </span>
48
+ {% if p.unreadCount > 0 %}
49
+ <span class="ap-tab__count">{{ p.unreadCount }}</span>
50
+ {% endif %}
51
+ </a>
52
+ {% endfor %}
53
+ </aside>
54
+ {% endif %}
55
+
56
+ {# Messages list #}
57
+ <div class="ap-messages__content">
58
+ {% if items.length > 0 %}
59
+ <div class="ap-timeline">
60
+ {% for item in items %}
61
+ {% include "partials/ap-message-card.njk" %}
62
+ {% endfor %}
63
+ </div>
64
+
65
+ {# Pagination — preserve active partner #}
66
+ {% if before %}
67
+ <nav class="ap-pagination">
68
+ <a href="?{% if activePartner %}partner={{ activePartner | urlencode }}&{% endif %}before={{ before }}" class="ap-pagination__next">
69
+ {{ __("activitypub.reader.pagination.older") }}
70
+ </a>
71
+ </nav>
72
+ {% endif %}
73
+ {% else %}
74
+ {{ prose({ text: __("activitypub.messages.empty") }) }}
75
+ {% endif %}
76
+ </div>
77
+ </div>
78
+ {% endblock %}
@@ -23,6 +23,10 @@
23
23
  {{ __("activitypub.notifications.tabs.follows") }}
24
24
  {% if tabCounts.follow %}<span class="ap-tab__count">{{ tabCounts.follow }}</span>{% endif %}
25
25
  </a>
26
+ <a href="{{ notifBase }}?tab=dm" class="ap-tab{% if tab == 'dm' %} ap-tab--active{% endif %}">
27
+ {{ __("activitypub.notifications.tabs.dms") }}
28
+ {% if tabCounts.dm %}<span class="ap-tab__count">{{ tabCounts.dm }}</span>{% endif %}
29
+ </a>
26
30
  <a href="{{ notifBase }}?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}">
27
31
  {{ __("activitypub.notifications.tabs.all") }}
28
32
  {% if tabCounts.all %}<span class="ap-tab__count">{{ tabCounts.all }}</span>{% endif %}
@@ -0,0 +1,60 @@
1
+ {# Message card partial — inbound/outbound DM display #}
2
+
3
+ <div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}{% if item.direction == 'outbound' %} ap-message--outbound{% endif %}">
4
+ {# Dismiss button #}
5
+ <form method="post" action="{{ mountPath }}/admin/reader/messages/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.messages.delete') }}">&times;</button>
9
+ </form>
10
+
11
+ {# Actor avatar with direction badge #}
12
+ <div class="ap-notification__avatar-wrap" data-avatar-fallback>
13
+ {% if item.actorPhoto %}
14
+ <img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous">
15
+ {% endif %}
16
+ <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
17
+ <span class="ap-notification__type-badge">
18
+ {% if item.direction == "outbound" %}↗{% else %}✉{% endif %}
19
+ </span>
20
+ </div>
21
+
22
+ {# Message body #}
23
+ <div class="ap-notification__body">
24
+ <span class="ap-notification__actor">
25
+ {% if item.direction == "outbound" %}
26
+ <span class="ap-message__direction">{{ __("activitypub.messages.sentTo") }}</span>
27
+ {% endif %}
28
+ <a href="{{ item.actorUrl }}">{{ item.actorName }}</a>
29
+ {% if item.actorHandle %}
30
+ <span class="ap-notification__handle">{{ item.actorHandle }}</span>
31
+ {% endif %}
32
+ </span>
33
+
34
+ {% if item.content and item.content.html %}
35
+ <div class="ap-message__content">
36
+ {{ item.content.html | safe }}
37
+ </div>
38
+ {% elif item.content and item.content.text %}
39
+ <div class="ap-message__content">
40
+ {{ item.content.text }}
41
+ </div>
42
+ {% endif %}
43
+
44
+ {# Reply action (only for inbound messages) #}
45
+ {% if item.direction == "inbound" %}
46
+ <div class="ap-notification__actions">
47
+ <a href="{{ mountPath }}/admin/reader/messages/compose?to={{ item.actorHandle | urlencode }}&replyTo={{ item.uid | urlencode }}" class="ap-notification__reply-btn">
48
+ ↩ {{ __("activitypub.reader.actions.reply") }}
49
+ </a>
50
+ </div>
51
+ {% endif %}
52
+ </div>
53
+
54
+ {# Timestamp #}
55
+ {% if item.published %}
56
+ <time datetime="{{ item.published }}" class="ap-notification__time" x-data x-relative-time>
57
+ {{ item.published | date("PPp") }}
58
+ </time>
59
+ {% endif %}
60
+ </div>
@@ -15,7 +15,7 @@
15
15
  {% endif %}
16
16
  <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
17
17
  <span class="ap-notification__type-badge">
18
- {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% endif %}
18
+ {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% endif %}
19
19
  </span>
20
20
  </div>
21
21
 
@@ -36,6 +36,8 @@
36
36
  {{ __("activitypub.notifications.repliedTo") }}
37
37
  {% elif item.type == "mention" %}
38
38
  {{ __("activitypub.notifications.mentionedYou") }}
39
+ {% elif item.type == "dm" %}
40
+ {{ __("activitypub.messages.sentYouDM") }}
39
41
  {% endif %}
40
42
  </span>
41
43
 
@@ -60,6 +62,12 @@
60
62
  💬 {{ __("activitypub.notifications.viewThread") }}
61
63
  </a>
62
64
  </div>
65
+ {% elif item.type == "dm" %}
66
+ <div class="ap-notification__actions">
67
+ <a href="{{ mountPath }}/admin/reader/messages?partner={{ item.actorUrl | urlencode }}" class="ap-notification__thread-btn" title="{{ __('activitypub.messages.title') }}">
68
+ ✉ {{ __("activitypub.messages.viewMessage") }}
69
+ </a>
70
+ </div>
63
71
  {% endif %}
64
72
  </div>
65
73