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>
125 lines
5.3 KiB
JavaScript
125 lines
5.3 KiB
JavaScript
/**
|
||
* 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 30–120 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)`);
|
||
}
|