From b5ebf6a1e4cabbc0ceae15950c5a49b024dc1725 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:16:15 +0100 Subject: [PATCH] feat(mastodon-api): implement POST /api/v1/statuses/:id/pin and /unpin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Mastodon Client API endpoints for pinning and unpinning posts: - POST /api/v1/statuses/:id/pin — upserts a document into ap_featured (same store the admin UI uses), enforces the 5-post maximum, fires broadcastActorUpdate() so remote servers re-fetch the featured collection - POST /api/v1/statuses/:id/unpin — removes from ap_featured, broadcasts update - loadItemInteractions() now also queries ap_featured and returns pinnedIds - GET /api/v1/statuses/:id response now reflects actual pin state - broadcastActorUpdate wired into mastodon pluginOptions in index.js Co-Authored-By: Claude Sonnet 4.6 --- index.js | 1 + lib/mastodon/routes/statuses.js | 125 ++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/index.js b/index.js index 064256e..5f8f0ce 100644 --- a/index.js +++ b/index.js @@ -1828,6 +1828,7 @@ export default class ActivityPubEndpoint { followActor: (url, info) => pluginRef.followActor(url, info), unfollowActor: (url) => pluginRef.unfollowActor(url), loadRsaKey: () => pluginRef._loadRsaPrivateKey(), + broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), }, }); Indiekit.addEndpoint({ diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 267bd29..612e4dc 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -45,11 +45,7 @@ router.get("/api/v1/statuses/:id", async (req, res, next) => { // Load interaction state if authenticated const interactionState = await loadItemInteractions(collections, item); - const status = serializeStatus(item, { - baseUrl, - ...interactionState, - pinnedIds: new Set(), - }); + const status = serializeStatus(item, { baseUrl, ...interactionState }); res.json(status); } catch (error) { @@ -566,7 +562,7 @@ router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => { // Force favourited=true since we just liked it interactionState.favouritedIds.add(item.uid); - res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + res.json(serializeStatus(item, { baseUrl, ...interactionState })); } catch (error) { next(error); } @@ -596,7 +592,7 @@ router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => { const interactionState = await loadItemInteractions(collections, item); interactionState.favouritedIds.delete(item.uid); - res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + res.json(serializeStatus(item, { baseUrl, ...interactionState })); } catch (error) { next(error); } @@ -626,7 +622,7 @@ router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => { const interactionState = await loadItemInteractions(collections, item); interactionState.rebloggedIds.add(item.uid); - res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + res.json(serializeStatus(item, { baseUrl, ...interactionState })); } catch (error) { next(error); } @@ -656,7 +652,7 @@ router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => { const interactionState = await loadItemInteractions(collections, item); interactionState.rebloggedIds.delete(item.uid); - res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + res.json(serializeStatus(item, { baseUrl, ...interactionState })); } catch (error) { next(error); } @@ -684,7 +680,7 @@ router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => { const interactionState = await loadItemInteractions(collections, item); interactionState.bookmarkedIds.add(item.uid); - res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + res.json(serializeStatus(item, { baseUrl, ...interactionState })); } catch (error) { next(error); } @@ -712,7 +708,81 @@ router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => { const interactionState = await loadItemInteractions(collections, item); interactionState.bookmarkedIds.delete(item.uid); - res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + res.json(serializeStatus(item, { baseUrl, ...interactionState })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/pin ────────────────────────────────────────── + +router.post("/api/v1/statuses/:id/pin", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const postUrl = item.uid || item.url; + if (collections.ap_featured) { + const count = await collections.ap_featured.countDocuments(); + if (count >= 5) { + return res.status(422).json({ error: "Maximum number of pinned posts reached" }); + } + await collections.ap_featured.updateOne( + { postUrl }, + { $set: { postUrl, pinnedAt: new Date().toISOString() } }, + { upsert: true }, + ); + } + + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + if (pluginOptions.broadcastActorUpdate) { + pluginOptions.broadcastActorUpdate().catch(() => {}); + } + + const interactionState = await loadItemInteractions(collections, item); + interactionState.pinnedIds.add(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/unpin ──────────────────────────────────────── + +router.post("/api/v1/statuses/:id/unpin", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const postUrl = item.uid || item.url; + if (collections.ap_featured) { + await collections.ap_featured.deleteOne({ postUrl }); + } + + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + if (pluginOptions.broadcastActorUpdate) { + pluginOptions.broadcastActorUpdate().catch(() => {}); + } + + const interactionState = await loadItemInteractions(collections, item); + interactionState.pinnedIds.delete(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState })); } catch (error) { next(error); } @@ -821,24 +891,35 @@ async function loadItemInteractions(collections, item) { const favouritedIds = new Set(); const rebloggedIds = new Set(); const bookmarkedIds = new Set(); + const pinnedIds = new Set(); - if (!collections.ap_interactions || !item.uid) { - return { favouritedIds, rebloggedIds, bookmarkedIds }; + if (!item.uid) { + return { favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }; } const lookupUrls = [item.uid, item.url].filter(Boolean); - const interactions = await collections.ap_interactions - .find({ objectUrl: { $in: lookupUrls } }) - .toArray(); - for (const i of interactions) { - const uid = item.uid; - if (i.type === "like") favouritedIds.add(uid); - else if (i.type === "boost") rebloggedIds.add(uid); - else if (i.type === "bookmark") bookmarkedIds.add(uid); + if (collections.ap_interactions) { + const interactions = await collections.ap_interactions + .find({ objectUrl: { $in: lookupUrls } }) + .toArray(); + + for (const i of interactions) { + const uid = item.uid; + if (i.type === "like") favouritedIds.add(uid); + else if (i.type === "boost") rebloggedIds.add(uid); + else if (i.type === "bookmark") bookmarkedIds.add(uid); + } } - return { favouritedIds, rebloggedIds, bookmarkedIds }; + if (collections.ap_featured) { + const pinDoc = await collections.ap_featured.findOne({ + postUrl: { $in: lookupUrls }, + }); + if (pinDoc) pinnedIds.add(item.uid); + } + + return { favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }; } /**