@rmdes/indiekit-endpoint-microsub 1.0.0-beta.6 → 1.0.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +9 -0
- package/lib/controllers/reader.js +182 -0
- package/locales/en.json +4 -2
- package/package.json +1 -1
- package/views/channel.njk +3 -0
- package/views/feeds.njk +58 -0
- package/views/reader.njk +3 -0
- package/views/search.njk +58 -0
package/index.js
CHANGED
|
@@ -78,9 +78,18 @@ export default class MicrosubEndpoint {
|
|
|
78
78
|
"/channels/:uid/settings",
|
|
79
79
|
readerController.updateSettings,
|
|
80
80
|
);
|
|
81
|
+
readerRouter.get("/channels/:uid/feeds", readerController.feeds);
|
|
82
|
+
readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
|
|
83
|
+
readerRouter.post(
|
|
84
|
+
"/channels/:uid/feeds/remove",
|
|
85
|
+
readerController.removeFeed,
|
|
86
|
+
);
|
|
81
87
|
readerRouter.get("/item/:id", readerController.item);
|
|
82
88
|
readerRouter.get("/compose", readerController.compose);
|
|
83
89
|
readerRouter.post("/compose", readerController.submitCompose);
|
|
90
|
+
readerRouter.get("/search", readerController.searchPage);
|
|
91
|
+
readerRouter.post("/search", readerController.searchFeeds);
|
|
92
|
+
readerRouter.post("/subscribe", readerController.subscribe);
|
|
84
93
|
router.use("/reader", readerRouter);
|
|
85
94
|
|
|
86
95
|
return router;
|
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
* @module controllers/reader
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { discoverFeedsFromUrl } from "../feeds/fetcher.js";
|
|
7
|
+
import { refreshFeedNow } from "../polling/scheduler.js";
|
|
6
8
|
import {
|
|
7
9
|
getChannels,
|
|
8
10
|
getChannel,
|
|
9
11
|
createChannel,
|
|
10
12
|
updateChannelSettings,
|
|
11
13
|
} from "../storage/channels.js";
|
|
14
|
+
import {
|
|
15
|
+
getFeedsForChannel,
|
|
16
|
+
createFeed,
|
|
17
|
+
deleteFeed,
|
|
18
|
+
} from "../storage/feeds.js";
|
|
12
19
|
import { getTimelineItems, getItemById } from "../storage/items.js";
|
|
13
20
|
import {
|
|
14
21
|
validateChannelName,
|
|
@@ -76,6 +83,7 @@ export async function createChannelAction(request, response) {
|
|
|
76
83
|
* View channel timeline
|
|
77
84
|
* @param {object} request - Express request
|
|
78
85
|
* @param {object} response - Express response
|
|
86
|
+
* @returns {Promise<void>}
|
|
79
87
|
*/
|
|
80
88
|
export async function channel(request, response) {
|
|
81
89
|
const { application } = request.app.locals;
|
|
@@ -107,6 +115,7 @@ export async function channel(request, response) {
|
|
|
107
115
|
* Channel settings form
|
|
108
116
|
* @param {object} request - Express request
|
|
109
117
|
* @param {object} response - Express response
|
|
118
|
+
* @returns {Promise<void>}
|
|
110
119
|
*/
|
|
111
120
|
export async function settings(request, response) {
|
|
112
121
|
const { application } = request.app.locals;
|
|
@@ -131,6 +140,7 @@ export async function settings(request, response) {
|
|
|
131
140
|
* Update channel settings
|
|
132
141
|
* @param {object} request - Express request
|
|
133
142
|
* @param {object} response - Express response
|
|
143
|
+
* @returns {Promise<void>}
|
|
134
144
|
*/
|
|
135
145
|
export async function updateSettings(request, response) {
|
|
136
146
|
const { application } = request.app.locals;
|
|
@@ -161,10 +171,92 @@ export async function updateSettings(request, response) {
|
|
|
161
171
|
response.redirect(`${request.baseUrl}/channels/${uid}`);
|
|
162
172
|
}
|
|
163
173
|
|
|
174
|
+
/**
|
|
175
|
+
* View feeds for a channel
|
|
176
|
+
* @param {object} request - Express request
|
|
177
|
+
* @param {object} response - Express response
|
|
178
|
+
* @returns {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
export async function feeds(request, response) {
|
|
181
|
+
const { application } = request.app.locals;
|
|
182
|
+
const userId = request.session?.userId;
|
|
183
|
+
const { uid } = request.params;
|
|
184
|
+
|
|
185
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
186
|
+
if (!channelDocument) {
|
|
187
|
+
return response.status(404).render("404");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const feedList = await getFeedsForChannel(application, channelDocument._id);
|
|
191
|
+
|
|
192
|
+
response.render("feeds", {
|
|
193
|
+
title: request.__("microsub.feeds.title"),
|
|
194
|
+
channel: channelDocument,
|
|
195
|
+
feeds: feedList,
|
|
196
|
+
baseUrl: request.baseUrl,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Add feed to channel
|
|
202
|
+
* @param {object} request - Express request
|
|
203
|
+
* @param {object} response - Express response
|
|
204
|
+
* @returns {Promise<void>}
|
|
205
|
+
*/
|
|
206
|
+
export async function addFeed(request, response) {
|
|
207
|
+
const { application } = request.app.locals;
|
|
208
|
+
const userId = request.session?.userId;
|
|
209
|
+
const { uid } = request.params;
|
|
210
|
+
const { url } = request.body;
|
|
211
|
+
|
|
212
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
213
|
+
if (!channelDocument) {
|
|
214
|
+
return response.status(404).render("404");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create feed subscription
|
|
218
|
+
const feed = await createFeed(application, {
|
|
219
|
+
channelId: channelDocument._id,
|
|
220
|
+
url,
|
|
221
|
+
title: undefined,
|
|
222
|
+
photo: undefined,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Trigger immediate fetch in background
|
|
226
|
+
refreshFeedNow(application, feed._id).catch((error) => {
|
|
227
|
+
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove feed from channel
|
|
235
|
+
* @param {object} request - Express request
|
|
236
|
+
* @param {object} response - Express response
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
export async function removeFeed(request, response) {
|
|
240
|
+
const { application } = request.app.locals;
|
|
241
|
+
const userId = request.session?.userId;
|
|
242
|
+
const { uid } = request.params;
|
|
243
|
+
const { url } = request.body;
|
|
244
|
+
|
|
245
|
+
const channelDocument = await getChannel(application, uid, userId);
|
|
246
|
+
if (!channelDocument) {
|
|
247
|
+
return response.status(404).render("404");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await deleteFeed(application, channelDocument._id, url);
|
|
251
|
+
|
|
252
|
+
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
253
|
+
}
|
|
254
|
+
|
|
164
255
|
/**
|
|
165
256
|
* View single item
|
|
166
257
|
* @param {object} request - Express request
|
|
167
258
|
* @param {object} response - Express response
|
|
259
|
+
* @returns {Promise<void>}
|
|
168
260
|
*/
|
|
169
261
|
export async function item(request, response) {
|
|
170
262
|
const { application } = request.app.locals;
|
|
@@ -187,6 +279,7 @@ export async function item(request, response) {
|
|
|
187
279
|
* Compose response form
|
|
188
280
|
* @param {object} request - Express request
|
|
189
281
|
* @param {object} response - Express response
|
|
282
|
+
* @returns {Promise<void>}
|
|
190
283
|
*/
|
|
191
284
|
export async function compose(request, response) {
|
|
192
285
|
const { replyTo, likeOf, repostOf } = request.query;
|
|
@@ -210,6 +303,89 @@ export async function submitCompose(request, response) {
|
|
|
210
303
|
response.redirect(`${request.baseUrl}/channels`);
|
|
211
304
|
}
|
|
212
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Search/discover feeds page
|
|
308
|
+
* @param {object} request - Express request
|
|
309
|
+
* @param {object} response - Express response
|
|
310
|
+
* @returns {Promise<void>}
|
|
311
|
+
*/
|
|
312
|
+
export async function searchPage(request, response) {
|
|
313
|
+
const { application } = request.app.locals;
|
|
314
|
+
const userId = request.session?.userId;
|
|
315
|
+
|
|
316
|
+
const channelList = await getChannels(application, userId);
|
|
317
|
+
|
|
318
|
+
response.render("search", {
|
|
319
|
+
title: request.__("microsub.search.title"),
|
|
320
|
+
channels: channelList,
|
|
321
|
+
baseUrl: request.baseUrl,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Search for feeds from URL
|
|
327
|
+
* @param {object} request - Express request
|
|
328
|
+
* @param {object} response - Express response
|
|
329
|
+
* @returns {Promise<void>}
|
|
330
|
+
*/
|
|
331
|
+
export async function searchFeeds(request, response) {
|
|
332
|
+
const { application } = request.app.locals;
|
|
333
|
+
const userId = request.session?.userId;
|
|
334
|
+
const { query } = request.body;
|
|
335
|
+
|
|
336
|
+
const channelList = await getChannels(application, userId);
|
|
337
|
+
|
|
338
|
+
let results = [];
|
|
339
|
+
if (query) {
|
|
340
|
+
try {
|
|
341
|
+
results = await discoverFeedsFromUrl(query);
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore discovery errors
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
response.render("search", {
|
|
348
|
+
title: request.__("microsub.search.title"),
|
|
349
|
+
channels: channelList,
|
|
350
|
+
query,
|
|
351
|
+
results,
|
|
352
|
+
searched: true,
|
|
353
|
+
baseUrl: request.baseUrl,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Subscribe to a feed from search results
|
|
359
|
+
* @param {object} request - Express request
|
|
360
|
+
* @param {object} response - Express response
|
|
361
|
+
* @returns {Promise<void>}
|
|
362
|
+
*/
|
|
363
|
+
export async function subscribe(request, response) {
|
|
364
|
+
const { application } = request.app.locals;
|
|
365
|
+
const userId = request.session?.userId;
|
|
366
|
+
const { url, channel: channelUid } = request.body;
|
|
367
|
+
|
|
368
|
+
const channelDocument = await getChannel(application, channelUid, userId);
|
|
369
|
+
if (!channelDocument) {
|
|
370
|
+
return response.status(404).render("404");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Create feed subscription
|
|
374
|
+
const feed = await createFeed(application, {
|
|
375
|
+
channelId: channelDocument._id,
|
|
376
|
+
url,
|
|
377
|
+
title: undefined,
|
|
378
|
+
photo: undefined,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Trigger immediate fetch in background
|
|
382
|
+
refreshFeedNow(application, feed._id).catch((error) => {
|
|
383
|
+
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
|
|
387
|
+
}
|
|
388
|
+
|
|
213
389
|
export const readerController = {
|
|
214
390
|
index,
|
|
215
391
|
channels,
|
|
@@ -218,7 +394,13 @@ export const readerController = {
|
|
|
218
394
|
channel,
|
|
219
395
|
settings,
|
|
220
396
|
updateSettings,
|
|
397
|
+
feeds,
|
|
398
|
+
addFeed,
|
|
399
|
+
removeFeed,
|
|
221
400
|
item,
|
|
222
401
|
compose,
|
|
223
402
|
submitCompose,
|
|
403
|
+
searchPage,
|
|
404
|
+
searchFeeds,
|
|
405
|
+
subscribe,
|
|
224
406
|
};
|
package/locales/en.json
CHANGED
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"title": "Feeds",
|
|
29
29
|
"follow": "Follow",
|
|
30
30
|
"unfollow": "Unfollow",
|
|
31
|
-
"empty": "No feeds followed in this channel"
|
|
31
|
+
"empty": "No feeds followed in this channel",
|
|
32
|
+
"url": "Feed URL",
|
|
33
|
+
"urlPlaceholder": "https://example.com/feed.xml"
|
|
32
34
|
},
|
|
33
35
|
"item": {
|
|
34
36
|
"reply": "Reply",
|
|
@@ -47,7 +49,7 @@
|
|
|
47
49
|
"repostOf": "Reposting"
|
|
48
50
|
},
|
|
49
51
|
"settings": {
|
|
50
|
-
"title": "
|
|
52
|
+
"title": "{{channel}} settings",
|
|
51
53
|
"excludeTypes": "Exclude interaction types",
|
|
52
54
|
"excludeTypesHelp": "Select types of posts to hide from this channel",
|
|
53
55
|
"excludeRegex": "Exclude pattern",
|
package/package.json
CHANGED
package/views/channel.njk
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
{{ icon("previous") }} {{ __("microsub.channels.title") }}
|
|
8
8
|
</a>
|
|
9
9
|
<div class="channel__actions">
|
|
10
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
|
|
11
|
+
{{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
|
|
12
|
+
</a>
|
|
10
13
|
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
|
|
11
14
|
{{ icon("updatePost") }} {{ __("microsub.channels.settings") }}
|
|
12
15
|
</a>
|
package/views/feeds.njk
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="feeds">
|
|
5
|
+
<header class="feeds__header">
|
|
6
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="back-link">
|
|
7
|
+
{{ icon("previous") }} {{ channel.name }}
|
|
8
|
+
</a>
|
|
9
|
+
</header>
|
|
10
|
+
|
|
11
|
+
<h2>{{ __("microsub.feeds.title") }}</h2>
|
|
12
|
+
|
|
13
|
+
{% if feeds.length > 0 %}
|
|
14
|
+
<ul class="feeds__list">
|
|
15
|
+
{% for feed in feeds %}
|
|
16
|
+
<li class="feeds__item">
|
|
17
|
+
<div class="feeds__info">
|
|
18
|
+
{% if feed.photo %}
|
|
19
|
+
<img src="{{ feed.photo }}" alt="" class="feeds__photo" width="32" height="32" loading="lazy">
|
|
20
|
+
{% endif %}
|
|
21
|
+
<div class="feeds__details">
|
|
22
|
+
<span class="feeds__name">{{ feed.title or feed.url }}</span>
|
|
23
|
+
<a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener">{{ feed.url }}</a>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" class="feeds__actions">
|
|
27
|
+
<input type="hidden" name="url" value="{{ feed.url }}">
|
|
28
|
+
{{ button({
|
|
29
|
+
text: __("microsub.feeds.unfollow"),
|
|
30
|
+
classes: "button--secondary button--small"
|
|
31
|
+
}) }}
|
|
32
|
+
</form>
|
|
33
|
+
</li>
|
|
34
|
+
{% endfor %}
|
|
35
|
+
</ul>
|
|
36
|
+
{% else %}
|
|
37
|
+
{{ prose({ text: __("microsub.feeds.empty") }) }}
|
|
38
|
+
{% endif %}
|
|
39
|
+
|
|
40
|
+
<div class="feeds__add">
|
|
41
|
+
<h3>{{ __("microsub.feeds.follow") }}</h3>
|
|
42
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="feeds__form">
|
|
43
|
+
{{ input({
|
|
44
|
+
id: "url",
|
|
45
|
+
name: "url",
|
|
46
|
+
label: __("microsub.feeds.url"),
|
|
47
|
+
type: "url",
|
|
48
|
+
required: true,
|
|
49
|
+
placeholder: __("microsub.feeds.urlPlaceholder"),
|
|
50
|
+
autocomplete: "off"
|
|
51
|
+
}) }}
|
|
52
|
+
<div class="button-group">
|
|
53
|
+
{{ button({ text: __("microsub.feeds.follow") }) }}
|
|
54
|
+
</div>
|
|
55
|
+
</form>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
{% endblock %}
|
package/views/reader.njk
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
{% endfor %}
|
|
22
22
|
</ul>
|
|
23
23
|
<p class="reader__actions">
|
|
24
|
+
<a href="{{ baseUrl }}/search" class="button button--primary">
|
|
25
|
+
{{ icon("syndicate") }} {{ __("microsub.feeds.follow") }}
|
|
26
|
+
</a>
|
|
24
27
|
<a href="{{ baseUrl }}/channels/new" class="button button--secondary">
|
|
25
28
|
{{ icon("createPost") }} {{ __("microsub.channels.new") }}
|
|
26
29
|
</a>
|
package/views/search.njk
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="search">
|
|
5
|
+
<a href="{{ baseUrl }}/channels" class="back-link">
|
|
6
|
+
{{ icon("previous") }} {{ __("microsub.channels.title") }}
|
|
7
|
+
</a>
|
|
8
|
+
|
|
9
|
+
<h2>{{ __("microsub.search.title") }}</h2>
|
|
10
|
+
|
|
11
|
+
<form method="post" action="{{ baseUrl }}/search" class="search__form">
|
|
12
|
+
{{ input({
|
|
13
|
+
id: "query",
|
|
14
|
+
name: "query",
|
|
15
|
+
label: __("microsub.search.placeholder"),
|
|
16
|
+
type: "url",
|
|
17
|
+
required: true,
|
|
18
|
+
placeholder: "https://example.com",
|
|
19
|
+
autocomplete: "off",
|
|
20
|
+
value: query,
|
|
21
|
+
attributes: { autofocus: true }
|
|
22
|
+
}) }}
|
|
23
|
+
<div class="button-group">
|
|
24
|
+
{{ button({ text: __("microsub.search.submit") }) }}
|
|
25
|
+
</div>
|
|
26
|
+
</form>
|
|
27
|
+
|
|
28
|
+
{% if results and results.length > 0 %}
|
|
29
|
+
<div class="search__results">
|
|
30
|
+
<h3>{{ __("microsub.search.title") }}</h3>
|
|
31
|
+
<ul class="search__list">
|
|
32
|
+
{% for result in results %}
|
|
33
|
+
<li class="search__item">
|
|
34
|
+
<div class="search__feed">
|
|
35
|
+
<span class="search__url">{{ result.url }}</span>
|
|
36
|
+
</div>
|
|
37
|
+
<form method="post" action="{{ baseUrl }}/subscribe" class="search__subscribe">
|
|
38
|
+
<input type="hidden" name="url" value="{{ result.url }}">
|
|
39
|
+
<label for="channel-{{ loop.index }}" class="visually-hidden">{{ __("microsub.channels.title") }}</label>
|
|
40
|
+
<select name="channel" id="channel-{{ loop.index }}" class="select select--small">
|
|
41
|
+
{% for channel in channels %}
|
|
42
|
+
<option value="{{ channel.uid }}">{{ channel.name }}</option>
|
|
43
|
+
{% endfor %}
|
|
44
|
+
</select>
|
|
45
|
+
{{ button({
|
|
46
|
+
text: __("microsub.feeds.follow"),
|
|
47
|
+
classes: "button--small"
|
|
48
|
+
}) }}
|
|
49
|
+
</form>
|
|
50
|
+
</li>
|
|
51
|
+
{% endfor %}
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
{% elif searched %}
|
|
55
|
+
{{ prose({ text: __("microsub.search.noResults") }) }}
|
|
56
|
+
{% endif %}
|
|
57
|
+
</div>
|
|
58
|
+
{% endblock %}
|