diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index b9dae27..9fcf4d0 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -21,6 +21,7 @@ import { getTimelineItems, getItemById, markItemsRead, + countReadItems, } from "../storage/items.js"; import { getUserId } from "../utils/auth.js"; import { @@ -95,24 +96,37 @@ export async function channel(request, response) { const { application } = request.app.locals; const userId = getUserId(request); const { uid } = request.params; - const { before, after } = request.query; + const { before, after, showRead } = request.query; const channelDocument = await getChannel(application, uid, userId); if (!channelDocument) { return response.status(404).render("404"); } + // Check if showing read items + const showReadItems = showRead === "true"; + const timeline = await getTimelineItems(application, channelDocument._id, { before, after, userId, + showRead: showReadItems, }); + // Count read items to show "View read items" button + const readCount = await countReadItems( + application, + channelDocument._id, + userId, + ); + response.render("channel", { title: channelDocument.name, channel: channelDocument, items: timeline.items, paging: timeline.paging, + readCount, + showRead: showReadItems, baseUrl: request.baseUrl, }); } diff --git a/lib/storage/items.js b/lib/storage/items.js index fe9299b..7e25096 100644 --- a/lib/storage/items.js +++ b/lib/storage/items.js @@ -78,6 +78,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) { * @param {string} [options.after] - After cursor * @param {number} [options.limit] - Items per page * @param {string} [options.userId] - User ID for read state + * @param {boolean} [options.showRead] - Whether to show read items (default: false) * @returns {Promise} Timeline with items and paging */ export async function getTimelineItems(application, channelId, options = {}) { @@ -86,7 +87,12 @@ export async function getTimelineItems(application, channelId, options = {}) { typeof channelId === "string" ? new ObjectId(channelId) : channelId; const limit = parseLimit(options.limit); + // Base query - filter out read items unless showRead is true const baseQuery = { channelId: objectId }; + if (options.userId && !options.showRead) { + baseQuery.readBy = { $ne: options.userId }; + } + const query = buildPaginationQuery({ before: options.before, after: options.after, @@ -256,6 +262,24 @@ export async function getItemsByUids(application, uids, userId) { return items.map((item) => transformToJf2(item, userId)); } +/** + * Count read items in a channel + * @param {object} application - Indiekit application + * @param {ObjectId|string} channelId - Channel ObjectId + * @param {string} userId - User ID + * @returns {Promise} Number of read items + */ +export async function countReadItems(application, channelId, userId) { + const collection = getCollection(application); + const objectId = + typeof channelId === "string" ? new ObjectId(channelId) : channelId; + + return collection.countDocuments({ + channelId: objectId, + readBy: userId, + }); +} + /** * Mark items as read * @param {object} application - Indiekit application diff --git a/locales/en.json b/locales/en.json index a10b441..66de21d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4,6 +4,9 @@ "title": "Reader", "empty": "No items to display", "markAllRead": "Mark all as read", + "showRead": "Show read ({{count}})", + "hideRead": "Hide read items", + "allRead": "All caught up!", "newer": "Newer", "older": "Older" }, diff --git a/package.json b/package.json index 018d1c6..41cd116 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-microsub", - "version": "1.0.16", + "version": "1.0.17", "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.", "keywords": [ "indiekit", diff --git a/views/channel.njk b/views/channel.njk index 79e12a8..8274fbf 100644 --- a/views/channel.njk +++ b/views/channel.njk @@ -7,6 +7,7 @@ {{ icon("previous") }} {{ __("microsub.channels.title") }}
+ {% if not showRead and items.length > 0 %}
@@ -14,6 +15,16 @@ {{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }}
+ {% endif %} + {% if showRead %} + + {{ icon("hide") }} {{ __("microsub.reader.hideRead") }} + + {% elif readCount > 0 %} + + {{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }} + + {% endif %} {{ icon("syndicate") }} {{ __("microsub.feeds.title") }} @@ -33,14 +44,14 @@ {% if paging %}
@@ -97,6 +116,62 @@ break; } }); + + // Handle individual mark-read buttons + const channelUid = timeline.dataset.channel; + timeline.addEventListener('click', async (e) => { + const button = e.target.closest('.item-actions__mark-read'); + if (!button) return; + + e.preventDefault(); + e.stopPropagation(); + + const itemId = button.dataset.itemId; + if (!itemId) return; + + // Disable button while processing + button.disabled = true; + + try { + const formData = new URLSearchParams(); + formData.append('action', 'timeline'); + formData.append('method', 'mark_read'); + formData.append('channel', channelUid); + formData.append('entry', itemId); + + const response = await fetch('{{ baseUrl }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + credentials: 'same-origin' + }); + + if (response.ok) { + // Hide the item with animation + const card = button.closest('.item-card'); + if (card) { + card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + card.style.opacity = '0'; + card.style.transform = 'translateX(-20px)'; + setTimeout(() => { + card.remove(); + // Check if timeline is now empty + if (timeline.querySelectorAll('.item-card').length === 0) { + location.reload(); + } + }, 300); + } + } else { + console.error('Failed to mark item as read'); + button.disabled = false; + } + } catch (error) { + console.error('Error marking item as read:', error); + button.disabled = false; + } + }); } {% endblock %}