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