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:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user