diff --git a/assets/reader.css b/assets/reader.css index 6a988d9..36155a4 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); @@ -3138,3 +3190,221 @@ } } +/* ========================================================================== + Federation Management + ========================================================================== */ + +.ap-federation__section { + margin-block-end: var(--space-l); +} + +.ap-federation__section h2 { + margin-block-end: var(--space-s); +} + +.ap-federation__stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: var(--space-s); +} + +.ap-federation__stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + padding: var(--space-s); + background: var(--color-offset); + border-radius: var(--border-radius-small); + text-align: center; +} + +.ap-federation__stat-count { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--color-on-background); +} + +.ap-federation__stat-label { + font-size: var(--font-size-s); + color: var(--color-on-offset); + word-break: break-word; +} + +.ap-federation__actions-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + align-items: center; +} + +.ap-federation__result { + margin-block-start: var(--space-xs); + color: var(--color-green50); + font-size: var(--font-size-s); +} + +.ap-federation__error { + margin-block-start: var(--space-xs); + color: var(--color-red45); + font-size: var(--font-size-s); +} + +.ap-federation__lookup-form { + display: flex; + gap: var(--space-s); +} + +.ap-federation__lookup-input { + flex: 1; + min-width: 0; + padding: 0.5rem 0.75rem; + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + font: inherit; + color: var(--color-on-background); + background: var(--color-background); +} + +.ap-federation__json-view { + margin-block-start: var(--space-s); + padding: var(--space-m); + background: var(--color-offset); + border-radius: var(--border-radius-small); + font-family: monospace; + font-size: var(--font-size-s); + color: var(--color-on-background); + max-height: 24rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.ap-federation__posts-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.ap-federation__post-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-m); + padding: var(--space-s); + background: var(--color-offset); + border-radius: var(--border-radius-small); +} + +.ap-federation__post-info { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.ap-federation__post-title { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ap-federation__post-meta { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-s); + color: var(--color-on-offset); +} + +.ap-federation__post-actions { + display: flex; + gap: var(--space-xs); + flex-shrink: 0; +} + +.ap-federation__post-btn { + padding: var(--space-xs) var(--space-s); + font-size: var(--font-size-s); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + background: var(--color-background); + color: var(--color-on-background); + cursor: pointer; +} + +.ap-federation__post-btn:hover { + background: var(--color-offset); +} + +.ap-federation__post-btn--danger { + color: var(--color-red45); + border-color: var(--color-red45); +} + +.ap-federation__post-btn--danger:hover { + background: color-mix(in srgb, var(--color-red45) 10%, transparent); +} + +.ap-federation__modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--tint-neutral) 10% / 0.5); +} + +.ap-federation__modal { + width: min(90vw, 48rem); + max-height: 80vh; + display: flex; + flex-direction: column; + background: var(--color-background); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2); +} + +.ap-federation__modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-s) var(--space-m); + border-block-end: var(--border-width-thin) solid var(--color-outline); +} + +.ap-federation__modal-header h3 { + margin: 0; + font-size: var(--font-size-m); +} + +.ap-federation__modal-close { + font-size: var(--font-size-xl); + line-height: 1; + padding: 0 var(--space-xs); + border: none; + background: none; + color: var(--color-on-offset); + cursor: pointer; +} + +.ap-federation__modal .ap-federation__json-view { + margin: 0; + border-radius: 0 0 var(--border-radius-small) var(--border-radius-small); + flex: 1; + overflow: auto; +} + +@media (max-width: 40rem) { + .ap-federation__post-row { + flex-direction: column; + align-items: flex-start; + } + + .ap-federation__lookup-form { + flex-direction: column; + } +} + diff --git a/index.js b/index.js index b91306a..09108c7 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 { @@ -99,6 +100,13 @@ import { resolveAuthor } from "./lib/resolve-author.js"; import { scheduleCleanup } from "./lib/timeline-cleanup.js"; import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js"; import { deleteFederationController } from "./lib/controllers/federation-delete.js"; +import { + federationMgmtController, + rebroadcastController, + viewApJsonController, + broadcastActorUpdateController, + lookupObjectController, +} from "./lib/controllers/federation-mgmt.js"; const defaults = { mountPath: "/activitypub", @@ -169,6 +177,11 @@ export default class ActivityPubEndpoint { text: "activitypub.myProfile.title", requiresDatabase: true, }, + { + href: `${this.options.mountPath}/admin/federation`, + text: "activitypub.federationMgmt.title", + requiresDatabase: true, + }, ]; } @@ -313,6 +326,11 @@ export default class ActivityPubEndpoint { router.post("/admin/refollow/resume", refollowResumeController(mp, this)); router.get("/admin/refollow/status", refollowStatusController(mp)); router.post("/admin/federation/delete", deleteFederationController(mp, this)); + router.get("/admin/federation", federationMgmtController(mp, this)); + router.post("/admin/federation/rebroadcast", rebroadcastController(mp, this)); + router.get("/admin/federation/ap-json", viewApJsonController(mp, this)); + router.post("/admin/federation/broadcast-actor", broadcastActorUpdateController(mp, this)); + router.get("/admin/federation/lookup", lookupObjectController(mp, this)); return router; } @@ -326,6 +344,38 @@ export default class ActivityPubEndpoint { const router = express.Router(); // eslint-disable-line new-cap const self = this; + // Intercept Micropub delete actions to broadcast Delete to fediverse. + // Wraps res.json to detect successful delete responses, then fires + // broadcastDelete asynchronously so remote servers remove the post. + router.use((req, res, next) => { + if (req.method !== "POST") return next(); + if (!req.path.endsWith("/micropub")) return next(); + + const action = req.query?.action || req.body?.action; + if (action !== "delete") return next(); + + const postUrl = req.query?.url || req.body?.url; + if (!postUrl) return next(); + + const originalJson = res.json.bind(res); + res.json = function (body) { + // Fire broadcastDelete after successful delete (status 200) + if (res.statusCode === 200 && body?.success === "delete") { + console.info( + `[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`, + ); + self.broadcastDelete(postUrl).catch((error) => { + console.warn( + `[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`, + ); + }); + } + return originalJson(body); + }; + + return next(); + }); + // Let Fedify handle NodeInfo data (/nodeinfo/2.1) // Only pass GET/HEAD requests — POST/PUT/DELETE must not go through // Fedify here, because fromExpressRequest() consumes the body stream, @@ -484,6 +534,41 @@ 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, + profileUrl: mentionedActor.url?.href || null, + }); + 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, @@ -492,6 +577,7 @@ export default class ActivityPubEndpoint { replyToActorUrl: replyToActor?.url, replyToActorHandle: replyToActor?.handle, visibility: self.options.defaultVisibility, + mentions: resolvedMentions, }, ); @@ -546,12 +632,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", @@ -559,7 +668,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 ab319d9..2082beb 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -221,7 +221,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"]; const isDirect = request.body["is-direct"] === "true"; @@ -353,6 +354,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/controllers/federation-mgmt.js b/lib/controllers/federation-mgmt.js new file mode 100644 index 0000000..e0030fa --- /dev/null +++ b/lib/controllers/federation-mgmt.js @@ -0,0 +1,310 @@ +/** + * Federation Management controllers — admin page for inspecting and managing + * the relationship between local content and the fediverse. + */ + +import { getToken, validateToken } from "../csrf.js"; +import { jf2ToActivityStreams } from "../jf2-to-as2.js"; + +const PAGE_SIZE = 20; + +const AP_COLLECTIONS = [ + "ap_followers", + "ap_following", + "ap_activities", + "ap_keys", + "ap_kv", + "ap_profile", + "ap_featured", + "ap_featured_tags", + "ap_timeline", + "ap_notifications", + "ap_muted", + "ap_blocked", + "ap_interactions", + "ap_followed_tags", + "ap_messages", + "ap_explore_tabs", + "ap_reports", +]; + +/** + * GET /admin/federation — main federation management page. + */ +export function federationMgmtController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const collections = application?.collections; + + // Parallel: collection stats + posts + recent activities + const [collectionStats, postsResult, recentActivities] = + await Promise.all([ + getCollectionStats(collections), + getPaginatedPosts(collections, request.query.page), + getRecentActivities(collections), + ]); + + const csrfToken = getToken(request.session); + const actorUrl = plugin._getActorUrl?.() || ""; + + response.render("activitypub-federation-mgmt", { + title: response.locals.__("activitypub.federationMgmt.title"), + parent: { + href: mountPath, + text: response.locals.__("activitypub.title"), + }, + collectionStats, + posts: postsResult.posts, + cursor: postsResult.cursor, + recentActivities, + csrfToken, + mountPath, + publicationUrl: plugin._publicationUrl, + actorUrl, + debugDashboardEnabled: plugin.options.debugDashboard, + }); + } catch (error) { + next(error); + } + }; +} + +/** + * POST /admin/federation/rebroadcast — re-send a Create activity for a post. + */ +export function rebroadcastController(mountPath, plugin) { + return async (request, response, next) => { + try { + if (!validateToken(request)) { + return response + .status(403) + .json({ success: false, error: "Invalid CSRF token" }); + } + + const { url } = request.body; + if (!url) { + return response + .status(400) + .json({ success: false, error: "Missing post URL" }); + } + + if (!plugin._federation) { + return response + .status(503) + .json({ success: false, error: "Federation not initialized" }); + } + + const { application } = request.app.locals; + const postsCol = application?.collections?.get("posts"); + if (!postsCol) { + return response + .status(500) + .json({ success: false, error: "Posts collection not available" }); + } + + const post = await postsCol.findOne({ "properties.url": url }); + if (!post) { + return response + .status(404) + .json({ success: false, error: "Post not found" }); + } + + // Reuse the full syndication pipeline (mention resolution, visibility, + // addressing, delivery) via the syndicator + await plugin.syndicator.syndicate(post.properties); + + return response.json({ success: true, url }); + } catch (error) { + next(error); + } + }; +} + +/** + * GET /admin/federation/ap-json — view ActivityStreams JSON for a post. + */ +export function viewApJsonController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { url } = request.query; + if (!url) { + return response + .status(400) + .json({ error: "Missing url query parameter" }); + } + + const { application } = request.app.locals; + const postsCol = application?.collections?.get("posts"); + if (!postsCol) { + return response + .status(500) + .json({ error: "Posts collection not available" }); + } + + const post = await postsCol.findOne({ "properties.url": url }); + if (!post) { + return response.status(404).json({ error: "Post not found" }); + } + + const actorUrl = plugin._getActorUrl?.() || ""; + const as2 = jf2ToActivityStreams( + post.properties, + actorUrl, + plugin._publicationUrl, + ); + + return response.json(as2); + } catch (error) { + next(error); + } + }; +} + +/** + * POST /admin/federation/broadcast-actor — broadcast an Update(Person) + * activity to all followers via Fedify. + */ +export function broadcastActorUpdateController(mountPath, plugin) { + return async (request, response, next) => { + try { + if (!validateToken(request)) { + return response + .status(403) + .json({ success: false, error: "Invalid CSRF token" }); + } + + if (!plugin._federation) { + return response + .status(503) + .json({ success: false, error: "Federation not initialized" }); + } + + await plugin.broadcastActorUpdate(); + + return response.json({ success: true }); + } catch (error) { + next(error); + } + }; +} + +/** + * GET /admin/federation/lookup — resolve a URL or @user@domain handle + * via Fedify's lookupObject (authenticated document loader). + */ +export function lookupObjectController(mountPath, plugin) { + return async (request, response, next) => { + try { + const query = (request.query.q || "").trim(); + if (!query) { + return response + .status(400) + .json({ error: "Missing q query parameter" }); + } + + if (!plugin._federation) { + return response + .status(503) + .json({ error: "Federation not initialized" }); + } + + const handle = plugin.options.actor.handle; + const ctx = plugin._federation.createContext( + new URL(plugin._publicationUrl), + { handle, publicationUrl: plugin._publicationUrl }, + ); + + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + + const object = await ctx.lookupObject(query, { documentLoader }); + + if (!object) { + return response + .status(404) + .json({ error: "Could not resolve object" }); + } + + const jsonLd = await object.toJsonLd(); + return response.json(jsonLd); + } catch (error) { + return response + .status(500) + .json({ error: error.message || "Lookup failed" }); + } + }; +} + +// --- Helpers --- + +async function getCollectionStats(collections) { + if (!collections) return []; + + const stats = await Promise.all( + AP_COLLECTIONS.map(async (name) => { + const col = collections.get(name); + const count = col ? await col.countDocuments() : 0; + return { name, count }; + }), + ); + + return stats; +} + +async function getPaginatedPosts(collections, pageParam) { + const postsCol = collections?.get("posts"); + if (!postsCol) return { posts: [], cursor: null }; + + const page = Math.max(1, Number.parseInt(pageParam, 10) || 1); + const totalCount = await postsCol.countDocuments(); + const totalPages = Math.ceil(totalCount / PAGE_SIZE); + + const rawPosts = await postsCol + .find() + .sort({ "properties.published": -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .toArray(); + + const posts = rawPosts.map((post) => { + const props = post.properties || {}; + const url = props.url || ""; + const content = props.content?.text || props.content?.html || ""; + const name = + props.name || (content ? content.slice(0, 80) : url.split("/").pop()); + return { + url, + name, + postType: props["post-type"] || "unknown", + published: props.published || null, + syndication: props.syndication || [], + deleted: props.deleted || false, + }; + }); + + const cursor = buildCursor(page, totalPages, "admin/federation"); + + return { posts, cursor }; +} + +async function getRecentActivities(collections) { + const col = collections?.get("ap_activities"); + if (!col) return []; + + return col.find().sort({ receivedAt: -1 }).limit(5).toArray(); +} + +function buildCursor(page, totalPages, basePath) { + if (totalPages <= 1) return null; + + return { + previous: + page > 1 ? { href: `${basePath}?page=${page - 1}` } : undefined, + next: + page < totalPages + ? { href: `${basePath}?page=${page + 1}` } + : undefined, + }; +} diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index a7a44f8..df62b87 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) // --------------------------------------------------------------------------- @@ -155,10 +199,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", @@ -310,7 +371,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) { @@ -322,10 +383,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..7034ee0 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", @@ -316,6 +322,27 @@ "deleteSuccess": "Delete activity sent to followers", "deleteButton": "Delete from fediverse" }, + "federationMgmt": { + "title": "Federation", + "collections": "Collection health", + "quickActions": "Quick actions", + "broadcastActor": "Broadcast actor update", + "debugDashboard": "Debug dashboard", + "objectLookup": "Object lookup", + "lookupPlaceholder": "URL or @user@domain handle…", + "lookup": "Look up", + "lookupLoading": "Resolving…", + "postActions": "Post federation", + "viewJson": "JSON", + "rebroadcast": "Re-broadcast Create activity", + "rebroadcastShort": "Re-send", + "broadcastDelete": "Broadcast Delete activity", + "deleteShort": "Delete", + "noPosts": "No posts found.", + "apJsonTitle": "ActivityStreams JSON-LD", + "recentActivity": "Recent activity", + "viewAllActivities": "View all activities →" + }, "reports": { "sentReport": "filed a report", "title": "Reports" diff --git a/package.json b/package.json index db08f2c..8ba941d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.10.1", + "version": "2.12.2", "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 8641f66..5a924f6 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -37,6 +37,18 @@ {% endif %} + {# Content warning toggle + summary #} +