@rmdes/indiekit-endpoint-microsub 1.0.9 → 1.0.11

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
@@ -679,6 +679,43 @@
679
679
  text-align: right;
680
680
  }
681
681
 
682
+ .compose__syndication {
683
+ background: var(--color-offset);
684
+ border: none;
685
+ border-radius: var(--border-radius);
686
+ margin: var(--space-m) 0;
687
+ padding: var(--space-m);
688
+ }
689
+
690
+ .compose__syndication legend {
691
+ font-weight: 600;
692
+ margin-bottom: var(--space-xs);
693
+ }
694
+
695
+ .compose__syndication .hint {
696
+ color: var(--color-text-muted);
697
+ font-size: var(--font-size-small);
698
+ margin-bottom: var(--space-s);
699
+ }
700
+
701
+ .syndication-target {
702
+ align-items: center;
703
+ cursor: pointer;
704
+ display: flex;
705
+ gap: var(--space-s);
706
+ padding: var(--space-xs) 0;
707
+ }
708
+
709
+ .syndication-target input[type="checkbox"] {
710
+ flex-shrink: 0;
711
+ height: 1.25rem;
712
+ width: 1.25rem;
713
+ }
714
+
715
+ .syndication-target__name {
716
+ flex: 1;
717
+ }
718
+
682
719
  /* ==========================================================================
683
720
  Settings
684
721
  ========================================================================== */
@@ -21,6 +21,7 @@ import {
21
21
  getTimelineItems,
22
22
  getItemById,
23
23
  markItemsRead,
24
+ countReadItems,
24
25
  } from "../storage/items.js";
25
26
  import { getUserId } from "../utils/auth.js";
26
27
  import {
@@ -95,24 +96,37 @@ export async function channel(request, response) {
95
96
  const { application } = request.app.locals;
96
97
  const userId = getUserId(request);
97
98
  const { uid } = request.params;
98
- const { before, after } = request.query;
99
+ const { before, after, showRead } = request.query;
99
100
 
100
101
  const channelDocument = await getChannel(application, uid, userId);
101
102
  if (!channelDocument) {
102
103
  return response.status(404).render("404");
103
104
  }
104
105
 
106
+ // Check if showing read items
107
+ const showReadItems = showRead === "true";
108
+
105
109
  const timeline = await getTimelineItems(application, channelDocument._id, {
106
110
  before,
107
111
  after,
108
112
  userId,
113
+ showRead: showReadItems,
109
114
  });
110
115
 
116
+ // Count read items to show "View read items" button
117
+ const readCount = await countReadItems(
118
+ application,
119
+ channelDocument._id,
120
+ userId,
121
+ );
122
+
111
123
  response.render("channel", {
112
124
  title: channelDocument.name,
113
125
  channel: channelDocument,
114
126
  items: timeline.items,
115
127
  paging: timeline.paging,
128
+ readCount,
129
+ showRead: showReadItems,
116
130
  baseUrl: request.baseUrl,
117
131
  });
118
132
  }
@@ -346,6 +360,38 @@ function ensureString(value) {
346
360
  return String(value);
347
361
  }
348
362
 
363
+ /**
364
+ * Fetch syndication targets from Micropub config
365
+ * @param {object} application - Indiekit application
366
+ * @param {string} token - Auth token
367
+ * @returns {Promise<Array>} Syndication targets
368
+ */
369
+ async function getSyndicationTargets(application, token) {
370
+ try {
371
+ const micropubEndpoint = application.micropubEndpoint;
372
+ if (!micropubEndpoint) return [];
373
+
374
+ const micropubUrl = micropubEndpoint.startsWith("http")
375
+ ? micropubEndpoint
376
+ : new URL(micropubEndpoint, application.url).href;
377
+
378
+ const configUrl = `${micropubUrl}?q=config`;
379
+ const configResponse = await fetch(configUrl, {
380
+ headers: {
381
+ Authorization: `Bearer ${token}`,
382
+ Accept: "application/json",
383
+ },
384
+ });
385
+
386
+ if (!configResponse.ok) return [];
387
+
388
+ const config = await configResponse.json();
389
+ return config["syndicate-to"] || [];
390
+ } catch {
391
+ return [];
392
+ }
393
+ }
394
+
349
395
  /**
350
396
  * Compose response form
351
397
  * @param {object} request - Express request
@@ -353,6 +399,8 @@ function ensureString(value) {
353
399
  * @returns {Promise<void>}
354
400
  */
355
401
  export async function compose(request, response) {
402
+ const { application } = request.app.locals;
403
+
356
404
  // Support both long-form (replyTo) and short-form (reply) query params
357
405
  const {
358
406
  replyTo,
@@ -365,12 +413,19 @@ export async function compose(request, response) {
365
413
  bookmark,
366
414
  } = request.query;
367
415
 
416
+ // Fetch syndication targets if user is authenticated
417
+ const token = request.session?.access_token;
418
+ const syndicationTargets = token
419
+ ? await getSyndicationTargets(application, token)
420
+ : [];
421
+
368
422
  response.render("compose", {
369
423
  title: request.__("microsub.compose.title"),
370
424
  replyTo: ensureString(replyTo || reply),
371
425
  likeOf: ensureString(likeOf || like),
372
426
  repostOf: ensureString(repostOf || repost),
373
427
  bookmarkOf: ensureString(bookmarkOf || bookmark),
428
+ syndicationTargets,
374
429
  baseUrl: request.baseUrl,
375
430
  });
376
431
  }
@@ -388,6 +443,7 @@ export async function submitCompose(request, response) {
388
443
  const likeOf = request.body["like-of"];
389
444
  const repostOf = request.body["repost-of"];
390
445
  const bookmarkOf = request.body["bookmark-of"];
446
+ const syndicateTo = request.body["mp-syndicate-to"];
391
447
 
392
448
  // Debug logging
393
449
  console.info(
@@ -400,6 +456,7 @@ export async function submitCompose(request, response) {
400
456
  likeOf,
401
457
  repostOf,
402
458
  bookmarkOf,
459
+ syndicateTo,
403
460
  });
404
461
 
405
462
  // Get Micropub endpoint
@@ -427,16 +484,22 @@ export async function submitCompose(request, response) {
427
484
  micropubData.append("h", "entry");
428
485
 
429
486
  if (likeOf) {
430
- // Like post (no content needed)
487
+ // Like post - content is optional comment
431
488
  micropubData.append("like-of", likeOf);
489
+ if (content && content.trim()) {
490
+ micropubData.append("content", content.trim());
491
+ }
432
492
  } else if (repostOf) {
433
- // Repost (no content needed)
493
+ // Repost - content is optional comment
434
494
  micropubData.append("repost-of", repostOf);
495
+ if (content && content.trim()) {
496
+ micropubData.append("content", content.trim());
497
+ }
435
498
  } else if (bookmarkOf) {
436
- // Bookmark (content optional)
499
+ // Bookmark - content is optional comment
437
500
  micropubData.append("bookmark-of", bookmarkOf);
438
- if (content) {
439
- micropubData.append("content", content);
501
+ if (content && content.trim()) {
502
+ micropubData.append("content", content.trim());
440
503
  }
441
504
  } else if (inReplyTo) {
442
505
  // Reply
@@ -447,6 +510,14 @@ export async function submitCompose(request, response) {
447
510
  micropubData.append("content", content || "");
448
511
  }
449
512
 
513
+ // Add syndication targets
514
+ if (syndicateTo) {
515
+ const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];
516
+ for (const target of targets) {
517
+ micropubData.append("mp-syndicate-to", target);
518
+ }
519
+ }
520
+
450
521
  // Debug: log what we're sending
451
522
  console.info("[Microsub] Sending to Micropub:", {
452
523
  url: micropubUrl,
@@ -78,6 +78,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
78
78
  * @param {string} [options.after] - After cursor
79
79
  * @param {number} [options.limit] - Items per page
80
80
  * @param {string} [options.userId] - User ID for read state
81
+ * @param {boolean} [options.showRead] - Whether to show read items (default: false)
81
82
  * @returns {Promise<object>} Timeline with items and paging
82
83
  */
83
84
  export async function getTimelineItems(application, channelId, options = {}) {
@@ -86,7 +87,12 @@ export async function getTimelineItems(application, channelId, options = {}) {
86
87
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
87
88
  const limit = parseLimit(options.limit);
88
89
 
90
+ // Base query - filter out read items unless showRead is true
89
91
  const baseQuery = { channelId: objectId };
92
+ if (options.userId && !options.showRead) {
93
+ baseQuery.readBy = { $ne: options.userId };
94
+ }
95
+
90
96
  const query = buildPaginationQuery({
91
97
  before: options.before,
92
98
  after: options.after,
@@ -256,6 +262,24 @@ export async function getItemsByUids(application, uids, userId) {
256
262
  return items.map((item) => transformToJf2(item, userId));
257
263
  }
258
264
 
265
+ /**
266
+ * Count read items in a channel for a user
267
+ * @param {object} application - Indiekit application
268
+ * @param {ObjectId|string} channelId - Channel ObjectId
269
+ * @param {string} userId - User ID
270
+ * @returns {Promise<number>} Count of read items
271
+ */
272
+ export async function countReadItems(application, channelId, userId) {
273
+ const collection = getCollection(application);
274
+ const objectId =
275
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
276
+
277
+ return collection.countDocuments({
278
+ channelId: objectId,
279
+ readBy: userId,
280
+ });
281
+ }
282
+
259
283
  /**
260
284
  * Mark items as read
261
285
  * @param {object} application - Indiekit application
package/locales/en.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "title": "Reader",
5
5
  "empty": "No items to display",
6
6
  "markAllRead": "Mark all as read",
7
+ "showRead": "Show read ({{count}})",
8
+ "hideRead": "Hide read items",
9
+ "allRead": "All caught up!",
7
10
  "newer": "Newer",
8
11
  "older": "Older"
9
12
  },
@@ -43,12 +46,16 @@
43
46
  "compose": {
44
47
  "title": "Compose",
45
48
  "content": "What's on your mind?",
49
+ "comment": "Add a comment (optional)",
50
+ "commentHint": "Your comment will be included with the syndicated post",
46
51
  "submit": "Post",
47
52
  "cancel": "Cancel",
48
53
  "replyTo": "Replying to",
49
54
  "likeOf": "Liking",
50
55
  "repostOf": "Reposting",
51
- "bookmarkOf": "Bookmarking"
56
+ "bookmarkOf": "Bookmarking",
57
+ "syndicateTo": "Syndicate to",
58
+ "syndicateHint": "Also share this to your connected accounts"
52
59
  },
53
60
  "settings": {
54
61
  "title": "{{channel}} settings",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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
@@ -7,6 +7,7 @@
7
7
  {{ icon("previous") }} {{ __("microsub.channels.title") }}
8
8
  </a>
9
9
  <div class="channel__actions">
10
+ {% if not showRead and items.length > 0 %}
10
11
  <form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
11
12
  <input type="hidden" name="channel" value="{{ channel.uid }}">
12
13
  <input type="hidden" name="entry" value="last-read-entry">
@@ -14,6 +15,16 @@
14
15
  {{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }}
15
16
  </button>
16
17
  </form>
18
+ {% endif %}
19
+ {% if showRead %}
20
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary button--small">
21
+ {{ icon("hide") }} {{ __("microsub.reader.hideRead") }}
22
+ </a>
23
+ {% elif readCount > 0 %}
24
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}?showRead=true" class="button button--secondary button--small">
25
+ {{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }}
26
+ </a>
27
+ {% endif %}
17
28
  <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
18
29
  {{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
19
30
  </a>
@@ -48,11 +59,19 @@
48
59
  {% endif %}
49
60
  {% else %}
50
61
  <div class="reader__empty">
62
+ {% if readCount > 0 and not showRead %}
63
+ {{ icon("checkboxChecked") }}
64
+ <p>{{ __("microsub.reader.allRead") }}</p>
65
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}?showRead=true" class="button button--secondary">
66
+ {{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }}
67
+ </a>
68
+ {% else %}
51
69
  {{ icon("syndicate") }}
52
70
  <p>{{ __("microsub.timeline.empty") }}</p>
53
71
  <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--primary">
54
72
  {{ __("microsub.feeds.subscribe") }}
55
73
  </a>
74
+ {% endif %}
56
75
  </div>
57
76
  {% endif %}
58
77
  </div>
package/views/compose.njk CHANGED
@@ -71,6 +71,32 @@
71
71
  <div class="compose__counter">
72
72
  <span id="char-count">0</span> characters
73
73
  </div>
74
+ {% else %}
75
+ {# Comment field for likes/reposts/bookmarks #}
76
+ {{ textarea({
77
+ label: __("microsub.compose.comment"),
78
+ id: "content",
79
+ name: "content",
80
+ rows: 3,
81
+ hint: __("microsub.compose.commentHint")
82
+ }) }}
83
+ <div class="compose__counter">
84
+ <span id="char-count">0</span> characters
85
+ </div>
86
+ {% endif %}
87
+
88
+ {# Syndication targets #}
89
+ {% if syndicationTargets and syndicationTargets.length %}
90
+ <fieldset class="compose__syndication">
91
+ <legend>{{ __("microsub.compose.syndicateTo") }}</legend>
92
+ <p class="hint">{{ __("microsub.compose.syndicateHint") }}</p>
93
+ {% for target in syndicationTargets %}
94
+ <label class="syndication-target">
95
+ <input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}"{% if target.checked %} checked{% endif %}>
96
+ <span class="syndication-target__name">{{ target.name }}</span>
97
+ </label>
98
+ {% endfor %}
99
+ </fieldset>
74
100
  {% endif %}
75
101
 
76
102
  <div class="button-group">
@@ -84,7 +110,6 @@
84
110
  </form>
85
111
  </div>
86
112
 
87
- {% if not isAction %}
88
113
  <script type="module">
89
114
  const textarea = document.getElementById('content');
90
115
  const counter = document.getElementById('char-count');
@@ -94,5 +119,4 @@
94
119
  });
95
120
  }
96
121
  </script>
97
- {% endif %}
98
122
  {% endblock %}