@rmdes/indiekit-endpoint-microsub 1.0.61 → 1.0.63

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
@@ -12,11 +12,11 @@ import { asyncHandler } from "./lib/utils/async-handler.js";
12
12
  import { handleMediaProxy } from "./lib/media/proxy.js";
13
13
  import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
14
14
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
15
- import { ensureActivityPubChannel } from "./lib/storage/channels.js";
16
15
  import { createIndexes } from "./lib/storage/items.js";
17
16
  import {
18
17
  cleanupAllReadItems,
19
18
  cleanupStaleItems,
19
+ removeActivityPubData,
20
20
  } from "./lib/storage/items-retention.js";
21
21
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
22
22
  import { websubHandler } from "./lib/websub/handler.js";
@@ -133,9 +133,6 @@ export default class MicrosubEndpoint {
133
133
  readerRouter.get("/search", asyncHandler(readerController.searchPage));
134
134
  readerRouter.post("/search", asyncHandler(readerController.searchFeeds));
135
135
  readerRouter.post("/subscribe", asyncHandler(readerController.subscribe));
136
- readerRouter.get("/actor", asyncHandler(readerController.actorProfile));
137
- readerRouter.post("/actor/follow", asyncHandler(readerController.followActorAction));
138
- readerRouter.post("/actor/unfollow", asyncHandler(readerController.unfollowActorAction));
139
136
  readerRouter.post("/api/mark-read", asyncHandler(readerController.markAllRead));
140
137
  readerRouter.post("/api/mark-view-read", asyncHandler(readerController.markViewRead));
141
138
  readerRouter.get("/opml", opmlController.exportOpml);
@@ -214,10 +211,11 @@ export default class MicrosubEndpoint {
214
211
  console.info("[Microsub] Starting scheduler and maintenance tasks");
215
212
  startScheduler(indiekit);
216
213
 
217
- // Ensure system channels exist
218
- ensureActivityPubChannel(indiekit).catch((error) => {
214
+ // One-time migration: drop the abandoned "Fediverse" channel and its
215
+ // items. Idempotent — does nothing once the channel is gone.
216
+ removeActivityPubData(indiekit).catch((error) => {
219
217
  console.warn(
220
- "[Microsub] ActivityPub channel creation failed:",
218
+ "[Microsub] ActivityPub data removal failed:",
221
219
  error.message,
222
220
  );
223
221
  });
@@ -18,7 +18,13 @@ import {
18
18
  validateChannelName,
19
19
  validateExcludeTypes,
20
20
  validateExcludeRegex,
21
+ validateRetentionSetting,
21
22
  } from "../../utils/validation.js";
23
+ import {
24
+ DEFAULT_MAX_ITEMS,
25
+ DEFAULT_MAX_ITEMS_PER_FEED,
26
+ DEFAULT_MAX_UNREAD_AGE_DAYS,
27
+ } from "../../storage/items-retention.js";
22
28
  import { proxyItemImages } from "../../media/proxy.js";
23
29
 
24
30
  /**
@@ -212,6 +218,14 @@ export async function channelHtml(request, response) {
212
218
  * @param {object} response - Express response
213
219
  * @returns {Promise<void>}
214
220
  */
221
+ // Defaults exposed to the settings template so placeholders show what the
222
+ // channel falls back to when a field is left blank.
223
+ const RETENTION_DEFAULTS = {
224
+ maxItems: DEFAULT_MAX_ITEMS,
225
+ maxItemsPerFeed: DEFAULT_MAX_ITEMS_PER_FEED,
226
+ maxUnreadAgeDays: DEFAULT_MAX_UNREAD_AGE_DAYS,
227
+ };
228
+
215
229
  export async function settings(request, response) {
216
230
  const { application } = request.app.locals;
217
231
  const userId = getUserId(request);
@@ -236,6 +250,7 @@ export async function settings(request, response) {
236
250
  { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
237
251
  { text: "Settings" },
238
252
  ],
253
+ retentionDefaults: RETENTION_DEFAULTS,
239
254
  });
240
255
  }
241
256
 
@@ -249,7 +264,13 @@ export async function updateSettings(request, response) {
249
264
  const { application } = request.app.locals;
250
265
  const userId = getUserId(request);
251
266
  const { uid } = request.params;
252
- const { excludeTypes, excludeRegex } = request.body;
267
+ const {
268
+ excludeTypes,
269
+ excludeRegex,
270
+ maxItems,
271
+ maxItemsPerFeed,
272
+ maxUnreadAgeDays,
273
+ } = request.body;
253
274
 
254
275
  const channelDocument = await getChannel(application, uid, userId);
255
276
  if (!channelDocument) {
@@ -261,12 +282,30 @@ export async function updateSettings(request, response) {
261
282
  );
262
283
  const validatedRegex = validateExcludeRegex(excludeRegex);
263
284
 
285
+ // Retention overrides. Empty/invalid input falls back to the global default
286
+ // (stored as undefined so future global default changes apply automatically).
287
+ const validatedMaxItems = validateRetentionSetting(maxItems, {
288
+ min: 10,
289
+ max: 100_000,
290
+ });
291
+ const validatedMaxItemsPerFeed = validateRetentionSetting(maxItemsPerFeed, {
292
+ min: 1,
293
+ max: 10_000,
294
+ });
295
+ const validatedMaxUnreadAgeDays = validateRetentionSetting(
296
+ maxUnreadAgeDays,
297
+ { min: 1, max: 3650 },
298
+ );
299
+
264
300
  await updateChannelSettings(
265
301
  application,
266
302
  uid,
267
303
  {
268
304
  excludeTypes: validatedTypes,
269
305
  excludeRegex: validatedRegex,
306
+ maxItems: validatedMaxItems,
307
+ maxItemsPerFeed: validatedMaxItemsPerFeed,
308
+ maxUnreadAgeDays: validatedMaxUnreadAgeDays,
270
309
  },
271
310
  userId,
272
311
  );
@@ -286,7 +325,7 @@ export async function deleteChannelAction(request, response) {
286
325
  const { uid } = request.params;
287
326
 
288
327
  // Don't allow deleting system channels
289
- if (uid === "notifications" || uid === "activitypub") {
328
+ if (uid === "notifications") {
290
329
  return response.redirect(`${request.baseUrl}/channels`);
291
330
  }
292
331
 
@@ -38,12 +38,6 @@ export { compose, submitCompose } from "./compose.js";
38
38
 
39
39
  export { searchPage, searchFeeds, subscribe } from "./search.js";
40
40
 
41
- export {
42
- actorProfile,
43
- followActorAction,
44
- unfollowActorAction,
45
- } from "./actor.js";
46
-
47
41
  export { deck, deckSettings, saveDeckSettings } from "./deck.js";
48
42
 
49
43
  import {
@@ -81,12 +75,6 @@ import { compose, submitCompose } from "./compose.js";
81
75
 
82
76
  import { searchPage, searchFeeds, subscribe } from "./search.js";
83
77
 
84
- import {
85
- actorProfile,
86
- followActorAction,
87
- unfollowActorAction,
88
- } from "./actor.js";
89
-
90
78
  import { deck, deckSettings, saveDeckSettings } from "./deck.js";
91
79
 
92
80
  export const readerController = {
@@ -115,9 +103,6 @@ export const readerController = {
115
103
  searchPage,
116
104
  searchFeeds,
117
105
  subscribe,
118
- actorProfile,
119
- followActorAction,
120
- unfollowActorAction,
121
106
  timeline,
122
107
  timelineHtml,
123
108
  deck,
@@ -298,7 +298,7 @@ export async function deleteChannel(application, uid, userId) {
298
298
  if (userId) query.userId = userId;
299
299
 
300
300
  // Don't allow deleting system channels
301
- if (uid === "notifications" || uid === "activitypub") {
301
+ if (uid === "notifications") {
302
302
  return false;
303
303
  }
304
304
 
@@ -396,39 +396,3 @@ export async function ensureNotificationsChannel(application, userId) {
396
396
  await collection.insertOne(channel);
397
397
  return channel;
398
398
  }
399
-
400
- /**
401
- * Ensure ActivityPub channel exists
402
- * @param {object} application - Indiekit application
403
- * @param {string} [userId] - User ID
404
- * @returns {Promise<object>} ActivityPub channel
405
- */
406
- export async function ensureActivityPubChannel(application, userId) {
407
- const collection = getCollection(application);
408
-
409
- const existing = await collection.findOne({
410
- uid: "activitypub",
411
- ...(userId && { userId }),
412
- });
413
-
414
- if (existing) {
415
- return existing;
416
- }
417
-
418
- const channel = {
419
- uid: "activitypub",
420
- name: "Fediverse",
421
- userId,
422
- source: "activitypub",
423
- order: -0.5, // After notifications (-1), before user channels (0+)
424
- settings: {
425
- excludeTypes: [],
426
- excludeRegex: undefined,
427
- },
428
- createdAt: new Date().toISOString(),
429
- updatedAt: new Date().toISOString(),
430
- };
431
-
432
- await collection.insertOne(channel);
433
- return channel;
434
- }
@@ -8,13 +8,18 @@ import { getCollection } from "./items.js";
8
8
  // Maximum number of full read items to keep per channel before stripping content.
9
9
  const MAX_FULL_READ_ITEMS = 200;
10
10
 
11
- // Maximum age (in days) for stripped skeletons and unread items.
12
- // After this period, both are hard-deleted to prevent unbounded growth.
13
- const MAX_ITEM_AGE_DAYS = 30;
11
+ // Global retention defaults. Each can be overridden per channel via
12
+ // channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}. The "notifications"
13
+ // channel is exempt from these caps entirely — webmentions are high-signal and
14
+ // users may want long history there.
15
+ export const DEFAULT_MAX_ITEMS = 1000;
16
+ export const DEFAULT_MAX_ITEMS_PER_FEED = 50;
17
+ export const DEFAULT_MAX_UNREAD_AGE_DAYS = 30;
14
18
 
15
19
  /**
16
20
  * Cleanup all read items across all channels (startup cleanup).
17
- * RSS items are stripped to dedup skeletons; AP items are hard-deleted.
21
+ * Read items beyond MAX_FULL_READ_ITEMS are stripped to skeletons (kept for
22
+ * dedup, content removed).
18
23
  * @param {object} application - Indiekit application
19
24
  * @returns {Promise<number>} Total number of items cleaned up
20
25
  */
@@ -49,59 +54,36 @@ export async function cleanupAllReadItems(application) {
49
54
  })
50
55
  .sort({ published: -1, _id: -1 })
51
56
  .skip(MAX_FULL_READ_ITEMS)
52
- .project({ _id: 1, feedId: 1 })
57
+ .project({ _id: 1 })
53
58
  .toArray();
54
59
 
55
60
  if (itemsToCleanup.length > 0) {
56
- const apItemIds = [];
57
- const rssItemIds = [];
58
- for (const item of itemsToCleanup) {
59
- if (item.feedId) {
60
- rssItemIds.push(item._id);
61
- } else {
62
- apItemIds.push(item._id);
63
- }
64
- }
65
-
66
- // Hard-delete AP items
67
- if (apItemIds.length > 0) {
68
- const deleted = await collection.deleteMany({
69
- _id: { $in: apItemIds },
70
- });
71
- totalCleaned += deleted.deletedCount;
72
- console.info(
73
- `[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
74
- );
75
- }
76
-
77
- // Strip RSS items to skeletons
78
- if (rssItemIds.length > 0) {
79
- const stripped = await collection.updateMany(
80
- { _id: { $in: rssItemIds } },
81
- {
82
- $set: { _stripped: true },
83
- $unset: {
84
- name: "",
85
- content: "",
86
- summary: "",
87
- author: "",
88
- category: "",
89
- photo: "",
90
- video: "",
91
- audio: "",
92
- likeOf: "",
93
- repostOf: "",
94
- bookmarkOf: "",
95
- inReplyTo: "",
96
- source: "",
97
- },
61
+ const ids = itemsToCleanup.map((item) => item._id);
62
+ const stripped = await collection.updateMany(
63
+ { _id: { $in: ids } },
64
+ {
65
+ $set: { _stripped: true },
66
+ $unset: {
67
+ name: "",
68
+ content: "",
69
+ summary: "",
70
+ author: "",
71
+ category: "",
72
+ photo: "",
73
+ video: "",
74
+ audio: "",
75
+ likeOf: "",
76
+ repostOf: "",
77
+ bookmarkOf: "",
78
+ inReplyTo: "",
79
+ source: "",
98
80
  },
99
- );
100
- totalCleaned += stripped.modifiedCount;
101
- console.info(
102
- `[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
103
- );
104
- }
81
+ },
82
+ );
83
+ totalCleaned += stripped.modifiedCount;
84
+ console.info(
85
+ `[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} items from channel "${channel.name}"`,
86
+ );
105
87
  }
106
88
  }
107
89
  }
@@ -117,58 +99,187 @@ export async function cleanupAllReadItems(application) {
117
99
  }
118
100
 
119
101
  /**
120
- * Delete stale items: stripped skeletons and unread items older than MAX_ITEM_AGE_DAYS.
121
- * Stripped skeletons have served their dedup purpose; stale unread items are unlikely
122
- * to be read. Both are hard-deleted to prevent unbounded collection growth.
102
+ * Per-channel retention cleanup. For each channel (excluding `notifications`):
103
+ * 1. Drop unread items + stripped skeletons older than `maxUnreadAgeDays`.
104
+ * 2. Per-feed cap: keep most recent `maxItemsPerFeed` items per feed, drop the rest.
105
+ * 3. Channel-wide cap: keep most recent `maxItems` items total, drop the rest.
106
+ *
107
+ * Each channel uses its own `channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}`
108
+ * when present; otherwise the module-level defaults apply. This makes the policy
109
+ * configurable per channel — a noisy aggregator channel can set tight caps while
110
+ * a low-volume curated channel keeps a long tail.
111
+ *
112
+ * The order matters: per-feed cap runs before channel cap so a single prolific
113
+ * feed cannot starve other feeds in the channel of representation after the
114
+ * channel-wide trim.
115
+ *
123
116
  * @param {object} application - Indiekit application
124
- * @returns {Promise<number>} Total number of items deleted
117
+ * @returns {Promise<number>} Total number of items deleted across all channels
125
118
  */
126
119
  export async function cleanupStaleItems(application) {
127
- const collection = getCollection(application);
128
- const cutoff = new Date();
129
- cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
130
-
131
- // Delete stripped skeletons older than cutoff
132
- const strippedResult = await collection.deleteMany({
133
- _stripped: true,
134
- $or: [
135
- { published: { $lt: cutoff } },
136
- { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
137
- ],
138
- });
120
+ const itemsCollection = getCollection(application);
121
+ const channelsCollection = application.collections.get("microsub_channels");
139
122
 
140
- // Delete unread items older than cutoff
141
- const unreadResult = await collection.deleteMany({
142
- readBy: { $in: [null, []] },
143
- _stripped: { $ne: true },
144
- $or: [
145
- { published: { $lt: cutoff } },
146
- { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
147
- ],
148
- });
123
+ const channels = await channelsCollection.find({}).toArray();
124
+ let totalDeleted = 0;
149
125
 
150
- // Also catch items with no readBy field at all
151
- const noReadByResult = await collection.deleteMany({
152
- readBy: { $exists: false },
153
- _stripped: { $ne: true },
154
- $or: [
155
- { published: { $lt: cutoff } },
156
- { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
157
- ],
158
- });
126
+ for (const channel of channels) {
127
+ // Notifications channel (webmentions) is exempt — high-signal, kept indefinitely.
128
+ if (channel.uid === "notifications") continue;
129
+
130
+ const settings = channel.settings || {};
131
+ const maxItems = settings.maxItems ?? DEFAULT_MAX_ITEMS;
132
+ const maxItemsPerFeed =
133
+ settings.maxItemsPerFeed ?? DEFAULT_MAX_ITEMS_PER_FEED;
134
+ const maxUnreadAgeDays =
135
+ settings.maxUnreadAgeDays ?? DEFAULT_MAX_UNREAD_AGE_DAYS;
136
+
137
+ const cutoff = new Date();
138
+ cutoff.setDate(cutoff.getDate() - maxUnreadAgeDays);
139
+ const cutoffIso = cutoff.toISOString();
140
+ let channelDeleted = 0;
141
+ let staleDeleted = 0;
142
+ let perFeedDeleted = 0;
143
+ let channelCapDeleted = 0;
144
+
145
+ // 1. Drop stripped skeletons older than cutoff (served their dedup purpose).
146
+ const strippedResult = await itemsCollection.deleteMany({
147
+ channelId: channel._id,
148
+ _stripped: true,
149
+ $or: [
150
+ { published: { $lt: cutoff } },
151
+ {
152
+ published: { $exists: false },
153
+ createdAt: { $lt: cutoffIso },
154
+ },
155
+ ],
156
+ });
157
+ staleDeleted += strippedResult.deletedCount;
158
+
159
+ // 1b. Drop unread (or never-read) items older than cutoff.
160
+ const unreadAgeResult = await itemsCollection.deleteMany({
161
+ channelId: channel._id,
162
+ _stripped: { $ne: true },
163
+ $and: [
164
+ {
165
+ $or: [
166
+ { readBy: { $exists: false } },
167
+ { readBy: { $size: 0 } },
168
+ { readBy: null },
169
+ ],
170
+ },
171
+ {
172
+ $or: [
173
+ { published: { $lt: cutoff } },
174
+ {
175
+ published: { $exists: false },
176
+ createdAt: { $lt: cutoffIso },
177
+ },
178
+ ],
179
+ },
180
+ ],
181
+ });
182
+ staleDeleted += unreadAgeResult.deletedCount;
183
+ channelDeleted += staleDeleted;
184
+
185
+ // 2. Per-feed cap. Iterate feeds in the channel; for each, delete oldest
186
+ // items beyond maxItemsPerFeed regardless of read state.
187
+ const feedIds = await itemsCollection.distinct("feedId", {
188
+ channelId: channel._id,
189
+ feedId: { $exists: true, $ne: null },
190
+ });
191
+
192
+ for (const feedId of feedIds) {
193
+ const excess = await itemsCollection
194
+ .find({ channelId: channel._id, feedId })
195
+ .sort({ published: -1, _id: -1 })
196
+ .skip(maxItemsPerFeed)
197
+ .project({ _id: 1 })
198
+ .toArray();
199
+
200
+ if (excess.length > 0) {
201
+ const ids = excess.map((item) => item._id);
202
+ const result = await itemsCollection.deleteMany({
203
+ _id: { $in: ids },
204
+ });
205
+ perFeedDeleted += result.deletedCount;
206
+ channelDeleted += result.deletedCount;
207
+ }
208
+ }
159
209
 
160
- const total =
161
- strippedResult.deletedCount +
162
- unreadResult.deletedCount +
163
- noReadByResult.deletedCount;
210
+ // 3. Channel-wide cap. Catches items without feedId plus anything still over
211
+ // the per-channel ceiling after the per-feed pass.
212
+ const excessChannel = await itemsCollection
213
+ .find({ channelId: channel._id })
214
+ .sort({ published: -1, _id: -1 })
215
+ .skip(maxItems)
216
+ .project({ _id: 1 })
217
+ .toArray();
164
218
 
165
- if (total > 0) {
219
+ if (excessChannel.length > 0) {
220
+ const ids = excessChannel.map((item) => item._id);
221
+ const result = await itemsCollection.deleteMany({ _id: { $in: ids } });
222
+ channelCapDeleted += result.deletedCount;
223
+ channelDeleted += result.deletedCount;
224
+ }
225
+
226
+ if (channelDeleted > 0) {
227
+ console.info(
228
+ `[Microsub] Retention cleanup "${channel.name}": deleted ${channelDeleted} items ` +
229
+ `(stale: ${staleDeleted}, per-feed: ${perFeedDeleted}, channel-cap: ${channelCapDeleted}; ` +
230
+ `maxItems=${maxItems}, maxItemsPerFeed=${maxItemsPerFeed}, maxUnreadAgeDays=${maxUnreadAgeDays})`,
231
+ );
232
+ }
233
+ totalDeleted += channelDeleted;
234
+ }
235
+
236
+ if (totalDeleted > 0) {
166
237
  console.info(
167
- `[Microsub] Stale cleanup: deleted ${strippedResult.deletedCount} stripped skeletons, ` +
168
- `${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
169
- `(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
238
+ `[Microsub] Retention cleanup complete: ${totalDeleted} total items deleted across ${channels.length} channels`,
170
239
  );
171
240
  }
172
241
 
173
- return total;
242
+ return totalDeleted;
243
+ }
244
+
245
+ /**
246
+ * One-time migration: remove the abandoned "Fediverse" channel and its items.
247
+ * The microsub reader briefly tried to ingest ActivityPub outboxes into a
248
+ * dedicated channel (uid: "activitypub"). That feature was abandoned — fediverse
249
+ * federation lives entirely in the separate `indiekit-endpoint-activitypub`
250
+ * plugin now. This migration cleans up the leftover channel and items.
251
+ * Idempotent — safe to run on every startup.
252
+ * @param {object} application - Indiekit application
253
+ * @returns {Promise<{ channelsRemoved: number, itemsRemoved: number }>}
254
+ */
255
+ export async function removeActivityPubData(application) {
256
+ const itemsCollection = getCollection(application);
257
+ const channelsCollection = application.collections.get("microsub_channels");
258
+
259
+ const apChannels = await channelsCollection
260
+ .find({ uid: "activitypub" })
261
+ .toArray();
262
+
263
+ if (apChannels.length === 0) {
264
+ return { channelsRemoved: 0, itemsRemoved: 0 };
265
+ }
266
+
267
+ const channelIds = apChannels.map((c) => c._id);
268
+
269
+ const itemsResult = await itemsCollection.deleteMany({
270
+ channelId: { $in: channelIds },
271
+ });
272
+
273
+ const channelsResult = await channelsCollection.deleteMany({
274
+ _id: { $in: channelIds },
275
+ });
276
+
277
+ console.info(
278
+ `[Microsub] Removed abandoned Fediverse channel: ${channelsResult.deletedCount} channel(s), ${itemsResult.deletedCount} item(s)`,
279
+ );
280
+
281
+ return {
282
+ channelsRemoved: channelsResult.deletedCount,
283
+ itemsRemoved: itemsResult.deletedCount,
284
+ };
174
285
  }
@@ -163,6 +163,31 @@ export function validateExcludeTypes(types) {
163
163
  return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
164
164
  }
165
165
 
166
+ /**
167
+ * Validate a per-channel retention numeric setting.
168
+ * Accepts a positive integer; returns undefined for empty/invalid input so the
169
+ * caller can fall back to the global default.
170
+ * @param {string|number|undefined} value - Raw form value
171
+ * @param {object} [options]
172
+ * @param {number} [options.min] - Minimum allowed value (inclusive). Default 1.
173
+ * @param {number} [options.max] - Maximum allowed value (inclusive). Default 1_000_000.
174
+ * @returns {number|undefined} Validated integer, or undefined if empty/invalid
175
+ */
176
+ export function validateRetentionSetting(value, options = {}) {
177
+ const { min = 1, max = 1_000_000 } = options;
178
+
179
+ if (value === undefined || value === null || value === "") {
180
+ return undefined;
181
+ }
182
+
183
+ const parsed = Number.parseInt(String(value), 10);
184
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
185
+ return undefined;
186
+ }
187
+
188
+ return parsed;
189
+ }
190
+
166
191
  /**
167
192
  * Validate regex pattern
168
193
  * @param {string} pattern - Regex pattern to validate
package/locales/en.json CHANGED
@@ -75,6 +75,14 @@
75
75
  "excludeRegex": "Exclude pattern",
76
76
  "excludeRegexHelp": "Regular expression to filter out matching content",
77
77
  "save": "Save settings",
78
+ "retentionTitle": "Retention",
79
+ "retentionHelp": "Control how many items this channel keeps in MongoDB. Useful for high-volume aggregator channels that would otherwise grow without bound. Leave any field blank to use the global default.",
80
+ "maxItems": "Maximum items in channel",
81
+ "maxItemsHelp": "Keep at most this many items total. Oldest items are deleted regardless of read state. Default: {{default}}.",
82
+ "maxItemsPerFeed": "Maximum items per feed",
83
+ "maxItemsPerFeedHelp": "Keep at most this many items per feed inside this channel. Prevents one prolific feed from monopolising the channel cap. Default: {{default}}.",
84
+ "maxUnreadAgeDays": "Drop unread items older than (days)",
85
+ "maxUnreadAgeDaysHelp": "Unread items older than this are deleted, even if the channel is below its item cap. Default: {{default}}.",
78
86
  "dangerZone": "Danger zone",
79
87
  "deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
80
88
  "deleteConfirm": "Are you sure you want to delete this channel and all its content?",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -153,12 +153,6 @@
153
153
 
154
154
  {# Inline actions (Aperture pattern) #}
155
155
  <div class="ms-item-actions">
156
- {% if item._source and item._source.type === "activitypub" and item.author and item.author.url %}
157
- <a href="{{ baseUrl }}/actor?url={{ item.author.url | urlencode }}" class="ms-item-actions__button" title="View actor profile">
158
- {{ icon("mention") }}
159
- <span class="-!-visually-hidden">Actor profile</span>
160
- </a>
161
- {% endif %}
162
156
  {% if item.url %}
163
157
  <a href="{{ item.url }}" class="ms-item-actions__button" target="_blank" rel="noopener" title="View original">
164
158
  {{ icon("syndicate") }}
@@ -49,6 +49,43 @@
49
49
  value: channel.settings.excludeRegex
50
50
  }) }}
51
51
 
52
+ {% if channel.uid !== "notifications" %}
53
+ <fieldset class="ms-retention-settings">
54
+ <legend>{{ __("microsub.settings.retentionTitle") }}</legend>
55
+ <p class="hint">{{ __("microsub.settings.retentionHelp") }}</p>
56
+
57
+ {{ input({
58
+ id: "maxItems",
59
+ name: "maxItems",
60
+ type: "number",
61
+ label: __("microsub.settings.maxItems"),
62
+ hint: __("microsub.settings.maxItemsHelp", { default: retentionDefaults.maxItems }),
63
+ attributes: { min: 10, max: 100000, placeholder: retentionDefaults.maxItems },
64
+ value: channel.settings.maxItems
65
+ }) }}
66
+
67
+ {{ input({
68
+ id: "maxItemsPerFeed",
69
+ name: "maxItemsPerFeed",
70
+ type: "number",
71
+ label: __("microsub.settings.maxItemsPerFeed"),
72
+ hint: __("microsub.settings.maxItemsPerFeedHelp", { default: retentionDefaults.maxItemsPerFeed }),
73
+ attributes: { min: 1, max: 10000, placeholder: retentionDefaults.maxItemsPerFeed },
74
+ value: channel.settings.maxItemsPerFeed
75
+ }) }}
76
+
77
+ {{ input({
78
+ id: "maxUnreadAgeDays",
79
+ name: "maxUnreadAgeDays",
80
+ type: "number",
81
+ label: __("microsub.settings.maxUnreadAgeDays"),
82
+ hint: __("microsub.settings.maxUnreadAgeDaysHelp", { default: retentionDefaults.maxUnreadAgeDays }),
83
+ attributes: { min: 1, max: 3650, placeholder: retentionDefaults.maxUnreadAgeDays },
84
+ value: channel.settings.maxUnreadAgeDays
85
+ }) }}
86
+ </fieldset>
87
+ {% endif %}
88
+
52
89
  <div class="button-group">
53
90
  {{ button({
54
91
  text: __("microsub.settings.save")
@@ -1,267 +0,0 @@
1
- /**
2
- * Fetch a remote ActivityPub actor's outbox for on-demand reading.
3
- * Returns ephemeral jf2 items — nothing is stored in MongoDB.
4
- *
5
- * @module activitypub/outbox-fetcher
6
- */
7
-
8
- import sanitizeHtml from "sanitize-html";
9
-
10
- import { isPrivateUrl } from "../media/proxy.js";
11
- import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
12
-
13
- const AP_ACCEPT =
14
- 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
15
- const FETCH_TIMEOUT = 10_000;
16
- const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
17
-
18
- /**
19
- * Fetch a remote actor's profile and recent posts from their outbox.
20
- *
21
- * @param {string} actorUrl - Full URL of the AP actor
22
- * @param {object} [options]
23
- * @param {number} [options.limit=20] - Max items to return
24
- * @returns {Promise<{ actor: object, items: Array }>}
25
- */
26
- export async function fetchActorOutbox(actorUrl, options = {}) {
27
- const limit = options.limit || 20;
28
-
29
- // 1. Fetch actor profile
30
- const actor = await fetchJson(actorUrl);
31
- if (!actor || !actor.outbox) {
32
- throw new Error("Could not resolve actor or outbox URL");
33
- }
34
-
35
- const actorInfo = {
36
- name:
37
- actor.name ||
38
- actor.preferredUsername ||
39
- new URL(actorUrl).pathname.split("/").pop(),
40
- url: actor.url || actor.id || actorUrl,
41
- photo: actor.icon?.url || actor.icon || "",
42
- summary: stripHtml(actor.summary || ""),
43
- handle: actor.preferredUsername || "",
44
- followersCount: 0,
45
- followingCount: 0,
46
- };
47
-
48
- // Resolve follower/following counts if available
49
- if (typeof actor.followers === "string") {
50
- try {
51
- const followersCollection = await fetchJson(actor.followers);
52
- actorInfo.followersCount = followersCollection?.totalItems || 0;
53
- } catch {
54
- /* ignore */
55
- }
56
- }
57
- if (typeof actor.following === "string") {
58
- try {
59
- const followingCollection = await fetchJson(actor.following);
60
- actorInfo.followingCount = followingCollection?.totalItems || 0;
61
- } catch {
62
- /* ignore */
63
- }
64
- }
65
-
66
- // 2. Fetch outbox (OrderedCollection)
67
- const outboxUrl =
68
- typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
69
- const outbox = await fetchJson(outboxUrl);
70
- if (!outbox) {
71
- return { actor: actorInfo, items: [] };
72
- }
73
-
74
- // 3. Get items — may be inline or on a first page
75
- let activities = [];
76
-
77
- if (outbox.orderedItems?.length > 0) {
78
- activities = outbox.orderedItems;
79
- } else if (outbox.first) {
80
- const firstPageUrl =
81
- typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
82
- if (firstPageUrl) {
83
- const firstPage = await fetchJson(firstPageUrl);
84
- activities = firstPage?.orderedItems || firstPage?.items || [];
85
- }
86
- }
87
-
88
- // 4. Convert Create activities to jf2 items
89
- const items = [];
90
- for (const activity of activities) {
91
- if (items.length >= limit) break;
92
-
93
- const item = activityToJf2(activity, actorInfo);
94
- if (item) items.push(item);
95
- }
96
-
97
- return { actor: actorInfo, items };
98
- }
99
-
100
- /**
101
- * Convert a single AP activity (or bare object) to jf2 format.
102
- * @param {object} activity - AP activity or object
103
- * @param {object} actorInfo - Actor profile info
104
- * @returns {object|null} jf2 item or null if not displayable
105
- */
106
- function activityToJf2(activity, actorInfo) {
107
- // Unwrap Create/Announce — the displayable content is the inner object
108
- let object = activity;
109
- const activityType = activity.type;
110
-
111
- if (activityType === "Create" || activityType === "Announce") {
112
- object = activity.object;
113
- if (!object || typeof object === "string") return null; // Unresolved reference
114
- }
115
-
116
- // Skip non-content types (Follow, Like, etc.)
117
- const contentTypes = new Set([
118
- "Note",
119
- "Article",
120
- "Page",
121
- "Video",
122
- "Audio",
123
- "Image",
124
- "Event",
125
- "Question",
126
- ]);
127
- if (!contentTypes.has(object.type)) return null;
128
-
129
- const rawHtml = object.content || "";
130
- const contentHtml = rawHtml ? sanitizeHtml(rawHtml, SANITIZE_OPTIONS) : "";
131
- const contentText = stripHtml(rawHtml);
132
-
133
- const jf2 = {
134
- type: "entry",
135
- url: object.url || object.id || "",
136
- uid: object.id || object.url || "",
137
- name: object.name || undefined,
138
- content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
139
- summary: object.summary ? stripHtml(object.summary) : undefined,
140
- published: object.published || activity.published || undefined,
141
- author: {
142
- name: actorInfo.name,
143
- url: actorInfo.url,
144
- photo: actorInfo.photo,
145
- },
146
- category: extractTags(object.tag),
147
- photo: extractMedia(object.attachment, "image"),
148
- video: extractMedia(object.attachment, "video"),
149
- audio: extractMedia(object.attachment, "audio"),
150
- _source: { type: "activitypub", actorUrl: actorInfo.url },
151
- };
152
-
153
- // Boost attribution
154
- if (activityType === "Announce" && activity.actor) {
155
- jf2._boostedBy = actorInfo;
156
- // The inner object may have its own author
157
- if (object.attributedTo) {
158
- const attributedUrl =
159
- typeof object.attributedTo === "string"
160
- ? object.attributedTo
161
- : object.attributedTo?.id || object.attributedTo?.url;
162
- if (attributedUrl) {
163
- jf2.author = {
164
- name:
165
- object.attributedTo?.name ||
166
- object.attributedTo?.preferredUsername ||
167
- attributedUrl,
168
- url: attributedUrl,
169
- photo: object.attributedTo?.icon?.url || "",
170
- };
171
- }
172
- }
173
- }
174
-
175
- if (object.inReplyTo) {
176
- const replyUrl =
177
- typeof object.inReplyTo === "string"
178
- ? object.inReplyTo
179
- : object.inReplyTo?.id;
180
- if (replyUrl) jf2["in-reply-to"] = [replyUrl];
181
- }
182
-
183
- return jf2;
184
- }
185
-
186
- /**
187
- * Extract hashtags from AP tag array.
188
- * @param {Array} tags - AP tag objects
189
- * @returns {Array<string>}
190
- */
191
- function extractTags(tags) {
192
- if (!Array.isArray(tags)) return [];
193
- return tags
194
- .filter((t) => t.type === "Hashtag" || t.type === "Tag")
195
- .map((t) => (t.name || "").replace(/^#/, ""))
196
- .filter(Boolean);
197
- }
198
-
199
- /**
200
- * Extract media URLs from AP attachment array.
201
- * @param {Array} attachments - AP attachment objects
202
- * @param {string} mediaPrefix - "image", "video", or "audio"
203
- * @returns {Array<string>}
204
- */
205
- function extractMedia(attachments, mediaPrefix) {
206
- if (!Array.isArray(attachments)) return [];
207
- return attachments
208
- .filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
209
- .map((a) => a.url || a.href || "")
210
- .filter(Boolean);
211
- }
212
-
213
- /**
214
- * Fetch a URL as ActivityPub JSON.
215
- * @param {string} url
216
- * @returns {Promise<object|null>}
217
- */
218
- async function fetchJson(url) {
219
- if (!url) return null;
220
-
221
- // SSRF protection — block private/internal IPs (including DNS rebinding)
222
- if (await isPrivateUrl(url)) {
223
- console.warn(`[Microsub] AP fetch blocked private URL: ${url}`);
224
- return null;
225
- }
226
-
227
- const controller = new AbortController();
228
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
229
-
230
- try {
231
- const response = await fetch(url, {
232
- headers: {
233
- Accept: AP_ACCEPT,
234
- "User-Agent": USER_AGENT,
235
- },
236
- signal: controller.signal,
237
- redirect: "follow",
238
- });
239
-
240
- if (!response.ok) {
241
- console.warn(
242
- `[Microsub] AP fetch failed: ${response.status} for ${url}`,
243
- );
244
- return null;
245
- }
246
-
247
- return await response.json();
248
- } catch (error) {
249
- if (error.name === "AbortError") {
250
- console.warn(`[Microsub] AP fetch timeout for ${url}`);
251
- } else {
252
- console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
253
- }
254
- return null;
255
- } finally {
256
- clearTimeout(timeout);
257
- }
258
- }
259
-
260
- /**
261
- * Strip HTML tags for plain text.
262
- * @param {string} html
263
- * @returns {string}
264
- */
265
- function stripHtml(html) {
266
- return (html || "").replace(/<[^>]*>/g, "").trim();
267
- }
@@ -1,142 +0,0 @@
1
- /**
2
- * ActivityPub actor profiles
3
- * @module controllers/reader/actor
4
- */
5
-
6
- import { fetchActorOutbox } from "../../activitypub/outbox-fetcher.js";
7
-
8
- const ACTOR_OUTBOX_LIMIT = 30;
9
-
10
- /**
11
- * Find the ActivityPub plugin instance from installed plugins.
12
- * @param {object} request - Express request
13
- * @returns {object|undefined} The AP plugin instance
14
- */
15
- function getApPlugin(request) {
16
- const installedPlugins = request.app.locals.installedPlugins;
17
- if (!installedPlugins) return undefined;
18
- return [...installedPlugins].find(
19
- (p) => p.name === "ActivityPub endpoint",
20
- );
21
- }
22
-
23
- /**
24
- * Actor profile — fetch and display a remote AP actor's recent posts
25
- * @param {object} request - Express request
26
- * @param {object} response - Express response
27
- */
28
- export async function actorProfile(request, response) {
29
- const actorUrl = request.query.url;
30
- if (!actorUrl) {
31
- return response.status(400).render("404");
32
- }
33
-
34
- // Check if we already follow this actor
35
- const { application } = request.app.locals;
36
- const apFollowing = application?.collections?.get("ap_following");
37
- let isFollowing = false;
38
- if (apFollowing) {
39
- const existing = await apFollowing.findOne({ actorUrl });
40
- isFollowing = !!existing;
41
- }
42
-
43
- // Check if AP plugin is available (for follow button visibility)
44
- const apPlugin = getApPlugin(request);
45
- const canFollow = !!apPlugin;
46
-
47
- try {
48
- const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
49
-
50
- response.render("actor", {
51
- title: actor.name || "Actor",
52
- actor,
53
- items,
54
- actorUrl,
55
- isFollowing,
56
- canFollow,
57
- baseUrl: request.baseUrl,
58
- readerBaseUrl: request.baseUrl,
59
- activeView: "channels",
60
- breadcrumbs: [
61
- { text: "Reader", href: request.baseUrl },
62
- { text: actor.name || "Actor" },
63
- ],
64
- });
65
- } catch (error) {
66
- console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
67
- response.render("actor", {
68
- title: "Actor",
69
- actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
70
- items: [],
71
- actorUrl,
72
- isFollowing,
73
- canFollow,
74
- baseUrl: request.baseUrl,
75
- readerBaseUrl: request.baseUrl,
76
- activeView: "channels",
77
- error: "Could not fetch this actor's profile. They may have restricted access.",
78
- breadcrumbs: [
79
- { text: "Reader", href: request.baseUrl },
80
- { text: "Actor" },
81
- ],
82
- });
83
- }
84
- }
85
-
86
- /**
87
- * Follow an ActivityPub actor
88
- * @param {object} request - Express request
89
- * @param {object} response - Express response
90
- */
91
- export async function followActorAction(request, response) {
92
- const { actorUrl, actorName } = request.body;
93
- if (!actorUrl) {
94
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
95
- }
96
-
97
- const apPlugin = getApPlugin(request);
98
- if (!apPlugin) {
99
- console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
100
- return response.redirect(
101
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
102
- );
103
- }
104
-
105
- const result = await apPlugin.followActor(actorUrl, { name: actorName });
106
- if (!result.ok) {
107
- console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
108
- }
109
-
110
- return response.redirect(
111
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
112
- );
113
- }
114
-
115
- /**
116
- * Unfollow an ActivityPub actor
117
- * @param {object} request - Express request
118
- * @param {object} response - Express response
119
- */
120
- export async function unfollowActorAction(request, response) {
121
- const { actorUrl } = request.body;
122
- if (!actorUrl) {
123
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
124
- }
125
-
126
- const apPlugin = getApPlugin(request);
127
- if (!apPlugin) {
128
- console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
129
- return response.redirect(
130
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
131
- );
132
- }
133
-
134
- const result = await apPlugin.unfollowActor(actorUrl);
135
- if (!result.ok) {
136
- console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
137
- }
138
-
139
- return response.redirect(
140
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
141
- );
142
- }
package/views/actor.njk DELETED
@@ -1,188 +0,0 @@
1
- {% extends "layouts/reader.njk" %}
2
-
3
- {% block reader %}
4
- <div class="ms-channel">
5
- <header class="ms-channel__header">
6
- <a href="{{ baseUrl }}/channels/activitypub" class="back-link">
7
- {{ icon("previous") }} Fediverse
8
- </a>
9
- </header>
10
-
11
- {# Actor profile card #}
12
- <div class="ms-actor-profile">
13
- <div class="ms-actor-profile__header">
14
- <div class="ms-actor-profile__avatar-wrap" data-avatar-fallback>
15
- {% if actor.photo %}
16
- <img src="{{ actor.photo }}"
17
- alt=""
18
- class="ms-actor-profile__avatar"
19
- width="80"
20
- height="80">
21
- {% endif %}
22
- <span class="ms-actor-profile__avatar ms-actor-profile__avatar--default" aria-hidden="true">{{ actor.name[0] | upper if actor.name else "?" }}</span>
23
- </div>
24
- <div class="ms-actor-profile__info">
25
- <h2 class="ms-actor-profile__name">{{ actor.name }}</h2>
26
- {% if actor.handle %}
27
- <span class="ms-actor-profile__handle">@{{ actor.handle }}</span>
28
- {% endif %}
29
- {% if actor.summary %}
30
- <p class="ms-actor-profile__summary">{{ actor.summary }}</p>
31
- {% endif %}
32
- <div class="ms-actor-profile__stats">
33
- {% if actor.followersCount %}
34
- <span>{{ actor.followersCount }} followers</span>
35
- {% endif %}
36
- {% if actor.followingCount %}
37
- <span>{{ actor.followingCount }} following</span>
38
- {% endif %}
39
- </div>
40
- </div>
41
- </div>
42
- <div class="ms-actor-profile__actions">
43
- <a href="{{ actor.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
44
- {{ icon("syndicate") }} View profile
45
- </a>
46
- {% if canFollow %}
47
- {% if isFollowing %}
48
- <form action="{{ baseUrl }}/actor/unfollow" method="POST" style="display: inline;">
49
- <input type="hidden" name="_csrf" value="{{ csrfToken }}">
50
- <input type="hidden" name="actorUrl" value="{{ actorUrl }}">
51
- <button type="submit" class="button button--secondary button--small">
52
- {{ icon("tick") }} Following
53
- </button>
54
- </form>
55
- {% else %}
56
- <form action="{{ baseUrl }}/actor/follow" method="POST" style="display: inline;">
57
- <input type="hidden" name="_csrf" value="{{ csrfToken }}">
58
- <input type="hidden" name="actorUrl" value="{{ actorUrl }}">
59
- <input type="hidden" name="actorName" value="{{ actor.name }}">
60
- <button type="submit" class="button button--primary button--small">
61
- {{ icon("syndicate") }} Follow
62
- </button>
63
- </form>
64
- {% endif %}
65
- {% endif %}
66
- </div>
67
- </div>
68
-
69
- {% if error %}
70
- <div class="ms-reader__empty">
71
- {{ icon("warning") }}
72
- <p>{{ error }}</p>
73
- </div>
74
- {% elif items.length > 0 %}
75
- <div class="ms-timeline" id="timeline">
76
- {% for item in items %}
77
- <article class="ms-item-card">
78
- {# Author #}
79
- {% if item.author %}
80
- <div class="ms-item-card__author" style="padding: 12px 16px 0;">
81
- <div class="ms-item-card__avatar-wrap" data-avatar-fallback>
82
- {% if item.author.photo %}
83
- <img src="{{ item.author.photo }}"
84
- alt=""
85
- class="ms-item-card__author-photo"
86
- width="40"
87
- height="40"
88
- loading="lazy">
89
- {% endif %}
90
- <span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
91
- </div>
92
- <div class="ms-item-card__author-info">
93
- <span class="ms-item-card__author-name">{{ item.author.name or "Unknown" }}</span>
94
- {% if item.author.url %}
95
- <span class="ms-item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
96
- {% endif %}
97
- </div>
98
- </div>
99
- {% endif %}
100
-
101
- <a href="{{ item.url }}" class="ms-item-card__link" target="_blank" rel="noopener">
102
- {# Reply context #}
103
- {% if item["in-reply-to"] and item["in-reply-to"].length > 0 %}
104
- <div class="ms-item-card__context">
105
- {{ icon("reply") }}
106
- <span>Reply to</span>
107
- <span>{{ item["in-reply-to"][0] | replace("https://", "") | replace("http://", "") | truncate(50) }}</span>
108
- </div>
109
- {% endif %}
110
-
111
- {# Title #}
112
- {% if item.name %}
113
- <h3 class="ms-item-card__title">{{ item.name }}</h3>
114
- {% endif %}
115
-
116
- {# Content #}
117
- {% if item.content %}
118
- <div class="ms-item-card__content{% if (item.content.text or '') | length > 300 %} ms-item-card__content--truncated{% endif %}">
119
- {% if item.content.html %}
120
- {{ item.content.html | safe | striptags | truncate(400) }}
121
- {% elif item.content.text %}
122
- {{ item.content.text | truncate(400) }}
123
- {% endif %}
124
- </div>
125
- {% endif %}
126
-
127
- {# Tags #}
128
- {% if item.category and item.category.length > 0 %}
129
- <div class="ms-item-card__categories">
130
- {% for cat in item.category %}
131
- {% if loop.index0 < 5 %}
132
- <span class="ms-item-card__category">#{{ cat }}</span>
133
- {% endif %}
134
- {% endfor %}
135
- </div>
136
- {% endif %}
137
-
138
- {# Photos #}
139
- {% if item.photo and item.photo.length > 0 %}
140
- {% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
141
- <div class="ms-item-card__photos ms-item-card__photos--{{ photoCount }}">
142
- {% for photo in item.photo %}
143
- {% if loop.index0 < 4 %}
144
- <img src="{{ photo }}" alt="" class="ms-item-card__photo" loading="lazy">
145
- {% endif %}
146
- {% endfor %}
147
- </div>
148
- {% endif %}
149
-
150
- {# Footer #}
151
- <footer class="ms-item-card__footer">
152
- {% if item.published %}
153
- <time datetime="{{ item.published }}" class="ms-item-card__date">
154
- {{ item.published | date("PP", { locale: locale, timeZone: application.timeZone }) }}
155
- </time>
156
- {% endif %}
157
- </footer>
158
- </a>
159
-
160
- {# Actions #}
161
- <div class="ms-item-actions">
162
- <a href="{{ item.url }}" class="ms-item-actions__button" target="_blank" rel="noopener" title="View original">
163
- {{ icon("syndicate") }}
164
- </a>
165
- <a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="ms-item-actions__button" title="Reply">
166
- {{ icon("reply") }}
167
- </a>
168
- <a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="ms-item-actions__button" title="Like">
169
- {{ icon("like") }}
170
- </a>
171
- <a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="ms-item-actions__button" title="Repost">
172
- {{ icon("repost") }}
173
- </a>
174
- <a href="{{ baseUrl }}/compose?bookmark={{ item.url | urlencode }}" class="ms-item-actions__button" title="Bookmark">
175
- {{ icon("bookmark") }}
176
- </a>
177
- </div>
178
- </article>
179
- {% endfor %}
180
- </div>
181
- {% else %}
182
- <div class="ms-reader__empty">
183
- {{ icon("syndicate") }}
184
- <p>No posts found for this actor.</p>
185
- </div>
186
- {% endif %}
187
- </div>
188
- {% endblock %}