feat: direct message (DM) receive and reply support

- Detect incoming DM visibility in inbox listener by checking absence of
  the public collection URL in object.toIds/ccIds; store isDirect and
  senderActorUrl on mention notifications
- Add native AP reply path in compose controller: when is-direct=true,
  build Create(Note) addressed only to the sender and deliver via
  ctx.sendActivity() instead of posting a public Micropub blog reply
- Add dedicated "Direct" tab to notifications view (separate from Replies)
  with its own count; update storage query so mention tab filters only
  mention type, reply tab filters only reply type
- Show lock badge (🔒) on direct mention notification cards and add
  ap-notification--direct CSS class
- Compose view: show DM notice banner, hide syndication targets, and
  change submit label when replying to a direct message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-13 06:32:50 +01:00
parent 1c2fb321bc
commit ea9a9856e9
7 changed files with 156 additions and 14 deletions
+115 -3
View File
@@ -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 { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.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. * Fetch syndication targets from the Micropub config endpoint.
* @param {object} application - Indiekit application locals * @param {object} application - Indiekit application locals
@@ -76,9 +105,13 @@ export function composeController(mountPath, plugin) {
{ handle, publicationUrl: plugin._publicationUrl }, { handle, publicationUrl: plugin._publicationUrl },
); );
// Use authenticated document loader for Authorized Fetch // Use authenticated document loader for Authorized Fetch
const documentLoader = await ctx.getDocumentLoader({ const rawDocumentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,
}); });
const documentLoader = createPublicationAwareDocumentLoader(
rawDocumentLoader,
plugin._publicationUrl,
);
const remoteObject = await ctx.lookupObject(new URL(replyTo), { const remoteObject = await ctx.lookupObject(new URL(replyTo), {
documentLoader, documentLoader,
}); });
@@ -135,6 +168,23 @@ export function composeController(mountPath, plugin) {
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; 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); const csrfToken = getToken(request.session);
response.render("activitypub-compose", { response.render("activitypub-compose", {
@@ -143,6 +193,8 @@ export function composeController(mountPath, plugin) {
replyTo, replyTo,
replyContext, replyContext,
syndicationTargets, syndicationTargets,
isDirect,
senderActorUrl,
csrfToken, csrfToken,
mountPath, mountPath,
}); });
@@ -171,6 +223,8 @@ export function submitComposeController(mountPath, plugin) {
const { content } = request.body; const { content } = request.body;
const inReplyTo = request.body["in-reply-to"]; const inReplyTo = request.body["in-reply-to"];
const syndicateTo = request.body["mp-syndicate-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()) { if (!content || !content.trim()) {
return response.status(400).render("error", { 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; const micropubEndpoint = application.micropubEndpoint;
if (!micropubEndpoint) { if (!micropubEndpoint) {
+1 -1
View File
@@ -117,7 +117,7 @@ export function readerController(mountPath) {
} }
export function notificationsController(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) => { return async (request, response, next) => {
try { try {
+10
View File
@@ -454,6 +454,14 @@ export function registerInboxListeners(inboxChain, options) {
const tags = Array.isArray(object.tag) ? object.tag : [object.tag]; const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
const ourActorUrl = ctx.getActorUri(handle).href; 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) { for (const tag of tags) {
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) { if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader }); const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
@@ -469,6 +477,8 @@ export function registerInboxListeners(inboxChain, options) {
actorName: actorInfo.name, actorName: actorInfo.name,
actorPhoto: actorInfo.photo, actorPhoto: actorInfo.photo,
actorHandle: actorInfo.handle, actorHandle: actorInfo.handle,
isDirect,
senderActorUrl: actorInfo.url,
content: { content: {
text: contentText, text: contentText,
html: mentionHtml, html: mentionHtml,
+6 -4
View File
@@ -64,9 +64,9 @@ export async function getNotifications(collections, options = {}) {
// Type filter // Type filter
if (options.type) { 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") { if (options.type === "reply") {
query.type = { $in: ["reply", "mention"] }; query.type = "reply";
} else { } else {
query.type = options.type; query.type = options.type;
} }
@@ -126,11 +126,13 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals
const results = await ap_notifications.aggregate(pipeline).toArray(); 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) { for (const { _id, count } of results) {
counts.all += count; counts.all += count;
if (_id === "reply" || _id === "mention") { if (_id === "reply") {
counts.reply += count; counts.reply += count;
} else if (_id === "mention") {
counts.mention += count;
} else if (counts[_id] !== undefined) { } else if (counts[_id] !== undefined) {
counts[_id] = count; counts[_id] = count;
} }
+18 -4
View File
@@ -21,11 +21,21 @@
</div> </div>
{% endif %} {% endif %}
{% if isDirect %}
<div class="ap-compose__dm-notice">
🔒 {{ __("activitypub.compose.directNotice") if __("activitypub.compose.directNotice") != "activitypub.compose.directNotice" else "Direct message — reply stays private" }}
</div>
{% endif %}
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form"> <form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}"> <input type="hidden" name="_csrf" value="{{ csrfToken }}">
{% if replyTo %} {% if replyTo %}
<input type="hidden" name="in-reply-to" value="{{ replyTo }}"> <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
{% endif %} {% endif %}
{% if isDirect %}
<input type="hidden" name="is-direct" value="true">
<input type="hidden" name="sender-actor-url" value="{{ senderActorUrl }}">
{% endif %}
{# Content textarea #} {# Content textarea #}
<div class="ap-compose__editor"> <div class="ap-compose__editor">
@@ -35,8 +45,8 @@
required></textarea> required></textarea>
</div> </div>
{# Syndication targets #} {# Syndication targets — hidden for direct messages #}
{% if syndicationTargets.length > 0 %} {% if syndicationTargets.length > 0 and not isDirect %}
<fieldset class="ap-compose__syndication"> <fieldset class="ap-compose__syndication">
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend> <legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
{% for target in syndicationTargets %} {% for target in syndicationTargets %}
@@ -50,9 +60,13 @@
<div class="ap-compose__actions"> <div class="ap-compose__actions">
<button type="submit" class="ap-compose__submit"> <button type="submit" class="ap-compose__submit">
{{ __("activitypub.compose.submitMicropub") }} {% if isDirect %}
🔒 {{ __("activitypub.compose.submitDirect") if __("activitypub.compose.submitDirect") != "activitypub.compose.submitDirect" else "Send direct reply" }}
{% else %}
{{ __("activitypub.compose.submitMicropub") }}
{% endif %}
</button> </button>
<a href="{{ mountPath }}/admin/reader" class="ap-compose__cancel"> <a href="{{ mountPath }}/admin/reader/notifications" class="ap-compose__cancel">
{{ __("activitypub.compose.cancel") }} {{ __("activitypub.compose.cancel") }}
</a> </a>
</div> </div>
+4
View File
@@ -11,6 +11,10 @@
{{ __("activitypub.notifications.tabs.replies") }} {{ __("activitypub.notifications.tabs.replies") }}
{% if tabCounts.reply %}<span class="ap-tab__count">{{ tabCounts.reply }}</span>{% endif %} {% if tabCounts.reply %}<span class="ap-tab__count">{{ tabCounts.reply }}</span>{% endif %}
</a> </a>
<a href="{{ notifBase }}?tab=mention" class="ap-tab{% if tab == 'mention' %} ap-tab--active{% endif %}">
🔒 {{ __("activitypub.notifications.tabs.direct") if __("activitypub.notifications.tabs.direct") != "activitypub.notifications.tabs.direct" else "Direct" }}
{% if tabCounts.mention %}<span class="ap-tab__count">{{ tabCounts.mention }}</span>{% endif %}
</a>
<a href="{{ notifBase }}?tab=like" class="ap-tab{% if tab == 'like' %} ap-tab--active{% endif %}"> <a href="{{ notifBase }}?tab=like" class="ap-tab{% if tab == 'like' %} ap-tab--active{% endif %}">
{{ __("activitypub.notifications.tabs.likes") }} {{ __("activitypub.notifications.tabs.likes") }}
{% if tabCounts.like %}<span class="ap-tab__count">{{ tabCounts.like }}</span>{% endif %} {% if tabCounts.like %}<span class="ap-tab__count">{{ tabCounts.like }}</span>{% endif %}
+2 -2
View File
@@ -1,6 +1,6 @@
{# Notification card partial #} {# Notification card partial #}
<div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}"> <div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}{% if item.isDirect %} ap-notification--direct{% endif %}">
{# Dismiss button #} {# Dismiss button #}
<form method="post" action="{{ mountPath }}/admin/reader/notifications/delete" class="ap-notification__dismiss"> <form method="post" action="{{ mountPath }}/admin/reader/notifications/delete" class="ap-notification__dismiss">
<input type="hidden" name="_csrf" value="{{ csrfToken }}"> <input type="hidden" name="_csrf" value="{{ csrfToken }}">
@@ -18,7 +18,7 @@
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span> <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
{% endif %} {% endif %}
<span class="ap-notification__type-badge"> <span class="ap-notification__type-badge">
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% endif %} {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}{% if item.isDirect %}🔒{% else %}@{% endif %}{% endif %}
</span> </span>
</div> </div>