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
This commit is contained in:
Ricardo
2026-02-20 14:00:00 +01:00
parent 06b8509d8a
commit 5604771c69
4 changed files with 108 additions and 19 deletions

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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",

View File

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