@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 +52 -0
- package/index.js +60 -1
- package/lib/controllers/compose.js +11 -1
- package/lib/jf2-to-as2.js +98 -1
- package/locales/en.json +7 -1
- package/package.json +1 -1
- package/views/activitypub-compose.njk +29 -0
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.
|
|
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">
|