fix: resolve in_reply_to_id for threading + append permalink to timeline content
Two fixes for Mastodon API parity: 1. Implement in_reply_to_id resolution (was hardcoded null TODO). New resolve-reply-ids.js batch-looks up parent posts in ap_timeline and returns cursor IDs. Wired into status, context, and timeline route handlers via replyIdMap option on serializeStatus. 2. Append permalink to own posts in ap_timeline content, matching the AS2 federation output. Without this, posts viewed via Mastodon API (Phanpy/Moshidon) lacked the source link that fediverse users see.
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 }) {
|
||||
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) {
|
||||
if (!item) return null;
|
||||
|
||||
// Use published-based cursor as the status ID so pagination cursors
|
||||
@@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
|
||||
return {
|
||||
id,
|
||||
created_at: published,
|
||||
in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
|
||||
in_reply_to_account_id: null, // TODO: resolve
|
||||
in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
|
||||
in_reply_to_account_id: null,
|
||||
sensitive,
|
||||
spoiler_text: spoilerText,
|
||||
visibility,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Batch-resolve inReplyTo URLs to Mastodon cursor IDs.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
import { encodeCursor } from "./pagination.js";
|
||||
|
||||
export async function resolveReplyIds(collection, items) {
|
||||
const map = new Map();
|
||||
if (!collection || !items?.length) return map;
|
||||
|
||||
// Collect unique inReplyTo URLs
|
||||
const urls = [
|
||||
...new Set(
|
||||
items
|
||||
.map((item) => item.inReplyTo)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (urls.length === 0) return map;
|
||||
|
||||
// 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 })
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import express from "express";
|
||||
import { serializeStatus } from "../entities/status.js";
|
||||
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
|
||||
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
|
||||
import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
|
||||
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
|
||||
import { tokenRequired } from "../middleware/token-required.js";
|
||||
@@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
||||
items,
|
||||
);
|
||||
|
||||
// Resolve reply parent IDs for threading
|
||||
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
||||
|
||||
// Serialize to Mastodon Status entities
|
||||
const statuses = items.map((item) =>
|
||||
serializeStatus(item, {
|
||||
@@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
||||
rebloggedIds,
|
||||
bookmarkedIds,
|
||||
pinnedIds: new Set(),
|
||||
replyIdMap,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -163,6 +168,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
||||
));
|
||||
}
|
||||
|
||||
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
||||
|
||||
const statuses = items.map((item) =>
|
||||
serializeStatus(item, {
|
||||
baseUrl,
|
||||
@@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
||||
rebloggedIds,
|
||||
bookmarkedIds,
|
||||
pinnedIds: new Set(),
|
||||
replyIdMap,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -226,6 +234,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
||||
));
|
||||
}
|
||||
|
||||
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
||||
|
||||
const statuses = items.map((item) =>
|
||||
serializeStatus(item, {
|
||||
baseUrl,
|
||||
@@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
||||
rebloggedIds,
|
||||
bookmarkedIds,
|
||||
pinnedIds: new Set(),
|
||||
replyIdMap,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+12
-1
@@ -344,8 +344,19 @@ function buildTimelineContent(properties) {
|
||||
};
|
||||
}
|
||||
|
||||
// Regular post — return body content as-is
|
||||
// Regular post — append permalink to match federated AS2 content.
|
||||
// Without this, the Mastodon API timeline entry lacks the link back
|
||||
// to the source post that fediverse users see via federation.
|
||||
if (bodyText || bodyHtml) {
|
||||
const postUrl = properties.url;
|
||||
if (postUrl) {
|
||||
const linkText = `\n\n\u{1F517} ${postUrl}`;
|
||||
const linkHtml = `<p>\u{1F517} <a href="${esc(postUrl)}">${esc(postUrl)}</a></p>`;
|
||||
return {
|
||||
text: `${bodyText}${linkText}`,
|
||||
html: `${bodyHtml}\n${linkHtml}`,
|
||||
};
|
||||
}
|
||||
return { text: bodyText, html: bodyHtml };
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||
"version": "3.11.5",
|
||||
"version": "3.11.6",
|
||||
"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