diff --git a/assets/reader.css b/assets/reader.css index 6a988d9..daf3c47 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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); diff --git a/index.js b/index.js index 1971978..a17c364 100644 --- a/index.js +++ b/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( diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index c916a60..3d76f5a 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -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 diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index 5def897..60d83e9 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -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 = /(?@${handle}`, + ); + } + 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); diff --git a/locales/en.json b/locales/en.json index 84eda29..c69308b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/package.json b/package.json index 1994a8e..0e84266 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/views/activitypub-compose.njk b/views/activitypub-compose.njk index 1e21f95..8d43a3f 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -27,6 +27,18 @@ {% endif %} + {# Content warning toggle + summary #} +
+ + +
+ {# Content textarea #}
+ {# Visibility #} +
+ {{ __("activitypub.compose.visibilityLabel") }} + + + +
+ {# Syndication targets #} {% if syndicationTargets.length > 0 %}