@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -9,6 +9,28 @@ import {
9
9
  jf2ToAS2Activity,
10
10
  } from "./lib/jf2-to-as2.js";
11
11
  import { dashboardController } from "./lib/controllers/dashboard.js";
12
+ import {
13
+ readerController,
14
+ notificationsController,
15
+ composeController,
16
+ submitComposeController,
17
+ remoteProfileController,
18
+ followController,
19
+ unfollowController,
20
+ } from "./lib/controllers/reader.js";
21
+ import {
22
+ likeController,
23
+ unlikeController,
24
+ boostController,
25
+ unboostController,
26
+ } from "./lib/controllers/interactions.js";
27
+ import {
28
+ muteController,
29
+ unmuteController,
30
+ blockController,
31
+ unblockController,
32
+ moderationController,
33
+ } from "./lib/controllers/moderation.js";
12
34
  import { followersController } from "./lib/controllers/followers.js";
13
35
  import { followingController } from "./lib/controllers/following.js";
14
36
  import { activitiesController } from "./lib/controllers/activities.js";
@@ -38,6 +60,7 @@ import {
38
60
  } from "./lib/controllers/refollow.js";
39
61
  import { startBatchRefollow } from "./lib/batch-refollow.js";
40
62
  import { logActivity } from "./lib/activity-log.js";
63
+ import { scheduleCleanup } from "./lib/timeline-cleanup.js";
41
64
 
42
65
  const defaults = {
43
66
  mountPath: "/activitypub",
@@ -54,6 +77,7 @@ const defaults = {
54
77
  redisUrl: "",
55
78
  parallelWorkers: 5,
56
79
  actorType: "Person",
80
+ timelineRetention: 1000,
57
81
  };
58
82
 
59
83
  export default class ActivityPubEndpoint {
@@ -72,8 +96,8 @@ export default class ActivityPubEndpoint {
72
96
 
73
97
  get navigationItems() {
74
98
  return {
75
- href: this.options.mountPath,
76
- text: "activitypub.title",
99
+ href: `${this.options.mountPath}/admin/reader`,
100
+ text: "activitypub.reader.title",
77
101
  requiresDatabase: true,
78
102
  };
79
103
  }
@@ -145,17 +169,33 @@ export default class ActivityPubEndpoint {
145
169
  const mp = this.options.mountPath;
146
170
 
147
171
  router.get("/", dashboardController(mp));
172
+ router.get("/admin/reader", readerController(mp));
173
+ router.get("/admin/reader/notifications", notificationsController(mp));
174
+ router.get("/admin/reader/compose", composeController(mp, this));
175
+ router.post("/admin/reader/compose", submitComposeController(mp, this));
176
+ router.post("/admin/reader/like", likeController(mp, this));
177
+ router.post("/admin/reader/unlike", unlikeController(mp, this));
178
+ router.post("/admin/reader/boost", boostController(mp, this));
179
+ router.post("/admin/reader/unboost", unboostController(mp, this));
180
+ router.get("/admin/reader/profile", remoteProfileController(mp, this));
181
+ router.post("/admin/reader/follow", followController(mp, this));
182
+ router.post("/admin/reader/unfollow", unfollowController(mp, this));
183
+ router.get("/admin/reader/moderation", moderationController(mp));
184
+ router.post("/admin/reader/mute", muteController(mp, this));
185
+ router.post("/admin/reader/unmute", unmuteController(mp, this));
186
+ router.post("/admin/reader/block", blockController(mp, this));
187
+ router.post("/admin/reader/unblock", unblockController(mp, this));
148
188
  router.get("/admin/followers", followersController(mp));
149
189
  router.get("/admin/following", followingController(mp));
150
190
  router.get("/admin/activities", activitiesController(mp));
151
191
  router.get("/admin/featured", featuredGetController(mp));
152
- router.post("/admin/featured/pin", featuredPinController(mp));
153
- router.post("/admin/featured/unpin", featuredUnpinController(mp));
192
+ router.post("/admin/featured/pin", featuredPinController(mp, this));
193
+ router.post("/admin/featured/unpin", featuredUnpinController(mp, this));
154
194
  router.get("/admin/tags", featuredTagsGetController(mp));
155
- router.post("/admin/tags/add", featuredTagsAddController(mp));
156
- router.post("/admin/tags/remove", featuredTagsRemoveController(mp));
195
+ router.post("/admin/tags/add", featuredTagsAddController(mp, this));
196
+ router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this));
157
197
  router.get("/admin/profile", profileGetController(mp));
158
- router.post("/admin/profile", profilePostController(mp));
198
+ router.post("/admin/profile", profilePostController(mp, this));
159
199
  router.get("/admin/migrate", migrateGetController(mp, this.options));
160
200
  router.post("/admin/migrate", migratePostController(mp, this.options));
161
201
  router.post(
@@ -483,7 +523,7 @@ export default class ActivityPubEndpoint {
483
523
  inbox,
484
524
  sharedInbox,
485
525
  followedAt: new Date().toISOString(),
486
- source: "microsub-reader",
526
+ source: "reader",
487
527
  },
488
528
  },
489
529
  { upsert: true },
@@ -593,6 +633,59 @@ export default class ActivityPubEndpoint {
593
633
  }
594
634
  }
595
635
 
636
+ /**
637
+ * Send an Update(Person) activity to all followers so remote servers
638
+ * re-fetch the actor object (picking up profile changes, new featured
639
+ * collections, attachments, etc.).
640
+ */
641
+ async broadcastActorUpdate() {
642
+ if (!this._federation) return;
643
+
644
+ try {
645
+ const { Update } = await import("@fedify/fedify");
646
+ const handle = this.options.actor.handle;
647
+ const ctx = this._federation.createContext(
648
+ new URL(this._publicationUrl),
649
+ { handle, publicationUrl: this._publicationUrl },
650
+ );
651
+
652
+ // Retrieve the full actor from the dispatcher (same object remote
653
+ // servers will get when they re-fetch the actor URL)
654
+ const actor = await ctx.getActor(handle);
655
+ if (!actor) {
656
+ console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
657
+ return;
658
+ }
659
+
660
+ const update = new Update({
661
+ actor: ctx.getActorUri(handle),
662
+ object: actor,
663
+ });
664
+
665
+ await ctx.sendActivity(
666
+ { identifier: handle },
667
+ "followers",
668
+ update,
669
+ { preferSharedInbox: true },
670
+ );
671
+
672
+ console.info("[ActivityPub] Sent Update(Person) to followers");
673
+
674
+ await logActivity(this._collections.ap_activities, {
675
+ direction: "outbound",
676
+ type: "Update",
677
+ actorUrl: this._publicationUrl,
678
+ objectUrl: this._getActorUrl(),
679
+ summary: "Sent Update(Person) to followers",
680
+ }).catch(() => {});
681
+ } catch (error) {
682
+ console.error(
683
+ "[ActivityPub] broadcastActorUpdate failed:",
684
+ error.message,
685
+ );
686
+ }
687
+ }
688
+
596
689
  /**
597
690
  * Build the full actor URL from config.
598
691
  * @returns {string}
@@ -619,6 +712,12 @@ export default class ActivityPubEndpoint {
619
712
  Indiekit.addCollection("ap_profile");
620
713
  Indiekit.addCollection("ap_featured");
621
714
  Indiekit.addCollection("ap_featured_tags");
715
+ // Reader collections
716
+ Indiekit.addCollection("ap_timeline");
717
+ Indiekit.addCollection("ap_notifications");
718
+ Indiekit.addCollection("ap_muted");
719
+ Indiekit.addCollection("ap_blocked");
720
+ Indiekit.addCollection("ap_interactions");
622
721
 
623
722
  // Store collection references (posts resolved lazily)
624
723
  const indiekitCollections = Indiekit.collections;
@@ -631,16 +730,15 @@ export default class ActivityPubEndpoint {
631
730
  ap_profile: indiekitCollections.get("ap_profile"),
632
731
  ap_featured: indiekitCollections.get("ap_featured"),
633
732
  ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
733
+ // Reader collections
734
+ ap_timeline: indiekitCollections.get("ap_timeline"),
735
+ ap_notifications: indiekitCollections.get("ap_notifications"),
736
+ ap_muted: indiekitCollections.get("ap_muted"),
737
+ ap_blocked: indiekitCollections.get("ap_blocked"),
738
+ ap_interactions: indiekitCollections.get("ap_interactions"),
634
739
  get posts() {
635
740
  return indiekitCollections.get("posts");
636
741
  },
637
- // Lazy access to Microsub collections (may not exist if plugin not loaded)
638
- get microsub_items() {
639
- return indiekitCollections.get("microsub_items");
640
- },
641
- get microsub_channels() {
642
- return indiekitCollections.get("microsub_channels");
643
- },
644
742
  _publicationUrl: this._publicationUrl,
645
743
  };
646
744
 
@@ -675,6 +773,60 @@ export default class ActivityPubEndpoint {
675
773
  { background: true },
676
774
  );
677
775
 
776
+ // Reader indexes (timeline, notifications, moderation, interactions)
777
+ this._collections.ap_timeline.createIndex(
778
+ { uid: 1 },
779
+ { unique: true, background: true },
780
+ );
781
+ this._collections.ap_timeline.createIndex(
782
+ { published: -1 },
783
+ { background: true },
784
+ );
785
+ this._collections.ap_timeline.createIndex(
786
+ { "author.url": 1 },
787
+ { background: true },
788
+ );
789
+ this._collections.ap_timeline.createIndex(
790
+ { type: 1, published: -1 },
791
+ { background: true },
792
+ );
793
+
794
+ this._collections.ap_notifications.createIndex(
795
+ { uid: 1 },
796
+ { unique: true, background: true },
797
+ );
798
+ this._collections.ap_notifications.createIndex(
799
+ { published: -1 },
800
+ { background: true },
801
+ );
802
+ this._collections.ap_notifications.createIndex(
803
+ { read: 1 },
804
+ { background: true },
805
+ );
806
+
807
+ this._collections.ap_muted.createIndex(
808
+ { url: 1 },
809
+ { unique: true, sparse: true, background: true },
810
+ );
811
+ this._collections.ap_muted.createIndex(
812
+ { keyword: 1 },
813
+ { unique: true, sparse: true, background: true },
814
+ );
815
+
816
+ this._collections.ap_blocked.createIndex(
817
+ { url: 1 },
818
+ { unique: true, background: true },
819
+ );
820
+
821
+ this._collections.ap_interactions.createIndex(
822
+ { objectUrl: 1, type: 1 },
823
+ { unique: true, background: true },
824
+ );
825
+ this._collections.ap_interactions.createIndex(
826
+ { type: 1 },
827
+ { background: true },
828
+ );
829
+
678
830
  // Seed actor profile from config on first run
679
831
  this._seedProfile().catch((error) => {
680
832
  console.warn("[ActivityPub] Profile seed failed:", error.message);
@@ -720,6 +872,11 @@ export default class ActivityPubEndpoint {
720
872
  console.error("[ActivityPub] Batch refollow start failed:", error.message);
721
873
  });
722
874
  }, 10_000);
875
+
876
+ // Schedule timeline retention cleanup (runs on startup + every 24h)
877
+ if (this.options.timelineRetention > 0) {
878
+ scheduleCleanup(this._collections, this.options.timelineRetention);
879
+ }
723
880
  }
724
881
 
725
882
  /**
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Compose controllers — reply form via Micropub or direct AP.
3
+ */
4
+
5
+ import { Temporal } from "@js-temporal/polyfill";
6
+ import { getTimelineItem } from "../storage/timeline.js";
7
+ import { getToken, validateToken } from "../csrf.js";
8
+
9
+ /**
10
+ * Fetch syndication targets from the Micropub config endpoint.
11
+ * @param {object} application - Indiekit application locals
12
+ * @param {string} token - Session access token
13
+ * @returns {Promise<Array>}
14
+ */
15
+ async function getSyndicationTargets(application, token) {
16
+ try {
17
+ const micropubEndpoint = application.micropubEndpoint;
18
+
19
+ if (!micropubEndpoint) return [];
20
+
21
+ const micropubUrl = micropubEndpoint.startsWith("http")
22
+ ? micropubEndpoint
23
+ : new URL(micropubEndpoint, application.url).href;
24
+
25
+ const configUrl = `${micropubUrl}?q=config`;
26
+ const configResponse = await fetch(configUrl, {
27
+ headers: {
28
+ Authorization: `Bearer ${token}`,
29
+ Accept: "application/json",
30
+ },
31
+ });
32
+
33
+ if (configResponse.ok) {
34
+ const config = await configResponse.json();
35
+ return config["syndicate-to"] || [];
36
+ }
37
+
38
+ return [];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * GET /admin/reader/compose — Show compose form.
46
+ * @param {string} mountPath - Plugin mount path
47
+ * @param {object} plugin - ActivityPub plugin instance
48
+ */
49
+ export function composeController(mountPath, plugin) {
50
+ return async (request, response, next) => {
51
+ try {
52
+ const { application } = request.app.locals;
53
+ const replyTo = request.query.replyTo || "";
54
+
55
+ // Fetch reply context (the post being replied to)
56
+ let replyContext = null;
57
+
58
+ if (replyTo) {
59
+ const collections = {
60
+ ap_timeline: application?.collections?.get("ap_timeline"),
61
+ };
62
+
63
+ // Try to find the post in our timeline first
64
+ replyContext = await getTimelineItem(collections, replyTo);
65
+
66
+ // If not in timeline, try to look up remotely
67
+ if (!replyContext && plugin._federation) {
68
+ try {
69
+ const handle = plugin.options.actor.handle;
70
+ const ctx = plugin._federation.createContext(
71
+ new URL(plugin._publicationUrl),
72
+ { handle, publicationUrl: plugin._publicationUrl },
73
+ );
74
+ const remoteObject = await ctx.lookupObject(new URL(replyTo));
75
+
76
+ if (remoteObject) {
77
+ let authorName = "";
78
+ let authorUrl = "";
79
+
80
+ if (typeof remoteObject.getAttributedTo === "function") {
81
+ const author = await remoteObject.getAttributedTo();
82
+ const actor = Array.isArray(author) ? author[0] : author;
83
+
84
+ if (actor) {
85
+ authorName =
86
+ actor.name?.toString() ||
87
+ actor.preferredUsername?.toString() ||
88
+ "";
89
+ authorUrl = actor.id?.href || "";
90
+ }
91
+ }
92
+
93
+ replyContext = {
94
+ url: replyTo,
95
+ name: remoteObject.name?.toString() || "",
96
+ content: {
97
+ text:
98
+ remoteObject.content?.toString()?.slice(0, 300) || "",
99
+ },
100
+ author: { name: authorName, url: authorUrl },
101
+ };
102
+ }
103
+ } catch {
104
+ // Could not resolve — form still works without context
105
+ }
106
+ }
107
+ }
108
+
109
+ // Fetch syndication targets for Micropub path
110
+ const token = request.session?.access_token;
111
+ const syndicationTargets = token
112
+ ? await getSyndicationTargets(application, token)
113
+ : [];
114
+
115
+ const csrfToken = getToken(request.session);
116
+
117
+ response.render("activitypub-compose", {
118
+ title: response.locals.__("activitypub.compose.title"),
119
+ replyTo,
120
+ replyContext,
121
+ syndicationTargets,
122
+ csrfToken,
123
+ mountPath,
124
+ });
125
+ } catch (error) {
126
+ next(error);
127
+ }
128
+ };
129
+ }
130
+
131
+ /**
132
+ * POST /admin/reader/compose — Submit reply via Micropub or direct AP.
133
+ * @param {string} mountPath - Plugin mount path
134
+ * @param {object} plugin - ActivityPub plugin instance
135
+ */
136
+ export function submitComposeController(mountPath, plugin) {
137
+ return async (request, response, next) => {
138
+ try {
139
+ if (!validateToken(request)) {
140
+ return response.status(403).render("error", {
141
+ title: "Error",
142
+ content: "Invalid CSRF token",
143
+ });
144
+ }
145
+
146
+ const { application } = request.app.locals;
147
+ const { content, mode } = request.body;
148
+ const inReplyTo = request.body["in-reply-to"];
149
+ const syndicateTo = request.body["mp-syndicate-to"];
150
+
151
+ if (!content || !content.trim()) {
152
+ return response.status(400).render("error", {
153
+ title: "Error",
154
+ content: response.locals.__("activitypub.compose.errorEmpty"),
155
+ });
156
+ }
157
+
158
+ // Quick reply — direct AP
159
+ if (mode === "quick") {
160
+ if (!plugin._federation) {
161
+ return response.status(503).render("error", {
162
+ title: "Error",
163
+ content: "Federation not initialized",
164
+ });
165
+ }
166
+
167
+ const { Create, Note } = await import("@fedify/fedify");
168
+ const handle = plugin.options.actor.handle;
169
+ const ctx = plugin._federation.createContext(
170
+ new URL(plugin._publicationUrl),
171
+ { handle, publicationUrl: plugin._publicationUrl },
172
+ );
173
+
174
+ const noteId = `urn:uuid:${crypto.randomUUID()}`;
175
+ const actorUri = ctx.getActorUri(handle);
176
+
177
+ const note = new Note({
178
+ id: new URL(noteId),
179
+ attribution: actorUri,
180
+ content: content.trim(),
181
+ inReplyTo: inReplyTo ? new URL(inReplyTo) : undefined,
182
+ published: Temporal.Now.instant(),
183
+ });
184
+
185
+ const create = new Create({
186
+ id: new URL(`${noteId}#activity`),
187
+ actor: actorUri,
188
+ object: note,
189
+ });
190
+
191
+ // Send to followers
192
+ await ctx.sendActivity({ identifier: handle }, "followers", create, {
193
+ preferSharedInbox: true,
194
+ syncCollection: true,
195
+ orderingKey: noteId,
196
+ });
197
+
198
+ // If replying, also send to the original author
199
+ if (inReplyTo) {
200
+ try {
201
+ const remoteObject = await ctx.lookupObject(new URL(inReplyTo));
202
+
203
+ if (
204
+ remoteObject &&
205
+ typeof remoteObject.getAttributedTo === "function"
206
+ ) {
207
+ const author = await remoteObject.getAttributedTo();
208
+ const recipient = Array.isArray(author)
209
+ ? author[0]
210
+ : author;
211
+
212
+ if (recipient) {
213
+ await ctx.sendActivity(
214
+ { identifier: handle },
215
+ recipient,
216
+ create,
217
+ { orderingKey: noteId },
218
+ );
219
+ }
220
+ }
221
+ } catch {
222
+ // Non-critical — followers still got it
223
+ }
224
+ }
225
+
226
+ console.info(
227
+ `[ActivityPub] Sent quick reply${inReplyTo ? ` to ${inReplyTo}` : ""}`,
228
+ );
229
+
230
+ return response.redirect(`${mountPath}/admin/reader`);
231
+ }
232
+
233
+ // Micropub path — post as blog reply
234
+ const micropubEndpoint = application.micropubEndpoint;
235
+
236
+ if (!micropubEndpoint) {
237
+ return response.status(500).render("error", {
238
+ title: "Error",
239
+ content: "Micropub endpoint not configured",
240
+ });
241
+ }
242
+
243
+ const micropubUrl = micropubEndpoint.startsWith("http")
244
+ ? micropubEndpoint
245
+ : new URL(micropubEndpoint, application.url).href;
246
+
247
+ const token = request.session?.access_token;
248
+
249
+ if (!token) {
250
+ return response.redirect(
251
+ "/session/login?redirect=" + request.originalUrl,
252
+ );
253
+ }
254
+
255
+ const micropubData = new URLSearchParams();
256
+ micropubData.append("h", "entry");
257
+ micropubData.append("content", content.trim());
258
+
259
+ if (inReplyTo) {
260
+ micropubData.append("in-reply-to", inReplyTo);
261
+ }
262
+
263
+ if (syndicateTo) {
264
+ const targets = Array.isArray(syndicateTo)
265
+ ? syndicateTo
266
+ : [syndicateTo];
267
+
268
+ for (const target of targets) {
269
+ micropubData.append("mp-syndicate-to", target);
270
+ }
271
+ }
272
+
273
+ const micropubResponse = await fetch(micropubUrl, {
274
+ method: "POST",
275
+ headers: {
276
+ Authorization: `Bearer ${token}`,
277
+ "Content-Type": "application/x-www-form-urlencoded",
278
+ Accept: "application/json",
279
+ },
280
+ body: micropubData.toString(),
281
+ });
282
+
283
+ if (
284
+ micropubResponse.ok ||
285
+ micropubResponse.status === 201 ||
286
+ micropubResponse.status === 202
287
+ ) {
288
+ const location = micropubResponse.headers.get("Location");
289
+ console.info(
290
+ `[ActivityPub] Created blog reply via Micropub: ${location || "success"}`,
291
+ );
292
+
293
+ return response.redirect(`${mountPath}/admin/reader`);
294
+ }
295
+
296
+ const errorBody = await micropubResponse.text();
297
+ let errorMessage = `Micropub error: ${micropubResponse.statusText}`;
298
+
299
+ try {
300
+ const errorJson = JSON.parse(errorBody);
301
+
302
+ if (errorJson.error_description) {
303
+ errorMessage = String(errorJson.error_description);
304
+ } else if (errorJson.error) {
305
+ errorMessage = String(errorJson.error);
306
+ }
307
+ } catch {
308
+ // Not JSON
309
+ }
310
+
311
+ return response.status(micropubResponse.status).render("error", {
312
+ title: "Error",
313
+ content: errorMessage,
314
+ });
315
+ } catch (error) {
316
+ console.error("[ActivityPub] Compose submit failed:", error.message);
317
+ return response.status(500).render("error", {
318
+ title: "Error",
319
+ content: "Failed to create post. Please try again later.",
320
+ });
321
+ }
322
+ };
323
+ }
@@ -24,7 +24,7 @@ export function featuredTagsGetController(mountPath) {
24
24
  };
25
25
  }
26
26
 
27
- export function featuredTagsAddController(mountPath) {
27
+ export function featuredTagsAddController(mountPath, plugin) {
28
28
  return async (request, response, next) => {
29
29
  try {
30
30
  const { application } = request.app.locals;
@@ -44,6 +44,11 @@ export function featuredTagsAddController(mountPath) {
44
44
  { upsert: true },
45
45
  );
46
46
 
47
+ // Notify followers so they re-fetch featured tags
48
+ if (plugin?.broadcastActorUpdate) {
49
+ plugin.broadcastActorUpdate().catch(() => {});
50
+ }
51
+
47
52
  response.redirect(`${mountPath}/admin/tags`);
48
53
  } catch (error) {
49
54
  next(error);
@@ -51,7 +56,7 @@ export function featuredTagsAddController(mountPath) {
51
56
  };
52
57
  }
53
58
 
54
- export function featuredTagsRemoveController(mountPath) {
59
+ export function featuredTagsRemoveController(mountPath, plugin) {
55
60
  return async (request, response, next) => {
56
61
  try {
57
62
  const { application } = request.app.locals;
@@ -63,6 +68,11 @@ export function featuredTagsRemoveController(mountPath) {
63
68
 
64
69
  await collection.deleteOne({ tag });
65
70
 
71
+ // Notify followers so they re-fetch featured tags
72
+ if (plugin?.broadcastActorUpdate) {
73
+ plugin.broadcastActorUpdate().catch(() => {});
74
+ }
75
+
66
76
  response.redirect(`${mountPath}/admin/tags`);
67
77
  } catch (error) {
68
78
  next(error);
@@ -69,7 +69,7 @@ export function featuredGetController(mountPath) {
69
69
  };
70
70
  }
71
71
 
72
- export function featuredPinController(mountPath) {
72
+ export function featuredPinController(mountPath, plugin) {
73
73
  return async (request, response, next) => {
74
74
  try {
75
75
  const { application } = request.app.locals;
@@ -90,6 +90,11 @@ export function featuredPinController(mountPath) {
90
90
  { upsert: true },
91
91
  );
92
92
 
93
+ // Notify followers so they re-fetch the featured collection
94
+ if (plugin?.broadcastActorUpdate) {
95
+ plugin.broadcastActorUpdate().catch(() => {});
96
+ }
97
+
93
98
  response.redirect(`${mountPath}/admin/featured`);
94
99
  } catch (error) {
95
100
  next(error);
@@ -97,7 +102,7 @@ export function featuredPinController(mountPath) {
97
102
  };
98
103
  }
99
104
 
100
- export function featuredUnpinController(mountPath) {
105
+ export function featuredUnpinController(mountPath, plugin) {
101
106
  return async (request, response, next) => {
102
107
  try {
103
108
  const { application } = request.app.locals;
@@ -109,6 +114,11 @@ export function featuredUnpinController(mountPath) {
109
114
 
110
115
  await collection.deleteOne({ postUrl });
111
116
 
117
+ // Notify followers so they re-fetch the featured collection
118
+ if (plugin?.broadcastActorUpdate) {
119
+ plugin.broadcastActorUpdate().catch(() => {});
120
+ }
121
+
112
122
  response.redirect(`${mountPath}/admin/featured`);
113
123
  } catch (error) {
114
124
  next(error);