feat: mark source as read — split button with popover

Add ability to mark all items from a specific feed/source as read at once,
instead of clicking each item individually. The mark-read button becomes a
split button group with a caret that opens a popover offering "Mark [source]
as read". Items without a feedId (AP items) keep the simple button.

Confab-Link: http://localhost:8080/sessions/a477883d-4aef-4013-983c-ce3d3157cfba
This commit is contained in:
Ricardo
2026-03-11 16:08:53 +01:00
parent 5037ff3d8f
commit e48335da2c
7 changed files with 294 additions and 2 deletions

View File

@@ -416,7 +416,57 @@
color: var(--color-background);
}
/* Mark as read button */
/* Mark as read — split button group */
.item-actions__mark-read-group {
display: inline-flex;
margin-left: auto;
position: relative;
}
.item-actions__mark-read-group .item-actions__mark-read {
border-bottom-right-radius: 0;
border-right: 0;
border-top-right-radius: 0;
margin-left: 0;
}
.item-actions__mark-read-caret {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
font-size: 0.625rem;
padding: var(--space-xs) 6px;
}
.item-actions__mark-read-popover {
background: var(--color-background);
border: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
bottom: calc(100% + 4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: var(--space-xs);
position: absolute;
right: 0;
white-space: nowrap;
z-index: 10;
}
.item-actions__mark-source-read {
background: transparent;
border: 0;
border-radius: var(--border-radius);
color: var(--color-text);
cursor: pointer;
font-size: var(--font-size-small);
padding: var(--space-xs) var(--space-s);
text-align: left;
width: 100%;
}
.item-actions__mark-source-read:hover {
background: var(--color-offset);
}
/* Mark as read button (standalone, no split group) */
.item-actions__mark-read {
margin-left: auto;
}

View File

@@ -9,6 +9,7 @@ import { proxyItemImages } from "../media/proxy.js";
import { getChannel, getChannelById } from "../storage/channels.js";
import {
getTimelineItems,
markFeedItemsRead,
markItemsRead,
markItemsUnread,
removeItems,
@@ -103,6 +104,22 @@ export async function action(request, response) {
return response.json({ result: "ok", updated: count });
}
case "mark_read_source": {
const feedId = request.body.feed;
if (!feedId) {
throw new IndiekitError("feed parameter required", {
status: 400,
});
}
const count = await markFeedItemsRead(
application,
channelDocument._id,
feedId,
userId,
);
return response.json({ result: "ok", updated: count });
}
case "mark_unread": {
validateEntries(entries);
const count = await markItemsUnread(

View File

@@ -271,6 +271,7 @@ function transformToJf2(item, userId) {
_id: item._id.toString(),
_is_read: userId ? item.readBy?.includes(userId) : false,
_channelId: item.channelId?.toString(),
_feedId: item.feedId?.toString(),
};
// Optional fields
@@ -695,6 +696,41 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
return result.modifiedCount;
}
/**
* Mark all items from a specific feed as read in a channel
* @param {object} application - Indiekit application
* @param {ObjectId|string} channelId - Channel ObjectId
* @param {ObjectId|string} feedId - Feed ObjectId
* @param {string} userId - User ID
* @returns {Promise<number>} Number of items updated
*/
export async function markFeedItemsRead(
application,
channelId,
feedId,
userId,
) {
const collection = getCollection(application);
const channelObjectId =
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
const feedObjectId =
typeof feedId === "string" ? new ObjectId(feedId) : feedId;
const result = await collection.updateMany(
{ channelId: channelObjectId, feedId: feedObjectId },
{ $addToSet: { readBy: userId } },
);
console.info(
`[Microsub] markFeedItemsRead: marked ${result.modifiedCount} items from feed ${feedId} as read`,
);
// Cleanup old read items
await cleanupOldReadItems(collection, channelObjectId, userId);
return result.modifiedCount;
}
/**
* Mark items as unread
* @param {object} application - Indiekit application

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-microsub",
"version": "1.0.44",
"version": "1.0.45",
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
"keywords": [
"indiekit",

View File

@@ -174,6 +174,84 @@
}
});
// Handle caret toggle for mark-source-read popover
timeline.addEventListener('click', (e) => {
const caret = e.target.closest('.item-actions__mark-read-caret');
if (!caret) return;
e.preventDefault();
e.stopPropagation();
// Close other open popovers
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
if (p !== caret.nextElementSibling) p.hidden = true;
}
const popover = caret.nextElementSibling;
if (popover) popover.hidden = !popover.hidden;
});
// Handle mark-source-read button
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__mark-source-read');
if (!button) return;
e.preventDefault();
e.stopPropagation();
const feedId = button.dataset.feedId;
if (!feedId) return;
button.disabled = true;
try {
const formData = new URLSearchParams();
formData.append('action', 'timeline');
formData.append('method', 'mark_read_source');
formData.append('channel', channelUid);
formData.append('feed', feedId);
const response = await fetch(microsubApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
credentials: 'same-origin'
});
if (response.ok) {
// Animate out all cards from this feed
const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
for (const card of cards) {
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
card.style.opacity = '0';
card.style.transform = 'translateX(-20px)';
}
setTimeout(() => {
for (const card of [...cards]) {
card.remove();
}
if (timeline.querySelectorAll('.item-card').length === 0) {
location.reload();
}
}, 300);
} else {
button.disabled = false;
}
} catch (error) {
console.error('Error marking source as read:', error);
button.disabled = false;
}
});
// Close popovers on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.item-actions__mark-read-group')) {
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
p.hidden = true;
}
}
});
// Handle save-for-later buttons
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__save-later');

View File

@@ -4,6 +4,7 @@
#}
<article class="item-card{% if item._is_read %} item-card--read{% endif %}"
data-item-id="{{ item._id }}"
data-feed-id="{{ item._feedId or '' }}"
data-is-read="{{ item._is_read | default(false) }}">
{# Context bar for interactions (Aperture pattern) #}
@@ -198,6 +199,33 @@
<span class="visually-hidden">Bookmark</span>
</a>
{% if not item._is_read %}
{% if item._feedId %}
<span class="item-actions__mark-read-group">
<button type="button"
class="item-actions__button item-actions__mark-read"
data-action="mark-read"
data-item-id="{{ item._id }}"
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}
title="Mark as read">
{{ icon("checkboxChecked") }}
<span class="visually-hidden">Mark read</span>
</button>
<button type="button"
class="item-actions__button item-actions__mark-read-caret"
aria-label="More mark-read options"
title="More options">&#9662;</button>
<div class="item-actions__mark-read-popover" hidden>
<button type="button"
class="item-actions__mark-source-read"
data-feed-id="{{ item._feedId }}"
{% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
{% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}>
Mark {{ item._source.name or item.author.name or "source" }} as read
</button>
</div>
</span>
{% else %}
<button type="button"
class="item-actions__button item-actions__mark-read"
data-action="mark-read"
@@ -209,6 +237,7 @@
<span class="visually-hidden">Mark read</span>
</button>
{% endif %}
{% endif %}
{% if application.readlaterEndpoint %}
<button type="button"
class="item-actions__button item-actions__save-later"

View File

@@ -152,6 +152,88 @@
}
});
// Handle caret toggle for mark-source-read popover
timeline.addEventListener('click', (e) => {
const caret = e.target.closest('.item-actions__mark-read-caret');
if (!caret) return;
e.preventDefault();
e.stopPropagation();
// Close other open popovers
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
if (p !== caret.nextElementSibling) p.hidden = true;
}
const popover = caret.nextElementSibling;
if (popover) popover.hidden = !popover.hidden;
});
// Handle mark-source-read button
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__mark-source-read');
if (!button) return;
e.preventDefault();
e.stopPropagation();
const feedId = button.dataset.feedId;
const channelUid = button.dataset.channelUid;
const channelId = button.dataset.channelId;
if (!feedId || (!channelUid && !channelId)) return;
button.disabled = true;
try {
const formData = new URLSearchParams();
formData.append('action', 'timeline');
formData.append('method', 'mark_read_source');
formData.append('channel', channelUid || channelId);
formData.append('feed', feedId);
const response = await fetch(microsubApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
credentials: 'same-origin'
});
if (response.ok) {
// Animate out all cards from this feed
const cards = timeline.querySelectorAll(`.item-card[data-feed-id="${feedId}"]`);
for (const card of cards) {
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
card.style.opacity = '0';
card.style.transform = 'translateX(-20px)';
}
setTimeout(() => {
for (const card of [...cards]) {
const wrapper = card.closest('.timeline-view__item');
if (wrapper) wrapper.remove();
else card.remove();
}
if (timeline.querySelectorAll('.item-card').length === 0) {
location.reload();
}
}, 300);
} else {
button.disabled = false;
}
} catch (error) {
console.error('Error marking source as read:', error);
button.disabled = false;
}
});
// Close popovers on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.item-actions__mark-read-group')) {
for (const p of timeline.querySelectorAll('.item-actions__mark-read-popover:not([hidden])')) {
p.hidden = true;
}
}
});
// Handle save-for-later buttons
timeline.addEventListener('click', async (e) => {
const button = e.target.closest('.item-actions__save-later');