From 97d99976ea7bff1eed05df133c67c92f6cc25c14 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 30 Mar 2026 08:12:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(ap):=20fix=20reply=20threading=20=E2=80=94?= =?UTF-8?q?=20pre-check=20AP=20syndication=20and=20resolve=20in=5Freply=5F?= =?UTF-8?q?to=5Fid=20immediately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 4 +- scripts/patch-ap-compose-default-checked.mjs | 90 +++++++++++++ scripts/patch-ap-mastodon-reply-threading.mjs | 124 ++++++++++++++++++ 3 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 scripts/patch-ap-compose-default-checked.mjs create mode 100644 scripts/patch-ap-mastodon-reply-threading.mjs diff --git a/package.json b/package.json index e7179267..fc89978d 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs", - "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs && node scripts/patch-ap-compose-default-checked.mjs && node scripts/patch-ap-mastodon-reply-threading.mjs", + "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node scripts/patch-ap-og-image.mjs && node scripts/patch-ap-webfinger-before-auth.mjs && node scripts/patch-ap-federation-bridge-base-url.mjs && node scripts/patch-ap-compose-default-checked.mjs && node scripts/patch-ap-mastodon-reply-threading.mjs && node --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-ap-compose-default-checked.mjs b/scripts/patch-ap-compose-default-checked.mjs new file mode 100644 index 00000000..5f5520eb --- /dev/null +++ b/scripts/patch-ap-compose-default-checked.mjs @@ -0,0 +1,90 @@ +/** + * Patch: fix hardcoded defaultChecked handles in AP reader compose controller. + * + * Root cause: + * composeController() in compose.js sets target.defaultChecked using a + * hardcoded name comparison: + * + * target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; + * + * These are the original developer's handles and will never match any target + * on this installation. As a result, ALL syndication checkboxes in the AP + * reader compose form are rendered unchecked, so replies composed through the + * AP reader are never syndicated to ActivityPub. + * + * Fix: + * Replace the hardcoded comparison with `target.checked === true`. + * The Micropub config endpoint (q=config) already returns each syndicator's + * `checked` state. The AP syndicator has `checked: true` in indiekit.config.mjs, + * so the AP checkbox will be pre-checked by default, matching the same behaviour + * as the microsub reader compose form. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const MARKER = "// [patch] ap-compose-default-checked"; + +const candidates = [ + "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", +]; + +const OLD_SNIPPET = ` // Default-check only AP (Fedify) and Bluesky targets + // "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky + for (const target of syndicationTargets) { + const name = target.name || ""; + target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; + }`; + +const NEW_SNIPPET = ` // Pre-check syndication targets based on their configured checked state ${MARKER} + for (const target of syndicationTargets) { ${MARKER} + target.defaultChecked = target.checked === true; ${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-compose-default-checked: already applied to ${filePath}`); + continue; + } + + if (!source.includes(OLD_SNIPPET)) { + console.warn(`[postinstall] patch-ap-compose-default-checked: target snippet not found in ${filePath}`); + continue; + } + + const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); + + if (updated === source) { + console.log(`[postinstall] patch-ap-compose-default-checked: no changes in ${filePath}`); + continue; + } + + await writeFile(filePath, updated, "utf8"); + patched += 1; + console.log(`[postinstall] Applied patch-ap-compose-default-checked to ${filePath}`); +} + +if (checked === 0) { + console.log("[postinstall] patch-ap-compose-default-checked: no target files found"); +} else if (patched === 0) { + console.log("[postinstall] patch-ap-compose-default-checked: already up to date"); +} else { + console.log(`[postinstall] patch-ap-compose-default-checked: patched ${patched} file(s)`); +} diff --git a/scripts/patch-ap-mastodon-reply-threading.mjs b/scripts/patch-ap-mastodon-reply-threading.mjs new file mode 100644 index 00000000..90618912 --- /dev/null +++ b/scripts/patch-ap-mastodon-reply-threading.mjs @@ -0,0 +1,124 @@ +/** + * 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: \`

\${contentHtml}

\` }, ${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)`); +}