@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.58

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.
Files changed (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +251 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -1,1562 +0,0 @@
1
- /**
2
- * Reader UI controller
3
- * @module controllers/reader
4
- */
5
-
6
- import { discoverAndValidateFeeds, getBestFeed } from "../feeds/discovery.js";
7
- import { validateFeedUrl } from "../feeds/validator.js";
8
- import { ObjectId } from "mongodb";
9
- import { refreshFeedNow } from "../polling/scheduler.js";
10
- import {
11
- getChannels,
12
- getChannelsWithColors,
13
- getChannel,
14
- getChannelById,
15
- createChannel,
16
- updateChannelSettings,
17
- deleteChannel,
18
- } from "../storage/channels.js";
19
- import {
20
- getFeedsForChannel,
21
- getFeedById,
22
- createFeed,
23
- deleteFeed,
24
- updateFeed,
25
- updateFeedStatus,
26
- } from "../storage/feeds.js";
27
- import {
28
- getTimelineItems,
29
- getAllTimelineItems,
30
- getItemById,
31
- markItemsRead,
32
- countReadItems,
33
- } from "../storage/items.js";
34
- import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
35
-
36
- const ACTOR_OUTBOX_LIMIT = 30;
37
- import { getUserId } from "../utils/auth.js";
38
- import {
39
- validateChannelName,
40
- validateExcludeTypes,
41
- validateExcludeRegex,
42
- } from "../utils/validation.js";
43
- import { proxyItemImages } from "../media/proxy.js";
44
- import { getDeckConfig, saveDeckConfig } from "../storage/deck.js";
45
-
46
- /**
47
- * Reader index - redirect to channels
48
- * @param {object} request - Express request
49
- * @param {object} response - Express response
50
- */
51
- export async function index(request, response) {
52
- const lastView = request.session?.microsubView || "timeline";
53
- const validViews = ["channels", "deck", "timeline"];
54
- const view = validViews.includes(lastView) ? lastView : "timeline";
55
- response.redirect(`${request.baseUrl}/${view}`);
56
- }
57
-
58
- /**
59
- * List channels
60
- * @param {object} request - Express request
61
- * @param {object} response - Express response
62
- */
63
- export async function channels(request, response) {
64
- const { application } = request.app.locals;
65
- const userId = getUserId(request);
66
-
67
- const channelList = await getChannels(application, userId);
68
-
69
- if (request.session) request.session.microsubView = "channels";
70
-
71
- response.render("reader", {
72
- title: request.__("microsub.views.channels"),
73
- channels: channelList,
74
- baseUrl: request.baseUrl,
75
- readerBaseUrl: request.baseUrl,
76
- activeView: "channels",
77
- breadcrumbs: [
78
- { text: "Reader", href: request.baseUrl },
79
- { text: "Channels" },
80
- ],
81
- });
82
- }
83
-
84
- /**
85
- * New channel form
86
- * @param {object} request - Express request
87
- * @param {object} response - Express response
88
- */
89
- export async function newChannel(request, response) {
90
- response.render("channel-new", {
91
- title: request.__("microsub.channels.new"),
92
- baseUrl: request.baseUrl,
93
- readerBaseUrl: request.baseUrl,
94
- activeView: "channels",
95
- breadcrumbs: [
96
- { text: "Reader", href: request.baseUrl },
97
- { text: "Channels", href: `${request.baseUrl}/channels` },
98
- { text: request.__("microsub.channels.new") },
99
- ],
100
- });
101
- }
102
-
103
- /**
104
- * Create channel
105
- * @param {object} request - Express request
106
- * @param {object} response - Express response
107
- */
108
- export async function createChannelAction(request, response) {
109
- const { application } = request.app.locals;
110
- const userId = getUserId(request);
111
- const { name } = request.body;
112
-
113
- validateChannelName(name);
114
-
115
- await createChannel(application, { name, userId });
116
-
117
- response.redirect(`${request.baseUrl}/channels`);
118
- }
119
-
120
- /**
121
- * View channel timeline
122
- * @param {object} request - Express request
123
- * @param {object} response - Express response
124
- * @returns {Promise<void>}
125
- */
126
- export async function channel(request, response) {
127
- const { application } = request.app.locals;
128
- const userId = getUserId(request);
129
- const { uid } = request.params;
130
- const { before, after, showRead } = request.query;
131
-
132
- const channelDocument = await getChannel(application, uid, userId);
133
- if (!channelDocument) {
134
- return response.status(404).render("404");
135
- }
136
-
137
- // Check if showing read items
138
- const showReadItems = showRead === "true";
139
-
140
- const timeline = await getTimelineItems(application, channelDocument._id, {
141
- before,
142
- after,
143
- userId,
144
- showRead: showReadItems,
145
- });
146
-
147
- // Proxy images through media endpoint for privacy
148
- const proxyBaseUrl = application.url;
149
- if (proxyBaseUrl && timeline.items) {
150
- timeline.items = timeline.items.map((item) =>
151
- proxyItemImages(item, proxyBaseUrl),
152
- );
153
- }
154
-
155
- // Count read items to show "View read items" button
156
- const readCount = await countReadItems(
157
- application,
158
- channelDocument._id,
159
- userId,
160
- );
161
-
162
- response.render("channel", {
163
- title: channelDocument.name,
164
- channel: channelDocument,
165
- items: timeline.items,
166
- paging: timeline.paging,
167
- readCount,
168
- showRead: showReadItems,
169
- baseUrl: request.baseUrl,
170
- readerBaseUrl: request.baseUrl,
171
- activeView: "channels",
172
- breadcrumbs: [
173
- { text: "Reader", href: request.baseUrl },
174
- { text: "Channels", href: `${request.baseUrl}/channels` },
175
- { text: channelDocument.name },
176
- ],
177
- });
178
- }
179
-
180
- /**
181
- * Channel settings form
182
- * @param {object} request - Express request
183
- * @param {object} response - Express response
184
- * @returns {Promise<void>}
185
- */
186
- export async function settings(request, response) {
187
- const { application } = request.app.locals;
188
- const userId = getUserId(request);
189
- const { uid } = request.params;
190
-
191
- const channelDocument = await getChannel(application, uid, userId);
192
- if (!channelDocument) {
193
- return response.status(404).render("404");
194
- }
195
-
196
- response.render("settings", {
197
- title: request.__("microsub.settings.title", {
198
- channel: channelDocument.name,
199
- }),
200
- channel: channelDocument,
201
- baseUrl: request.baseUrl,
202
- readerBaseUrl: request.baseUrl,
203
- activeView: "channels",
204
- breadcrumbs: [
205
- { text: "Reader", href: request.baseUrl },
206
- { text: "Channels", href: `${request.baseUrl}/channels` },
207
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
208
- { text: "Settings" },
209
- ],
210
- });
211
- }
212
-
213
- /**
214
- * Update channel settings
215
- * @param {object} request - Express request
216
- * @param {object} response - Express response
217
- * @returns {Promise<void>}
218
- */
219
- export async function updateSettings(request, response) {
220
- const { application } = request.app.locals;
221
- const userId = getUserId(request);
222
- const { uid } = request.params;
223
- const { excludeTypes, excludeRegex } = request.body;
224
-
225
- const channelDocument = await getChannel(application, uid, userId);
226
- if (!channelDocument) {
227
- return response.status(404).render("404");
228
- }
229
-
230
- const validatedTypes = validateExcludeTypes(
231
- Array.isArray(excludeTypes) ? excludeTypes : [excludeTypes].filter(Boolean),
232
- );
233
- const validatedRegex = validateExcludeRegex(excludeRegex);
234
-
235
- await updateChannelSettings(
236
- application,
237
- uid,
238
- {
239
- excludeTypes: validatedTypes,
240
- excludeRegex: validatedRegex,
241
- },
242
- userId,
243
- );
244
-
245
- response.redirect(`${request.baseUrl}/channels/${uid}`);
246
- }
247
-
248
- /**
249
- * Delete channel
250
- * @param {object} request - Express request
251
- * @param {object} response - Express response
252
- * @returns {Promise<void>}
253
- */
254
- export async function deleteChannelAction(request, response) {
255
- const { application } = request.app.locals;
256
- const userId = getUserId(request);
257
- const { uid } = request.params;
258
-
259
- // Don't allow deleting system channels
260
- if (uid === "notifications" || uid === "activitypub") {
261
- return response.redirect(`${request.baseUrl}/channels`);
262
- }
263
-
264
- const channelDocument = await getChannel(application, uid, userId);
265
- if (!channelDocument) {
266
- return response.status(404).render("404");
267
- }
268
-
269
- await deleteChannel(application, uid, userId);
270
-
271
- response.redirect(`${request.baseUrl}/channels`);
272
- }
273
-
274
- /**
275
- * View feeds for a channel
276
- * @param {object} request - Express request
277
- * @param {object} response - Express response
278
- * @returns {Promise<void>}
279
- */
280
- export async function feeds(request, response) {
281
- const { application } = request.app.locals;
282
- const userId = getUserId(request);
283
- const { uid } = request.params;
284
-
285
- const channelDocument = await getChannel(application, uid, userId);
286
- if (!channelDocument) {
287
- return response.status(404).render("404");
288
- }
289
-
290
- const feedList = await getFeedsForChannel(application, channelDocument._id);
291
-
292
- response.render("feeds", {
293
- title: request.__("microsub.feeds.title"),
294
- channel: channelDocument,
295
- feeds: feedList,
296
- baseUrl: request.baseUrl,
297
- readerBaseUrl: request.baseUrl,
298
- activeView: "channels",
299
- breadcrumbs: [
300
- { text: "Reader", href: request.baseUrl },
301
- { text: "Channels", href: `${request.baseUrl}/channels` },
302
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
303
- { text: "Feeds" },
304
- ],
305
- });
306
- }
307
-
308
- /**
309
- * Add feed to channel
310
- * @param {object} request - Express request
311
- * @param {object} response - Express response
312
- * @returns {Promise<void>}
313
- */
314
- export async function addFeed(request, response) {
315
- const { application } = request.app.locals;
316
- const userId = getUserId(request);
317
- const { uid } = request.params;
318
- const { url } = request.body;
319
-
320
- const channelDocument = await getChannel(application, uid, userId);
321
- if (!channelDocument) {
322
- return response.status(404).render("404");
323
- }
324
-
325
- try {
326
- // Create feed subscription (throws DUPLICATE_FEED if already exists)
327
- const feed = await createFeed(application, {
328
- channelId: channelDocument._id,
329
- url,
330
- title: undefined,
331
- photo: undefined,
332
- });
333
-
334
- // Trigger immediate fetch in background
335
- refreshFeedNow(application, feed._id).catch((error) => {
336
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
337
- });
338
-
339
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
340
- } catch (error) {
341
- if (error.code === "DUPLICATE_FEED") {
342
- // Re-render feeds page with error message
343
- const feedList = await getFeedsForChannel(application, channelDocument._id);
344
- return response.render("feeds", {
345
- title: request.__("microsub.feeds.title"),
346
- channel: channelDocument,
347
- feeds: feedList,
348
- baseUrl: request.baseUrl,
349
- readerBaseUrl: request.baseUrl,
350
- activeView: "channels",
351
- error: `This feed already exists in channel "${error.channelName}"`,
352
- breadcrumbs: [
353
- { text: "Reader", href: request.baseUrl },
354
- { text: "Channels", href: `${request.baseUrl}/channels` },
355
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
356
- { text: "Feeds" },
357
- ],
358
- });
359
- }
360
- throw error;
361
- }
362
- }
363
-
364
- /**
365
- * Remove feed from channel
366
- * @param {object} request - Express request
367
- * @param {object} response - Express response
368
- * @returns {Promise<void>}
369
- */
370
- export async function removeFeed(request, response) {
371
- const { application } = request.app.locals;
372
- const userId = getUserId(request);
373
- const { uid } = request.params;
374
- const { url } = request.body;
375
-
376
- const channelDocument = await getChannel(application, uid, userId);
377
- if (!channelDocument) {
378
- return response.status(404).render("404");
379
- }
380
-
381
- await deleteFeed(application, channelDocument._id, url);
382
-
383
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
384
- }
385
-
386
- /**
387
- * View single item
388
- * @param {object} request - Express request
389
- * @param {object} response - Express response
390
- * @returns {Promise<void>}
391
- */
392
- export async function item(request, response) {
393
- const { application } = request.app.locals;
394
- const userId = getUserId(request);
395
- const { id } = request.params;
396
-
397
- const itemDocument = await getItemById(application, id, userId);
398
- if (!itemDocument) {
399
- return response.status(404).render("404");
400
- }
401
-
402
- // Get the channel for this item (needed for mark-read)
403
- let channel = null;
404
- if (itemDocument.channelId) {
405
- channel = await getChannelById(application, itemDocument.channelId);
406
- }
407
-
408
- const itemBreadcrumbs = [
409
- { text: "Reader", href: request.baseUrl },
410
- ];
411
- if (channel) {
412
- itemBreadcrumbs.push(
413
- { text: "Channels", href: `${request.baseUrl}/channels` },
414
- { text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
415
- );
416
- }
417
- itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
418
-
419
- response.render("item", {
420
- title: itemDocument.name || "Item",
421
- item: itemDocument,
422
- channel,
423
- baseUrl: request.baseUrl,
424
- readerBaseUrl: request.baseUrl,
425
- activeView: "channels",
426
- breadcrumbs: itemBreadcrumbs,
427
- });
428
- }
429
-
430
- /**
431
- * Ensure value is a string URL
432
- * @param {string|object|undefined} value - Value to check
433
- * @returns {string|undefined} String value or undefined
434
- */
435
- function ensureString(value) {
436
- if (!value) return;
437
- if (typeof value === "string") return value;
438
- if (typeof value === "object" && value.url) return value.url;
439
- return String(value);
440
- }
441
-
442
- /**
443
- * Detect the protocol of a URL for auto-syndication targeting
444
- * @param {string} url - URL to classify
445
- * @returns {string} "atmosphere" | "fediverse" | "web"
446
- */
447
- function detectProtocol(url) {
448
- if (!url || typeof url !== "string") return "web";
449
- const lower = url.toLowerCase();
450
- if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
451
- if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
452
- lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse";
453
- return "web";
454
- }
455
-
456
- /**
457
- * Fetch syndication targets from Micropub config
458
- * @param {object} application - Indiekit application
459
- * @param {string} token - Auth token
460
- * @returns {Promise<Array>} Syndication targets
461
- */
462
- async function getSyndicationTargets(application, token) {
463
- try {
464
- const micropubEndpoint = application.micropubEndpoint;
465
- if (!micropubEndpoint) return [];
466
-
467
- const micropubUrl = micropubEndpoint.startsWith("http")
468
- ? micropubEndpoint
469
- : new URL(micropubEndpoint, application.url).href;
470
-
471
- const configUrl = `${micropubUrl}?q=config`;
472
- const configResponse = await fetch(configUrl, {
473
- headers: {
474
- Authorization: `Bearer ${token}`,
475
- Accept: "application/json",
476
- },
477
- });
478
-
479
- if (!configResponse.ok) return [];
480
-
481
- const config = await configResponse.json();
482
- return config["syndicate-to"] || [];
483
- } catch {
484
- return [];
485
- }
486
- }
487
-
488
- /**
489
- * Compose response form
490
- * @param {object} request - Express request
491
- * @param {object} response - Express response
492
- * @returns {Promise<void>}
493
- */
494
- export async function compose(request, response) {
495
- const { application } = request.app.locals;
496
-
497
- // Support both long-form (replyTo) and short-form (reply) query params
498
- const {
499
- replyTo,
500
- reply,
501
- likeOf,
502
- like,
503
- repostOf,
504
- repost,
505
- bookmarkOf,
506
- bookmark,
507
- } = request.query;
508
-
509
- // Fetch syndication targets if user is authenticated
510
- const token = request.session?.access_token;
511
- const syndicationTargets = token
512
- ? await getSyndicationTargets(application, token)
513
- : [];
514
-
515
- // Auto-select syndication target based on interaction URL protocol
516
- const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
517
- if (interactionUrl && syndicationTargets.length > 0) {
518
- const protocol = detectProtocol(interactionUrl);
519
- for (const target of syndicationTargets) {
520
- const targetId = (target.uid || target.name || "").toLowerCase();
521
- if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
522
- target.checked = true;
523
- } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
524
- target.checked = true;
525
- }
526
- }
527
- }
528
-
529
- response.render("compose", {
530
- title: request.__("microsub.compose.title"),
531
- replyTo: ensureString(replyTo || reply),
532
- likeOf: ensureString(likeOf || like),
533
- repostOf: ensureString(repostOf || repost),
534
- bookmarkOf: ensureString(bookmarkOf || bookmark),
535
- syndicationTargets,
536
- baseUrl: request.baseUrl,
537
- readerBaseUrl: request.baseUrl,
538
- activeView: "channels",
539
- breadcrumbs: [
540
- { text: "Reader", href: request.baseUrl },
541
- { text: "Compose" },
542
- ],
543
- });
544
- }
545
-
546
- /**
547
- * Submit composed response via Micropub
548
- * @param {object} request - Express request
549
- * @param {object} response - Express response
550
- * @returns {Promise<void>}
551
- */
552
- export async function submitCompose(request, response) {
553
- const { application } = request.app.locals;
554
- const { content } = request.body;
555
- const inReplyTo = request.body["in-reply-to"];
556
- const likeOf = request.body["like-of"];
557
- const repostOf = request.body["repost-of"];
558
- const bookmarkOf = request.body["bookmark-of"];
559
- const syndicateTo = request.body["mp-syndicate-to"];
560
-
561
- // Get Micropub endpoint
562
- const micropubEndpoint = application.micropubEndpoint;
563
- if (!micropubEndpoint) {
564
- return response.status(500).render("error", {
565
- title: "Error",
566
- content: "Micropub endpoint not configured",
567
- });
568
- }
569
-
570
- // Build absolute Micropub URL
571
- const micropubUrl = micropubEndpoint.startsWith("http")
572
- ? micropubEndpoint
573
- : new URL(micropubEndpoint, application.url).href;
574
-
575
- // Get auth token from session
576
- const token = request.session?.access_token;
577
- if (!token) {
578
- return response.redirect("/session/login?redirect=" + request.originalUrl);
579
- }
580
-
581
- // Build Micropub request body
582
- const micropubData = new URLSearchParams();
583
- micropubData.append("h", "entry");
584
-
585
- if (likeOf) {
586
- // Like post - content is optional comment
587
- micropubData.append("like-of", likeOf);
588
- if (content && content.trim()) {
589
- micropubData.append("content", content.trim());
590
- }
591
- } else if (repostOf) {
592
- // Repost - content is optional comment
593
- micropubData.append("repost-of", repostOf);
594
- if (content && content.trim()) {
595
- micropubData.append("content", content.trim());
596
- }
597
- } else if (bookmarkOf) {
598
- // Bookmark - content is optional comment
599
- micropubData.append("bookmark-of", bookmarkOf);
600
- if (content && content.trim()) {
601
- micropubData.append("content", content.trim());
602
- }
603
- } else if (inReplyTo) {
604
- // Reply
605
- micropubData.append("in-reply-to", inReplyTo);
606
- micropubData.append("content", content || "");
607
- } else {
608
- // Regular note
609
- micropubData.append("content", content || "");
610
- }
611
-
612
- // Add syndication targets
613
- if (syndicateTo) {
614
- const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];
615
- for (const target of targets) {
616
- micropubData.append("mp-syndicate-to", target);
617
- }
618
- }
619
-
620
- try {
621
- const micropubResponse = await fetch(micropubUrl, {
622
- method: "POST",
623
- headers: {
624
- Authorization: `Bearer ${token}`,
625
- "Content-Type": "application/x-www-form-urlencoded",
626
- Accept: "application/json",
627
- },
628
- body: micropubData.toString(),
629
- });
630
-
631
- if (
632
- micropubResponse.ok ||
633
- micropubResponse.status === 201 ||
634
- micropubResponse.status === 202
635
- ) {
636
- // Success - get the Location header for the new post URL
637
- const location = micropubResponse.headers.get("Location");
638
- console.info(
639
- `[Microsub] Created post via Micropub: ${location || "success"}`,
640
- );
641
-
642
- // Redirect back to reader with success message
643
- return response.redirect(`${request.baseUrl}/channels`);
644
- }
645
-
646
- // Handle error
647
- const errorBody = await micropubResponse.text();
648
- const statusText = micropubResponse.statusText || "Unknown error";
649
- console.error(
650
- `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
651
- );
652
-
653
- // Parse error message from response body if JSON
654
- let errorMessage = `Micropub error: ${statusText}`;
655
- try {
656
- const errorJson = JSON.parse(errorBody);
657
- if (errorJson.error_description) {
658
- errorMessage = String(errorJson.error_description);
659
- } else if (errorJson.error) {
660
- errorMessage = String(errorJson.error);
661
- }
662
- } catch {
663
- // Not JSON, use status text
664
- }
665
-
666
- return response.status(micropubResponse.status).render("error", {
667
- title: "Error",
668
- content: errorMessage,
669
- });
670
- } catch (error) {
671
- console.error(`[Microsub] Micropub request failed: ${error.message}`);
672
-
673
- return response.status(500).render("error", {
674
- title: "Error",
675
- content: `Failed to create post: ${error.message}`,
676
- });
677
- }
678
- }
679
-
680
- /**
681
- * Search/discover feeds page
682
- * @param {object} request - Express request
683
- * @param {object} response - Express response
684
- * @returns {Promise<void>}
685
- */
686
- export async function searchPage(request, response) {
687
- const { application } = request.app.locals;
688
- const userId = getUserId(request);
689
-
690
- const channelList = await getChannels(application, userId);
691
-
692
- response.render("search", {
693
- title: request.__("microsub.search.title"),
694
- channels: channelList,
695
- baseUrl: request.baseUrl,
696
- readerBaseUrl: request.baseUrl,
697
- activeView: "channels",
698
- breadcrumbs: [
699
- { text: "Reader", href: request.baseUrl },
700
- { text: "Search" },
701
- ],
702
- });
703
- }
704
-
705
- /**
706
- * Search for feeds from URL - enhanced with validation
707
- * @param {object} request - Express request
708
- * @param {object} response - Express response
709
- * @returns {Promise<void>}
710
- */
711
- export async function searchFeeds(request, response) {
712
- const { application } = request.app.locals;
713
- const userId = getUserId(request);
714
- const { query } = request.body;
715
-
716
- const channelList = await getChannels(application, userId);
717
-
718
- let results = [];
719
- let discoveryError = null;
720
-
721
- if (query) {
722
- try {
723
- // Use enhanced discovery with validation
724
- results = await discoverAndValidateFeeds(query);
725
- } catch (error) {
726
- discoveryError = error.message;
727
- }
728
- }
729
-
730
- response.render("search", {
731
- title: request.__("microsub.search.title"),
732
- channels: channelList,
733
- query,
734
- results,
735
- discoveryError,
736
- searched: true,
737
- baseUrl: request.baseUrl,
738
- readerBaseUrl: request.baseUrl,
739
- activeView: "channels",
740
- breadcrumbs: [
741
- { text: "Reader", href: request.baseUrl },
742
- { text: "Search" },
743
- ],
744
- });
745
- }
746
-
747
- /**
748
- * Subscribe to a feed from search results - with validation
749
- * @param {object} request - Express request
750
- * @param {object} response - Express response
751
- * @returns {Promise<void>}
752
- */
753
- export async function subscribe(request, response) {
754
- const { application } = request.app.locals;
755
- const userId = getUserId(request);
756
- const { url, channel: channelUid, skipValidation } = request.body;
757
-
758
- const channelDocument = await getChannel(application, channelUid, userId);
759
- if (!channelDocument) {
760
- return response.status(404).render("404");
761
- }
762
-
763
- // Validate feed unless explicitly skipped (for power users)
764
- if (!skipValidation) {
765
- const validation = await validateFeedUrl(url);
766
-
767
- if (!validation.valid) {
768
- const channelList = await getChannels(application, userId);
769
- return response.render("search", {
770
- title: request.__("microsub.search.title"),
771
- channels: channelList,
772
- query: url,
773
- validationError: validation.error,
774
- baseUrl: request.baseUrl,
775
- readerBaseUrl: request.baseUrl,
776
- activeView: "channels",
777
- breadcrumbs: [
778
- { text: "Reader", href: request.baseUrl },
779
- { text: "Search" },
780
- ],
781
- });
782
- }
783
-
784
- // Warn about comments feeds but allow subscription
785
- if (validation.isCommentsFeed) {
786
- console.warn(`[Microsub] Subscribing to comments feed: ${url}`);
787
- }
788
- }
789
-
790
- // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
791
- try {
792
- const feed = await createFeed(application, {
793
- channelId: channelDocument._id,
794
- url,
795
- title: undefined,
796
- photo: undefined,
797
- });
798
-
799
- // Trigger immediate fetch in background
800
- refreshFeedNow(application, feed._id).catch((error) => {
801
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
802
- });
803
-
804
- response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
805
- } catch (error) {
806
- if (error.code === "DUPLICATE_FEED") {
807
- const channelList = await getChannels(application, userId);
808
- return response.render("search", {
809
- title: request.__("microsub.search.title"),
810
- channels: channelList,
811
- query: url,
812
- validationError: `This feed already exists in channel "${error.channelName}"`,
813
- baseUrl: request.baseUrl,
814
- readerBaseUrl: request.baseUrl,
815
- activeView: "channels",
816
- breadcrumbs: [
817
- { text: "Reader", href: request.baseUrl },
818
- { text: "Search" },
819
- ],
820
- });
821
- }
822
- throw error;
823
- }
824
- }
825
-
826
- /**
827
- * Mark all items in channel as read
828
- * @param {object} request - Express request
829
- * @param {object} response - Express response
830
- * @returns {Promise<void>}
831
- */
832
- export async function markAllRead(request, response) {
833
- const { application } = request.app.locals;
834
- const userId = getUserId(request);
835
- const { channel: channelUid } = request.body;
836
-
837
- const channelDocument = await getChannel(application, channelUid, userId);
838
- if (!channelDocument) {
839
- return response.status(404).render("404");
840
- }
841
-
842
- // Mark all items as read using the special "last-read-entry" value
843
- await markItemsRead(
844
- application,
845
- channelDocument._id,
846
- ["last-read-entry"],
847
- userId,
848
- );
849
-
850
- response.redirect(`${request.baseUrl}/channels/${channelUid}`);
851
- }
852
-
853
- /**
854
- * Return rendered HTML fragments for infinite scroll
855
- * @param {object} request - Express request
856
- * @param {object} response - Express response
857
- * @returns {Promise<void>}
858
- */
859
- export async function channelHtml(request, response) {
860
- const { application } = request.app.locals;
861
- const userId = getUserId(request);
862
- const { uid } = request.params;
863
- const { before, after, showRead } = request.query;
864
-
865
- const channelDocument = await getChannel(application, uid, userId);
866
- if (!channelDocument) {
867
- return response.status(404).json({ error: "Channel not found" });
868
- }
869
-
870
- const showReadItems = showRead === "true";
871
-
872
- const timeline = await getTimelineItems(application, channelDocument._id, {
873
- before,
874
- after,
875
- userId,
876
- showRead: showReadItems,
877
- });
878
-
879
- // Proxy images
880
- const proxyBaseUrl = application.url;
881
- if (proxyBaseUrl && timeline.items) {
882
- timeline.items = timeline.items.map((item) =>
883
- proxyItemImages(item, proxyBaseUrl),
884
- );
885
- }
886
-
887
- // Render items via layout-less fragment template (standard response.render
888
- // with callback returns HTML string without sending a response)
889
- const fragmentHtml = await new Promise((resolve, reject) => {
890
- response.render("partials/items-fragment", {
891
- items: timeline.items,
892
- channel: channelDocument,
893
- baseUrl: request.baseUrl,
894
- }, (error, html) => error ? reject(error) : resolve(html));
895
- });
896
-
897
- response.json({
898
- html: fragmentHtml,
899
- paging: timeline.paging,
900
- count: timeline.items.length,
901
- });
902
- }
903
-
904
- /**
905
- * Return rendered HTML fragments for timeline infinite scroll
906
- * @param {object} request - Express request
907
- * @param {object} response - Express response
908
- * @returns {Promise<void>}
909
- */
910
- export async function timelineHtml(request, response) {
911
- const { application } = request.app.locals;
912
- const userId = getUserId(request);
913
- const { before, after } = request.query;
914
-
915
- const channelList = await getChannelsWithColors(application, userId);
916
- const channelMap = new Map();
917
- for (const ch of channelList) {
918
- channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
919
- }
920
-
921
- const excludeParam = request.query.exclude;
922
- const excludeIds = excludeParam
923
- ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
924
- : [];
925
-
926
- const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
927
- const excludeChannelIds = [...excludeIds];
928
- if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
929
- excludeChannelIds.push(notificationsChannel._id.toString());
930
- }
931
-
932
- const result = await getAllTimelineItems(application, {
933
- before,
934
- after,
935
- userId,
936
- excludeChannelIds,
937
- });
938
-
939
- const proxyBaseUrl = application.url;
940
- if (proxyBaseUrl && result.items) {
941
- result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
942
- }
943
-
944
- for (const item of result.items) {
945
- if (item._channelId) {
946
- const info = channelMap.get(item._channelId);
947
- if (info) {
948
- item._channelName = info.name;
949
- item._channelColor = info.color;
950
- item._channelUid = info.uid;
951
- }
952
- }
953
- }
954
-
955
- const fragmentHtml = await new Promise((resolve, reject) => {
956
- response.render("partials/items-fragment-timeline", {
957
- items: result.items,
958
- baseUrl: request.baseUrl,
959
- }, (error, html) => error ? reject(error) : resolve(html));
960
- });
961
-
962
- response.json({
963
- html: fragmentHtml,
964
- paging: result.paging,
965
- count: result.items.length,
966
- });
967
- }
968
-
969
- /**
970
- * Mark specific items as read (no-JS form fallback for mark-view-as-read)
971
- * @param {object} request - Express request
972
- * @param {object} response - Express response
973
- */
974
- export async function markViewRead(request, response) {
975
- const { application } = request.app.locals;
976
- const userId = getUserId(request);
977
- const { channel: channelUid } = request.body;
978
- let { entry } = request.body;
979
-
980
- const channelDocument = await getChannel(application, channelUid, userId);
981
- if (!channelDocument) {
982
- return response.status(404).render("404");
983
- }
984
-
985
- const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
986
- if (entryIds.length > 0) {
987
- await markItemsRead(application, channelDocument._id, entryIds, userId);
988
- }
989
-
990
- response.redirect(`${request.baseUrl}/channels/${channelUid}`);
991
- }
992
-
993
- /**
994
- * View single feed details with status - redirects to edit form
995
- * @param {object} request - Express request
996
- * @param {object} response - Express response
997
- * @returns {Promise<void>}
998
- */
999
- export async function feedDetails(request, response) {
1000
- const { uid, feedId } = request.params;
1001
- // Redirect to edit form which shows all details
1002
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
1003
- }
1004
-
1005
- /**
1006
- * Edit feed URL form
1007
- * @param {object} request - Express request
1008
- * @param {object} response - Express response
1009
- * @returns {Promise<void>}
1010
- */
1011
- export async function editFeedForm(request, response) {
1012
- const { application } = request.app.locals;
1013
- const userId = getUserId(request);
1014
- const { uid, feedId } = request.params;
1015
-
1016
- const channelDocument = await getChannel(application, uid, userId);
1017
- if (!channelDocument) {
1018
- return response.status(404).render("404");
1019
- }
1020
-
1021
- const feed = await getFeedById(application, feedId);
1022
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1023
- return response.status(404).render("404");
1024
- }
1025
-
1026
- response.render("feed-edit", {
1027
- title: request.__("microsub.feeds.edit"),
1028
- channel: channelDocument,
1029
- feed,
1030
- baseUrl: request.baseUrl,
1031
- readerBaseUrl: request.baseUrl,
1032
- activeView: "channels",
1033
- breadcrumbs: [
1034
- { text: "Reader", href: request.baseUrl },
1035
- { text: "Channels", href: `${request.baseUrl}/channels` },
1036
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
1037
- { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
1038
- { text: "Edit" },
1039
- ],
1040
- });
1041
- }
1042
-
1043
- /**
1044
- * Update feed URL
1045
- * @param {object} request - Express request
1046
- * @param {object} response - Express response
1047
- * @returns {Promise<void>}
1048
- */
1049
- export async function updateFeedUrl(request, response) {
1050
- const { application } = request.app.locals;
1051
- const userId = getUserId(request);
1052
- const { uid, feedId } = request.params;
1053
- const { url: newUrl } = request.body;
1054
-
1055
- const channelDocument = await getChannel(application, uid, userId);
1056
- if (!channelDocument) {
1057
- return response.status(404).render("404");
1058
- }
1059
-
1060
- const feed = await getFeedById(application, feedId);
1061
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1062
- return response.status(404).render("404");
1063
- }
1064
-
1065
- // Validate the new URL is a valid feed
1066
- const validation = await validateFeedUrl(newUrl);
1067
-
1068
- if (!validation.valid) {
1069
- return response.render("feed-edit", {
1070
- title: request.__("microsub.feeds.edit"),
1071
- channel: channelDocument,
1072
- feed,
1073
- error: validation.error,
1074
- baseUrl: request.baseUrl,
1075
- readerBaseUrl: request.baseUrl,
1076
- activeView: "channels",
1077
- breadcrumbs: [
1078
- { text: "Reader", href: request.baseUrl },
1079
- { text: "Channels", href: `${request.baseUrl}/channels` },
1080
- { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
1081
- { text: "Feeds", href: `${request.baseUrl}/channels/${uid}/feeds` },
1082
- { text: "Edit" },
1083
- ],
1084
- });
1085
- }
1086
-
1087
- // Update the feed URL and reset error state
1088
- await updateFeed(application, feedId, {
1089
- url: newUrl,
1090
- title: validation.title || feed.title,
1091
- status: "active",
1092
- lastError: undefined,
1093
- lastErrorAt: undefined,
1094
- consecutiveErrors: 0,
1095
- });
1096
-
1097
- // Trigger immediate fetch
1098
- refreshFeedNow(application, feedId).catch((error) => {
1099
- console.error(
1100
- `[Microsub] Error refreshing updated feed ${newUrl}:`,
1101
- error.message,
1102
- );
1103
- });
1104
-
1105
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
1106
- }
1107
-
1108
- /**
1109
- * Rediscover feed - run discovery on URL to find actual RSS feed
1110
- * @param {object} request - Express request
1111
- * @param {object} response - Express response
1112
- * @returns {Promise<void>}
1113
- */
1114
- export async function rediscoverFeed(request, response) {
1115
- const { application } = request.app.locals;
1116
- const userId = getUserId(request);
1117
- const { uid, feedId } = request.params;
1118
-
1119
- const channelDocument = await getChannel(application, uid, userId);
1120
- if (!channelDocument) {
1121
- return response.status(404).render("404");
1122
- }
1123
-
1124
- const feed = await getFeedById(application, feedId);
1125
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1126
- return response.status(404).render("404");
1127
- }
1128
-
1129
- // Run feed discovery on the current URL
1130
- try {
1131
- const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
1132
- const bestFeed = getBestFeed(discoveredFeeds);
1133
-
1134
- if (bestFeed && bestFeed.url !== feed.url) {
1135
- // Found a different (better) feed URL - update the record
1136
- await updateFeed(application, feedId, {
1137
- url: bestFeed.url,
1138
- title: bestFeed.title || feed.title,
1139
- status: "active",
1140
- lastError: undefined,
1141
- lastErrorAt: undefined,
1142
- consecutiveErrors: 0,
1143
- });
1144
-
1145
- console.info(
1146
- `[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
1147
- );
1148
-
1149
- // Trigger immediate fetch
1150
- refreshFeedNow(application, feedId).catch((error) => {
1151
- console.error(
1152
- `[Microsub] Error refreshing rediscovered feed:`,
1153
- error.message,
1154
- );
1155
- });
1156
- } else if (bestFeed) {
1157
- // Same URL but valid - just reset error state and refresh
1158
- await updateFeedStatus(application, feedId, { success: true });
1159
- await updateFeed(application, feedId, {
1160
- status: "active",
1161
- lastError: undefined,
1162
- lastErrorAt: undefined,
1163
- consecutiveErrors: 0,
1164
- });
1165
-
1166
- refreshFeedNow(application, feedId).catch((error) => {
1167
- console.error(`[Microsub] Error refreshing feed:`, error.message);
1168
- });
1169
- } else {
1170
- // No valid feed found
1171
- await updateFeedStatus(application, feedId, {
1172
- success: false,
1173
- error: "No valid feed found at this URL",
1174
- });
1175
- }
1176
- } catch (error) {
1177
- await updateFeedStatus(application, feedId, {
1178
- success: false,
1179
- error: error.message,
1180
- });
1181
- }
1182
-
1183
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
1184
- }
1185
-
1186
- /**
1187
- * Force refresh a feed
1188
- * @param {object} request - Express request
1189
- * @param {object} response - Express response
1190
- * @returns {Promise<void>}
1191
- */
1192
- export async function refreshFeed(request, response) {
1193
- const { application } = request.app.locals;
1194
- const userId = getUserId(request);
1195
- const { uid, feedId } = request.params;
1196
-
1197
- const channelDocument = await getChannel(application, uid, userId);
1198
- if (!channelDocument) {
1199
- return response.status(404).render("404");
1200
- }
1201
-
1202
- const feed = await getFeedById(application, feedId);
1203
- if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
1204
- return response.status(404).render("404");
1205
- }
1206
-
1207
- // Trigger immediate fetch
1208
- refreshFeedNow(application, feedId).catch((error) => {
1209
- console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
1210
- });
1211
-
1212
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
1213
- }
1214
-
1215
- /**
1216
- * Actor profile — fetch and display a remote AP actor's recent posts
1217
- * @param {object} request - Express request
1218
- * @param {object} response - Express response
1219
- */
1220
- /**
1221
- * Find the ActivityPub plugin instance from installed plugins.
1222
- * @param {object} request - Express request
1223
- * @returns {object|undefined} The AP plugin instance
1224
- */
1225
- function getApPlugin(request) {
1226
- const installedPlugins = request.app.locals.installedPlugins;
1227
- if (!installedPlugins) return undefined;
1228
- return [...installedPlugins].find(
1229
- (p) => p.name === "ActivityPub endpoint",
1230
- );
1231
- }
1232
-
1233
- export async function actorProfile(request, response) {
1234
- const actorUrl = request.query.url;
1235
- if (!actorUrl) {
1236
- return response.status(400).render("404");
1237
- }
1238
-
1239
- // Check if we already follow this actor
1240
- const { application } = request.app.locals;
1241
- const apFollowing = application?.collections?.get("ap_following");
1242
- let isFollowing = false;
1243
- if (apFollowing) {
1244
- const existing = await apFollowing.findOne({ actorUrl });
1245
- isFollowing = !!existing;
1246
- }
1247
-
1248
- // Check if AP plugin is available (for follow button visibility)
1249
- const apPlugin = getApPlugin(request);
1250
- const canFollow = !!apPlugin;
1251
-
1252
- try {
1253
- const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
1254
-
1255
- response.render("actor", {
1256
- title: actor.name || "Actor",
1257
- actor,
1258
- items,
1259
- actorUrl,
1260
- isFollowing,
1261
- canFollow,
1262
- baseUrl: request.baseUrl,
1263
- readerBaseUrl: request.baseUrl,
1264
- activeView: "channels",
1265
- breadcrumbs: [
1266
- { text: "Reader", href: request.baseUrl },
1267
- { text: actor.name || "Actor" },
1268
- ],
1269
- });
1270
- } catch (error) {
1271
- console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
1272
- response.render("actor", {
1273
- title: "Actor",
1274
- actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
1275
- items: [],
1276
- actorUrl,
1277
- isFollowing,
1278
- canFollow,
1279
- baseUrl: request.baseUrl,
1280
- readerBaseUrl: request.baseUrl,
1281
- activeView: "channels",
1282
- error: "Could not fetch this actor's profile. They may have restricted access.",
1283
- breadcrumbs: [
1284
- { text: "Reader", href: request.baseUrl },
1285
- { text: "Actor" },
1286
- ],
1287
- });
1288
- }
1289
- }
1290
-
1291
- export async function followActorAction(request, response) {
1292
- const { actorUrl, actorName } = request.body;
1293
- if (!actorUrl) {
1294
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
1295
- }
1296
-
1297
- const apPlugin = getApPlugin(request);
1298
- if (!apPlugin) {
1299
- console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
1300
- return response.redirect(
1301
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1302
- );
1303
- }
1304
-
1305
- const result = await apPlugin.followActor(actorUrl, { name: actorName });
1306
- if (!result.ok) {
1307
- console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
1308
- }
1309
-
1310
- return response.redirect(
1311
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1312
- );
1313
- }
1314
-
1315
- export async function unfollowActorAction(request, response) {
1316
- const { actorUrl } = request.body;
1317
- if (!actorUrl) {
1318
- return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
1319
- }
1320
-
1321
- const apPlugin = getApPlugin(request);
1322
- if (!apPlugin) {
1323
- console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
1324
- return response.redirect(
1325
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1326
- );
1327
- }
1328
-
1329
- const result = await apPlugin.unfollowActor(actorUrl);
1330
- if (!result.ok) {
1331
- console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
1332
- }
1333
-
1334
- return response.redirect(
1335
- `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1336
- );
1337
- }
1338
-
1339
- /**
1340
- * Timeline view - all channels chronologically
1341
- * @param {object} request - Express request
1342
- * @param {object} response - Express response
1343
- */
1344
- export async function timeline(request, response) {
1345
- const { application } = request.app.locals;
1346
- const userId = getUserId(request);
1347
- const { before, after } = request.query;
1348
-
1349
- // Get channels with colors for filtering UI and item decoration
1350
- const channelList = await getChannelsWithColors(application, userId);
1351
-
1352
- // Build channel lookup map (ObjectId string -> { name, color, uid })
1353
- const channelMap = new Map();
1354
- for (const ch of channelList) {
1355
- channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
1356
- }
1357
-
1358
- // Parse excluded channel IDs from query params
1359
- const excludeParam = request.query.exclude;
1360
- const excludeIds = excludeParam
1361
- ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
1362
- : [];
1363
-
1364
- // Exclude the notifications channel by default
1365
- const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
1366
- const excludeChannelIds = [...excludeIds];
1367
- if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
1368
- excludeChannelIds.push(notificationsChannel._id.toString());
1369
- }
1370
-
1371
- const result = await getAllTimelineItems(application, {
1372
- before,
1373
- after,
1374
- userId,
1375
- excludeChannelIds,
1376
- });
1377
-
1378
- // Proxy images
1379
- const proxyBaseUrl = application.url;
1380
- if (proxyBaseUrl && result.items) {
1381
- result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
1382
- }
1383
-
1384
- // Decorate items with channel name and color
1385
- for (const item of result.items) {
1386
- if (item._channelId) {
1387
- const info = channelMap.get(item._channelId);
1388
- if (info) {
1389
- item._channelName = info.name;
1390
- item._channelColor = info.color;
1391
- item._channelUid = info.uid;
1392
- }
1393
- }
1394
- }
1395
-
1396
- // Set view preference cookie
1397
- if (request.session) request.session.microsubView = "timeline";
1398
-
1399
- response.render("timeline", {
1400
- title: "Timeline",
1401
- channels: channelList,
1402
- items: result.items,
1403
- paging: result.paging,
1404
- excludeIds,
1405
- baseUrl: request.baseUrl,
1406
- readerBaseUrl: request.baseUrl,
1407
- activeView: "timeline",
1408
- breadcrumbs: [
1409
- { text: "Reader", href: request.baseUrl },
1410
- { text: "Timeline" },
1411
- ],
1412
- });
1413
- }
1414
-
1415
- /**
1416
- * Deck view - TweetDeck-style columns
1417
- * @param {object} request - Express request
1418
- * @param {object} response - Express response
1419
- */
1420
- export async function deck(request, response) {
1421
- const { application } = request.app.locals;
1422
- const userId = getUserId(request);
1423
-
1424
- const channelList = await getChannelsWithColors(application, userId);
1425
- const deckConfig = await getDeckConfig(application, userId);
1426
-
1427
- // Determine which channels to show as columns
1428
- let columnChannels;
1429
- if (deckConfig?.columns?.length > 0) {
1430
- // Use saved config order
1431
- const channelMap = new Map(channelList.map((ch) => [ch._id.toString(), ch]));
1432
- columnChannels = deckConfig.columns
1433
- .map((col) => channelMap.get(col.channelId.toString()))
1434
- .filter(Boolean);
1435
- } else {
1436
- // Default: all channels except notifications
1437
- columnChannels = channelList.filter((ch) => ch.uid !== "notifications");
1438
- }
1439
-
1440
- // Fetch items for each column (limited to 10 per column for performance)
1441
- const proxyBaseUrl = application.url;
1442
- const columns = await Promise.all(
1443
- columnChannels.map(async (channel) => {
1444
- const result = await getTimelineItems(application, channel._id, {
1445
- userId,
1446
- limit: 10,
1447
- });
1448
-
1449
- if (proxyBaseUrl && result.items) {
1450
- result.items = result.items.map((item) =>
1451
- proxyItemImages(item, proxyBaseUrl),
1452
- );
1453
- }
1454
-
1455
- return {
1456
- channel,
1457
- items: result.items,
1458
- paging: result.paging,
1459
- };
1460
- }),
1461
- );
1462
-
1463
- // Set view preference cookie
1464
- if (request.session) request.session.microsubView = "deck";
1465
-
1466
- response.render("deck", {
1467
- title: "Deck",
1468
- columns,
1469
- baseUrl: request.baseUrl,
1470
- readerBaseUrl: request.baseUrl,
1471
- activeView: "deck",
1472
- breadcrumbs: [
1473
- { text: "Reader", href: request.baseUrl },
1474
- { text: "Deck" },
1475
- ],
1476
- });
1477
- }
1478
-
1479
- /**
1480
- * Deck settings page
1481
- * @param {object} request - Express request
1482
- * @param {object} response - Express response
1483
- */
1484
- export async function deckSettings(request, response) {
1485
- const { application } = request.app.locals;
1486
- const userId = getUserId(request);
1487
-
1488
- const channelList = await getChannelsWithColors(application, userId);
1489
- const deckConfig = await getDeckConfig(application, userId);
1490
-
1491
- const selectedIds = deckConfig?.columns
1492
- ? deckConfig.columns.map((col) => col.channelId.toString())
1493
- : channelList.filter((ch) => ch.uid !== "notifications").map((ch) => ch._id.toString());
1494
-
1495
- response.render("deck-settings", {
1496
- title: "Deck settings",
1497
- channels: channelList,
1498
- selectedIds,
1499
- baseUrl: request.baseUrl,
1500
- readerBaseUrl: request.baseUrl,
1501
- activeView: "deck",
1502
- breadcrumbs: [
1503
- { text: "Reader", href: request.baseUrl },
1504
- { text: "Deck", href: `${request.baseUrl}/deck` },
1505
- { text: "Settings" },
1506
- ],
1507
- });
1508
- }
1509
-
1510
- /**
1511
- * Save deck settings
1512
- * @param {object} request - Express request
1513
- * @param {object} response - Express response
1514
- */
1515
- export async function saveDeckSettings(request, response) {
1516
- const { application } = request.app.locals;
1517
- const userId = getUserId(request);
1518
-
1519
- let { columns } = request.body;
1520
- if (!columns) columns = [];
1521
- if (!Array.isArray(columns)) columns = [columns];
1522
-
1523
- await saveDeckConfig(application, userId, columns);
1524
-
1525
- response.redirect(`${request.baseUrl}/deck`);
1526
- }
1527
-
1528
- export const readerController = {
1529
- index,
1530
- channels,
1531
- newChannel,
1532
- createChannel: createChannelAction,
1533
- channel,
1534
- channelHtml,
1535
- settings,
1536
- updateSettings,
1537
- markAllRead,
1538
- markViewRead,
1539
- deleteChannel: deleteChannelAction,
1540
- feeds,
1541
- addFeed,
1542
- removeFeed,
1543
- feedDetails,
1544
- editFeedForm,
1545
- updateFeedUrl,
1546
- rediscoverFeed,
1547
- refreshFeed,
1548
- item,
1549
- compose,
1550
- submitCompose,
1551
- searchPage,
1552
- searchFeeds,
1553
- subscribe,
1554
- actorProfile,
1555
- followActorAction,
1556
- unfollowActorAction,
1557
- timeline,
1558
- timelineHtml,
1559
- deck,
1560
- deckSettings,
1561
- saveDeckSettings,
1562
- };