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 * @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, replyIdMap } = {}) { export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) {
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
@@ -205,7 +205,7 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
id, id,
created_at: published, created_at: published,
in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null, 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, sensitive,
spoiler_text: spoilerText, spoiler_text: spoilerText,
visibility, 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(). * Used by route handlers before calling serializeStatus().
* *
* @param {object} collection - ap_timeline MongoDB collection * @param {object} collection - ap_timeline MongoDB collection
* @param {Array<object>} items - Timeline items with optional inReplyTo * @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 { encodeCursor } from "./pagination.js";
import { remoteActorId } from "./id-mapping.js";
export async function resolveReplyIds(collection, items) { export async function resolveReplyIds(collection, items) {
const map = new Map(); const replyIdMap = new Map();
if (!collection || !items?.length) return map; const replyAccountIdMap = new Map();
if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap };
// Collect unique inReplyTo URLs // Collect unique inReplyTo URLs
const urls = [ const urls = [
@@ -23,22 +27,27 @@ export async function resolveReplyIds(collection, items) {
.filter(Boolean), .filter(Boolean),
), ),
]; ];
if (urls.length === 0) return map; if (urls.length === 0) return { replyIdMap, replyAccountIdMap };
// Batch lookup parents by uid or url // Batch lookup parents by uid or url
const parents = await collection const parents = await collection
.find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] }) .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(); .toArray();
for (const parent of parents) { for (const parent of parents) {
const cursorId = encodeCursor(parent.published); const cursorId = encodeCursor(parent.published);
if (cursorId && cursorId !== "0") { const authorUrl = parent.author?.url;
// Map both uid and url to the cursor ID const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null;
if (parent.uid) map.set(parent.uid, cursorId);
if (parent.url && parent.url !== parent.uid) map.set(parent.url, cursorId); 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 // Load interaction state if authenticated
const interactionState = await loadItemInteractions(collections, item); 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, { const status = serializeStatus(item, {
baseUrl, baseUrl,
...interactionState, ...interactionState,
pinnedIds: new Set(), pinnedIds: new Set(),
replyIdMap, replyIdMap,
replyAccountIdMap,
}); });
res.json(status); res.json(status);
@@ -126,8 +127,8 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read",
}; };
const allItems = [...ancestors, ...descendants]; const allItems = [...ancestors, ...descendants];
const replyIdMap = await resolveReplyIds(collections.ap_timeline, allItems); const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap }; const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };
res.json({ res.json({
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), 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 // 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 // Serialize to Mastodon Status entities
const statuses = items.map((item) => const statuses = items.map((item) =>
@@ -76,6 +76,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
bookmarkedIds, bookmarkedIds,
pinnedIds: new Set(), pinnedIds: new Set(),
replyIdMap, replyIdMap,
replyAccountIdMap,
}), }),
); );
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "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",