diff --git a/lib/syndicator.js b/lib/syndicator.js index 94108d5..92b7cef 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -220,46 +220,52 @@ export function createSyndicator(plugin) { `[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`, ); - // Mirror own Micropub-created posts into ap_timeline so the Mastodon - // Client API (context, statuses, etc.) can find them by ID. - if (typeName === "Create" && properties.url) { - try { - const rawHtml = properties.content?.html || (typeof properties.content === "string" ? properties.content : "") || ""; - const now = new Date().toISOString(); - const postType = properties["post-type"] || "note"; - const asArray = (v) => Array.isArray(v) ? v : v ? [v] : []; - await addTimelineItem(plugin._collections, { - uid: properties.url, - url: properties.url, - type: postType, - content: { html: rawHtml, text: rawHtml.replace(/<[^>]*>/g, "") }, - summary: properties["content-warning"] || properties.summary || "", - sensitive: !!(properties.sensitive || properties["content-warning"]), - 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: [], + // 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 = buildTimelineContent(properties); + 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: [], - }); - } catch (timelineError) { - console.warn("[ActivityPub] Failed to mirror syndicated post to ap_timeline:", timelineError.message); + 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; @@ -280,3 +286,94 @@ export function createSyndicator(plugin) { 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, "&").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 || ""; + } + } + + // 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: 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: 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: 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: "" }; +} + +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..1955000 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "indiekit",