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:
+115
-3
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,21 @@
|
||||
</div>
|
||||
{% 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">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
{% if replyTo %}
|
||||
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
||||
{% endif %}
|
||||
{% if isDirect %}
|
||||
<input type="hidden" name="is-direct" value="true">
|
||||
<input type="hidden" name="sender-actor-url" value="{{ senderActorUrl }}">
|
||||
{% endif %}
|
||||
|
||||
{# Content textarea #}
|
||||
<div class="ap-compose__editor">
|
||||
@@ -35,8 +45,8 @@
|
||||
required></textarea>
|
||||
</div>
|
||||
|
||||
{# Syndication targets #}
|
||||
{% if syndicationTargets.length > 0 %}
|
||||
{# Syndication targets — hidden for direct messages #}
|
||||
{% if syndicationTargets.length > 0 and not isDirect %}
|
||||
<fieldset class="ap-compose__syndication">
|
||||
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
|
||||
{% for target in syndicationTargets %}
|
||||
@@ -50,9 +60,13 @@
|
||||
|
||||
<div class="ap-compose__actions">
|
||||
<button type="submit" class="ap-compose__submit">
|
||||
{% if isDirect %}
|
||||
🔒 {{ __("activitypub.compose.submitDirect") if __("activitypub.compose.submitDirect") != "activitypub.compose.submitDirect" else "Send direct reply" }}
|
||||
{% else %}
|
||||
{{ __("activitypub.compose.submitMicropub") }}
|
||||
{% endif %}
|
||||
</button>
|
||||
<a href="{{ mountPath }}/admin/reader" class="ap-compose__cancel">
|
||||
<a href="{{ mountPath }}/admin/reader/notifications" class="ap-compose__cancel">
|
||||
{{ __("activitypub.compose.cancel") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
{{ __("activitypub.notifications.tabs.replies") }}
|
||||
{% if tabCounts.reply %}<span class="ap-tab__count">{{ tabCounts.reply }}</span>{% endif %}
|
||||
</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 %}">
|
||||
{{ __("activitypub.notifications.tabs.likes") }}
|
||||
{% if tabCounts.like %}<span class="ap-tab__count">{{ tabCounts.like }}</span>{% endif %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# 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 #}
|
||||
<form method="post" action="{{ mountPath }}/admin/reader/notifications/delete" class="ap-notification__dismiss">
|
||||
<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>
|
||||
{% endif %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user