Skip to main content

max / balanced_breakfast

10.2 KB · 259 lines History Blame Raw
1 /**
2 * @fileoverview Items list panel: fetching, rendering, selection, read/star state.
3 *
4 * Manages the centre column showing feed items. Subscribes to `BB.state.items`
5 * to re-render on every change. Supports pagination via `loadMore()`.
6 */
7 (function() {
8 'use strict';
9
10 const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils;
11
12 /** Set of item IDs with in-flight star/read toggles to prevent double-clicks. */
13 const inFlight = new Set();
14
15 /**
16 * Build the filter object for the current view state.
17 * @param {number} [page] - Page number override; defaults to current page from state.
18 * @returns {Object} Filter object for the list_items API call.
19 */
20 function buildFilter(page) {
21 const unreadFromSort = BB.state.currentSource === '' && BB.state.currentOrder === 'unread';
22 const unreadToggle = BB.state.unreadOnly;
23 return {
24 source: BB.state.currentSource || undefined,
25 unread: (unreadFromSort || unreadToggle) ? true : undefined,
26 starred: BB.state.currentSource === '' && BB.state.currentOrder === 'starred' ? true : undefined,
27 search: BB.state.currentSearch || undefined,
28 order: BB.state.currentOrder || undefined,
29 page: page !== undefined ? page : BB.state.currentPage,
30 tag: BB.state.currentTag || undefined,
31 queryFeedId: BB.state.currentQueryFeed || undefined,
32 };
33 }
34
35 /**
36 * Load reading list bookmarks (delegates to BB.bookmarks).
37 * @returns {Promise<void>}
38 */
39 async function loadSaved() {
40 BB.state.set('hasMore', false);
41 BB.state.set('items', []);
42 if (BB.bookmarks) BB.bookmarks.load();
43 }
44
45 /**
46 * Fetch items from the backend and update state.
47 * @param {boolean} [append=false] - If true, append to existing items (pagination).
48 */
49 async function load(append) {
50 if (BB.state.currentSource === '__saved__') return loadSaved();
51 try {
52 const data = await BB.api.items.list(buildFilter());
53 BB.state.set('hasMore', data.hasMore);
54
55 if (append) {
56 const existing = BB.state.items;
57 BB.state.set('items', existing.concat(data.items));
58 } else {
59 BB.state.set('items', data.items);
60 }
61 } catch (err) {
62 BB.ui.showErrorWithRetry('Failed to load items: ' + getErrorMessage(err), () => load(append));
63 }
64 }
65
66 /**
67 * Re-fetch all loaded pages from the backend without showing skeletons.
68 * Used after mutations (star, read) to get authoritative state.
69 * @returns {Promise<void>}
70 */
71 async function reload() {
72 try {
73 const lastPage = BB.state.currentPage;
74 let allItems = [];
75 for (let p = 0; p <= lastPage; p++) {
76 const data = await BB.api.items.list(buildFilter(p));
77 allItems = allItems.concat(data.items);
78 if (p === lastPage) BB.state.set('hasMore', data.hasMore);
79 }
80 BB.state.set('items', allItems);
81 } catch (err) {
82 console.warn('Reload failed:', err);
83 }
84 }
85
86 /**
87 * Render the items list into the DOM. Shows an empty-state message when
88 * there are no items, with different copy for "no feeds" vs "no matches".
89 * @param {Array<Object>} items - Items from state.
90 */
91 function render(items) {
92 const list = document.getElementById('items-list');
93 const selected = BB.state.selectedItemId;
94
95 // Set listbox role on the container for accessibility
96 list.setAttribute('role', 'listbox');
97 list.setAttribute('aria-label', 'Feed items');
98
99 if (items.length === 0) {
100 const hasFeeds = BB.state.sources && BB.state.sources.length > 0;
101 const totalItems = hasFeeds ? BB.state.sources.reduce((n, s) => n + s.totalCount, 0) : 0;
102 let message;
103 if (!hasFeeds) {
104 message = '<div class="empty-icon">&#x1F373;</div>No feeds yet.<br>Click <strong>+ Add Feed</strong> to get started, or import an OPML file.<br><kbd>?</kbd> for keyboard shortcuts.';
105 } else if (totalItems === 0) {
106 message = '<div class="empty-icon">&#x1F504;</div>Feeds added but no articles yet.<br>Click <strong>Refresh</strong> to fetch posts.';
107 } else if (BB.state.unreadOnly) {
108 message = '<div class="empty-icon">&#x2713;</div>All caught up! No unread items.';
109 } else {
110 message = '<div class="empty-icon">&#x1F50D;</div>No items match the current filter.<br>Try switching to "All" or a different source.';
111 }
112 list.innerHTML = '<li class="item empty-state">' + message + '</li>';
113 document.getElementById('load-more').style.display = 'none';
114 return;
115 }
116
117 // Build all items in a fragment first, then swap in one operation
118 const frag = document.createDocumentFragment();
119
120 items.forEach(item => {
121 const li = document.createElement('li');
122 li.className = 'item'
123 + (item.isRead ? ' read' : ' unread')
124 + (selected === item.id ? ' selected' : '');
125 li.dataset.id = item.id;
126 li.setAttribute('role', 'option');
127 li.setAttribute('aria-selected', selected === item.id ? 'true' : 'false');
128
129 li.innerHTML = `
130 <div class="item-indicators">
131 <button class="star-btn${item.isStarred ? ' starred' : ''}"
132 aria-label="Toggle star"
133 onclick="event.stopPropagation(); BB.items.toggleStar('${escapeAttr(item.id)}', ${item.isStarred})">
134 ${item.isStarred ? '\u2605' : '\u2606'}
135 </button>
136 <div class="read-indicator${item.isRead ? '' : ' unread'}"></div>
137 </div>
138 <div class="item-content">
139 <div class="item-header">
140 <span class="item-author">${escapeHtml(item.author)}</span>
141 <span class="item-time">${escapeHtml(item.timeAgo)}</span>
142 </div>
143 <span class="item-source-badge">${escapeHtml(item.sourceName)}</span>
144 <div class="item-text">${escapeHtml(item.title || item.text)}</div>
145 ${item.secondary ? `<div class="item-secondary">${escapeHtml(item.secondary)}</div>` : ''}
146 </div>
147 `;
148
149 li.setAttribute('tabindex', '0');
150 li.onclick = () => selectItem(item.id);
151 li.ondblclick = (e) => {
152 e.preventDefault();
153 expandReaderView(item.id);
154 };
155 li.onkeydown = (e) => {
156 if (e.key === 'Enter' || e.key === ' ') {
157 e.preventDefault();
158 selectItem(item.id);
159 }
160 };
161 frag.appendChild(li);
162 });
163
164 list.innerHTML = '';
165 list.appendChild(frag);
166
167 document.getElementById('load-more').style.display =
168 BB.state.hasMore ? 'block' : 'none';
169 }
170
171 /**
172 * Select an item: update state, re-render list, load detail panel, mark read.
173 * @param {string} id - Item external_id.
174 */
175 async function selectItem(id) {
176 BB.state.set('selectedItemId', id);
177 render(BB.state.items);
178 await BB.detail.load(id);
179
180 // Mark as read in the background — don't let the reload close
181 // the detail panel we just opened (e.g. when viewing unread-only
182 // and the item disappears from the filtered list).
183 const item = BB.state.items.find(i => i.id === id);
184 if (item && !item.isRead) {
185 BB.state.set('sessionArticlesRead', BB.state.sessionArticlesRead + 1);
186 }
187 BB.api.items.markRead(id).then(() => reload()).catch(err => {
188 console.warn('Failed to mark item as read:', err);
189 });
190 }
191
192 /**
193 * Toggle the starred state of an item.
194 * @param {string} id - Item external_id.
195 * @param {boolean} isStarred - Current starred state.
196 */
197 async function toggleStar(id, isStarred) {
198 if (inFlight.has('star-' + id)) return;
199 inFlight.add('star-' + id);
200 try {
201 if (isStarred) {
202 await BB.api.items.unstar(id);
203 } else {
204 await BB.api.items.star(id);
205 BB.state.set('sessionArticlesStarred', BB.state.sessionArticlesStarred + 1);
206 }
207 await reload();
208 BB.ui.showToast(isStarred ? 'Unstarred' : 'Starred');
209 } catch (err) {
210 BB.ui.showToast('Failed to update star: ' + getErrorMessage(err), 'error');
211 } finally {
212 inFlight.delete('star-' + id);
213 }
214 }
215
216 /**
217 * Toggle the read state of an item.
218 * @param {string} id - Item external_id.
219 * @param {boolean} isRead - Current read state.
220 */
221 async function toggleRead(id, isRead) {
222 if (inFlight.has('read-' + id)) return;
223 inFlight.add('read-' + id);
224 try {
225 if (isRead) {
226 await BB.api.items.markUnread(id);
227 } else {
228 await BB.api.items.markRead(id);
229 }
230 await reload();
231 BB.ui.showToast(isRead ? 'Marked unread' : 'Marked read');
232 } catch (err) {
233 BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error');
234 } finally {
235 inFlight.delete('read-' + id);
236 }
237 }
238
239 /** Increment the page counter and fetch the next page (appended to list). */
240 function loadMore() {
241 BB.state.set('currentPage', BB.state.currentPage + 1);
242 load(true).catch(err => console.warn('Load more failed:', err));
243 }
244
245 /**
246 * Expand reader view for an item: load detail, extract article, expand panel.
247 * Triggered by double-clicking an item in the list.
248 * @param {string} id - Item external_id.
249 */
250 async function expandReaderView(id) {
251 await BB.detail.load(id);
252 BB.detail.expandReader();
253 }
254
255 BB.state.subscribe('items', render);
256
257 BB.items = { load, reload, render, selectItem, toggleStar, toggleRead, loadMore, expandReaderView };
258 })();
259