mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
1
index.js
1
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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user