mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: visibility/CW compose controls, @mention support (v2.11.0)
Add visibility and content warning controls to the reply compose form. Add @user@domain mention parsing, WebFinger resolution, Mention tags, inbox delivery, and content linkification for outbound posts. Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83
This commit is contained in:
@@ -1094,6 +1094,58 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.ap-compose__cw {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-compose__cw-toggle {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-on-offset);
|
||||
}
|
||||
|
||||
.ap-compose__cw-input {
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
background: var(--color-offset);
|
||||
color: var(--color-on-background);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--space-s);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ap-compose__cw-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ap-compose__visibility {
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-s) var(--space-m);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.ap-compose__visibility legend {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ap-compose__visibility-option {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
.ap-compose__syndication {
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
|
||||
61
index.js
61
index.js
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
jf2ToActivityStreams,
|
||||
jf2ToAS2Activity,
|
||||
parseMentions,
|
||||
} from "./lib/jf2-to-as2.js";
|
||||
import { dashboardController } from "./lib/controllers/dashboard.js";
|
||||
import {
|
||||
@@ -467,6 +468,40 @@ export default class ActivityPubEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve @user@domain mentions in content via WebFinger
|
||||
const contentText = properties.content?.html || properties.content || "";
|
||||
const mentionHandles = parseMentions(contentText);
|
||||
const resolvedMentions = [];
|
||||
const mentionRecipients = [];
|
||||
|
||||
for (const { handle } of mentionHandles) {
|
||||
try {
|
||||
const mentionedActor = await ctx.lookupObject(
|
||||
new URL(`acct:${handle}`),
|
||||
);
|
||||
if (mentionedActor?.id) {
|
||||
resolvedMentions.push({
|
||||
handle,
|
||||
actorUrl: mentionedActor.id.href,
|
||||
});
|
||||
mentionRecipients.push({
|
||||
handle,
|
||||
actorUrl: mentionedActor.id.href,
|
||||
actor: mentionedActor,
|
||||
});
|
||||
console.info(
|
||||
`[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
|
||||
);
|
||||
// Still add with no actorUrl so it gets a fallback link
|
||||
resolvedMentions.push({ handle, actorUrl: null });
|
||||
}
|
||||
}
|
||||
|
||||
const activity = jf2ToAS2Activity(
|
||||
properties,
|
||||
actorUrl,
|
||||
@@ -475,6 +510,7 @@ export default class ActivityPubEndpoint {
|
||||
replyToActorUrl: replyToActor?.url,
|
||||
replyToActorHandle: replyToActor?.handle,
|
||||
visibility: self.options.defaultVisibility,
|
||||
mentions: resolvedMentions,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -529,12 +565,35 @@ export default class ActivityPubEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
|
||||
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
|
||||
if (replyToActor?.url === mUrl) continue;
|
||||
try {
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
mActor,
|
||||
activity,
|
||||
{ orderingKey: properties.url },
|
||||
);
|
||||
console.info(
|
||||
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine activity type name
|
||||
const typeName =
|
||||
activity.constructor?.name || "Create";
|
||||
const replyNote = replyToActor
|
||||
? ` (reply to ${replyToActor.url})`
|
||||
: "";
|
||||
const mentionNote = mentionRecipients.length > 0
|
||||
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
|
||||
: "";
|
||||
|
||||
await logActivity(self._collections.ap_activities, {
|
||||
direction: "outbound",
|
||||
@@ -542,7 +601,7 @@ export default class ActivityPubEndpoint {
|
||||
actorUrl: self._publicationUrl,
|
||||
objectUrl: properties.url,
|
||||
targetUrl: properties["in-reply-to"] || undefined,
|
||||
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
|
||||
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
|
||||
});
|
||||
|
||||
console.info(
|
||||
|
||||
@@ -168,7 +168,8 @@ export function submitComposeController(mountPath, plugin) {
|
||||
}
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const { content } = request.body;
|
||||
const { content, visibility, summary } = request.body;
|
||||
const cwEnabled = request.body["cw-enabled"];
|
||||
const inReplyTo = request.body["in-reply-to"];
|
||||
const syndicateTo = request.body["mp-syndicate-to"];
|
||||
|
||||
@@ -209,6 +210,15 @@ export function submitComposeController(mountPath, plugin) {
|
||||
micropubData.append("in-reply-to", inReplyTo);
|
||||
}
|
||||
|
||||
if (visibility && visibility !== "public") {
|
||||
micropubData.append("visibility", visibility);
|
||||
}
|
||||
|
||||
if (cwEnabled && summary && summary.trim()) {
|
||||
micropubData.append("summary", summary.trim());
|
||||
micropubData.append("sensitive", "true");
|
||||
}
|
||||
|
||||
if (syndicateTo) {
|
||||
const targets = Array.isArray(syndicateTo)
|
||||
? syndicateTo
|
||||
|
||||
@@ -36,6 +36,50 @@ function linkifyUrls(html) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @user@domain mention patterns from text content.
|
||||
* Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }.
|
||||
*/
|
||||
export function parseMentions(text) {
|
||||
if (!text) return [];
|
||||
// Strip HTML tags for parsing
|
||||
const plain = text.replace(/<[^>]*>/g, " ");
|
||||
const mentionRegex = /(?<![\/\w])@([\w.-]+)@([\w.-]+\.\w{2,})/g;
|
||||
const mentions = [];
|
||||
const seen = new Set();
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(plain)) !== null) {
|
||||
const handle = `${match[1]}@${match[2]}`;
|
||||
if (!seen.has(handle.toLowerCase())) {
|
||||
seen.add(handle.toLowerCase());
|
||||
mentions.push({ handle, username: match[1], domain: match[2] });
|
||||
}
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace @user@domain patterns in HTML with linked mentions.
|
||||
* resolvedMentions: [{ handle, actorUrl }]
|
||||
* Unresolved handles get a WebFinger-style link as fallback.
|
||||
*/
|
||||
function linkifyMentions(html, resolvedMentions) {
|
||||
if (!html || !resolvedMentions?.length) return html;
|
||||
for (const { handle, actorUrl } of resolvedMentions) {
|
||||
// Escape handle for regex (dots, hyphens)
|
||||
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Match @handle not already inside an HTML tag attribute or anchor text
|
||||
const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
|
||||
const parts = handle.split("@");
|
||||
const url = actorUrl || `https://${parts[1]}/@${parts[0]}`;
|
||||
html = html.replace(
|
||||
pattern,
|
||||
`<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
|
||||
);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plain JSON-LD (content negotiation on individual post URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -137,10 +181,27 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
|
||||
}
|
||||
|
||||
const tags = buildPlainTags(properties, publicationUrl, object.tag);
|
||||
|
||||
// Add Mention tags + cc addressing + content linkification for @mentions
|
||||
const resolvedMentions = options.mentions || [];
|
||||
for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
|
||||
if (mentionUrl) {
|
||||
tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` });
|
||||
if (!object.cc.includes(mentionUrl)) {
|
||||
object.cc.push(mentionUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
object.tag = tags;
|
||||
}
|
||||
|
||||
// Linkify @mentions in content (resolved get actor links, unresolved get profile links)
|
||||
if (resolvedMentions.length > 0 && object.content) {
|
||||
object.content = linkifyMentions(object.content, resolvedMentions);
|
||||
}
|
||||
|
||||
return {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
type: "Create",
|
||||
@@ -292,7 +353,7 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
|
||||
noteOptions.attachments = fedifyAttachments;
|
||||
}
|
||||
|
||||
// Tags: hashtags + Mention for reply addressing
|
||||
// Tags: hashtags + Mention for reply addressing + @mentions
|
||||
const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
|
||||
|
||||
if (replyToActorUrl) {
|
||||
@@ -304,10 +365,46 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
|
||||
);
|
||||
}
|
||||
|
||||
// Add Mention tags + cc addressing for resolved @mentions
|
||||
const resolvedMentions = options.mentions || [];
|
||||
const ccUrls = [];
|
||||
for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
|
||||
if (mentionUrl) {
|
||||
// Skip if same as replyToActorUrl (already added above)
|
||||
const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl;
|
||||
if (!alreadyTagged) {
|
||||
fedifyTags.push(
|
||||
new Mention({
|
||||
href: new URL(mentionUrl),
|
||||
name: `@${handle}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
ccUrls.push(new URL(mentionUrl));
|
||||
}
|
||||
}
|
||||
|
||||
// Merge mention actors into cc/ccs
|
||||
if (ccUrls.length > 0) {
|
||||
if (noteOptions.ccs) {
|
||||
noteOptions.ccs = [...noteOptions.ccs, ...ccUrls];
|
||||
} else if (noteOptions.cc) {
|
||||
noteOptions.ccs = [noteOptions.cc, ...ccUrls];
|
||||
delete noteOptions.cc;
|
||||
} else {
|
||||
noteOptions.ccs = ccUrls;
|
||||
}
|
||||
}
|
||||
|
||||
if (fedifyTags.length > 0) {
|
||||
noteOptions.tags = fedifyTags;
|
||||
}
|
||||
|
||||
// Linkify @mentions in content
|
||||
if (resolvedMentions.length > 0 && noteOptions.content) {
|
||||
noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions);
|
||||
}
|
||||
|
||||
const object = isArticle
|
||||
? new Article(noteOptions)
|
||||
: new Note(noteOptions);
|
||||
|
||||
@@ -145,7 +145,13 @@
|
||||
"syndicateLabel": "Syndicate to",
|
||||
"submitMicropub": "Post reply",
|
||||
"cancel": "Cancel",
|
||||
"errorEmpty": "Reply content cannot be empty"
|
||||
"errorEmpty": "Reply content cannot be empty",
|
||||
"visibilityLabel": "Visibility",
|
||||
"visibilityPublic": "Public",
|
||||
"visibilityUnlisted": "Unlisted",
|
||||
"visibilityFollowers": "Followers only",
|
||||
"cwLabel": "Content warning",
|
||||
"cwPlaceholder": "Write your warning here…"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.0",
|
||||
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
||||
{% endif %}
|
||||
|
||||
{# Content warning toggle + summary #}
|
||||
<div class="ap-compose__cw">
|
||||
<label class="ap-compose__cw-toggle">
|
||||
<input type="checkbox" name="cw-enabled" id="cw-toggle"
|
||||
onchange="document.getElementById('cw-text').style.display = this.checked ? 'block' : 'none'">
|
||||
{{ __("activitypub.compose.cwLabel") }}
|
||||
</label>
|
||||
<input type="text" name="summary" id="cw-text" class="ap-compose__cw-input"
|
||||
placeholder="{{ __('activitypub.compose.cwPlaceholder') }}"
|
||||
style="display: none">
|
||||
</div>
|
||||
|
||||
{# Content textarea #}
|
||||
<div class="ap-compose__editor">
|
||||
<textarea name="content" class="ap-compose__textarea"
|
||||
@@ -35,6 +47,23 @@
|
||||
required></textarea>
|
||||
</div>
|
||||
|
||||
{# Visibility #}
|
||||
<fieldset class="ap-compose__visibility">
|
||||
<legend>{{ __("activitypub.compose.visibilityLabel") }}</legend>
|
||||
<label class="ap-compose__visibility-option">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
{{ __("activitypub.compose.visibilityPublic") }}
|
||||
</label>
|
||||
<label class="ap-compose__visibility-option">
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
{{ __("activitypub.compose.visibilityUnlisted") }}
|
||||
</label>
|
||||
<label class="ap-compose__visibility-option">
|
||||
<input type="radio" name="visibility" value="followers">
|
||||
{{ __("activitypub.compose.visibilityFollowers") }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{# Syndication targets #}
|
||||
{% if syndicationTargets.length > 0 %}
|
||||
<fieldset class="ap-compose__syndication">
|
||||
|
||||
Reference in New Issue
Block a user