diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index ec5b089..e86e5fe 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -503,6 +503,119 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", } }); +// ─── GET /api/v1/accounts/:id/boosts ──────────────────────────────────────── +// Chronicle PAI extension — returns own boosts from ap_interactions + ap_timeline. +// ap_interactions stores {objectUrl, createdAt} for each own Announce activity. +// ap_timeline stores the boosted post (with its original author). +// Returns Mastodon-compatible status array with reblog wrapper (like /statuses). + +router.get("/api/v1/accounts/:id/boosts", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + + // Pagination and since filter + const maxDate = req.query.max_date ? new Date(req.query.max_date) : null; + const sinceDate = req.query.since_date ? new Date(req.query.since_date) : null; + + // Build filter for ap_interactions + const interactionFilter = { type: "boost" }; + if (sinceDate) interactionFilter.createdAt = { ...interactionFilter.createdAt, $gt: sinceDate.toISOString() }; + if (maxDate) interactionFilter.createdAt = { ...interactionFilter.createdAt, $lt: maxDate.toISOString() }; + + const interactions = await collections.ap_interactions + .find(interactionFilter) + .sort({ createdAt: -1 }) + .limit(limit) + .toArray(); + + if (interactions.length === 0) { + return res.json([]); + } + + // Look up each boosted post in ap_timeline by objectUrl + const objectUrls = interactions.map((i) => i.objectUrl); + const timelineItems = await collections.ap_timeline + .find({ uid: { $in: objectUrls } }) + .toArray(); + + // Build lookup map for O(1) access + const timelineMap = new Map(timelineItems.map((item) => [item.uid, item])); + + // Get own actor profile for the boost wrapper account + const profile = await collections.ap_profile.findOne({}); + + const statuses = interactions.flatMap((interaction) => { + const original = timelineMap.get(interaction.objectUrl); + if (!original) return []; // post not in local timeline, skip + + const innerStatus = serializeStatus(original, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }); + + // Wrap in boost envelope — outer status represents the boost action + return [{ + id: `boost-${interaction._id || interaction.objectUrl}`, + created_at: interaction.createdAt, + in_reply_to_id: null, + in_reply_to_account_id: null, + sensitive: false, + spoiler_text: "", + visibility: "public", + language: null, + uri: interaction.objectUrl, + url: interaction.objectUrl, + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + edited_at: null, + favourited: false, + reblogged: true, + muted: false, + bookmarked: false, + pinned: false, + content: "", + filtered: [], + reblog: innerStatus, + application: null, + account: profile ? { + id: profile._id?.toString() || "1", + username: profile.preferredUsername || "user", + acct: profile.preferredUsername || "user", + display_name: profile.name || "", + url: profile.url || baseUrl, + avatar: profile.icon?.url || "", + avatar_static: profile.icon?.url || "", + header: "", + header_static: "", + note: "", + emojis: [], + fields: [], + followers_count: 0, + following_count: 0, + statuses_count: 0, + created_at: new Date().toISOString(), + } : null, + media_attachments: [], + mentions: [], + tags: [], + emojis: [], + card: null, + poll: null, + }]; + }); + + res.json(statuses); + } catch (error) { + next(error); + } +}); + // ─── GET /api/v1/accounts/:id/followers ───────────────────────────────────── router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {