@rmdes/indiekit-endpoint-microsub 1.0.44 → 1.0.45

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
@@ -416,7 +416,57 @@
416
416
  color: var(--color-background);
417
417
  }
418
418
 
419
- /* Mark as read button */
419
+ /* Mark as read — split button group */
420
+ .item-actions__mark-read-group {
421
+ display: inline-flex;
422
+ margin-left: auto;
423
+ position: relative;
424
+ }
425
+
426
+ .item-actions__mark-read-group .item-actions__mark-read {
427
+ border-bottom-right-radius: 0;
428
+ border-right: 0;
429
+ border-top-right-radius: 0;
430
+ margin-left: 0;
431
+ }
432
+
433
+ .item-actions__mark-read-caret {
434
+ border-bottom-left-radius: 0;
435
+ border-top-left-radius: 0;
436
+ font-size: 0.625rem;
437
+ padding: var(--space-xs) 6px;
438
+ }
439
+
440
+ .item-actions__mark-read-popover {
441
+ background: var(--color-background);
442
+ border: 1px solid var(--color-offset-active);
443
+ border-radius: var(--border-radius);
444
+ bottom: calc(100% + 4px);
445
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
446
+ padding: var(--space-xs);
447
+ position: absolute;
448
+ right: 0;
449
+ white-space: nowrap;
450
+ z-index: 10;
451
+ }
452
+
453
+ .item-actions__mark-source-read {
454
+ background: transparent;
455
+ border: 0;
456
+ border-radius: var(--border-radius);
457
+ color: var(--color-text);
458
+ cursor: pointer;
459
+ font-size: var(--font-size-small);
460
+ padding: var(--space-xs) var(--space-s);
461
+ text-align: left;
462
+ width: 100%;
463
+ }
464
+
465
+ .item-actions__mark-source-read:hover {
466
+ background: var(--color-offset);
467
+ }
468
+
469
+ /* Mark as read button (standalone, no split group) */
420
470
  .item-actions__mark-read {
421
471
  margin-left: auto;
422
472
  }
@@ -9,6 +9,7 @@ import { proxyItemImages } from "../media/proxy.js";
9
9
  import { getChannel, getChannelById } from "../storage/channels.js";
10
10
  import {
11
11
  getTimelineItems,
12
+ markFeedItemsRead,
12
13
  markItemsRead,
13
14
  markItemsUnread,
14
15
  removeItems,
@@ -103,6 +104,22 @@ export async function action(request, response) {
103
104
  return response.json({ result: "ok", updated: count });
104
105
  }
105
106
 
107
+ case "mark_read_source": {
108
+ const feedId = request.body.feed;
109
+ if (!feedId) {
110
+ throw new IndiekitError("feed parameter required", {
111
+ status: 400,
112
+ });
113
+ }
114
+ const count = await markFeedItemsRead(
115
+ application,
116
+ channelDocument._id,
117
+ feedId,
118
+ userId,
119
+ );
120
+ return response.json({ result: "ok", updated: count });
121
+ }
122
+
106
123
  case "mark_unread": {
107
124
  validateEntries(entries);
108
125
  const count = await markItemsUnread(
@@ -271,6 +271,7 @@ function transformToJf2(item, userId) {
271
271
  _id: item._id.toString(),
272
272
  _is_read: userId ? item.readBy?.includes(userId) : false,
273
273
  _channelId: item.channelId?.toString(),
274
+ _feedId: item.feedId?.toString(),
274
275
  };
275
276
 
276
277
  // Optional fields
@@ -695,6 +696,41 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
695
696
  return result.modifiedCount;
696
697
  }
697
698
 
699
+ /**
700
+ * Mark all items from a specific feed as read in a channel
701
+ * @param {object} application - Indiekit application
702
+ * @param {ObjectId|string} channelId - Channel ObjectId
703
+ * @param {ObjectId|string} feedId - Feed ObjectId
704
+ * @param {string} userId - User ID
705
+ * @returns {Promise<number>} Number of items updated
706
+ */
707
+ export async function markFeedItemsRead(
708
+ application,
709
+ channelId,
710
+ feedId,
711
+ userId,
712
+ ) {
713
+ const collection = getCollection(application);
714
+ const channelObjectId =
715
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
716
+ const feedObjectId =
717
+ typeof feedId === "string" ? new ObjectId(feedId) : feedId;
718
+
719
+ const result = await collection.updateMany(
720
+ { channelId: channelObjectId, feedId: feedObjectId },
721
+ { $addToSet: { readBy: userId } },
722
+ );
723
+
724
+ console.info(
725
+ `[Microsub] markFeedItemsRead: marked ${result.modifiedCount} items from feed ${feedId} as read`,
726
+ );
727
+
728
+ // Cleanup old read items
729
+ await cleanupOldReadItems(collection, channelObjectId, userId);
730
+
731
+ return result.modifiedCount;
732
+ }
733
+
698
734
  /**
699
735
  * Mark items as unread
700
736
  * @param {object} application - Indiekit application
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.44",
3
+ "version": "1.0.45",
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
@@ -174,6 +174,84 @@
174
174
  }
175
175
  });
176
176
 
177
+ // Handle caret toggle for mark-source-read popover
178
+ timeline.addEventListener('click', (e) => {
179
+ const caret = e.target.closest('.item-actions__mark-read-caret');
180
+ if (!caret) return;
181
+
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+
185
+ // Close other open popovers
186
+ for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
187
+ if (p !== caret.nextElementSibling) p.hidden = true;
188
+ }
189
+
190
+ const popover = caret.nextElementSibling;
191
+ if (popover) popover.hidden = !popover.hidden;
192
+ });
193
+
194
+ // Handle mark-source-read button
195
+ timeline.addEventListener('click', async (e) => {
196
+ const button = e.target.closest('.item-actions__mark-source-read');
197
+ if (!button) return;
198
+
199
+ e.preventDefault();
200
+ e.stopPropagation();
201
+
202
+ const feedId = button.dataset.feedId;
203
+ if (!feedId) return;
204
+
205
+ button.disabled = true;
206
+
207
+ try {
208
+ const formData = new URLSearchParams();
209
+ formData.append('action', 'timeline');
210
+ formData.append('method', 'mark_read_source');
211
+ formData.append('channel', channelUid);
212
+ formData.append('feed', feedId);
213
+
214
+ const response = await fetch(microsubApiUrl, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
217
+ body: formData.toString(),
218
+ credentials: 'same-origin'
219
+ });
220
+
221
+ if (response.ok) {
222
+ // Animate out all cards from this feed
223
+ const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
224
+ for (const card of cards) {
225
+ card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
226
+ card.style.opacity = '0';
227
+ card.style.transform = 'translateX(-20px)';
228
+ }
229
+ setTimeout(() => {
230
+ for (const card of [...cards]) {
231
+ card.remove();
232
+ }
233
+ if (timeline.querySelectorAll('.item-card').length === 0) {
234
+ location.reload();
235
+ }
236
+ }, 300);
237
+ } else {
238
+ button.disabled = false;
239
+ }
240
+ } catch (error) {
241
+ console.error('Error marking source as read:', error);
242
+ button.disabled = false;
243
+ }
244
+ });
245
+
246
+ // Close popovers on outside click
247
+ document.addEventListener('click', (e) => {
248
+ if (!e.target.closest('.item-actions__mark-read-group')) {
249
+ for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
250
+ p.hidden = true;
251
+ }
252
+ }
253
+ });
254
+
177
255
  // Handle save-for-later buttons
178
256
  timeline.addEventListener('click', async (e) => {
179
257
  const button = e.target.closest('.item-actions__save-later');
@@ -4,6 +4,7 @@
4
4
  #}
5
5
  <article class="item-card{% if item._is_read %} item-card--read{% endif %}"
6
6
  data-item-id="{{ item._id }}"
7
+ data-feed-id="{{ item._feedId or '' }}"
7
8
  data-is-read="{{ item._is_read | default(false) }}">
8
9
 
9
10
  {# Context bar for interactions (Aperture pattern) #}
@@ -198,6 +199,33 @@
198
199
  <span class="visually-hidden">Bookmark</span>
199
200
  </a>
200
201
  {% if not item._is_read %}
202
+ {% if item._feedId %}
203
+ <span class="item-actions__mark-read-group">
204
+ <button type="button"
205
+ class="item-actions__button item-actions__mark-read"
206
+ data-action="mark-read"
207
+ data-item-id="{{ item._id }}"
208
+ {% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
209
+ {% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}
210
+ title="Mark as read">
211
+ {{ icon("checkboxChecked") }}
212
+ <span class="visually-hidden">Mark read</span>
213
+ </button>
214
+ <button type="button"
215
+ class="item-actions__button item-actions__mark-read-caret"
216
+ aria-label="More mark-read options"
217
+ title="More options">&#9662;</button>
218
+ <div class="item-actions__mark-read-popover" hidden>
219
+ <button type="button"
220
+ class="item-actions__mark-source-read"
221
+ data-feed-id="{{ item._feedId }}"
222
+ {% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
223
+ {% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}>
224
+ Mark {{ item._source.name or item.author.name or "source" }} as read
225
+ </button>
226
+ </div>
227
+ </span>
228
+ {% else %}
201
229
  <button type="button"
202
230
  class="item-actions__button item-actions__mark-read"
203
231
  data-action="mark-read"
@@ -209,6 +237,7 @@
209
237
  <span class="visually-hidden">Mark read</span>
210
238
  </button>
211
239
  {% endif %}
240
+ {% endif %}
212
241
  {% if application.readlaterEndpoint %}
213
242
  <button type="button"
214
243
  class="item-actions__button item-actions__save-later"
@@ -152,6 +152,88 @@
152
152
  }
153
153
  });
154
154
 
155
+ // Handle caret toggle for mark-source-read popover
156
+ timeline.addEventListener('click', (e) => {
157
+ const caret = e.target.closest('.item-actions__mark-read-caret');
158
+ if (!caret) return;
159
+
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+
163
+ // Close other open popovers
164
+ for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
165
+ if (p !== caret.nextElementSibling) p.hidden = true;
166
+ }
167
+
168
+ const popover = caret.nextElementSibling;
169
+ if (popover) popover.hidden = !popover.hidden;
170
+ });
171
+
172
+ // Handle mark-source-read button
173
+ timeline.addEventListener('click', async (e) => {
174
+ const button = e.target.closest('.item-actions__mark-source-read');
175
+ if (!button) return;
176
+
177
+ e.preventDefault();
178
+ e.stopPropagation();
179
+
180
+ const feedId = button.dataset.feedId;
181
+ const channelUid = button.dataset.channelUid;
182
+ const channelId = button.dataset.channelId;
183
+ if (!feedId || (!channelUid && !channelId)) return;
184
+
185
+ button.disabled = true;
186
+
187
+ try {
188
+ const formData = new URLSearchParams();
189
+ formData.append('action', 'timeline');
190
+ formData.append('method', 'mark_read_source');
191
+ formData.append('channel', channelUid || channelId);
192
+ formData.append('feed', feedId);
193
+
194
+ const response = await fetch(microsubApiUrl, {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
197
+ body: formData.toString(),
198
+ credentials: 'same-origin'
199
+ });
200
+
201
+ if (response.ok) {
202
+ // Animate out all cards from this feed
203
+ const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
204
+ for (const card of cards) {
205
+ card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
206
+ card.style.opacity = '0';
207
+ card.style.transform = 'translateX(-20px)';
208
+ }
209
+ setTimeout(() => {
210
+ for (const card of [...cards]) {
211
+ const wrapper = card.closest('.timeline-view__item');
212
+ if (wrapper) wrapper.remove();
213
+ else card.remove();
214
+ }
215
+ if (timeline.querySelectorAll('.item-card').length === 0) {
216
+ location.reload();
217
+ }
218
+ }, 300);
219
+ } else {
220
+ button.disabled = false;
221
+ }
222
+ } catch (error) {
223
+ console.error('Error marking source as read:', error);
224
+ button.disabled = false;
225
+ }
226
+ });
227
+
228
+ // Close popovers on outside click
229
+ document.addEventListener('click', (e) => {
230
+ if (!e.target.closest('.item-actions__mark-read-group')) {
231
+ for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
232
+ p.hidden = true;
233
+ }
234
+ }
235
+ });
236
+
155
237
  // Handle save-for-later buttons
156
238
  timeline.addEventListener('click', async (e) => {
157
239
  const button = e.target.closest('.item-actions__save-later');