From 5604771c69a400a4dfaf014bcab8ddcd9719d542 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 20 Feb 2026 14:00:00 +0100 Subject: [PATCH] feat: deliver replies to original post author's inbox Replies syndicated via ActivityPub were only sent to followers. Remote servers (e.g. Mastodon) never received the Create(Note) activity, so replies didn't appear under the original post. Changes: - Resolve the reply-to post author via ctx.lookupObject() + getAttributedTo() - Include the original author in CC addressing (ccs) on the Note - Add a Mention tag for the original author - Deliver the activity to the author's inbox via a second sendActivity() call - Log reply delivery with targetUrl for debugging Also includes: following list badge fix from refollow work, version bump to 1.0.20 --- index.js | 75 +++++++++++++++++++++++++++++---- lib/jf2-to-as2.js | 34 +++++++++++++-- package.json | 2 +- views/activitypub-following.njk | 16 ++++--- 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/index.js b/index.js index c9c3c12..230d8be 100644 --- a/index.js +++ b/index.js @@ -261,10 +261,50 @@ export default class ActivityPubEndpoint { try { const actorUrl = self._getActorUrl(); + const handle = self.options.actor.handle; + + const ctx = self._federation.createContext( + new URL(self._publicationUrl), + {}, + ); + + // For replies, resolve the original post author for proper + // addressing (CC) and direct inbox delivery + let replyToActor = null; + if (properties["in-reply-to"]) { + try { + const remoteObject = await ctx.lookupObject( + new URL(properties["in-reply-to"]), + ); + if (remoteObject && typeof remoteObject.getAttributedTo === "function") { + const author = await remoteObject.getAttributedTo(); + const authorActor = Array.isArray(author) ? author[0] : author; + if (authorActor?.id) { + replyToActor = { + url: authorActor.id.href, + handle: authorActor.preferredUsername || null, + recipient: authorActor, + }; + console.info( + `[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`, + ); + } + } + } catch (error) { + console.warn( + `[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`, + ); + } + } + const activity = jf2ToAS2Activity( properties, actorUrl, self._publicationUrl, + { + replyToActorUrl: replyToActor?.url, + replyToActorHandle: replyToActor?.handle, + }, ); if (!activity) { @@ -278,11 +318,6 @@ export default class ActivityPubEndpoint { return undefined; } - const ctx = self._federation.createContext( - new URL(self._publicationUrl), - {}, - ); - // Count followers for logging const followerCount = await self._collections.ap_followers.countDocuments(); @@ -291,26 +326,50 @@ export default class ActivityPubEndpoint { `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`, ); + // Send to followers await ctx.sendActivity( - { identifier: self.options.actor.handle }, + { identifier: handle }, "followers", activity, ); + // For replies, also deliver to the original post author's inbox + // so their server can thread the reply under the original post + if (replyToActor?.recipient) { + try { + await ctx.sendActivity( + { identifier: handle }, + replyToActor.recipient, + activity, + ); + console.info( + `[ActivityPub] Reply delivered to author: ${replyToActor.url}`, + ); + } catch (error) { + console.warn( + `[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`, + ); + } + } + // Determine activity type name const typeName = activity.constructor?.name || "Create"; + const replyNote = replyToActor + ? ` (reply to ${replyToActor.url})` + : ""; await logActivity(self._collections.ap_activities, { direction: "outbound", type: typeName, actorUrl: self._publicationUrl, objectUrl: properties.url, - summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers`, + targetUrl: replyToActor?.url || undefined, + summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`, }); console.info( - `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}`, + `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`, ); return properties.url || undefined; diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index 9e64501..e4de2f6 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -15,6 +15,7 @@ import { Hashtag, Image, Like, + Mention, Note, Video, } from "@fedify/fedify"; @@ -126,9 +127,12 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) { * @param {object} properties - JF2 post properties * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick") * @param {string} publicationUrl - Publication base URL with trailing slash + * @param {object} [options] - Optional settings + * @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing) + * @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag) * @returns {import("@fedify/fedify").Activity | null} */ -export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) { +export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) { const postType = properties["post-type"]; const actorUri = new URL(actorUrl); @@ -154,13 +158,25 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) { const isArticle = postType === "article" && properties.name; const postUrl = resolvePostUrl(properties.url, publicationUrl); const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`; + const { replyToActorUrl, replyToActorHandle } = options; const noteOptions = { attributedTo: actorUri, - to: new URL("https://www.w3.org/ns/activitystreams#Public"), - cc: new URL(followersUrl), }; + // Addressing: for replies, include original author in CC so their server + // threads the reply and notifies them + if (replyToActorUrl && properties["in-reply-to"]) { + noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public"); + noteOptions.ccs = [ + new URL(followersUrl), + new URL(replyToActorUrl), + ]; + } else { + noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public"); + noteOptions.cc = new URL(followersUrl); + } + if (postUrl) { noteOptions.id = new URL(postUrl); noteOptions.url = new URL(postUrl); @@ -208,8 +224,18 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) { noteOptions.attachments = fedifyAttachments; } - // Hashtags + // Tags: hashtags + Mention for reply addressing const fedifyTags = buildFedifyTags(properties, publicationUrl, postType); + + if (replyToActorUrl) { + fedifyTags.push( + new Mention({ + href: new URL(replyToActorUrl), + name: replyToActorHandle ? `@${replyToActorHandle}` : undefined, + }), + ); + } + if (fedifyTags.length > 0) { noteOptions.tags = fedifyTags; } diff --git a/package.json b/package.json index e94cd39..3246931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.0.18", + "version": "1.0.20", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-following.njk b/views/activitypub-following.njk index f0c5b05..b50fd6f 100644 --- a/views/activitypub-following.njk +++ b/views/activitypub-following.njk @@ -11,17 +11,21 @@ {% if following.length > 0 %} {% for account in following %} + {% if account.source === "import" %} + {% set sourceBadge = __("activitypub.sourceImport") %} + {% elif account.source === "refollow:sent" %} + {% set sourceBadge = __("activitypub.sourceRefollowPending") %} + {% elif account.source === "refollow:failed" %} + {% set sourceBadge = __("activitypub.sourceRefollowFailed") %} + {% else %} + {% set sourceBadge = __("activitypub.sourceFederation") %} + {% endif %} {{ card({ title: account.name or account.handle or account.actorUrl, url: account.actorUrl, description: { text: "@" + account.handle if account.handle }, published: account.followedAt, - badges: [{ - text: __("activitypub.sourceImport") if account.source === "import" - else __("activitypub.sourceRefollowPending") if account.source === "refollow:sent" - else __("activitypub.sourceRefollowFailed") if account.source === "refollow:failed" - else __("activitypub.sourceFederation") - }] + badges: [{ text: sourceBadge }] }) }} {% endfor %}