@rmdes/indiekit-endpoint-microsub 1.0.50 → 1.0.53

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/styles.css CHANGED
@@ -1477,3 +1477,37 @@
1477
1477
  display: flex;
1478
1478
  gap: var(--space-xs);
1479
1479
  }
1480
+
1481
+ /* Infinite scroll */
1482
+ .ms-timeline__loader {
1483
+ display: flex;
1484
+ flex-direction: column;
1485
+ align-items: center;
1486
+ padding: 1.5rem;
1487
+ }
1488
+
1489
+ .ms-timeline__spinner {
1490
+ display: flex;
1491
+ justify-content: center;
1492
+ padding: 1rem;
1493
+ }
1494
+
1495
+ .ms-timeline__spinner .spinner {
1496
+ width: 24px;
1497
+ height: 24px;
1498
+ border: 3px solid rgba(255, 255, 255, 0.2);
1499
+ border-top-color: #3b82f6;
1500
+ border-radius: 50%;
1501
+ animation: ms-spin 0.8s linear infinite;
1502
+ }
1503
+
1504
+ @keyframes ms-spin {
1505
+ to { transform: rotate(360deg); }
1506
+ }
1507
+
1508
+ .ms-timeline__end {
1509
+ text-align: center;
1510
+ color: #888;
1511
+ font-style: italic;
1512
+ padding: 1rem;
1513
+ }
package/index.js CHANGED
@@ -92,6 +92,7 @@ export default class MicrosubEndpoint {
92
92
  readerRouter.get("/channels", readerController.channels);
93
93
  readerRouter.get("/channels/new", readerController.newChannel);
94
94
  readerRouter.post("/channels/new", readerController.createChannel);
95
+ readerRouter.get("/channels/:uid/html", readerController.channelHtml);
95
96
  readerRouter.get("/channels/:uid", readerController.channel);
96
97
  readerRouter.get("/channels/:uid/settings", readerController.settings);
97
98
  readerRouter.post(
@@ -135,7 +136,9 @@ export default class MicrosubEndpoint {
135
136
  readerRouter.post("/actor/follow", readerController.followActorAction);
136
137
  readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
137
138
  readerRouter.post("/api/mark-read", readerController.markAllRead);
139
+ readerRouter.post("/api/mark-view-read", readerController.markViewRead);
138
140
  readerRouter.get("/opml", opmlController.exportOpml);
141
+ readerRouter.get("/timeline/html", readerController.timelineHtml);
139
142
  readerRouter.get("/timeline", readerController.timeline);
140
143
  readerRouter.get("/deck", readerController.deck);
141
144
  readerRouter.get("/deck/settings", readerController.deckSettings);
@@ -868,6 +868,146 @@ export async function markAllRead(request, response) {
868
868
  response.redirect(`${request.baseUrl}/channels/${channelUid}`);
869
869
  }
870
870
 
871
+ /**
872
+ * Return rendered HTML fragments for infinite scroll
873
+ * @param {object} request - Express request
874
+ * @param {object} response - Express response
875
+ * @returns {Promise<void>}
876
+ */
877
+ export async function channelHtml(request, response) {
878
+ const { application } = request.app.locals;
879
+ const userId = getUserId(request);
880
+ const { uid } = request.params;
881
+ const { before, after, showRead } = request.query;
882
+
883
+ const channelDocument = await getChannel(application, uid, userId);
884
+ if (!channelDocument) {
885
+ return response.status(404).json({ error: "Channel not found" });
886
+ }
887
+
888
+ const showReadItems = showRead === "true";
889
+
890
+ const timeline = await getTimelineItems(application, channelDocument._id, {
891
+ before,
892
+ after,
893
+ userId,
894
+ showRead: showReadItems,
895
+ });
896
+
897
+ // Proxy images
898
+ const proxyBaseUrl = application.url;
899
+ if (proxyBaseUrl && timeline.items) {
900
+ timeline.items = timeline.items.map((item) =>
901
+ proxyItemImages(item, proxyBaseUrl),
902
+ );
903
+ }
904
+
905
+ // Render items via layout-less fragment template (standard response.render
906
+ // with callback returns HTML string without sending a response)
907
+ const fragmentHtml = await new Promise((resolve, reject) => {
908
+ response.render("partials/items-fragment", {
909
+ items: timeline.items,
910
+ channel: channelDocument,
911
+ baseUrl: request.baseUrl,
912
+ }, (error, html) => error ? reject(error) : resolve(html));
913
+ });
914
+
915
+ response.json({
916
+ html: fragmentHtml,
917
+ paging: timeline.paging,
918
+ count: timeline.items.length,
919
+ });
920
+ }
921
+
922
+ /**
923
+ * Return rendered HTML fragments for timeline infinite scroll
924
+ * @param {object} request - Express request
925
+ * @param {object} response - Express response
926
+ * @returns {Promise<void>}
927
+ */
928
+ export async function timelineHtml(request, response) {
929
+ const { application } = request.app.locals;
930
+ const userId = getUserId(request);
931
+ const { before, after } = request.query;
932
+
933
+ const channelList = await getChannelsWithColors(application, userId);
934
+ const channelMap = new Map();
935
+ for (const ch of channelList) {
936
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
937
+ }
938
+
939
+ const excludeParam = request.query.exclude;
940
+ const excludeIds = excludeParam
941
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
942
+ : [];
943
+
944
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
945
+ const excludeChannelIds = [...excludeIds];
946
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
947
+ excludeChannelIds.push(notificationsChannel._id.toString());
948
+ }
949
+
950
+ const result = await getAllTimelineItems(application, {
951
+ before,
952
+ after,
953
+ userId,
954
+ excludeChannelIds,
955
+ });
956
+
957
+ const proxyBaseUrl = application.url;
958
+ if (proxyBaseUrl && result.items) {
959
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
960
+ }
961
+
962
+ for (const item of result.items) {
963
+ if (item._channelId) {
964
+ const info = channelMap.get(item._channelId);
965
+ if (info) {
966
+ item._channelName = info.name;
967
+ item._channelColor = info.color;
968
+ item._channelUid = info.uid;
969
+ }
970
+ }
971
+ }
972
+
973
+ const fragmentHtml = await new Promise((resolve, reject) => {
974
+ response.render("partials/items-fragment-timeline", {
975
+ items: result.items,
976
+ baseUrl: request.baseUrl,
977
+ }, (error, html) => error ? reject(error) : resolve(html));
978
+ });
979
+
980
+ response.json({
981
+ html: fragmentHtml,
982
+ paging: result.paging,
983
+ count: result.items.length,
984
+ });
985
+ }
986
+
987
+ /**
988
+ * Mark specific items as read (no-JS form fallback for mark-view-as-read)
989
+ * @param {object} request - Express request
990
+ * @param {object} response - Express response
991
+ */
992
+ export async function markViewRead(request, response) {
993
+ const { application } = request.app.locals;
994
+ const userId = getUserId(request);
995
+ const { channel: channelUid } = request.body;
996
+ let { entry } = request.body;
997
+
998
+ const channelDocument = await getChannel(application, channelUid, userId);
999
+ if (!channelDocument) {
1000
+ return response.status(404).render("404");
1001
+ }
1002
+
1003
+ const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
1004
+ if (entryIds.length > 0) {
1005
+ await markItemsRead(application, channelDocument._id, entryIds, userId);
1006
+ }
1007
+
1008
+ response.redirect(`${request.baseUrl}/channels/${channelUid}`);
1009
+ }
1010
+
871
1011
  /**
872
1012
  * View single feed details with status - redirects to edit form
873
1013
  * @param {object} request - Express request
@@ -1409,9 +1549,11 @@ export const readerController = {
1409
1549
  newChannel,
1410
1550
  createChannel: createChannelAction,
1411
1551
  channel,
1552
+ channelHtml,
1412
1553
  settings,
1413
1554
  updateSettings,
1414
1555
  markAllRead,
1556
+ markViewRead,
1415
1557
  deleteChannel: deleteChannelAction,
1416
1558
  feeds,
1417
1559
  addFeed,
@@ -1431,6 +1573,7 @@ export const readerController = {
1431
1573
  followActorAction,
1432
1574
  unfollowActorAction,
1433
1575
  timeline,
1576
+ timelineHtml,
1434
1577
  deck,
1435
1578
  deckSettings,
1436
1579
  saveDeckSettings,
package/locales/en.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "title": "Reader",
5
5
  "empty": "No items to display",
6
6
  "markAllRead": "Mark all as read",
7
+ "markViewRead": "Mark view as read",
7
8
  "showRead": "Show read ({{count}})",
8
9
  "hideRead": "Hide read items",
9
10
  "allRead": "All caught up!",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.50",
3
+ "version": "1.0.53",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/channel.njk CHANGED
@@ -13,6 +13,12 @@
13
13
  {{ icon("tick") }} {{ __("microsub.reader.markAllRead") }}
14
14
  </button>
15
15
  </form>
16
+ <button type="button"
17
+ class="button button--secondary button--small js-mark-view-read"
18
+ data-channel="{{ channel.uid }}"
19
+ style="display:none">
20
+ {{ icon("tick") }} {{ __("microsub.reader.markViewRead") }}
21
+ </button>
16
22
  {% endif %}
17
23
  {% if showRead %}
18
24
  <a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary button--small">
@@ -33,27 +39,41 @@
33
39
  </header>
34
40
 
35
41
  {% if items.length > 0 %}
42
+ <form method="POST" action="{{ baseUrl }}/api/mark-view-read" id="mark-view-form">
43
+ <input type="hidden" name="channel" value="{{ channel.uid }}">
44
+ {% for item in items %}
45
+ {% if not item._is_read %}
46
+ <input type="hidden" name="entry" value="{{ item._id }}">
47
+ {% endif %}
48
+ {% endfor %}
49
+ <noscript>
50
+ <button type="submit" class="button button--secondary button--small">
51
+ {{ icon("tick") }} {{ __("microsub.reader.markViewRead") }}
52
+ </button>
53
+ </noscript>
54
+ </form>
36
55
  <div class="ms-timeline" id="timeline" data-channel="{{ channel.uid }}">
37
56
  {% for item in items %}
38
57
  {% include "partials/item-card.njk" %}
39
58
  {% endfor %}
40
59
  </div>
41
60
 
42
- {% if paging %}
43
- <nav class="ms-timeline__paging" aria-label="Pagination">
44
- {% if paging.before %}
45
- <a href="?before={{ paging.before }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary">
46
- {{ icon("previous") }} {{ __("microsub.reader.newer") }}
47
- </a>
48
- {% else %}
49
- <span></span>
50
- {% endif %}
51
- {% if paging.after %}
52
- <a href="?after={{ paging.after }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary">
53
- {{ __("microsub.reader.older") }} {{ icon("next") }}
61
+ {# Infinite scroll sentinel — JS upgrades the fallback link #}
62
+ {% if paging and paging.after %}
63
+ <div class="ms-timeline__loader" id="timeline-loader"
64
+ data-api-url="{{ baseUrl }}/channels/{{ channel.uid }}/html"
65
+ data-cursor="{{ paging.after }}"
66
+ data-show-read="{% if showRead %}true{% else %}false{% endif %}">
67
+ <div class="ms-timeline__spinner" style="display:none" aria-label="Loading more items">
68
+ <span class="spinner"></span>
69
+ </div>
70
+ <a href="?after={{ paging.after }}{% if showRead %}&showRead=true{% endif %}" class="button button--secondary ms-timeline__load-more">
71
+ {{ __("microsub.reader.older") }}
54
72
  </a>
55
- {% endif %}
56
- </nav>
73
+ <p class="ms-timeline__end" style="display:none">
74
+ {{ __("microsub.reader.allRead") }}
75
+ </p>
76
+ </div>
57
77
  {% endif %}
58
78
  {% else %}
59
79
  <div class="ms-reader__empty">
@@ -285,5 +305,136 @@
285
305
  }
286
306
  });
287
307
  }
308
+
309
+ // === Infinite scroll ===
310
+ const loader = document.getElementById('timeline-loader');
311
+ if (loader && timeline) {
312
+ const spinner = loader.querySelector('.ms-timeline__spinner');
313
+ const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
314
+ const endMessage = loader.querySelector('.ms-timeline__end');
315
+ let cursor = loader.dataset.cursor;
316
+ let loading = false;
317
+ let hasMore = true;
318
+ const apiUrl = loader.dataset.apiUrl;
319
+ const showReadParam = loader.dataset.showRead;
320
+
321
+ async function loadMore() {
322
+ if (loading || !hasMore) return;
323
+ loading = true;
324
+ if (spinner) spinner.style.display = '';
325
+ if (loadMoreLink) loadMoreLink.style.display = 'none';
326
+
327
+ try {
328
+ const params = new URLSearchParams({ after: cursor });
329
+ if (showReadParam === 'true') params.append('showRead', 'true');
330
+
331
+ const response = await fetch(`${apiUrl}?${params}`, {
332
+ credentials: 'same-origin'
333
+ });
334
+
335
+ if (!response.ok) throw new Error('Failed to load');
336
+
337
+ const data = await response.json();
338
+
339
+ if (data.html && data.count > 0) {
340
+ timeline.insertAdjacentHTML('beforeend', data.html);
341
+ }
342
+
343
+ if (data.paging?.after) {
344
+ cursor = data.paging.after;
345
+ if (loadMoreLink) {
346
+ loadMoreLink.href = `?after=${cursor}${showReadParam === 'true' ? '&showRead=true' : ''}`;
347
+ loadMoreLink.style.display = '';
348
+ }
349
+ } else {
350
+ hasMore = false;
351
+ if (loadMoreLink) loadMoreLink.style.display = 'none';
352
+ if (endMessage) endMessage.style.display = '';
353
+ }
354
+ } catch (error) {
355
+ console.error('Infinite scroll error:', error);
356
+ hasMore = false;
357
+ if (loadMoreLink) loadMoreLink.style.display = '';
358
+ } finally {
359
+ loading = false;
360
+ if (spinner) spinner.style.display = 'none';
361
+ }
362
+ }
363
+
364
+ // IntersectionObserver auto-loads when sentinel is visible
365
+ const observer = new IntersectionObserver((entries) => {
366
+ if (entries[0].isIntersecting && hasMore && !loading) {
367
+ loadMore();
368
+ }
369
+ }, { rootMargin: '200px' });
370
+ observer.observe(loader);
371
+
372
+ // Click handler for fallback link
373
+ if (loadMoreLink) {
374
+ loadMoreLink.addEventListener('click', (e) => {
375
+ e.preventDefault();
376
+ loadMore();
377
+ });
378
+ }
379
+
380
+ // Expose loadMore for mark-view-as-read to trigger
381
+ window.__microsubLoadMore = loadMore;
382
+ }
383
+
384
+ // === Mark view as read button ===
385
+ const markViewBtn = document.querySelector('.js-mark-view-read');
386
+ if (markViewBtn && timeline) {
387
+ markViewBtn.style.display = '';
388
+
389
+ markViewBtn.addEventListener('click', async () => {
390
+ const unreadCards = timeline.querySelectorAll('.ms-item-card:not(.ms-item-card--read)');
391
+ const itemIds = [...unreadCards].map((card) => card.dataset.itemId).filter(Boolean);
392
+
393
+ if (itemIds.length === 0) return;
394
+
395
+ markViewBtn.disabled = true;
396
+
397
+ const formData = new URLSearchParams();
398
+ formData.append('action', 'timeline');
399
+ formData.append('method', 'mark_read');
400
+ formData.append('channel', markViewBtn.dataset.channel);
401
+ for (const id of itemIds) {
402
+ formData.append('entry', id);
403
+ }
404
+
405
+ try {
406
+ const response = await fetch(microsubApiUrl, {
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
409
+ body: formData.toString(),
410
+ credentials: 'same-origin'
411
+ });
412
+
413
+ if (response.ok) {
414
+ for (const card of unreadCards) {
415
+ card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
416
+ card.style.opacity = '0';
417
+ card.style.transform = 'translateX(-20px)';
418
+ }
419
+ setTimeout(() => {
420
+ for (const card of [...unreadCards]) card.remove();
421
+ markViewBtn.disabled = false;
422
+
423
+ // Trigger infinite scroll to load next batch
424
+ if (typeof window.__microsubLoadMore === 'function') {
425
+ window.__microsubLoadMore();
426
+ } else if (timeline.querySelectorAll('.ms-item-card').length === 0) {
427
+ location.reload();
428
+ }
429
+ }, 300);
430
+ } else {
431
+ markViewBtn.disabled = false;
432
+ }
433
+ } catch (error) {
434
+ console.error('Error marking view as read:', error);
435
+ markViewBtn.disabled = false;
436
+ }
437
+ });
438
+ }
288
439
  </script>
289
440
  {% endblock %}
@@ -0,0 +1,11 @@
1
+ {# Layout-less fragment for AJAX infinite scroll in timeline view #}
2
+ {% for item in items %}
3
+ <div class="ms-timeline-view__item">
4
+ {% if item._channelName %}
5
+ <span class="ms-timeline-view__channel-badge" style="background: {{ item._channelColor or '#888' }}">
6
+ {{ item._channelName }}
7
+ </span>
8
+ {% endif %}
9
+ {% include "partials/item-card.njk" %}
10
+ </div>
11
+ {% endfor %}
@@ -0,0 +1,4 @@
1
+ {# Layout-less fragment for AJAX infinite scroll responses #}
2
+ {% for item in items %}
3
+ {% include "partials/item-card.njk" %}
4
+ {% endfor %}
@@ -42,21 +42,20 @@
42
42
  {% endfor %}
43
43
  </div>
44
44
 
45
- {% if paging %}
46
- <nav class="ms-timeline__paging" aria-label="Pagination">
47
- {% if paging.before %}
48
- <a href="?before={{ paging.before }}" class="button button--secondary">
49
- {{ __("microsub.reader.newer") }}
50
- </a>
51
- {% else %}
52
- <span></span>
53
- {% endif %}
54
- {% if paging.after %}
55
- <a href="?after={{ paging.after }}" class="button button--secondary">
45
+ {% if paging and paging.after %}
46
+ <div class="ms-timeline__loader" id="timeline-loader"
47
+ data-api-url="{{ baseUrl }}/timeline/html{% if excludeIds and excludeIds.length > 0 %}?{% for id in excludeIds %}exclude={{ id }}{% if not loop.last %}&{% endif %}{% endfor %}{% endif %}"
48
+ data-cursor="{{ paging.after }}">
49
+ <div class="ms-timeline__spinner" style="display:none" aria-label="Loading more items">
50
+ <span class="spinner"></span>
51
+ </div>
52
+ <a href="?after={{ paging.after }}" class="button button--secondary ms-timeline__load-more">
56
53
  {{ __("microsub.reader.older") }}
57
54
  </a>
58
- {% endif %}
59
- </nav>
55
+ <p class="ms-timeline__end" style="display:none">
56
+ {{ __("microsub.reader.allRead") }}
57
+ </p>
58
+ </div>
60
59
  {% endif %}
61
60
 
62
61
  {% else %}
@@ -266,6 +265,73 @@
266
265
  button.disabled = false;
267
266
  }
268
267
  });
268
+
269
+ // === Infinite scroll ===
270
+ const loader = document.getElementById('timeline-loader');
271
+ if (loader && timeline) {
272
+ const spinner = loader.querySelector('.ms-timeline__spinner');
273
+ const loadMoreLink = loader.querySelector('.ms-timeline__load-more');
274
+ const endMessage = loader.querySelector('.ms-timeline__end');
275
+ let cursor = loader.dataset.cursor;
276
+ let loading = false;
277
+ let hasMore = true;
278
+ const apiUrl = loader.dataset.apiUrl;
279
+
280
+ async function loadMore() {
281
+ if (loading || !hasMore) return;
282
+ loading = true;
283
+ if (spinner) spinner.style.display = '';
284
+ if (loadMoreLink) loadMoreLink.style.display = 'none';
285
+
286
+ try {
287
+ const sep = apiUrl.includes('?') ? '&' : '?';
288
+ const response = await fetch(`${apiUrl}${sep}after=${encodeURIComponent(cursor)}`, {
289
+ credentials: 'same-origin'
290
+ });
291
+
292
+ if (!response.ok) throw new Error('Failed to load');
293
+
294
+ const data = await response.json();
295
+
296
+ if (data.html && data.count > 0) {
297
+ timeline.insertAdjacentHTML('beforeend', data.html);
298
+ }
299
+
300
+ if (data.paging?.after) {
301
+ cursor = data.paging.after;
302
+ if (loadMoreLink) {
303
+ loadMoreLink.href = `?after=${cursor}`;
304
+ loadMoreLink.style.display = '';
305
+ }
306
+ } else {
307
+ hasMore = false;
308
+ if (loadMoreLink) loadMoreLink.style.display = 'none';
309
+ if (endMessage) endMessage.style.display = '';
310
+ }
311
+ } catch (error) {
312
+ console.error('Infinite scroll error:', error);
313
+ hasMore = false;
314
+ if (loadMoreLink) loadMoreLink.style.display = '';
315
+ } finally {
316
+ loading = false;
317
+ if (spinner) spinner.style.display = 'none';
318
+ }
319
+ }
320
+
321
+ const observer = new IntersectionObserver((entries) => {
322
+ if (entries[0].isIntersecting && hasMore && !loading) {
323
+ loadMore();
324
+ }
325
+ }, { rootMargin: '200px' });
326
+ observer.observe(loader);
327
+
328
+ if (loadMoreLink) {
329
+ loadMoreLink.addEventListener('click', (e) => {
330
+ e.preventDefault();
331
+ loadMore();
332
+ });
333
+ }
334
+ }
269
335
  }
270
336
  </script>
271
337
  {% endblock %}