@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.57
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/assets/reader.js +408 -0
- package/index.js +37 -36
- package/lib/cache/redis.js +12 -3
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- 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
|
-
};
|