@rmdes/indiekit-endpoint-activitypub 2.10.0 → 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/assets/reader.css CHANGED
@@ -1094,6 +1094,58 @@
1094
1094
  outline-offset: -2px;
1095
1095
  }
1096
1096
 
1097
+ .ap-compose__cw {
1098
+ display: flex;
1099
+ flex-direction: column;
1100
+ gap: var(--space-xs);
1101
+ }
1102
+
1103
+ .ap-compose__cw-toggle {
1104
+ cursor: pointer;
1105
+ display: flex;
1106
+ align-items: center;
1107
+ gap: var(--space-xs);
1108
+ font-size: var(--font-size-s);
1109
+ color: var(--color-on-offset);
1110
+ }
1111
+
1112
+ .ap-compose__cw-input {
1113
+ border: var(--border-width-thin) solid var(--color-outline);
1114
+ border-radius: var(--border-radius-small);
1115
+ background: var(--color-offset);
1116
+ color: var(--color-on-background);
1117
+ font: inherit;
1118
+ font-size: var(--font-size-s);
1119
+ padding: var(--space-s);
1120
+ width: 100%;
1121
+ }
1122
+
1123
+ .ap-compose__cw-input:focus {
1124
+ border-color: var(--color-primary);
1125
+ outline: none;
1126
+ }
1127
+
1128
+ .ap-compose__visibility {
1129
+ border: var(--border-width-thin) solid var(--color-outline);
1130
+ border-radius: var(--border-radius-small);
1131
+ display: flex;
1132
+ flex-wrap: wrap;
1133
+ gap: var(--space-s) var(--space-m);
1134
+ padding: var(--space-m);
1135
+ }
1136
+
1137
+ .ap-compose__visibility legend {
1138
+ font-weight: 600;
1139
+ }
1140
+
1141
+ .ap-compose__visibility-option {
1142
+ cursor: pointer;
1143
+ display: flex;
1144
+ align-items: center;
1145
+ gap: var(--space-xs);
1146
+ font-size: var(--font-size-s);
1147
+ }
1148
+
1097
1149
  .ap-compose__syndication {
1098
1150
  border: var(--border-width-thin) solid var(--color-outline);
1099
1151
  border-radius: var(--border-radius-small);
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(
@@ -168,7 +168,8 @@ export function submitComposeController(mountPath, plugin) {
168
168
  }
169
169
 
170
170
  const { application } = request.app.locals;
171
- const { content } = request.body;
171
+ const { content, visibility, summary } = request.body;
172
+ const cwEnabled = request.body["cw-enabled"];
172
173
  const inReplyTo = request.body["in-reply-to"];
173
174
  const syndicateTo = request.body["mp-syndicate-to"];
174
175
 
@@ -209,6 +210,15 @@ export function submitComposeController(mountPath, plugin) {
209
210
  micropubData.append("in-reply-to", inReplyTo);
210
211
  }
211
212
 
213
+ if (visibility && visibility !== "public") {
214
+ micropubData.append("visibility", visibility);
215
+ }
216
+
217
+ if (cwEnabled && summary && summary.trim()) {
218
+ micropubData.append("summary", summary.trim());
219
+ micropubData.append("sensitive", "true");
220
+ }
221
+
212
222
  if (syndicateTo) {
213
223
  const targets = Array.isArray(syndicateTo)
214
224
  ? syndicateTo
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/locales/en.json CHANGED
@@ -145,7 +145,13 @@
145
145
  "syndicateLabel": "Syndicate to",
146
146
  "submitMicropub": "Post reply",
147
147
  "cancel": "Cancel",
148
- "errorEmpty": "Reply content cannot be empty"
148
+ "errorEmpty": "Reply content cannot be empty",
149
+ "visibilityLabel": "Visibility",
150
+ "visibilityPublic": "Public",
151
+ "visibilityUnlisted": "Unlisted",
152
+ "visibilityFollowers": "Followers only",
153
+ "cwLabel": "Content warning",
154
+ "cwPlaceholder": "Write your warning here…"
149
155
  },
150
156
  "notifications": {
151
157
  "title": "Notifications",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.10.0",
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",
@@ -27,6 +27,18 @@
27
27
  <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
28
28
  {% endif %}
29
29
 
30
+ {# Content warning toggle + summary #}
31
+ <div class="ap-compose__cw">
32
+ <label class="ap-compose__cw-toggle">
33
+ <input type="checkbox" name="cw-enabled" id="cw-toggle"
34
+ onchange="document.getElementById('cw-text').style.display = this.checked ? 'block' : 'none'">
35
+ {{ __("activitypub.compose.cwLabel") }}
36
+ </label>
37
+ <input type="text" name="summary" id="cw-text" class="ap-compose__cw-input"
38
+ placeholder="{{ __('activitypub.compose.cwPlaceholder') }}"
39
+ style="display: none">
40
+ </div>
41
+
30
42
  {# Content textarea #}
31
43
  <div class="ap-compose__editor">
32
44
  <textarea name="content" class="ap-compose__textarea"
@@ -35,6 +47,23 @@
35
47
  required></textarea>
36
48
  </div>
37
49
 
50
+ {# Visibility #}
51
+ <fieldset class="ap-compose__visibility">
52
+ <legend>{{ __("activitypub.compose.visibilityLabel") }}</legend>
53
+ <label class="ap-compose__visibility-option">
54
+ <input type="radio" name="visibility" value="public" checked>
55
+ {{ __("activitypub.compose.visibilityPublic") }}
56
+ </label>
57
+ <label class="ap-compose__visibility-option">
58
+ <input type="radio" name="visibility" value="unlisted">
59
+ {{ __("activitypub.compose.visibilityUnlisted") }}
60
+ </label>
61
+ <label class="ap-compose__visibility-option">
62
+ <input type="radio" name="visibility" value="followers">
63
+ {{ __("activitypub.compose.visibilityFollowers") }}
64
+ </label>
65
+ </fieldset>
66
+
38
67
  {# Syndication targets #}
39
68
  {% if syndicationTargets.length > 0 %}
40
69
  <fieldset class="ap-compose__syndication">