From 55f48d210d7f12b4a7406339741ddc734c6fad9e Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 12 Apr 2026 13:51:24 +0200 Subject: [PATCH] feat: consolidated patch-ap-mastodon-statuses (threading, status-id, delete, reply-id, interactions) Co-Authored-By: Claude Sonnet 4.6 --- scripts/patch-ap-mastodon-statuses.mjs | 264 +++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 scripts/patch-ap-mastodon-statuses.mjs diff --git a/scripts/patch-ap-mastodon-statuses.mjs b/scripts/patch-ap-mastodon-statuses.mjs new file mode 100644 index 00000000..c700ab29 --- /dev/null +++ b/scripts/patch-ap-mastodon-statuses.mjs @@ -0,0 +1,264 @@ +/** + * Consolidated patch for lib/mastodon/routes/statuses.js. + * + * Absorbs: + * - patch-ap-mastodon-reply-threading (eager ap_timeline insert on POST /statuses) + * - patch-ap-mastodon-status-id (return addTimelineItem _id as status ID) + * - patch-ap-mastodon-delete-fix (Change C only — broadcastDelete after timeline removal) + * - patch-ap-status-reply-id (Change B only — store inReplyToId in timeline insert) + * - patch-ap-interactions-context-state (load real interaction state for thread context) + * + * Note: patch-ap-mastodon-delete-fix Change A (index.js) is handled separately. + * patch-ap-status-reply-id Change A (status.js entity) is upstream-fixed — omitted. + * + * Patches are applied in order. Patches 2 and 4 depend on markers written by patch 1, + * so each patch in the PATCHES array is applied sequentially per file. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; +const AP_ROOTS = [ + `node_modules/${AP_BASE}`, + `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, +]; + +function apPath(rel) { + return AP_ROOTS.map(r => `${r}/${rel}`); +} + +async function fileExists(p) { + try { await access(p); return true; } catch { return false; } +} + +async function applyPatch(filePath, marker, oldSnippet, newSnippet) { + if (!(await fileExists(filePath))) return "file_not_found"; + const src = await readFile(filePath, "utf8"); + if (src.includes(marker)) return "already_applied"; + if (!src.includes(oldSnippet)) return "snippet_not_found"; + await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); + return "applied"; +} + +/** + * Apply multiple replacements to a single file in one read/write cycle. + * Returns "already_applied" if marker is found, "applied" if changes were made, + * "snippet_not_found" if any required snippet was missing, "file_not_found" if absent. + */ +async function applyMultiPatch(filePath, marker, replacements) { + if (!(await fileExists(filePath))) return "file_not_found"; + let src = await readFile(filePath, "utf8"); + if (src.includes(marker)) return "already_applied"; + + let updated = src; + for (const { oldSnippet, newSnippet, label } of replacements) { + if (!updated.includes(oldSnippet)) { + console.warn(`[postinstall] patch-ap-mastodon-statuses: snippet "${label}" not found in ${filePath}`); + return "snippet_not_found"; + } + updated = updated.replace(oldSnippet, newSnippet); + } + + await writeFile(filePath, updated, "utf8"); + return "applied"; +} + +const SCRIPT = "patch-ap-mastodon-statuses"; + +// ── Patch 1: ap-mastodon-reply-threading ──────────────────────────────────── + +const MARKER_THREADING = "// [patch] ap-mastodon-reply-threading"; + +const OLD_THREADING = ` // 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_THREADING = ` // Return a minimal status to the Mastodon client. ${MARKER_THREADING} + // Eagerly insert own post into ap_timeline so the Mastodon client can resolve ${MARKER_THREADING} + // in_reply_to_id for this post immediately, without waiting for the build webhook. ${MARKER_THREADING} + // The AP syndicator will upsert the same uid later via $setOnInsert (no-op). ${MARKER_THREADING} + const profile = await collections.ap_profile.findOne({}); + const handle = pluginOptions.handle || "user"; + try { ${MARKER_THREADING} + const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); ${MARKER_THREADING} + await addTimelineItem(collections, { ${MARKER_THREADING} + uid: postUrl, ${MARKER_THREADING} + url: postUrl, ${MARKER_THREADING} + type: data.properties["post-type"] || "note", ${MARKER_THREADING} + content: { text: contentText, html: \`

\${contentHtml}

\` }, ${MARKER_THREADING} + author: { ${MARKER_THREADING} + name: profile?.name || handle, ${MARKER_THREADING} + url: profile?.url || publicationUrl, ${MARKER_THREADING} + photo: profile?.icon || "", ${MARKER_THREADING} + handle: \`@\${handle}@\${_ph}\`, ${MARKER_THREADING} + emojis: [], ${MARKER_THREADING} + bot: false, ${MARKER_THREADING} + }, ${MARKER_THREADING} + published: data.properties.published || new Date().toISOString(), ${MARKER_THREADING} + createdAt: new Date().toISOString(), ${MARKER_THREADING} + inReplyTo: inReplyTo || null, ${MARKER_THREADING} + visibility: jf2.visibility || "public", ${MARKER_THREADING} + sensitive: jf2.sensitive === "true", ${MARKER_THREADING} + category: [], ${MARKER_THREADING} + counts: { likes: 0, boosts: 0, replies: 0 }, ${MARKER_THREADING} + }); ${MARKER_THREADING} + } catch (tlErr) { ${MARKER_THREADING} + console.warn(\`[Mastodon API] Failed to pre-insert own post into timeline: \${tlErr.message}\`); ${MARKER_THREADING} + } ${MARKER_THREADING}`; + +// ── Patch 2: ap-mastodon-status-id (3 replacements, depends on patch 1 markers) ─ + +const MARKER_STATUS_ID = "// [patch] ap-mastodon-status-id"; + +const STATUS_ID_REPLACEMENTS = [ + { + label: "declare _tlItem before try", + oldSnippet: ` try { // [patch] ap-mastodon-reply-threading`, + newSnippet: ` let _tlItem = null; ${MARKER_STATUS_ID} + try { // [patch] ap-mastodon-reply-threading`, + }, + { + label: "capture addTimelineItem return value", + oldSnippet: ` await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading`, + newSnippet: ` _tlItem = await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading ${MARKER_STATUS_ID}`, + }, + { + label: "use _tlItem._id as status response ID", + oldSnippet: ` id: String(Date.now()),`, + newSnippet: ` id: _tlItem?._id?.toString() || String(Date.now()), ${MARKER_STATUS_ID}`, + }, +]; + +// ── Patch 3: ap-mastodon-delete-fix Change C (broadcastDelete call) ────────── + +const MARKER_DELETE_FIX = "// [patch] ap-mastodon-delete-fix"; + +const OLD_AFTER_DELETE = ` // Delete from timeline + await collections.ap_timeline.deleteOne({ _id: item._id }); + + // Clean up interactions`; + +const NEW_AFTER_DELETE = ` // Delete from timeline + await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER_DELETE_FIX} + + // Broadcast AP Delete activity to followers ${MARKER_DELETE_FIX} + const _pluginOpts = req.app.locals.mastodonPluginOptions || {}; + if (_pluginOpts.broadcastDelete && postUrl) { + _pluginOpts.broadcastDelete(postUrl).catch((err) => + console.warn(\`[Mastodon API] broadcastDelete failed for \${postUrl}: \${err.message}\`), + ); + } + + // Clean up interactions`; + +// ── Patch 4: ap-status-reply-id Change B (store inReplyToId in timeline insert) ─ + +const MARKER_REPLY_ID = "// [patch] ap-status-reply-id"; + +const OLD_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading`; +const NEW_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading + inReplyToId: inReplyToId || null, ${MARKER_REPLY_ID}`; + +// ── Patch 5: ap-interactions-context-state ─────────────────────────────────── + +const MARKER_CTX_STATE = "// [patch] ap-interactions-context-state"; + +const OLD_CTX_STATE = ` // Serialize all items + const emptyInteractions = { + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }; + + const allItems = [...ancestors, ...descendants]; + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); + const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };`; + +const NEW_CTX_STATE = ` // Serialize all items + const allItems = [...ancestors, ...descendants]; + const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); + + // Load real interaction state for thread context ${MARKER_CTX_STATE} + const ctxFavouritedIds = new Set(); + const ctxRebloggedIds = new Set(); + const ctxBookmarkedIds = new Set(); + if (allItems.length > 0 && collections.ap_interactions) { + const ctxUrlToUid = new Map(); + for (const ci of allItems) { + if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); } + if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); } + } + const ctxLookupUrls = [...ctxUrlToUid.keys()]; + if (ctxLookupUrls.length > 0) { + const ctxInteractions = await collections.ap_interactions + .find({ objectUrl: { $in: ctxLookupUrls } }) + .toArray(); + for (const ci of ctxInteractions) { + const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl; + if (ci.type === "like") ctxFavouritedIds.add(uid); + else if (ci.type === "boost") ctxRebloggedIds.add(uid); + else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid); + } + } + } + const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap };`; + +// ── Runner ─────────────────────────────────────────────────────────────────── + +const FILES = apPath("lib/mastodon/routes/statuses.js"); + +// Patches that use a single replacement (applyPatch) +const SINGLE_PATCHES = [ + { name: "reply-threading", marker: MARKER_THREADING, oldSnippet: OLD_THREADING, newSnippet: NEW_THREADING }, + { name: "delete-fix-C", marker: MARKER_DELETE_FIX, oldSnippet: OLD_AFTER_DELETE, newSnippet: NEW_AFTER_DELETE }, + { name: "status-reply-id-B", marker: MARKER_REPLY_ID, oldSnippet: OLD_REPLY_INSERT, newSnippet: NEW_REPLY_INSERT }, + { name: "interactions-context", marker: MARKER_CTX_STATE, oldSnippet: OLD_CTX_STATE, newSnippet: NEW_CTX_STATE }, +]; + +// Patches that need multiple replacements in one pass (applyMultiPatch) +const MULTI_PATCHES = [ + { name: "status-id", marker: MARKER_STATUS_ID, replacements: STATUS_ID_REPLACEMENTS }, +]; + +let total = 0; + +// Apply single patches +for (const p of SINGLE_PATCHES) { + let done = false; + for (const f of FILES) { + const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); + if (r === "applied") { + console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); + total++; done = true; break; + } else if (r === "already_applied") { + console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); + done = true; break; + } else if (r === "snippet_not_found") { + console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); + } + } + if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); +} + +// Apply multi-replacement patches +for (const p of MULTI_PATCHES) { + let done = false; + for (const f of FILES) { + const r = await applyMultiPatch(f, p.marker, p.replacements); + if (r === "applied") { + console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); + total++; done = true; break; + } else if (r === "already_applied") { + console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); + done = true; break; + } else if (r === "snippet_not_found") { + // warning already printed inside applyMultiPatch + } + } + if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); +} + +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);