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
|
* @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 }) {
|
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) {
|
||||||
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
|
||||||
@@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
created_at: published,
|
created_at: published,
|
||||||
in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
|
in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
|
||||||
in_reply_to_account_id: null, // TODO: resolve
|
in_reply_to_account_id: null,
|
||||||
sensitive,
|
sensitive,
|
||||||
spoiler_text: spoilerText,
|
spoiler_text: spoilerText,
|
||||||
visibility,
|
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 express from "express";
|
||||||
import { serializeStatus } from "../entities/status.js";
|
import { serializeStatus } from "../entities/status.js";
|
||||||
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.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 { loadModerationData, applyModerationFilters } from "../../item-processing.js";
|
||||||
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
|
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
|
||||||
import { tokenRequired } from "../middleware/token-required.js";
|
import { tokenRequired } from "../middleware/token-required.js";
|
||||||
@@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
|||||||
items,
|
items,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve reply parent IDs for threading
|
||||||
|
const replyIdMap = 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) =>
|
||||||
serializeStatus(item, {
|
serializeStatus(item, {
|
||||||
@@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
|||||||
rebloggedIds,
|
rebloggedIds,
|
||||||
bookmarkedIds,
|
bookmarkedIds,
|
||||||
pinnedIds: new Set(),
|
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) =>
|
const statuses = items.map((item) =>
|
||||||
serializeStatus(item, {
|
serializeStatus(item, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|||||||
rebloggedIds,
|
rebloggedIds,
|
||||||
bookmarkedIds,
|
bookmarkedIds,
|
||||||
pinnedIds: new Set(),
|
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) =>
|
const statuses = items.map((item) =>
|
||||||
serializeStatus(item, {
|
serializeStatus(item, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|||||||
rebloggedIds,
|
rebloggedIds,
|
||||||
bookmarkedIds,
|
bookmarkedIds,
|
||||||
pinnedIds: new Set(),
|
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) {
|
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 };
|
return { text: bodyText, html: bodyHtml };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"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