merge: upstream 42c0959..6436763 — own posts in ap_timeline with synthesized content for interactions

- 42c0959: feat: add own posts to ap_timeline after syndication
- f1ad18d: fix: synthesize timeline content for likes/bookmarks/reposts
- 6436763: fix: include interaction target URL in timeline content

Upstream's implementation supersedes our 42f8c2d: uses buildTimelineContent()
for synthesized display content on interaction types, reads real profile data,
normalizes media, idempotent via $setOnInsert.
This commit is contained in:
svemagie
2026-03-27 20:33:26 +01:00
2 changed files with 136 additions and 39 deletions

View File

@@ -220,46 +220,52 @@ export function createSyndicator(plugin) {
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`, `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
); );
// Mirror own Micropub-created posts into ap_timeline so the Mastodon // Add own post to ap_timeline so it appears in Mastodon Client API
// Client API (context, statuses, etc.) can find them by ID. // timelines (Phanpy/Moshidon). Uses $setOnInsert — idempotent.
if (typeName === "Create" && properties.url) { try {
try { const profile = await plugin._collections.ap_profile?.findOne({});
const rawHtml = properties.content?.html || (typeof properties.content === "string" ? properties.content : "") || ""; const content = buildTimelineContent(properties);
const now = new Date().toISOString(); const timelineItem = {
const postType = properties["post-type"] || "note"; uid: properties.url,
const asArray = (v) => Array.isArray(v) ? v : v ? [v] : []; url: properties.url,
await addTimelineItem(plugin._collections, { type: mapPostType(properties["post-type"]),
uid: properties.url, content,
url: properties.url, author: {
type: postType, name: profile?.name || handle,
content: { html: rawHtml, text: rawHtml.replace(/<[^>]*>/g, "") }, url: profile?.url || plugin._publicationUrl,
summary: properties["content-warning"] || properties.summary || "", photo: profile?.icon || "",
sensitive: !!(properties.sensitive || properties["content-warning"]), handle: `@${handle}`,
visibility: properties.visibility || plugin.options.defaultVisibility || "public",
language: properties.lang || properties.language || null,
inReplyTo: properties["in-reply-to"] || null,
published: properties.published || now,
createdAt: now,
author: {
name: plugin.options.actor.name || handle,
url: actorUrl,
photo: plugin.options.actor.icon || "",
handle: `@${handle}`,
emojis: [],
bot: false,
},
photo: asArray(properties.photo),
video: asArray(properties.video),
audio: asArray(properties.audio),
category: asArray(properties.category),
counts: { replies: 0, boosts: 0, likes: 0 },
linkPreviews: [],
mentions: [],
emojis: [], emojis: [],
}); bot: false,
} catch (timelineError) { },
console.warn("[ActivityPub] Failed to mirror syndicated post to ap_timeline:", timelineError.message); published: properties.published || new Date().toISOString(),
createdAt: new Date().toISOString(),
visibility: properties.visibility || "public",
sensitive: properties.sensitive === "true",
category: Array.isArray(properties.category)
? properties.category
: properties.category ? [properties.category] : [],
photo: normalizeMedia(properties.photo, plugin._publicationUrl),
video: normalizeMedia(properties.video, plugin._publicationUrl),
audio: normalizeMedia(properties.audio, plugin._publicationUrl),
counts: { replies: 0, boosts: 0, likes: 0 },
};
if (properties.name) timelineItem.name = properties.name;
if (properties.summary) timelineItem.summary = properties.summary;
if (properties["content-warning"]) {
timelineItem.summary = properties["content-warning"];
timelineItem.sensitive = true;
} }
if (properties["in-reply-to"]) {
timelineItem.inReplyTo = Array.isArray(properties["in-reply-to"])
? properties["in-reply-to"][0]
: properties["in-reply-to"];
}
await addTimelineItem(plugin._collections, timelineItem);
} catch (tlError) {
console.warn(
`[ActivityPub] Failed to add own post to timeline: ${tlError.message}`,
);
} }
return properties.url || undefined; return properties.url || undefined;
@@ -280,3 +286,94 @@ export function createSyndicator(plugin) {
update: async (properties) => plugin.update(properties), update: async (properties) => plugin.update(properties),
}; };
} }
// ─── Timeline helpers ───────────────────────────────────────────────────────
/**
* Build content from JF2 properties for the ap_timeline entry.
* For interaction types (likes, bookmarks, reposts), always includes
* the target URL — even when there's comment text alongside it.
*/
function buildTimelineContent(properties) {
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
// Extract any existing body content
const raw = properties.content;
let bodyText = "";
let bodyHtml = "";
if (raw) {
if (typeof raw === "string") {
bodyText = raw;
bodyHtml = `<p>${raw}</p>`;
} else {
bodyText = raw.text || raw.value || "";
bodyHtml = raw.html || raw.text || raw.value || "";
}
}
// Interaction types: prepend label + target URL, append any comment
const likeOf = properties["like-of"];
if (likeOf) {
const prefix = `Liked: ${likeOf}`;
const prefixHtml = `<p>Liked: <a href="${esc(likeOf)}">${esc(likeOf)}</a></p>`;
return {
text: bodyText ? `${prefix}\n\n${bodyText}` : prefix,
html: bodyText ? `${prefixHtml}\n${bodyHtml}` : prefixHtml,
};
}
const bookmarkOf = properties["bookmark-of"];
if (bookmarkOf) {
const label = properties.name || bookmarkOf;
const prefix = `Bookmarked: ${label}`;
const prefixHtml = `<p>Bookmarked: <a href="${esc(bookmarkOf)}">${esc(label)}</a></p>`;
return {
text: bodyText ? `${prefix}\n\n${bodyText}` : prefix,
html: bodyText ? `${prefixHtml}\n${bodyHtml}` : prefixHtml,
};
}
const repostOf = properties["repost-of"];
if (repostOf) {
const label = properties.name || repostOf;
const prefix = `Reposted: ${label}`;
const prefixHtml = `<p>Reposted: <a href="${esc(repostOf)}">${esc(label)}</a></p>`;
return {
text: bodyText ? `${prefix}\n\n${bodyText}` : prefix,
html: bodyText ? `${prefixHtml}\n${bodyHtml}` : prefixHtml,
};
}
// Regular post — return body content as-is
if (bodyText || bodyHtml) {
return { text: bodyText, html: bodyHtml };
}
// Article with title but no body
if (properties.name) {
return { text: properties.name, html: `<p>${esc(properties.name)}</p>` };
}
return { text: "", html: "" };
}
function mapPostType(postType) {
if (postType === "article") return "article";
if (postType === "repost") return "boost";
return "note";
}
function normalizeMedia(value, siteUrl) {
if (!value) return [];
const base = siteUrl?.replace(/\/$/, "") || "";
const arr = Array.isArray(value) ? value : [value];
return arr.map((item) => {
if (typeof item === "string") {
return item.startsWith("http") ? item : `${base}/${item.replace(/^\//, "")}`;
}
if (item?.url && !item.url.startsWith("http")) {
return { ...item, url: `${base}/${item.url.replace(/^\//, "")}` };
}
return item;
}).filter(Boolean);
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.10.0", "version": "3.10.3",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",