From 42c0959c8ae244bc4e22a1ec6c60ab319e9e95a0 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 27 Mar 2026 14:03:41 +0100 Subject: [PATCH 1/3] feat: add own posts to ap_timeline after syndication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own Micropub posts weren't appearing in Mastodon Client API timelines (Phanpy/Moshidon) because there was no mechanism to add them to ap_timeline. The inbox round-trip doesn't work (we don't follow ourselves) and startup backfill only runs once. Now the AP syndicator adds the post to ap_timeline after successful delivery, using addTimelineItem ($setOnInsert — idempotent). Content flows directly from Micropub properties with proper HTML links. --- lib/syndicator.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/lib/syndicator.js b/lib/syndicator.js index b76596d..4c7c960 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -8,6 +8,7 @@ import { } from "./jf2-to-as2.js"; import { lookupWithSecurity } from "./lookup-helpers.js"; import { logActivity } from "./activity-log.js"; +import { addTimelineItem } from "./storage/timeline.js"; /** * Create the ActivityPub syndicator object. @@ -219,6 +220,54 @@ export function createSyndicator(plugin) { `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`, ); + // Add own post to ap_timeline so it appears in Mastodon Client API + // timelines (Phanpy/Moshidon). Uses $setOnInsert — idempotent. + try { + const profile = await plugin._collections.ap_profile?.findOne({}); + const content = normalizeContent(properties.content); + const timelineItem = { + uid: properties.url, + url: properties.url, + type: mapPostType(properties["post-type"]), + content, + author: { + name: profile?.name || handle, + url: profile?.url || plugin._publicationUrl, + photo: profile?.icon || "", + handle: `@${handle}`, + emojis: [], + bot: false, + }, + 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; } catch (error) { console.error("[ActivityPub] Syndication failed:", error.message); @@ -237,3 +286,35 @@ export function createSyndicator(plugin) { update: async (properties) => plugin.update(properties), }; } + +// ─── Timeline helpers ─────────────────────────────────────────────────────── + +function normalizeContent(content) { + if (!content) return { text: "", html: "" }; + if (typeof content === "string") return { text: content, html: `

${content}

` }; + return { + text: content.text || content.value || "", + html: content.html || content.text || content.value || "", + }; +} + +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); +} diff --git a/package.json b/package.json index b560a14..3db4f2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.0", + "version": "3.10.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From f1ad18d92d68151a99a4dc542a3a13e6d3fc8069 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 27 Mar 2026 15:32:15 +0100 Subject: [PATCH 2/3] fix: synthesize timeline content for likes/bookmarks/reposts Interaction types (likes, bookmarks, reposts) have no body content in their JF2 properties. The timeline entry was created with empty content, showing blank posts in Phanpy/Moshidon. Now synthesizes display content (e.g. "Liked: https://...") matching backfill-timeline.js behavior. --- lib/syndicator.js | 54 ++++++++++++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/syndicator.js b/lib/syndicator.js index 4c7c960..8bf29c3 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -224,7 +224,7 @@ export function createSyndicator(plugin) { // timelines (Phanpy/Moshidon). Uses $setOnInsert — idempotent. try { const profile = await plugin._collections.ap_profile?.findOne({}); - const content = normalizeContent(properties.content); + const content = buildTimelineContent(properties); const timelineItem = { uid: properties.url, url: properties.url, @@ -289,13 +289,51 @@ export function createSyndicator(plugin) { // ─── Timeline helpers ─────────────────────────────────────────────────────── -function normalizeContent(content) { - if (!content) return { text: "", html: "" }; - if (typeof content === "string") return { text: content, html: `

${content}

` }; - return { - text: content.text || content.value || "", - html: content.html || content.text || content.value || "", - }; +/** + * Build content from JF2 properties. Synthesizes content for interaction + * types (likes, bookmarks, reposts) that have no body text. + */ +function buildTimelineContent(properties) { + const content = properties.content; + if (content) { + if (typeof content === "string") return { text: content, html: `

${content}

` }; + if (content.text || content.html) { + return { + text: content.text || content.value || "", + html: content.html || content.text || content.value || "", + }; + } + } + + // Synthesize for interaction types + const esc = (s) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const likeOf = properties["like-of"]; + if (likeOf) { + return { + text: `Liked: ${likeOf}`, + html: `

Liked: ${esc(likeOf)}

`, + }; + } + const bookmarkOf = properties["bookmark-of"]; + if (bookmarkOf) { + const label = properties.name || bookmarkOf; + return { + text: `Bookmarked: ${label}`, + html: `

Bookmarked: ${esc(label)}

`, + }; + } + const repostOf = properties["repost-of"]; + if (repostOf) { + const label = properties.name || repostOf; + return { + text: `Reposted: ${label}`, + html: `

Reposted: ${esc(label)}

`, + }; + } + if (properties.name) { + return { text: properties.name, html: `

${esc(properties.name)}

` }; + } + return { text: "", html: "" }; } function mapPostType(postType) { diff --git a/package.json b/package.json index 3db4f2a..f08d620 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.1", + "version": "3.10.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 6436763dabfb5ce574beba8467cd402c89ed24a9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 27 Mar 2026 16:26:13 +0100 Subject: [PATCH 3/3] fix: include interaction target URL in timeline content Interaction types with comment text (e.g. repost with a comment) were showing only the comment, losing the repost-of/bookmark-of/like-of URL. Now always includes the target URL for interaction types, combining it with any body text. --- lib/syndicator.js | 57 ++++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/lib/syndicator.js b/lib/syndicator.js index 8bf29c3..55b918b 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -290,49 +290,70 @@ export function createSyndicator(plugin) { // ─── Timeline helpers ─────────────────────────────────────────────────────── /** - * Build content from JF2 properties. Synthesizes content for interaction - * types (likes, bookmarks, reposts) that have no body text. + * 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 content = properties.content; - if (content) { - if (typeof content === "string") return { text: content, html: `

${content}

` }; - if (content.text || content.html) { - return { - text: content.text || content.value || "", - html: content.html || content.text || content.value || "", - }; + const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + + // Extract any existing body content + const raw = properties.content; + let bodyText = ""; + let bodyHtml = ""; + if (raw) { + if (typeof raw === "string") { + bodyText = raw; + bodyHtml = `

${raw}

`; + } else { + bodyText = raw.text || raw.value || ""; + bodyHtml = raw.html || raw.text || raw.value || ""; } } - // Synthesize for interaction types - const esc = (s) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + // Interaction types: prepend label + target URL, append any comment const likeOf = properties["like-of"]; if (likeOf) { + const prefix = `Liked: ${likeOf}`; + const prefixHtml = `

Liked: ${esc(likeOf)}

`; return { - text: `Liked: ${likeOf}`, - html: `

Liked: ${esc(likeOf)}

`, + 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 = `

Bookmarked: ${esc(label)}

`; return { - text: `Bookmarked: ${label}`, - html: `

Bookmarked: ${esc(label)}

`, + 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 = `

Reposted: ${esc(label)}

`; return { - text: `Reposted: ${label}`, - html: `

Reposted: ${esc(label)}

`, + 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: `

${esc(properties.name)}

` }; } + return { text: "", html: "" }; } diff --git a/package.json b/package.json index f08d620..1955000 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.10.2", + "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.", "keywords": [ "indiekit",