feat(mastodon-api): implement POST /api/v1/statuses/:id/pin and /unpin

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 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-23 11:16:15 +01:00
parent 2660a1a604
commit b5ebf6a1e4
2 changed files with 104 additions and 22 deletions

View File

@@ -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({

View File

@@ -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 };
}
/**