From 42c0959c8ae244bc4e22a1ec6c60ab319e9e95a0 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 27 Mar 2026 14:03:41 +0100 Subject: [PATCH] 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",