fix: resolve in_reply_to_account_id for Phanpy reply threading

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.
This commit is contained in:
Ricardo
2026-03-29 20:09:55 +02:00
parent 23df10ad87
commit 232d942e3f
5 changed files with 32 additions and 21 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) {
* @param {Set<string>} [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,
+23 -14
View File
@@ -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<object>} items - Timeline items with optional inReplyTo
* @returns {Promise<Map<string, string>>} Map of URL → cursor ID
* @returns {Promise<{replyIdMap: Map<string, string>, replyAccountIdMap: Map<string, string>}>}
*/
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 };
}
+4 -3
View File
@@ -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)),
+2 -1
View File
@@ -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,
}),
);
+1 -1
View File
@@ -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",