@rmdes/indiekit-endpoint-activitypub 3.11.4 → 3.11.7
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.
|
@@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) {
|
|
|
44
44
|
* @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
|
|
45
45
|
* @returns {object} Mastodon Status entity
|
|
46
46
|
*/
|
|
47
|
-
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
|
|
47
|
+
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) {
|
|
48
48
|
if (!item) return null;
|
|
49
49
|
|
|
50
50
|
// Use published-based cursor as the status ID so pagination cursors
|
|
@@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
|
|
|
204
204
|
return {
|
|
205
205
|
id,
|
|
206
206
|
created_at: published,
|
|
207
|
-
in_reply_to_id: item.inReplyTo
|
|
208
|
-
in_reply_to_account_id: null,
|
|
207
|
+
in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
|
|
208
|
+
in_reply_to_account_id: replyAccountIdMap?.get(item.inReplyTo) ?? null,
|
|
209
209
|
sensitive,
|
|
210
210
|
spoiler_text: spoilerText,
|
|
211
211
|
visibility,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch-resolve inReplyTo URLs to Mastodon cursor IDs and account IDs.
|
|
3
|
+
*
|
|
4
|
+
* Looks up parent posts in ap_timeline by uid/url and returns two Maps:
|
|
5
|
+
* - replyIdMap: inReplyTo URL → cursor ID (status ID)
|
|
6
|
+
* - replyAccountIdMap: inReplyTo URL → author account ID
|
|
7
|
+
*
|
|
8
|
+
* Used by route handlers before calling serializeStatus().
|
|
9
|
+
*
|
|
10
|
+
* @param {object} collection - ap_timeline MongoDB collection
|
|
11
|
+
* @param {Array<object>} items - Timeline items with optional inReplyTo
|
|
12
|
+
* @returns {Promise<{replyIdMap: Map<string, string>, replyAccountIdMap: Map<string, string>}>}
|
|
13
|
+
*/
|
|
14
|
+
import { encodeCursor } from "./pagination.js";
|
|
15
|
+
import { remoteActorId } from "./id-mapping.js";
|
|
16
|
+
|
|
17
|
+
export async function resolveReplyIds(collection, items) {
|
|
18
|
+
const replyIdMap = new Map();
|
|
19
|
+
const replyAccountIdMap = new Map();
|
|
20
|
+
if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap };
|
|
21
|
+
|
|
22
|
+
// Collect unique inReplyTo URLs
|
|
23
|
+
const urls = [
|
|
24
|
+
...new Set(
|
|
25
|
+
items
|
|
26
|
+
.map((item) => item.inReplyTo)
|
|
27
|
+
.filter(Boolean),
|
|
28
|
+
),
|
|
29
|
+
];
|
|
30
|
+
if (urls.length === 0) return { replyIdMap, replyAccountIdMap };
|
|
31
|
+
|
|
32
|
+
// Batch lookup parents by uid or url
|
|
33
|
+
const parents = await collection
|
|
34
|
+
.find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] })
|
|
35
|
+
.project({ uid: 1, url: 1, published: 1, "author.url": 1 })
|
|
36
|
+
.toArray();
|
|
37
|
+
|
|
38
|
+
for (const parent of parents) {
|
|
39
|
+
const cursorId = encodeCursor(parent.published);
|
|
40
|
+
const authorUrl = parent.author?.url;
|
|
41
|
+
const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null;
|
|
42
|
+
|
|
43
|
+
const setMaps = (key) => {
|
|
44
|
+
if (cursorId && cursorId !== "0") replyIdMap.set(key, cursorId);
|
|
45
|
+
if (authorAccountId) replyAccountIdMap.set(key, authorAccountId);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (parent.uid) setMaps(parent.uid);
|
|
49
|
+
if (parent.url && parent.url !== parent.uid) setMaps(parent.url);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { replyIdMap, replyAccountIdMap };
|
|
53
|
+
}
|
|
@@ -18,6 +18,7 @@ import express from "express";
|
|
|
18
18
|
import { ObjectId } from "mongodb";
|
|
19
19
|
import { serializeStatus } from "../entities/status.js";
|
|
20
20
|
import { decodeCursor } from "../helpers/pagination.js";
|
|
21
|
+
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
|
|
21
22
|
import {
|
|
22
23
|
likePost, unlikePost,
|
|
23
24
|
boostPost, unboostPost,
|
|
@@ -43,11 +44,14 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st
|
|
|
43
44
|
|
|
44
45
|
// Load interaction state if authenticated
|
|
45
46
|
const interactionState = await loadItemInteractions(collections, item);
|
|
47
|
+
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [item]);
|
|
46
48
|
|
|
47
49
|
const status = serializeStatus(item, {
|
|
48
50
|
baseUrl,
|
|
49
51
|
...interactionState,
|
|
50
52
|
pinnedIds: new Set(),
|
|
53
|
+
replyIdMap,
|
|
54
|
+
replyAccountIdMap,
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
res.json(status);
|
|
@@ -122,7 +126,9 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read",
|
|
|
122
126
|
pinnedIds: new Set(),
|
|
123
127
|
};
|
|
124
128
|
|
|
125
|
-
const
|
|
129
|
+
const allItems = [...ancestors, ...descendants];
|
|
130
|
+
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
|
|
131
|
+
const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };
|
|
126
132
|
|
|
127
133
|
res.json({
|
|
128
134
|
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
|
|
@@ -245,7 +251,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|
|
245
251
|
for (const m of mediaUrls) {
|
|
246
252
|
if (m.type?.startsWith("image/")) {
|
|
247
253
|
if (!jf2.photo) jf2.photo = [];
|
|
248
|
-
jf2.photo.push({
|
|
254
|
+
jf2.photo.push({ url: m.url, alt: m.alt });
|
|
249
255
|
} else if (m.type?.startsWith("video/")) {
|
|
250
256
|
if (!jf2.video) jf2.video = [];
|
|
251
257
|
jf2.video.push(m.url);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import express from "express";
|
|
9
9
|
import { serializeStatus } from "../entities/status.js";
|
|
10
10
|
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
|
|
11
|
+
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
|
|
11
12
|
import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
|
|
12
13
|
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
|
|
13
14
|
import { tokenRequired } from "../middleware/token-required.js";
|
|
@@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
|
|
63
64
|
items,
|
|
64
65
|
);
|
|
65
66
|
|
|
67
|
+
// Resolve reply parent IDs for threading
|
|
68
|
+
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, items);
|
|
69
|
+
|
|
66
70
|
// Serialize to Mastodon Status entities
|
|
67
71
|
const statuses = items.map((item) =>
|
|
68
72
|
serializeStatus(item, {
|
|
@@ -71,6 +75,8 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
|
|
71
75
|
rebloggedIds,
|
|
72
76
|
bookmarkedIds,
|
|
73
77
|
pinnedIds: new Set(),
|
|
78
|
+
replyIdMap,
|
|
79
|
+
replyAccountIdMap,
|
|
74
80
|
}),
|
|
75
81
|
);
|
|
76
82
|
|
|
@@ -163,6 +169,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
163
169
|
));
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
173
|
+
|
|
166
174
|
const statuses = items.map((item) =>
|
|
167
175
|
serializeStatus(item, {
|
|
168
176
|
baseUrl,
|
|
@@ -170,6 +178,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
170
178
|
rebloggedIds,
|
|
171
179
|
bookmarkedIds,
|
|
172
180
|
pinnedIds: new Set(),
|
|
181
|
+
replyIdMap,
|
|
173
182
|
}),
|
|
174
183
|
);
|
|
175
184
|
|
|
@@ -226,6 +235,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|
|
226
235
|
));
|
|
227
236
|
}
|
|
228
237
|
|
|
238
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
239
|
+
|
|
229
240
|
const statuses = items.map((item) =>
|
|
230
241
|
serializeStatus(item, {
|
|
231
242
|
baseUrl,
|
|
@@ -233,6 +244,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|
|
233
244
|
rebloggedIds,
|
|
234
245
|
bookmarkedIds,
|
|
235
246
|
pinnedIds: new Set(),
|
|
247
|
+
replyIdMap,
|
|
236
248
|
}),
|
|
237
249
|
);
|
|
238
250
|
|
package/lib/syndicator.js
CHANGED
|
@@ -344,8 +344,19 @@ function buildTimelineContent(properties) {
|
|
|
344
344
|
};
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
// Regular post —
|
|
347
|
+
// Regular post — append permalink to match federated AS2 content.
|
|
348
|
+
// Without this, the Mastodon API timeline entry lacks the link back
|
|
349
|
+
// to the source post that fediverse users see via federation.
|
|
348
350
|
if (bodyText || bodyHtml) {
|
|
351
|
+
const postUrl = properties.url;
|
|
352
|
+
if (postUrl) {
|
|
353
|
+
const linkText = `\n\n\u{1F517} ${postUrl}`;
|
|
354
|
+
const linkHtml = `<p>\u{1F517} <a href="${esc(postUrl)}">${esc(postUrl)}</a></p>`;
|
|
355
|
+
return {
|
|
356
|
+
text: `${bodyText}${linkText}`,
|
|
357
|
+
html: `${bodyHtml}\n${linkHtml}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
349
360
|
return { text: bodyText, html: bodyHtml };
|
|
350
361
|
}
|
|
351
362
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.7",
|
|
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",
|