@rmdes/indiekit-endpoint-activitypub 2.8.2 → 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 +160 -0
- package/index.js +52 -0
- package/lib/controllers/messages.js +372 -0
- package/lib/controllers/reader.js +1 -1
- package/lib/inbox-listeners.js +97 -0
- package/lib/storage/messages.js +175 -0
- package/lib/storage/notifications.js +1 -1
- package/locales/en.json +23 -1
- package/package.json +1 -1
- package/views/activitypub-message-compose.njk +59 -0
- package/views/activitypub-messages.njk +78 -0
- package/views/activitypub-notifications.njk +4 -0
- package/views/partials/ap-message-card.njk +60 -0
- package/views/partials/ap-notification-card.njk +9 -1
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 {
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -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') }}">×</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
|
|