@rmdes/indiekit-endpoint-microsub 1.0.50 → 1.0.51

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,183 @@ 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 each item card to HTML using the existing partial
906
+ const renderLocals = {
907
+ channel: channelDocument,
908
+ baseUrl: request.baseUrl,
909
+ __: request.__,
910
+ icon: response.locals.icon,
911
+ application,
912
+ };
913
+
914
+ const htmlParts = await Promise.all(
915
+ timeline.items.map(
916
+ (item) =>
917
+ new Promise((resolve, reject) => {
918
+ request.app.render(
919
+ "partials/item-card.njk",
920
+ { ...renderLocals, item },
921
+ (error, html) => {
922
+ if (error) reject(error);
923
+ else resolve(html);
924
+ },
925
+ );
926
+ }),
927
+ ),
928
+ );
929
+
930
+ response.json({
931
+ html: htmlParts.join(""),
932
+ paging: timeline.paging,
933
+ count: timeline.items.length,
934
+ });
935
+ }
936
+
937
+ /**
938
+ * Return rendered HTML fragments for timeline infinite scroll
939
+ * @param {object} request - Express request
940
+ * @param {object} response - Express response
941
+ * @returns {Promise<void>}
942
+ */
943
+ export async function timelineHtml(request, response) {
944
+ const { application } = request.app.locals;
945
+ const userId = getUserId(request);
946
+ const { before, after } = request.query;
947
+
948
+ const channelList = await getChannelsWithColors(application, userId);
949
+ const channelMap = new Map();
950
+ for (const ch of channelList) {
951
+ channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
952
+ }
953
+
954
+ const excludeParam = request.query.exclude;
955
+ const excludeIds = excludeParam
956
+ ? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
957
+ : [];
958
+
959
+ const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
960
+ const excludeChannelIds = [...excludeIds];
961
+ if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
962
+ excludeChannelIds.push(notificationsChannel._id.toString());
963
+ }
964
+
965
+ const result = await getAllTimelineItems(application, {
966
+ before,
967
+ after,
968
+ userId,
969
+ excludeChannelIds,
970
+ });
971
+
972
+ const proxyBaseUrl = application.url;
973
+ if (proxyBaseUrl && result.items) {
974
+ result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
975
+ }
976
+
977
+ for (const item of result.items) {
978
+ if (item._channelId) {
979
+ const info = channelMap.get(item._channelId);
980
+ if (info) {
981
+ item._channelName = info.name;
982
+ item._channelColor = info.color;
983
+ item._channelUid = info.uid;
984
+ }
985
+ }
986
+ }
987
+
988
+ const renderLocals = {
989
+ baseUrl: request.baseUrl,
990
+ __: request.__,
991
+ icon: response.locals.icon,
992
+ application,
993
+ };
994
+
995
+ const htmlParts = await Promise.all(
996
+ result.items.map(
997
+ (item) =>
998
+ new Promise((resolve, reject) => {
999
+ request.app.render(
1000
+ "partials/item-card.njk",
1001
+ { ...renderLocals, item },
1002
+ (error, cardHtml) => {
1003
+ if (error) {
1004
+ reject(error);
1005
+ } else {
1006
+ const badge = item._channelName
1007
+ ? `<span class="ms-timeline-view__channel-badge" style="background: ${item._channelColor || "#888"}">${item._channelName}</span>`
1008
+ : "";
1009
+ resolve(`<div class="ms-timeline-view__item">${badge}${cardHtml}</div>`);
1010
+ }
1011
+ },
1012
+ );
1013
+ }),
1014
+ ),
1015
+ );
1016
+
1017
+ response.json({
1018
+ html: htmlParts.join(""),
1019
+ paging: result.paging,
1020
+ count: result.items.length,
1021
+ });
1022
+ }
1023
+
1024
+ /**
1025
+ * Mark specific items as read (no-JS form fallback for mark-view-as-read)
1026
+ * @param {object} request - Express request
1027
+ * @param {object} response - Express response
1028
+ */
1029
+ export async function markViewRead(request, response) {
1030
+ const { application } = request.app.locals;
1031
+ const userId = getUserId(request);
1032
+ const { channel: channelUid } = request.body;
1033
+ let { entry } = request.body;
1034
+
1035
+ const channelDocument = await getChannel(application, channelUid, userId);
1036
+ if (!channelDocument) {
1037
+ return response.status(404).render("404");
1038
+ }
1039
+
1040
+ const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
1041
+ if (entryIds.length > 0) {
1042
+ await markItemsRead(application, channelDocument._id, entryIds, userId);
1043
+ }
1044
+
1045
+ response.redirect(`${request.baseUrl}/channels/${channelUid}`);
1046
+ }
1047
+
871
1048
  /**
872
1049
  * View single feed details with status - redirects to edit form
873
1050
  * @param {object} request - Express request
@@ -1409,9 +1586,11 @@ export const readerController = {
1409
1586
  newChannel,
1410
1587
  createChannel: createChannelAction,
1411
1588
  channel,
1589
+ channelHtml,
1412
1590
  settings,
1413
1591
  updateSettings,
1414
1592
  markAllRead,
1593
+ markViewRead,
1415
1594
  deleteChannel: deleteChannelAction,
1416
1595
  feeds,
1417
1596
  addFeed,
@@ -1431,6 +1610,7 @@ export const readerController = {
1431
1610
  followActorAction,
1432
1611
  unfollowActorAction,
1433
1612
  timeline,
1613
+ timelineHtml,
1434
1614
  deck,
1435
1615
  deckSettings,
1436
1616
  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.51",
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 %}
@@ -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 %}