From 23df10ad87c712a9a08bf37fe554abfca6d19a08 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 17:41:01 +0200 Subject: [PATCH] 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. --- lib/mastodon/entities/status.js | 6 ++-- lib/mastodon/helpers/resolve-reply-ids.js | 44 +++++++++++++++++++++++ lib/mastodon/routes/timelines.js | 11 ++++++ lib/syndicator.js | 13 ++++++- package.json | 2 +- 5 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 lib/mastodon/helpers/resolve-reply-ids.js diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index db476ce..871e3af 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) { * @param {Set} [options.pinnedIds] - UIDs the user has pinned * @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; // Use published-based cursor as the status ID so pagination cursors @@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo return { id, created_at: published, - in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID - in_reply_to_account_id: null, // TODO: resolve + in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null, + in_reply_to_account_id: null, sensitive, spoiler_text: spoilerText, visibility, diff --git a/lib/mastodon/helpers/resolve-reply-ids.js b/lib/mastodon/helpers/resolve-reply-ids.js new file mode 100644 index 0000000..2295ded --- /dev/null +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -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} items - Timeline items with optional inReplyTo + * @returns {Promise>} 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; +} diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 991565e..66924cd 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -8,6 +8,7 @@ import express from "express"; import { serializeStatus } from "../entities/status.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 { enrichAccountStats } from "../helpers/enrich-accounts.js"; import { tokenRequired } from "../middleware/token-required.js"; @@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: items, ); + // Resolve reply parent IDs for threading + const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + // Serialize to Mastodon Status entities const statuses = items.map((item) => serializeStatus(item, { @@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: rebloggedIds, bookmarkedIds, 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) => serializeStatus(item, { baseUrl, @@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { rebloggedIds, bookmarkedIds, 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) => serializeStatus(item, { baseUrl, @@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { rebloggedIds, bookmarkedIds, pinnedIds: new Set(), + replyIdMap, }), ); diff --git a/lib/syndicator.js b/lib/syndicator.js index 55b918b..b7f9320 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -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) { + const postUrl = properties.url; + if (postUrl) { + const linkText = `\n\n\u{1F517} ${postUrl}`; + const linkHtml = `

\u{1F517} ${esc(postUrl)}

`; + return { + text: `${bodyText}${linkText}`, + html: `${bodyHtml}\n${linkHtml}`, + }; + } return { text: bodyText, html: bodyHtml }; } diff --git a/package.json b/package.json index b920a7c..35ec73f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit",