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