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 %}