@rmdes/indiekit-endpoint-activitypub 2.10.1 → 2.11.0

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
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  jf2ToActivityStreams,
10
10
  jf2ToAS2Activity,
11
+ parseMentions,
11
12
  } from "./lib/jf2-to-as2.js";
12
13
  import { dashboardController } from "./lib/controllers/dashboard.js";
13
14
  import {
@@ -467,6 +468,40 @@ export default class ActivityPubEndpoint {
467
468
  }
468
469
  }
469
470
 
471
+ // Resolve @user@domain mentions in content via WebFinger
472
+ const contentText = properties.content?.html || properties.content || "";
473
+ const mentionHandles = parseMentions(contentText);
474
+ const resolvedMentions = [];
475
+ const mentionRecipients = [];
476
+
477
+ for (const { handle } of mentionHandles) {
478
+ try {
479
+ const mentionedActor = await ctx.lookupObject(
480
+ new URL(`acct:${handle}`),
481
+ );
482
+ if (mentionedActor?.id) {
483
+ resolvedMentions.push({
484
+ handle,
485
+ actorUrl: mentionedActor.id.href,
486
+ });
487
+ mentionRecipients.push({
488
+ handle,
489
+ actorUrl: mentionedActor.id.href,
490
+ actor: mentionedActor,
491
+ });
492
+ console.info(
493
+ `[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`,
494
+ );
495
+ }
496
+ } catch (error) {
497
+ console.warn(
498
+ `[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
499
+ );
500
+ // Still add with no actorUrl so it gets a fallback link
501
+ resolvedMentions.push({ handle, actorUrl: null });
502
+ }
503
+ }
504
+
470
505
  const activity = jf2ToAS2Activity(
471
506
  properties,
472
507
  actorUrl,
@@ -475,6 +510,7 @@ export default class ActivityPubEndpoint {
475
510
  replyToActorUrl: replyToActor?.url,
476
511
  replyToActorHandle: replyToActor?.handle,
477
512
  visibility: self.options.defaultVisibility,
513
+ mentions: resolvedMentions,
478
514
  },
479
515
  );
480
516
 
@@ -529,12 +565,35 @@ export default class ActivityPubEndpoint {
529
565
  }
530
566
  }
531
567
 
568
+ // Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
569
+ for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
570
+ if (replyToActor?.url === mUrl) continue;
571
+ try {
572
+ await ctx.sendActivity(
573
+ { identifier: handle },
574
+ mActor,
575
+ activity,
576
+ { orderingKey: properties.url },
577
+ );
578
+ console.info(
579
+ `[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
580
+ );
581
+ } catch (error) {
582
+ console.warn(
583
+ `[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
584
+ );
585
+ }
586
+ }
587
+
532
588
  // Determine activity type name
533
589
  const typeName =
534
590
  activity.constructor?.name || "Create";
535
591
  const replyNote = replyToActor
536
592
  ? ` (reply to ${replyToActor.url})`
537
593
  : "";
594
+ const mentionNote = mentionRecipients.length > 0
595
+ ? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
596
+ : "";
538
597
 
539
598
  await logActivity(self._collections.ap_activities, {
540
599
  direction: "outbound",
@@ -542,7 +601,7 @@ export default class ActivityPubEndpoint {
542
601
  actorUrl: self._publicationUrl,
543
602
  objectUrl: properties.url,
544
603
  targetUrl: properties["in-reply-to"] || undefined,
545
- summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
604
+ summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
546
605
  });
547
606
 
548
607
  console.info(
package/lib/jf2-to-as2.js CHANGED
@@ -36,6 +36,50 @@ function linkifyUrls(html) {
36
36
  );
37
37
  }
38
38
 
39
+ /**
40
+ * Parse @user@domain mention patterns from text content.
41
+ * Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }.
42
+ */
43
+ export function parseMentions(text) {
44
+ if (!text) return [];
45
+ // Strip HTML tags for parsing
46
+ const plain = text.replace(/<[^>]*>/g, " ");
47
+ const mentionRegex = /(?<![\/\w])@([\w.-]+)@([\w.-]+\.\w{2,})/g;
48
+ const mentions = [];
49
+ const seen = new Set();
50
+ let match;
51
+ while ((match = mentionRegex.exec(plain)) !== null) {
52
+ const handle = `${match[1]}@${match[2]}`;
53
+ if (!seen.has(handle.toLowerCase())) {
54
+ seen.add(handle.toLowerCase());
55
+ mentions.push({ handle, username: match[1], domain: match[2] });
56
+ }
57
+ }
58
+ return mentions;
59
+ }
60
+
61
+ /**
62
+ * Replace @user@domain patterns in HTML with linked mentions.
63
+ * resolvedMentions: [{ handle, actorUrl }]
64
+ * Unresolved handles get a WebFinger-style link as fallback.
65
+ */
66
+ function linkifyMentions(html, resolvedMentions) {
67
+ if (!html || !resolvedMentions?.length) return html;
68
+ for (const { handle, actorUrl } of resolvedMentions) {
69
+ // Escape handle for regex (dots, hyphens)
70
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ // Match @handle not already inside an HTML tag attribute or anchor text
72
+ const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
73
+ const parts = handle.split("@");
74
+ const url = actorUrl || `https://${parts[1]}/@${parts[0]}`;
75
+ html = html.replace(
76
+ pattern,
77
+ `<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
78
+ );
79
+ }
80
+ return html;
81
+ }
82
+
39
83
  // ---------------------------------------------------------------------------
40
84
  // Plain JSON-LD (content negotiation on individual post URLs)
41
85
  // ---------------------------------------------------------------------------
@@ -137,10 +181,27 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
137
181
  }
138
182
 
139
183
  const tags = buildPlainTags(properties, publicationUrl, object.tag);
184
+
185
+ // Add Mention tags + cc addressing + content linkification for @mentions
186
+ const resolvedMentions = options.mentions || [];
187
+ for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
188
+ if (mentionUrl) {
189
+ tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` });
190
+ if (!object.cc.includes(mentionUrl)) {
191
+ object.cc.push(mentionUrl);
192
+ }
193
+ }
194
+ }
195
+
140
196
  if (tags.length > 0) {
141
197
  object.tag = tags;
142
198
  }
143
199
 
200
+ // Linkify @mentions in content (resolved get actor links, unresolved get profile links)
201
+ if (resolvedMentions.length > 0 && object.content) {
202
+ object.content = linkifyMentions(object.content, resolvedMentions);
203
+ }
204
+
144
205
  return {
145
206
  "@context": "https://www.w3.org/ns/activitystreams",
146
207
  type: "Create",
@@ -292,7 +353,7 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
292
353
  noteOptions.attachments = fedifyAttachments;
293
354
  }
294
355
 
295
- // Tags: hashtags + Mention for reply addressing
356
+ // Tags: hashtags + Mention for reply addressing + @mentions
296
357
  const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
297
358
 
298
359
  if (replyToActorUrl) {
@@ -304,10 +365,46 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
304
365
  );
305
366
  }
306
367
 
368
+ // Add Mention tags + cc addressing for resolved @mentions
369
+ const resolvedMentions = options.mentions || [];
370
+ const ccUrls = [];
371
+ for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
372
+ if (mentionUrl) {
373
+ // Skip if same as replyToActorUrl (already added above)
374
+ const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl;
375
+ if (!alreadyTagged) {
376
+ fedifyTags.push(
377
+ new Mention({
378
+ href: new URL(mentionUrl),
379
+ name: `@${handle}`,
380
+ }),
381
+ );
382
+ }
383
+ ccUrls.push(new URL(mentionUrl));
384
+ }
385
+ }
386
+
387
+ // Merge mention actors into cc/ccs
388
+ if (ccUrls.length > 0) {
389
+ if (noteOptions.ccs) {
390
+ noteOptions.ccs = [...noteOptions.ccs, ...ccUrls];
391
+ } else if (noteOptions.cc) {
392
+ noteOptions.ccs = [noteOptions.cc, ...ccUrls];
393
+ delete noteOptions.cc;
394
+ } else {
395
+ noteOptions.ccs = ccUrls;
396
+ }
397
+ }
398
+
307
399
  if (fedifyTags.length > 0) {
308
400
  noteOptions.tags = fedifyTags;
309
401
  }
310
402
 
403
+ // Linkify @mentions in content
404
+ if (resolvedMentions.length > 0 && noteOptions.content) {
405
+ noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions);
406
+ }
407
+
311
408
  const object = isArticle
312
409
  ? new Article(noteOptions)
313
410
  : new Note(noteOptions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.10.1",
3
+ "version": "2.11.0",
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",