@rmdes/indiekit-endpoint-microsub 1.0.43 → 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
  }
@@ -67,13 +67,24 @@ export async function follow(request, response) {
67
67
  throw new IndiekitError("Channel not found", { status: 404 });
68
68
  }
69
69
 
70
- // Create feed subscription
71
- const feed = await createFeed(application, {
72
- channelId: channelDocument._id,
73
- url,
74
- title: undefined, // Will be populated on first fetch
75
- photo: undefined,
76
- });
70
+ // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
71
+ let feed;
72
+ try {
73
+ feed = await createFeed(application, {
74
+ channelId: channelDocument._id,
75
+ url,
76
+ title: undefined, // Will be populated on first fetch
77
+ photo: undefined,
78
+ });
79
+ } catch (error) {
80
+ if (error.code === "DUPLICATE_FEED") {
81
+ throw new IndiekitError(
82
+ `Feed already exists in channel "${error.channelName}"`,
83
+ { status: 409 },
84
+ );
85
+ }
86
+ throw error;
87
+ }
77
88
 
78
89
  // Trigger immediate fetch in background (don't await)
79
90
  // This will also discover and subscribe to WebSub hubs
@@ -319,20 +319,43 @@ export async function addFeed(request, response) {
319
319
  return response.status(404).render("404");
320
320
  }
321
321
 
322
- // Create feed subscription
323
- const feed = await createFeed(application, {
324
- channelId: channelDocument._id,
325
- url,
326
- title: undefined,
327
- photo: undefined,
328
- });
322
+ try {
323
+ // Create feed subscription (throws DUPLICATE_FEED if already exists)
324
+ const feed = await createFeed(application, {
325
+ channelId: channelDocument._id,
326
+ url,
327
+ title: undefined,
328
+ photo: undefined,
329
+ });
329
330
 
330
- // Trigger immediate fetch in background
331
- refreshFeedNow(application, feed._id).catch((error) => {
332
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
333
- });
331
+ // Trigger immediate fetch in background
332
+ refreshFeedNow(application, feed._id).catch((error) => {
333
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
334
+ });
334
335
 
335
- response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
336
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
337
+ } catch (error) {
338
+ if (error.code === "DUPLICATE_FEED") {
339
+ // Re-render feeds page with error message
340
+ const feedList = await getFeedsForChannel(application, channelDocument._id);
341
+ return response.render("feeds", {
342
+ title: request.__("microsub.feeds.title"),
343
+ channel: channelDocument,
344
+ feeds: feedList,
345
+ baseUrl: request.baseUrl,
346
+ readerBaseUrl: request.baseUrl,
347
+ activeView: "channels",
348
+ error: `This feed already exists in channel "${error.channelName}"`,
349
+ breadcrumbs: [
350
+ { text: "Reader", href: request.baseUrl },
351
+ { text: "Channels", href: `${request.baseUrl}/channels` },
352
+ { text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
353
+ { text: "Feeds" },
354
+ ],
355
+ });
356
+ }
357
+ throw error;
358
+ }
336
359
  }
337
360
 
338
361
  /**
@@ -782,20 +805,40 @@ export async function subscribe(request, response) {
782
805
  }
783
806
  }
784
807
 
785
- // Create feed subscription
786
- const feed = await createFeed(application, {
787
- channelId: channelDocument._id,
788
- url,
789
- title: undefined,
790
- photo: undefined,
791
- });
808
+ // Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
809
+ try {
810
+ const feed = await createFeed(application, {
811
+ channelId: channelDocument._id,
812
+ url,
813
+ title: undefined,
814
+ photo: undefined,
815
+ });
792
816
 
793
- // Trigger immediate fetch in background
794
- refreshFeedNow(application, feed._id).catch((error) => {
795
- console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
796
- });
817
+ // Trigger immediate fetch in background
818
+ refreshFeedNow(application, feed._id).catch((error) => {
819
+ console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
820
+ });
797
821
 
798
- response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
822
+ response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
823
+ } catch (error) {
824
+ if (error.code === "DUPLICATE_FEED") {
825
+ const channelList = await getChannels(application, userId);
826
+ return response.render("search", {
827
+ title: request.__("microsub.search.title"),
828
+ channels: channelList,
829
+ query: url,
830
+ validationError: `This feed already exists in channel "${error.channelName}"`,
831
+ baseUrl: request.baseUrl,
832
+ readerBaseUrl: request.baseUrl,
833
+ activeView: "channels",
834
+ breadcrumbs: [
835
+ { text: "Reader", href: request.baseUrl },
836
+ { text: "Search" },
837
+ ],
838
+ });
839
+ }
840
+ throw error;
841
+ }
799
842
  }
800
843
 
801
844
  /**
@@ -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(
@@ -171,12 +171,14 @@ export async function fetchAndParseFeed(url, options = {}) {
171
171
  // Fetch and parse the discovered feed
172
172
  const feedResult = await fetchFeed(fallbackFeed.url, options);
173
173
  if (!feedResult.notModified) {
174
+ const fallbackType = detectFeedType(feedResult.content, feedResult.contentType);
174
175
  const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
175
176
  contentType: feedResult.contentType,
176
177
  });
177
178
  return {
178
179
  ...feedResult,
179
180
  ...parsed,
181
+ feedType: fallbackType,
180
182
  hub: feedResult.hub || parsed._hub,
181
183
  discoveredFrom: url,
182
184
  };
@@ -194,6 +196,7 @@ export async function fetchAndParseFeed(url, options = {}) {
194
196
  return {
195
197
  ...result,
196
198
  ...parsed,
199
+ feedType: feedType,
197
200
  hub: result.hub || parsed._hub,
198
201
  };
199
202
  }
@@ -132,13 +132,16 @@ export async function processFeed(application, feed) {
132
132
  lastModified: parsed.lastModified,
133
133
  };
134
134
 
135
- // Update feed title/photo if discovered
135
+ // Update feed title/photo/feedType if discovered
136
136
  if (parsed.name && !feed.title) {
137
137
  updateData.title = parsed.name;
138
138
  }
139
139
  if (parsed.photo && !feed.photo) {
140
140
  updateData.photo = parsed.photo;
141
141
  }
142
+ if (parsed.feedType && !feed.feedType) {
143
+ updateData.feedType = parsed.feedType;
144
+ }
142
145
 
143
146
  await updateFeedAfterFetch(
144
147
  application,
@@ -16,6 +16,73 @@ function getCollection(application) {
16
16
  return application.collections.get("microsub_feeds");
17
17
  }
18
18
 
19
+ /**
20
+ * Normalize a feed URL for duplicate comparison.
21
+ * Strips trailing slashes, normalizes protocol to https, lowercases hostname.
22
+ * @param {string} url - Feed URL
23
+ * @returns {string} Normalized URL
24
+ */
25
+ export function normalizeUrl(url) {
26
+ try {
27
+ const parsed = new URL(url);
28
+ // Normalize protocol to https
29
+ parsed.protocol = "https:";
30
+ // Lowercase hostname
31
+ parsed.hostname = parsed.hostname.toLowerCase();
32
+ // Remove trailing slash from path (but keep "/" for root)
33
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
34
+ parsed.pathname = parsed.pathname.slice(0, -1);
35
+ }
36
+ return parsed.href;
37
+ } catch {
38
+ return url;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Find an existing feed across ALL channels by normalized URL
44
+ * @param {object} application - Indiekit application
45
+ * @param {string} url - Feed URL to check
46
+ * @returns {Promise<object|null>} Existing feed with channel info, or null
47
+ */
48
+ export async function findFeedAcrossChannels(application, url) {
49
+ const collection = getCollection(application);
50
+ const normalized = normalizeUrl(url);
51
+
52
+ // Get all feeds and check normalized URLs
53
+ // We check a few common URL variants directly for efficiency
54
+ const variants = new Set();
55
+ variants.add(url);
56
+ variants.add(normalized);
57
+ // Also try with/without trailing slash
58
+ if (url.endsWith("/")) {
59
+ variants.add(url.slice(0, -1));
60
+ } else {
61
+ variants.add(url + "/");
62
+ }
63
+ // Try http/https variants
64
+ if (url.startsWith("https://")) {
65
+ variants.add(url.replace("https://", "http://"));
66
+ } else if (url.startsWith("http://")) {
67
+ variants.add(url.replace("http://", "https://"));
68
+ }
69
+
70
+ const existing = await collection.findOne({
71
+ url: { $in: [...variants] },
72
+ });
73
+
74
+ if (!existing) return null;
75
+
76
+ // Look up the channel name for a useful error message
77
+ const channelsCollection = application.collections.get("microsub_channels");
78
+ const channel = await channelsCollection.findOne({ _id: existing.channelId });
79
+
80
+ return {
81
+ feed: existing,
82
+ channelName: channel?.name || "unknown channel",
83
+ };
84
+ }
85
+
19
86
  /**
20
87
  * Create a new feed subscription
21
88
  * @param {object} application - Indiekit application
@@ -32,12 +99,24 @@ export async function createFeed(
32
99
  ) {
33
100
  const collection = getCollection(application);
34
101
 
35
- // Check if feed already exists in channel
102
+ // Check if feed already exists in this channel (exact match)
36
103
  const existing = await collection.findOne({ channelId, url });
37
104
  if (existing) {
38
105
  return existing;
39
106
  }
40
107
 
108
+ // Check for duplicate across ALL channels (normalized URL)
109
+ const duplicate = await findFeedAcrossChannels(application, url);
110
+ if (duplicate) {
111
+ const error = new Error(
112
+ `Feed already exists in channel "${duplicate.channelName}"`,
113
+ );
114
+ error.code = "DUPLICATE_FEED";
115
+ error.existingFeed = duplicate.feed;
116
+ error.channelName = duplicate.channelName;
117
+ throw error;
118
+ }
119
+
41
120
  const feed = {
42
121
  channelId,
43
122
  url,
@@ -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.43",
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');
package/views/feeds.njk CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  <h2>{{ __("microsub.feeds.title") }}</h2>
12
12
 
13
+ {% if error %}
14
+ <div class="notice notice--error" role="alert">
15
+ {{ error }}
16
+ </div>
17
+ {% endif %}
18
+
13
19
  {% if feeds.length > 0 %}
14
20
  <div class="feeds__list">
15
21
  {% for feed in feeds %}
@@ -27,6 +33,9 @@
27
33
  <div class="feeds__details">
28
34
  <span class="feeds__name">
29
35
  {{ feed.title or feed.url }}
36
+ {% if feed.feedType %}
37
+ <span class="badge badge--offset badge--small" title="Feed format">{{ feed.feedType | upper }}</span>
38
+ {% endif %}
30
39
  {% if feed.status == 'error' %}
31
40
  <span class="badge badge--red">Error</span>
32
41
  {% elif feed.status == 'active' %}
@@ -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');