diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index db476ce..871e3af 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) { * @param {Set} [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, diff --git a/lib/mastodon/helpers/resolve-reply-ids.js b/lib/mastodon/helpers/resolve-reply-ids.js new file mode 100644 index 0000000..2295ded --- /dev/null +++ b/lib/mastodon/helpers/resolve-reply-ids.js @@ -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} items - Timeline items with optional inReplyTo + * @returns {Promise>} 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; +} diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 991565e..66924cd 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -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, }), ); diff --git a/lib/syndicator.js b/lib/syndicator.js index 55b918b..b7f9320 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -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 = `

\u{1F517} ${esc(postUrl)}

`; + return { + text: `${bodyText}${linkText}`, + html: `${bodyHtml}\n${linkHtml}`, + }; + } return { text: bodyText, html: bodyHtml }; } diff --git a/package.json b/package.json index b920a7c..35ec73f 100644 --- a/package.json +++ b/package.json @@ -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",