From 232d942e3f907d91cd2a5e832ce6c0ac36f4c460 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 29 Mar 2026 20:09:55 +0200 Subject: [PATCH] fix: resolve in_reply_to_account_id for Phanpy reply threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phanpy uses in_reply_to_account_id to show "Replying to @user" context. Without it, replies appear as standalone posts in the timeline. resolveReplyIds now returns both replyIdMap and replyAccountIdMap. Account IDs computed via remoteActorId(author.url) — same deterministic hash used by the account entity serializer. --- lib/mastodon/entities/status.js | 4 +-- lib/mastodon/helpers/resolve-reply-ids.js | 37 ++++++++++++++--------- lib/mastodon/routes/statuses.js | 7 +++-- lib/mastodon/routes/timelines.js | 3 +- package.json | 2 +- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index 871e3af..c0c53e1 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, replyIdMap } = {}) { +export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) { if (!item) return null; // Use published-based cursor as the status ID so pagination cursors @@ -205,7 +205,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo id, created_at: published, in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null, - in_reply_to_account_id: null, + in_reply_to_account_id: replyAccountIdMap?.get(item.inReplyTo) ?? null, sensitive, spoiler_text: spoilerText, visibility, diff --git a/lib/mastodon/helpers/resolve-reply-ids.js b/lib/mastodon/helpers/resolve-reply-ids.js index 2295ded..d8d8c6c 100644 --- a/lib/mastodon/helpers/resolve-reply-ids.js +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -1,19 +1,23 @@ /** - * Batch-resolve inReplyTo URLs to Mastodon cursor IDs. + * Batch-resolve inReplyTo URLs to Mastodon cursor IDs and account IDs. + * + * Looks up parent posts in ap_timeline by uid/url and returns two Maps: + * - replyIdMap: inReplyTo URL → cursor ID (status ID) + * - replyAccountIdMap: inReplyTo URL → author account ID * - * 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 + * @returns {Promise<{replyIdMap: Map, replyAccountIdMap: Map}>} */ import { encodeCursor } from "./pagination.js"; +import { remoteActorId } from "./id-mapping.js"; export async function resolveReplyIds(collection, items) { - const map = new Map(); - if (!collection || !items?.length) return map; + const replyIdMap = new Map(); + const replyAccountIdMap = new Map(); + if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap }; // Collect unique inReplyTo URLs const urls = [ @@ -23,22 +27,27 @@ export async function resolveReplyIds(collection, items) { .filter(Boolean), ), ]; - if (urls.length === 0) return map; + if (urls.length === 0) return { replyIdMap, replyAccountIdMap }; // 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 }) + .project({ uid: 1, url: 1, published: 1, "author.url": 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); - } + const authorUrl = parent.author?.url; + const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null; + + const setMaps = (key) => { + if (cursorId && cursorId !== "0") replyIdMap.set(key, cursorId); + if (authorAccountId) replyAccountIdMap.set(key, authorAccountId); + }; + + if (parent.uid) setMaps(parent.uid); + if (parent.url && parent.url !== parent.uid) setMaps(parent.url); } - return map; + return { replyIdMap, replyAccountIdMap }; } diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index e572b8c..d275b8e 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -44,13 +44,14 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st // Load interaction state if authenticated const interactionState = await loadItemInteractions(collections, item); - const replyIdMap = await resolveReplyIds(collections.ap_timeline, [item]); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, [item]); const status = serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set(), replyIdMap, + replyAccountIdMap, }); res.json(status); @@ -126,8 +127,8 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", }; const allItems = [...ancestors, ...descendants]; - const replyIdMap = await resolveReplyIds(collections.ap_timeline, allItems); - const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap }; + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); + const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap }; res.json({ ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 66924cd..dc88015 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -65,7 +65,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: ); // Resolve reply parent IDs for threading - const replyIdMap = await resolveReplyIds(collections.ap_timeline, items); + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, items); // Serialize to Mastodon Status entities const statuses = items.map((item) => @@ -76,6 +76,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read: bookmarkedIds, pinnedIds: new Set(), replyIdMap, + replyAccountIdMap, }), ); diff --git a/package.json b/package.json index 35ec73f..90313eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.11.6", + "version": "3.11.7", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",