feat: consolidated patch-ap-mastodon-statuses (threading, status-id, delete, reply-id, interactions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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: \`<p>\${contentHtml}</p>\` }, ${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)`);
|
||||
Reference in New Issue
Block a user