diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index c916a60..e69e115 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -1,10 +1,39 @@ /** - * Compose controllers — reply form via Micropub. + * Compose controllers — reply form via Micropub (public) or native AP (direct). */ +import { Create, Note, Mention } from "@fedify/fedify/vocab"; import { getToken, validateToken } from "../csrf.js"; import { sanitizeContent } from "../timeline-store.js"; +function createPublicationAwareDocumentLoader(documentLoader, publicationUrl) { + if (typeof documentLoader !== "function") { + return documentLoader; + } + + let publicationHost = ""; + try { + publicationHost = new URL(publicationUrl).hostname; + } catch { + return documentLoader; + } + + return (url, options = {}) => { + try { + const parsed = new URL( + typeof url === "string" ? url : (url?.href || String(url)), + ); + if (parsed.hostname === publicationHost) { + return documentLoader(url, { ...options, allowPrivateAddress: true }); + } + } catch { + // Fall through to default loader behavior. + } + + return documentLoader(url, options); + }; +} + /** * Fetch syndication targets from the Micropub config endpoint. * @param {object} application - Indiekit application locals @@ -76,9 +105,13 @@ export function composeController(mountPath, plugin) { { handle, publicationUrl: plugin._publicationUrl }, ); // Use authenticated document loader for Authorized Fetch - const documentLoader = await ctx.getDocumentLoader({ + const rawDocumentLoader = await ctx.getDocumentLoader({ identifier: handle, }); + const documentLoader = createPublicationAwareDocumentLoader( + rawDocumentLoader, + plugin._publicationUrl, + ); const remoteObject = await ctx.lookupObject(new URL(replyTo), { documentLoader, }); @@ -135,6 +168,23 @@ export function composeController(mountPath, plugin) { target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; } + // Check if this is a direct/private message reply by looking at notification metadata + let isDirect = false; + let senderActorUrl = ""; + if (replyTo) { + const ap_notifications = application?.collections?.get("ap_notifications"); + if (ap_notifications) { + const notif = await ap_notifications.findOne({ + $or: [{ uid: replyTo }, { url: replyTo }], + isDirect: true, + }); + if (notif) { + isDirect = true; + senderActorUrl = notif.senderActorUrl || notif.actorUrl || ""; + } + } + } + const csrfToken = getToken(request.session); response.render("activitypub-compose", { @@ -143,6 +193,8 @@ export function composeController(mountPath, plugin) { replyTo, replyContext, syndicationTargets, + isDirect, + senderActorUrl, csrfToken, mountPath, }); @@ -171,6 +223,8 @@ export function submitComposeController(mountPath, plugin) { const { content } = request.body; const inReplyTo = request.body["in-reply-to"]; const syndicateTo = request.body["mp-syndicate-to"]; + const isDirect = request.body["is-direct"] === "true"; + const senderActorUrl = request.body["sender-actor-url"] || ""; if (!content || !content.trim()) { return response.status(400).render("error", { @@ -179,7 +233,65 @@ export function submitComposeController(mountPath, plugin) { }); } - // Post as blog reply via Micropub + // --- Native AP path for direct/private replies --- + if (isDirect && senderActorUrl && plugin._federation) { + try { + const handle = plugin.options.actor.handle; + const ctx = plugin._federation.createContext( + new URL(plugin._publicationUrl), + { handle, publicationUrl: plugin._publicationUrl }, + ); + + const actorUri = ctx.getActorUri(handle); + const uuid = crypto.randomUUID(); + const noteId = new URL( + `${plugin._publicationUrl.replace(/\/$/, "")}/activitypub/notes/${uuid}`, + ); + + const note = new Note({ + id: noteId, + attributedTo: actorUri, + to: new URL(senderActorUrl), + content: content.trim(), + ...(inReplyTo ? { replyTarget: new URL(inReplyTo) } : {}), + tag: new Mention({ href: new URL(senderActorUrl) }), + }); + + const create = new Create({ + id: new URL(`${noteId.href}#create`), + actor: actorUri, + to: new URL(senderActorUrl), + object: note, + }); + + // Resolve recipient for delivery + const { resolveAuthor } = await import("../resolve-author.js"); + const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const recipient = await resolveAuthor( + senderActorUrl, + ctx, + documentLoader, + application?.collections, + ); + + if (recipient) { + await ctx.sendActivity({ identifier: handle }, recipient, create, { + orderingKey: noteId.href, + }); + console.info(`[ActivityPub] Sent direct AP reply to ${senderActorUrl}`); + } + + return response.redirect(`${mountPath}/admin/reader/notifications`); + } catch (error) { + console.error("[ActivityPub] Native AP DM reply failed:", error.message); + return response.status(500).render("error", { + title: "Error", + content: "Failed to send direct reply. Please try again later.", + }); + } + } + + // --- Public reply path via Micropub --- const micropubEndpoint = application.micropubEndpoint; if (!micropubEndpoint) { diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index f330276..b6160fc 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -117,7 +117,7 @@ export function readerController(mountPath) { } export function notificationsController(mountPath) { - const validTabs = ["all", "reply", "like", "boost", "follow"]; + const validTabs = ["all", "reply", "mention", "like", "boost", "follow"]; return async (request, response, next) => { try { diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index a8839e7..101756b 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -454,6 +454,14 @@ export function registerInboxListeners(inboxChain, options) { const tags = Array.isArray(object.tag) ? object.tag : [object.tag]; const ourActorUrl = ctx.getActorUri(handle).href; + // Detect direct/private visibility: no public collection in `to` or `cc` + const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public"; + const toHrefs = (object.toIds || []).map((u) => u?.href || String(u)); + const ccHrefs = (object.ccIds || []).map((u) => u?.href || String(u)); + const isDirect = + !toHrefs.includes(PUBLIC_COLLECTION) && + !ccHrefs.includes(PUBLIC_COLLECTION); + for (const tag of tags) { if (tag.type === "Mention" && tag.href?.href === ourActorUrl) { const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader }); @@ -469,6 +477,8 @@ export function registerInboxListeners(inboxChain, options) { actorName: actorInfo.name, actorPhoto: actorInfo.photo, actorHandle: actorInfo.handle, + isDirect, + senderActorUrl: actorInfo.url, content: { text: contentText, html: mentionHtml, diff --git a/lib/storage/notifications.js b/lib/storage/notifications.js index ce6cd85..80d8b3d 100644 --- a/lib/storage/notifications.js +++ b/lib/storage/notifications.js @@ -64,9 +64,9 @@ export async function getNotifications(collections, options = {}) { // Type filter if (options.type) { - // "reply" tab shows both replies and mentions + // "reply" tab shows replies only; mentions have their own "mention" tab if (options.type === "reply") { - query.type = { $in: ["reply", "mention"] }; + query.type = "reply"; } else { query.type = options.type; } @@ -126,11 +126,13 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals const results = await ap_notifications.aggregate(pipeline).toArray(); - const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0 }; + const counts = { all: 0, reply: 0, mention: 0, like: 0, boost: 0, follow: 0 }; for (const { _id, count } of results) { counts.all += count; - if (_id === "reply" || _id === "mention") { + if (_id === "reply") { counts.reply += count; + } else if (_id === "mention") { + counts.mention += count; } else if (counts[_id] !== undefined) { counts[_id] = count; } diff --git a/views/activitypub-compose.njk b/views/activitypub-compose.njk index c1783fb..85ec4ab 100644 --- a/views/activitypub-compose.njk +++ b/views/activitypub-compose.njk @@ -21,11 +21,21 @@ {% endif %} + {% if isDirect %} +