fix: resolve in_reply_to_id for threading + append permalink to timeline content

Two fixes for Mastodon API parity:

1. Implement in_reply_to_id resolution (was hardcoded null TODO).
   New resolve-reply-ids.js batch-looks up parent posts in ap_timeline
   and returns cursor IDs. Wired into status, context, and timeline
   route handlers via replyIdMap option on serializeStatus.

2. Append permalink to own posts in ap_timeline content, matching the
   AS2 federation output. Without this, posts viewed via Mastodon API
   (Phanpy/Moshidon) lacked the source link that fediverse users see.
This commit is contained in:
Ricardo
2026-03-29 17:41:01 +02:00
parent d194629f7d
commit 23df10ad87
5 changed files with 71 additions and 5 deletions
+3 -3
View File
@@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) {
* @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned * @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
* @returns {object} Mastodon Status entity * @returns {object} Mastodon Status entity
*/ */
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) { export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) {
if (!item) return null; if (!item) return null;
// Use published-based cursor as the status ID so pagination cursors // Use published-based cursor as the status ID so pagination cursors
@@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
return { return {
id, id,
created_at: published, created_at: published,
in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
in_reply_to_account_id: null, // TODO: resolve in_reply_to_account_id: null,
sensitive, sensitive,
spoiler_text: spoilerText, spoiler_text: spoilerText,
visibility, visibility,
+44
View File
@@ -0,0 +1,44 @@
/**
* Batch-resolve inReplyTo URLs to Mastodon cursor IDs.
*
* Looks up parent posts in ap_timeline by uid/url and returns a Map
* of inReplyTo URL → cursor ID (milliseconds since epoch as string).
* Used by route handlers before calling serializeStatus().
*
* @param {object} collection - ap_timeline MongoDB collection
* @param {Array<object>} items - Timeline items with optional inReplyTo
* @returns {Promise<Map<string, string>>} Map of URL → cursor ID
*/
import { encodeCursor } from "./pagination.js";
export async function resolveReplyIds(collection, items) {
const map = new Map();
if (!collection || !items?.length) return map;
// Collect unique inReplyTo URLs
const urls = [
...new Set(
items
.map((item) => item.inReplyTo)
.filter(Boolean),
),
];
if (urls.length === 0) return map;
// Batch lookup parents by uid or url
const parents = await collection
.find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] })
.project({ uid: 1, url: 1, published: 1 })
.toArray();
for (const parent of parents) {
const cursorId = encodeCursor(parent.published);
if (cursorId && cursorId !== "0") {
// Map both uid and url to the cursor ID
if (parent.uid) map.set(parent.uid, cursorId);
if (parent.url && parent.url !== parent.uid) map.set(parent.url, cursorId);
}
}
return map;
}
+11
View File
@@ -8,6 +8,7 @@
import express from "express"; import express from "express";
import { serializeStatus } from "../entities/status.js"; import { serializeStatus } from "../entities/status.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
import { enrichAccountStats } from "../helpers/enrich-accounts.js"; import { enrichAccountStats } from "../helpers/enrich-accounts.js";
import { tokenRequired } from "../middleware/token-required.js"; import { tokenRequired } from "../middleware/token-required.js";
@@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
items, items,
); );
// Resolve reply parent IDs for threading
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
// Serialize to Mastodon Status entities // Serialize to Mastodon Status entities
const statuses = items.map((item) => const statuses = items.map((item) =>
serializeStatus(item, { serializeStatus(item, {
@@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
rebloggedIds, rebloggedIds,
bookmarkedIds, bookmarkedIds,
pinnedIds: new Set(), pinnedIds: new Set(),
replyIdMap,
}), }),
); );
@@ -163,6 +168,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
)); ));
} }
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
const statuses = items.map((item) => const statuses = items.map((item) =>
serializeStatus(item, { serializeStatus(item, {
baseUrl, baseUrl,
@@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
rebloggedIds, rebloggedIds,
bookmarkedIds, bookmarkedIds,
pinnedIds: new Set(), pinnedIds: new Set(),
replyIdMap,
}), }),
); );
@@ -226,6 +234,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
)); ));
} }
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
const statuses = items.map((item) => const statuses = items.map((item) =>
serializeStatus(item, { serializeStatus(item, {
baseUrl, baseUrl,
@@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
rebloggedIds, rebloggedIds,
bookmarkedIds, bookmarkedIds,
pinnedIds: new Set(), pinnedIds: new Set(),
replyIdMap,
}), }),
); );
+12 -1
View File
@@ -344,8 +344,19 @@ function buildTimelineContent(properties) {
}; };
} }
// Regular post — return body content as-is // Regular post — append permalink to match federated AS2 content.
// Without this, the Mastodon API timeline entry lacks the link back
// to the source post that fediverse users see via federation.
if (bodyText || bodyHtml) { if (bodyText || bodyHtml) {
const postUrl = properties.url;
if (postUrl) {
const linkText = `\n\n\u{1F517} ${postUrl}`;
const linkHtml = `<p>\u{1F517} <a href="${esc(postUrl)}">${esc(postUrl)}</a></p>`;
return {
text: `${bodyText}${linkText}`,
html: `${bodyHtml}\n${linkHtml}`,
};
}
return { text: bodyText, html: bodyHtml }; return { text: bodyText, html: bodyHtml };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.11.5", "version": "3.11.6",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",