@rmdes/indiekit-endpoint-activitypub 1.0.19 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -261,10 +261,50 @@ export default class ActivityPubEndpoint {
261
261
 
262
262
  try {
263
263
  const actorUrl = self._getActorUrl();
264
+ const handle = self.options.actor.handle;
265
+
266
+ const ctx = self._federation.createContext(
267
+ new URL(self._publicationUrl),
268
+ {},
269
+ );
270
+
271
+ // For replies, resolve the original post author for proper
272
+ // addressing (CC) and direct inbox delivery
273
+ let replyToActor = null;
274
+ if (properties["in-reply-to"]) {
275
+ try {
276
+ const remoteObject = await ctx.lookupObject(
277
+ new URL(properties["in-reply-to"]),
278
+ );
279
+ if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
280
+ const author = await remoteObject.getAttributedTo();
281
+ const authorActor = Array.isArray(author) ? author[0] : author;
282
+ if (authorActor?.id) {
283
+ replyToActor = {
284
+ url: authorActor.id.href,
285
+ handle: authorActor.preferredUsername || null,
286
+ recipient: authorActor,
287
+ };
288
+ console.info(
289
+ `[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
290
+ );
291
+ }
292
+ }
293
+ } catch (error) {
294
+ console.warn(
295
+ `[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
296
+ );
297
+ }
298
+ }
299
+
264
300
  const activity = jf2ToAS2Activity(
265
301
  properties,
266
302
  actorUrl,
267
303
  self._publicationUrl,
304
+ {
305
+ replyToActorUrl: replyToActor?.url,
306
+ replyToActorHandle: replyToActor?.handle,
307
+ },
268
308
  );
269
309
 
270
310
  if (!activity) {
@@ -278,11 +318,6 @@ export default class ActivityPubEndpoint {
278
318
  return undefined;
279
319
  }
280
320
 
281
- const ctx = self._federation.createContext(
282
- new URL(self._publicationUrl),
283
- {},
284
- );
285
-
286
321
  // Count followers for logging
287
322
  const followerCount =
288
323
  await self._collections.ap_followers.countDocuments();
@@ -291,26 +326,50 @@ export default class ActivityPubEndpoint {
291
326
  `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
292
327
  );
293
328
 
329
+ // Send to followers
294
330
  await ctx.sendActivity(
295
- { identifier: self.options.actor.handle },
331
+ { identifier: handle },
296
332
  "followers",
297
333
  activity,
298
334
  );
299
335
 
336
+ // For replies, also deliver to the original post author's inbox
337
+ // so their server can thread the reply under the original post
338
+ if (replyToActor?.recipient) {
339
+ try {
340
+ await ctx.sendActivity(
341
+ { identifier: handle },
342
+ replyToActor.recipient,
343
+ activity,
344
+ );
345
+ console.info(
346
+ `[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
347
+ );
348
+ } catch (error) {
349
+ console.warn(
350
+ `[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
351
+ );
352
+ }
353
+ }
354
+
300
355
  // Determine activity type name
301
356
  const typeName =
302
357
  activity.constructor?.name || "Create";
358
+ const replyNote = replyToActor
359
+ ? ` (reply to ${replyToActor.url})`
360
+ : "";
303
361
 
304
362
  await logActivity(self._collections.ap_activities, {
305
363
  direction: "outbound",
306
364
  type: typeName,
307
365
  actorUrl: self._publicationUrl,
308
366
  objectUrl: properties.url,
309
- summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers`,
367
+ targetUrl: replyToActor?.url || undefined,
368
+ summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
310
369
  });
311
370
 
312
371
  console.info(
313
- `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}`,
372
+ `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
314
373
  );
315
374
 
316
375
  return properties.url || undefined;
package/lib/jf2-to-as2.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  Hashtag,
16
16
  Image,
17
17
  Like,
18
+ Mention,
18
19
  Note,
19
20
  Video,
20
21
  } from "@fedify/fedify";
@@ -126,9 +127,12 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
126
127
  * @param {object} properties - JF2 post properties
127
128
  * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
128
129
  * @param {string} publicationUrl - Publication base URL with trailing slash
130
+ * @param {object} [options] - Optional settings
131
+ * @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing)
132
+ * @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag)
129
133
  * @returns {import("@fedify/fedify").Activity | null}
130
134
  */
131
- export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
135
+ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
132
136
  const postType = properties["post-type"];
133
137
  const actorUri = new URL(actorUrl);
134
138
 
@@ -154,13 +158,25 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
154
158
  const isArticle = postType === "article" && properties.name;
155
159
  const postUrl = resolvePostUrl(properties.url, publicationUrl);
156
160
  const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
161
+ const { replyToActorUrl, replyToActorHandle } = options;
157
162
 
158
163
  const noteOptions = {
159
164
  attributedTo: actorUri,
160
- to: new URL("https://www.w3.org/ns/activitystreams#Public"),
161
- cc: new URL(followersUrl),
162
165
  };
163
166
 
167
+ // Addressing: for replies, include original author in CC so their server
168
+ // threads the reply and notifies them
169
+ if (replyToActorUrl && properties["in-reply-to"]) {
170
+ noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
171
+ noteOptions.ccs = [
172
+ new URL(followersUrl),
173
+ new URL(replyToActorUrl),
174
+ ];
175
+ } else {
176
+ noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
177
+ noteOptions.cc = new URL(followersUrl);
178
+ }
179
+
164
180
  if (postUrl) {
165
181
  noteOptions.id = new URL(postUrl);
166
182
  noteOptions.url = new URL(postUrl);
@@ -208,8 +224,18 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
208
224
  noteOptions.attachments = fedifyAttachments;
209
225
  }
210
226
 
211
- // Hashtags
227
+ // Tags: hashtags + Mention for reply addressing
212
228
  const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
229
+
230
+ if (replyToActorUrl) {
231
+ fedifyTags.push(
232
+ new Mention({
233
+ href: new URL(replyToActorUrl),
234
+ name: replyToActorHandle ? `@${replyToActorHandle}` : undefined,
235
+ }),
236
+ );
237
+ }
238
+
213
239
  if (fedifyTags.length > 0) {
214
240
  noteOptions.tags = fedifyTags;
215
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",