Files
indiekit-server/scripts/patch-ap-mastodon-reply-threading.mjs
Sven 97d99976ea fix(ap): fix reply threading — pre-check AP syndication and resolve in_reply_to_id immediately
Two bugs caused replies-to-replies to be posted as 'note' type without
ActivityPub federation:

1. patch-ap-compose-default-checked: The AP reader compose form had
   defaultChecked hardcoded to '@rick@rmendes.net' (original dev's handle),
   so the AP syndication checkbox was never pre-checked. Fixed to use
   target.checked from the Micropub q=config response, which already
   carries checked: true for the AP syndicator.

2. patch-ap-mastodon-reply-threading: POST /api/v1/statuses deferred
   ap_timeline insertion until the Eleventy build webhook fired (30–120 s).
   If the user replied to their own new post before the build finished,
   findTimelineItemById returned null → inReplyTo = null → no in-reply-to
   in JF2 → post-type-discovery returned 'note' → reply saved at /notes/
   and sent without inReplyTo in the AP activity, breaking thread display
   on remote servers. Fixed by eagerly inserting the provisional timeline
   item immediately after postContent.create() ($setOnInsert — idempotent;
   syndicator upsert later is a no-op).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:12:43 +02:00

125 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Patch: eagerly insert own post into ap_timeline after Mastodon API POST /statuses.
*
* Root cause:
* When a post is created via POST /api/v1/statuses (Mastodon client API), the
* handler creates the post through the Micropub pipeline but intentionally does
* NOT insert a timeline item immediately. The comment says:
*
* "No timeline entry is created here — the post will appear in the timeline
* after the normal flow: Eleventy rebuild → syndication webhook → AP delivery."
*
* This means there is a window (typically 30120 s while Eleventy rebuilds) where
* the own post does NOT exist in ap_timeline. If the user tries to reply to their
* own newly-created post during this window, POST /api/v1/statuses receives
* `in_reply_to_id` for the new post, but `findTimelineItemById` returns null.
* With inReplyTo = null, the JF2 object has no "in-reply-to" property, and
* post-type-discovery classifies the reply as "note" instead of "reply". The
* reply is then saved at /notes/{slug}/ rather than /replies/{slug}/, and
* since there is no in-reply-to, the ActivityPub activity has no inReplyTo
* field and the thread is broken on remote Mastodon servers.
*
* Fix:
* After calling postContent.create(), immediately insert a provisional timeline
* item into ap_timeline using addTimelineItem() (which uses $setOnInsert —
* idempotent). The AP syndicator will later attempt the same upsert after the
* build webhook fires, which is a no-op since the document already exists.
* This ensures the post is resolvable via in_reply_to_id with zero delay.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const MARKER = "// [patch] ap-mastodon-reply-threading";
const candidates = [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
];
const OLD_SNIPPET = ` // Return a minimal status to the Mastodon client.
// No timeline entry is created here — the post will appear in the timeline
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";`;
const NEW_SNIPPET = ` // Return a minimal status to the Mastodon client. ${MARKER}
// Eagerly insert own post into ap_timeline so the Mastodon client can resolve ${MARKER}
// in_reply_to_id for this post immediately, without waiting for the build webhook. ${MARKER}
// The AP syndicator will upsert the same uid later via $setOnInsert (no-op). ${MARKER}
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";
try { ${MARKER}
const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); ${MARKER}
await addTimelineItem(collections, { ${MARKER}
uid: postUrl, ${MARKER}
url: postUrl, ${MARKER}
type: data.properties["post-type"] || "note", ${MARKER}
content: { text: contentText, html: \`<p>\${contentHtml}</p>\` }, ${MARKER}
author: { ${MARKER}
name: profile?.name || handle, ${MARKER}
url: profile?.url || publicationUrl, ${MARKER}
photo: profile?.icon || "", ${MARKER}
handle: \`@\${handle}@\${_ph}\`, ${MARKER}
emojis: [], ${MARKER}
bot: false, ${MARKER}
}, ${MARKER}
published: data.properties.published || new Date().toISOString(), ${MARKER}
createdAt: new Date().toISOString(), ${MARKER}
inReplyTo: inReplyTo || null, ${MARKER}
visibility: jf2.visibility || "public", ${MARKER}
sensitive: jf2.sensitive === "true", ${MARKER}
category: [], ${MARKER}
counts: { likes: 0, boosts: 0, replies: 0 }, ${MARKER}
}); ${MARKER}
} catch (tlErr) { ${MARKER}
console.warn(\`[Mastodon API] Failed to pre-insert own post into timeline: \${tlErr.message}\`); ${MARKER}
} ${MARKER}`;
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) continue;
checked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(MARKER)) {
console.log(`[postinstall] patch-ap-mastodon-reply-threading: already applied to ${filePath}`);
continue;
}
if (!source.includes(OLD_SNIPPET)) {
console.warn(`[postinstall] patch-ap-mastodon-reply-threading: target snippet not found in ${filePath}`);
continue;
}
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
if (updated === source) {
console.log(`[postinstall] patch-ap-mastodon-reply-threading: no changes in ${filePath}`);
continue;
}
await writeFile(filePath, updated, "utf8");
patched += 1;
console.log(`[postinstall] Applied patch-ap-mastodon-reply-threading to ${filePath}`);
}
if (checked === 0) {
console.log("[postinstall] patch-ap-mastodon-reply-threading: no target files found");
} else if (patched === 0) {
console.log("[postinstall] patch-ap-mastodon-reply-threading: already up to date");
} else {
console.log(`[postinstall] patch-ap-mastodon-reply-threading: patched ${patched} file(s)`);
}