/** * @fileoverview Items list panel: fetching, rendering, selection, read/star state. * * Manages the centre column showing feed items. Subscribes to `BB.state.items` * to re-render on every change. Supports pagination via `loadMore()`. */ (function() { 'use strict'; const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils; /** Set of item IDs with in-flight star/read toggles to prevent double-clicks. */ const inFlight = new Set(); /** * Build the filter object for the current view state. * @param {number} [page] - Page number override; defaults to current page from state. * @returns {Object} Filter object for the list_items API call. */ function buildFilter(page) { const unreadFromSort = BB.state.currentSource === '' && BB.state.currentOrder === 'unread'; const unreadToggle = BB.state.unreadOnly; return { source: BB.state.currentSource || undefined, unread: (unreadFromSort || unreadToggle) ? true : undefined, starred: BB.state.currentSource === '' && BB.state.currentOrder === 'starred' ? true : undefined, search: BB.state.currentSearch || undefined, order: BB.state.currentOrder || undefined, page: page !== undefined ? page : BB.state.currentPage, tag: BB.state.currentTag || undefined, queryFeedId: BB.state.currentQueryFeed || undefined, }; } /** * Load reading list bookmarks (delegates to BB.bookmarks). * @returns {Promise} */ async function loadSaved() { BB.state.set('hasMore', false); BB.state.set('items', []); if (BB.bookmarks) BB.bookmarks.load(); } /** * Fetch items from the backend and update state. * @param {boolean} [append=false] - If true, append to existing items (pagination). */ async function load(append) { if (BB.state.currentSource === '__saved__') return loadSaved(); try { const data = await BB.api.items.list(buildFilter()); BB.state.set('hasMore', data.hasMore); if (append) { const existing = BB.state.items; BB.state.set('items', existing.concat(data.items)); } else { BB.state.set('items', data.items); } } catch (err) { BB.ui.showErrorWithRetry('Failed to load items: ' + getErrorMessage(err), () => load(append)); } } /** * Re-fetch all loaded pages from the backend without showing skeletons. * Used after mutations (star, read) to get authoritative state. * @returns {Promise} */ async function reload() { try { const lastPage = BB.state.currentPage; let allItems = []; for (let p = 0; p <= lastPage; p++) { const data = await BB.api.items.list(buildFilter(p)); allItems = allItems.concat(data.items); if (p === lastPage) BB.state.set('hasMore', data.hasMore); } BB.state.set('items', allItems); } catch (err) { console.warn('Reload failed:', err); } } /** * Render the items list into the DOM. Shows an empty-state message when * there are no items, with different copy for "no feeds" vs "no matches". * @param {Array} items - Items from state. */ function render(items) { const list = document.getElementById('items-list'); const selected = BB.state.selectedItemId; // Set listbox role on the container for accessibility list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Feed items'); if (items.length === 0) { const hasFeeds = BB.state.sources && BB.state.sources.length > 0; const totalItems = hasFeeds ? BB.state.sources.reduce((n, s) => n + s.totalCount, 0) : 0; let message; if (!hasFeeds) { message = '
🍳
No feeds yet.
Click + Add Feed to get started, or import an OPML file.
? for keyboard shortcuts.'; } else if (totalItems === 0) { message = '
🔄
Feeds added but no articles yet.
Click Refresh to fetch posts.'; } else if (BB.state.unreadOnly) { message = '
All caught up! No unread items.'; } else { message = '
🔍
No items match the current filter.
Try switching to "All" or a different source.'; } list.innerHTML = '
  • ' + message + '
  • '; document.getElementById('load-more').style.display = 'none'; return; } // Build all items in a fragment first, then swap in one operation const frag = document.createDocumentFragment(); items.forEach(item => { const li = document.createElement('li'); li.className = 'item' + (item.isRead ? ' read' : ' unread') + (selected === item.id ? ' selected' : ''); li.dataset.id = item.id; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', selected === item.id ? 'true' : 'false'); li.innerHTML = `
    ${escapeHtml(item.author)} ${escapeHtml(item.timeAgo)}
    ${escapeHtml(item.sourceName)}
    ${escapeHtml(item.title || item.text)}
    ${item.secondary ? `
    ${escapeHtml(item.secondary)}
    ` : ''}
    `; li.setAttribute('tabindex', '0'); li.onclick = () => selectItem(item.id); li.ondblclick = (e) => { e.preventDefault(); expandReaderView(item.id); }; li.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectItem(item.id); } }; frag.appendChild(li); }); list.innerHTML = ''; list.appendChild(frag); document.getElementById('load-more').style.display = BB.state.hasMore ? 'block' : 'none'; } /** * Select an item: update state, re-render list, load detail panel, mark read. * @param {string} id - Item external_id. */ async function selectItem(id) { BB.state.set('selectedItemId', id); render(BB.state.items); await BB.detail.load(id); // Mark as read in the background — don't let the reload close // the detail panel we just opened (e.g. when viewing unread-only // and the item disappears from the filtered list). const item = BB.state.items.find(i => i.id === id); if (item && !item.isRead) { BB.state.set('sessionArticlesRead', BB.state.sessionArticlesRead + 1); } BB.api.items.markRead(id).then(() => reload()).catch(err => { console.warn('Failed to mark item as read:', err); }); } /** * Toggle the starred state of an item. * @param {string} id - Item external_id. * @param {boolean} isStarred - Current starred state. */ async function toggleStar(id, isStarred) { if (inFlight.has('star-' + id)) return; inFlight.add('star-' + id); try { if (isStarred) { await BB.api.items.unstar(id); } else { await BB.api.items.star(id); BB.state.set('sessionArticlesStarred', BB.state.sessionArticlesStarred + 1); } await reload(); BB.ui.showToast(isStarred ? 'Unstarred' : 'Starred'); } catch (err) { BB.ui.showToast('Failed to update star: ' + getErrorMessage(err), 'error'); } finally { inFlight.delete('star-' + id); } } /** * Toggle the read state of an item. * @param {string} id - Item external_id. * @param {boolean} isRead - Current read state. */ async function toggleRead(id, isRead) { if (inFlight.has('read-' + id)) return; inFlight.add('read-' + id); try { if (isRead) { await BB.api.items.markUnread(id); } else { await BB.api.items.markRead(id); } await reload(); BB.ui.showToast(isRead ? 'Marked unread' : 'Marked read'); } catch (err) { BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); } finally { inFlight.delete('read-' + id); } } /** Increment the page counter and fetch the next page (appended to list). */ function loadMore() { BB.state.set('currentPage', BB.state.currentPage + 1); load(true).catch(err => console.warn('Load more failed:', err)); } /** * Expand reader view for an item: load detail, extract article, expand panel. * Triggered by double-clicking an item in the list. * @param {string} id - Item external_id. */ async function expandReaderView(id) { await BB.detail.load(id); BB.detail.expandReader(); } BB.state.subscribe('items', render); BB.items = { load, reload, render, selectItem, toggleStar, toggleRead, loadMore, expandReaderView }; })();