@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 ? null : null, // TODO: resolve to local ID
208
- in_reply_to_account_id: null, // TODO: resolve
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 serializeOpts = { baseUrl, ...emptyInteractions };
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({ value: m.url, alt: m.alt });
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 — return body content as-is
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.4",
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",