feat(like): send Like activity for AP objects, bookmark for regular URLs

When the `like-of` URL serves ActivityPub content (detected via content
negotiation with Accept: application/activity+json), deliver a proper
`Like { actor, object, to: Public }` activity to followers.

For likes of regular (non-AP) URLs, fall through to the existing
bookmark-style `Create(Note)` behaviour (🔖 content with #bookmark tag).

- Add `isApUrl()` async helper (3 s timeout, fails silently)
- Make `jf2ToAS2Activity` async; add Like detection before repost block
- Update all four call sites in federation-setup.js and index.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-19 08:50:00 +01:00
parent 03c4ba4aea
commit 842fc5af2a
3 changed files with 57 additions and 19 deletions

View File

@@ -583,7 +583,7 @@ export default class ActivityPubEndpoint {
}
}
const activity = jf2ToAS2Activity(
const activity = await jf2ToAS2Activity(
properties,
actorUrl,
self._publicationUrl,

View File

@@ -560,7 +560,7 @@ function setupFeatured(federation, mountPath, handle, collections, publicationUr
});
if (!post) continue;
const actorUrl = ctx.getActorUri(identifier).href;
const activity = jf2ToAS2Activity(
const activity = await jf2ToAS2Activity(
post.properties,
actorUrl,
publicationUrl,
@@ -637,19 +637,21 @@ function setupOutbox(federation, mountPath, handle, collections) {
.toArray();
const { jf2ToAS2Activity } = await import("./jf2-to-as2.js");
const items = posts
.map((post) => {
try {
return jf2ToAS2Activity(
post.properties,
ctx.getActorUri(identifier).href,
collections._publicationUrl,
);
} catch {
return null;
}
})
.filter(Boolean);
const items = (
await Promise.all(
posts.map(async (post) => {
try {
return await jf2ToAS2Activity(
post.properties,
ctx.getActorUri(identifier).href,
collections._publicationUrl,
);
} catch {
return null;
}
}),
)
).filter(Boolean);
return {
items,
@@ -687,7 +689,7 @@ function setupObjectDispatchers(federation, mountPath, handle, collections, publ
// Soft-deleted posts should not be dereferenceable
if (post.properties?.deleted) return null;
const actorUrl = ctx.getActorUri(handle).href;
const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
// Only Create activities wrap Note/Article objects
if (!(activity instanceof Create)) return null;
return await activity.getObject();

View File

@@ -14,6 +14,7 @@ import {
Create,
Hashtag,
Image,
Like,
Mention,
Note,
Video,
@@ -79,6 +80,30 @@ function linkifyMentions(html, resolvedMentions) {
return html;
}
// ---------------------------------------------------------------------------
// ActivityPub URL detection
// ---------------------------------------------------------------------------
/**
* Check whether a URL serves ActivityPub content by doing a quick content
* negotiation request. Returns true if the server responds with an AP
* media type (application/activity+json or application/ld+json).
* Fails silently — any network/timeout error returns false.
*/
async function isApUrl(url) {
try {
const res = await fetch(url, {
headers: { Accept: "application/activity+json, application/ld+json" },
redirect: "follow",
signal: AbortSignal.timeout(3000),
});
const ct = res.headers.get("content-type") || "";
return ct.includes("activity+json") || ct.includes("ld+json");
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Plain JSON-LD (content negotiation on individual post URLs)
// ---------------------------------------------------------------------------
@@ -239,13 +264,24 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
* @param {object} [options] - Optional settings
* @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing)
* @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag)
* @returns {import("@fedify/fedify").Activity | null}
* @returns {Promise<import("@fedify/fedify").Activity | null>}
*/
export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
const postType = properties["post-type"];
const actorUri = new URL(actorUrl);
// Likes are delivered as bookmarks — fall through to bookmark handling below
// Likes of ActivityPub objects are sent as a proper Like activity.
// Likes of regular URLs fall through to bookmark-style Create(Note) below.
if (postType === "like") {
const likeOfUrl = properties["like-of"];
if (likeOfUrl && (await isApUrl(likeOfUrl))) {
return new Like({
actor: actorUri,
object: new URL(likeOfUrl),
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
});
}
}
// Reposts are always public — upstream @rmdes addressing
if (postType === "repost") {