/** * @fileoverview App bootstrap, keyboard shortcuts, and native menu integration. * * `init()` runs on DOMContentLoaded: loads theme, data, wires up event listeners, * and shows first-run welcome. Keyboard shortcuts use vim-style navigation (j/k) * plus single-key actions (s=star, r=read, /=search, ?=help). */ (function() { 'use strict'; const { invoke } = window.__TAURI__.core; /** Guard to prevent registering event listeners multiple times. */ let initialized = false; /** Monotonic request ID so concurrent search/sort loads resolve in order. */ let loadRequestId = 0; /** * Initialize the application: load theme and data, wire up UI, show welcome. * @returns {Promise} */ async function init() { if (initialized) return; initialized = true; // Load theme before rendering content await BB.themes.init(); // Load data — catch so a backend error doesn't leave a blank screen try { await BB.sources.load(); await BB.items.load(); } catch (err) { BB.ui.showErrorWithRetry( 'Failed to load data: ' + BB.utils.getErrorMessage(err), () => { BB.sources.load(); BB.items.load(); } ); } // Migrate localStorage saved items to database bookmarks, then update badge BB.bookmarks.migrateFromLocalStorage().then(() => BB.bookmarks.updateBadge()); BB.navigation.init(); // First-run welcome try { const welcomed = await invoke('get_config', { key: 'bb-welcomed' }); if (!welcomed) { showWelcome(); } } catch (_) { // Non-critical — skip welcome on error } // Search input — debounced at 300ms, with request ID so last request wins const searchInput = document.getElementById('search-input'); const searchSpinner = document.getElementById('search-spinner'); searchInput.addEventListener('input', BB.utils.debounce(async () => { BB.state.set('currentSearch', searchInput.value); BB.state.resetPagination(); const myId = ++loadRequestId; searchSpinner.classList.add('active'); await BB.items.load(); if (loadRequestId === myId) { searchSpinner.classList.remove('active'); } }, 300)); // Sort select — guarded so rapid changes don't interleave results document.getElementById('sort-select').addEventListener('change', async (e) => { BB.state.set('currentOrder', e.target.value); BB.state.resetPagination(); ++loadRequestId; await BB.items.load(); }); // Button handlers document.getElementById('refresh-btn').addEventListener('click', BB.feeds.refresh); document.getElementById('add-feed-btn').addEventListener('click', BB.feeds.openAddFeed); document.getElementById('save-detail').addEventListener('click', BB.detail.bookmarkItem); document.getElementById('close-detail').addEventListener('click', BB.detail.close); document.getElementById('saved-articles-btn').addEventListener('click', () => BB.sources.select('__saved__')); document.getElementById('load-more-btn').addEventListener('click', BB.items.loadMore); document.getElementById('settings-btn').addEventListener('click', showSettings); document.getElementById('sync-settings-btn').addEventListener('click', BB.sync.openSettings); document.getElementById('help-btn').addEventListener('click', showHelp); document.getElementById('unread-toggle').addEventListener('click', toggleUnreadFilter); document.getElementById('mark-all-read-btn').addEventListener('click', markAllReadGlobal); // Modal close on overlay click document.getElementById('modal-overlay').addEventListener('click', (e) => { if (e.target.id === 'modal-overlay') BB.ui.closeModal(); }); document.getElementById('close-modal').addEventListener('click', BB.ui.closeModal); // Replace default context menu (which has "Reload" → full page reload) // with a minimal custom one using "Refresh" terminology. document.addEventListener('contextmenu', (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY); }); // Keyboard shortcuts document.addEventListener('keydown', handleKeyboard); // Menu events from native menu bar setupMenuListeners(); } /** * Toggle the "unread only" filter on/off. */ function toggleUnreadFilter() { const btn = document.getElementById('unread-toggle'); const active = btn.getAttribute('aria-pressed') === 'true'; btn.setAttribute('aria-pressed', !active); btn.classList.toggle('active', !active); BB.state.set('unreadOnly', !active); BB.state.resetPagination(true); BB.items.load(); } /** * Mark all items as read (global or per-source). */ async function markAllReadGlobal() { const source = BB.state.currentSource || null; const label = source ? (BB.state.sources.find(s => s.id === source) || {}).name || 'this source' : 'all sources'; const ok = await BB.ui.confirmAction('Mark all items in ' + label + ' as read?'); if (!ok) return; try { await BB.api.items.markAllRead(source); BB.ui.showToast('Marked all as read'); await BB.sources.load(); await BB.items.load(); } catch (err) { BB.ui.showToast('Failed: ' + BB.utils.getErrorMessage(err), 'error'); } } /** * Global keyboard shortcut handler. Skipped when focus is in a form input. * Key choices follow common reader conventions: j/k from vim, s/r from * Google Reader, /=search from vim, ?=help from many CLI tools. * @param {KeyboardEvent} e - The keydown event. */ function handleKeyboard(e) { // F3 justified touch branch: keyboard shortcuts are skipped on // touch devices (no physical keyboard, and the on-screen keyboard // would inadvertently trigger j/k/s/r on every letter typed). if (BB.state.isTouchDevice) return; // Don't handle when typing in inputs if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') { if (e.key === 'Escape') { e.target.blur(); } return; } const items = BB.state.items; const selectedId = BB.state.selectedItemId; const currentIdx = selectedId ? items.findIndex(i => i.id === selectedId) : -1; switch (e.key) { case '?': // Show help e.preventDefault(); showHelp(); return; case 'j': // Next item e.preventDefault(); if (currentIdx < items.length - 1) { BB.items.selectItem(items[currentIdx + 1].id); } else if (items.length > 0 && currentIdx === -1) { BB.items.selectItem(items[0].id); } break; case 'k': // Previous item e.preventDefault(); if (currentIdx > 0) { BB.items.selectItem(items[currentIdx - 1].id); } break; case 's': // Star/unstar e.preventDefault(); if (selectedId) { const item = items.find(i => i.id === selectedId); if (item) BB.items.toggleStar(selectedId, item.isStarred); } break; case 'r': // Toggle read if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); if (selectedId) { const item = items.find(i => i.id === selectedId); if (item) BB.items.toggleRead(selectedId, item.isRead); } } break; case 'u': // Toggle unread only e.preventDefault(); toggleUnreadFilter(); break; case 'A': // Mark all as read (Shift+A) if (e.shiftKey) { e.preventDefault(); markAllReadGlobal(); } break; case '/': // Focus search e.preventDefault(); document.getElementById('search-input').focus(); break; case 'Escape': if (document.querySelector('.main.reader-expanded')) { BB.detail.collapseReader(); } else if (BB.state.selectedItemId) { BB.detail.close(); } break; case 'o': // Open URL case 'Enter': if (selectedId) { BB.detail.openUrl(); } break; } } /** * Show a custom right-click context menu at the given coordinates. * @param {number} x - Viewport X position in pixels. * @param {number} y - Viewport Y position in pixels. */ function showContextMenu(x, y) { const old = document.getElementById('context-menu'); if (old) old.remove(); const menu = document.createElement('div'); menu.id = 'context-menu'; menu.className = 'context-menu'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; const refreshItem = document.createElement('button'); refreshItem.className = 'context-menu-item'; refreshItem.textContent = 'Refresh Feeds'; refreshItem.onclick = () => { menu.remove(); BB.feeds.refresh(); }; menu.appendChild(refreshItem); document.body.appendChild(menu); // Clamp to viewport const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px'; if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px'; // Dismiss on any click outside requestAnimationFrame(() => { document.addEventListener('click', function dismiss() { menu.remove(); document.removeEventListener('click', dismiss); }, { once: true }); }); } /** Listen for Tauri native menu events and auto-fetch background updates. */ function setupMenuListeners() { const listen = window.__TAURI__.event.listen; // Auto-fetch background updates listen('auto-fetch-complete', () => { BB.sources.load(); BB.items.load(); }); listen('auto-fetch-error', (event) => { const pluginId = event.payload?.pluginId || 'unknown'; const error = event.payload?.error || ''; BB.ui.showToast('Failed to fetch ' + pluginId + (error ? ': ' + error : ''), 'error'); }); listen('feed-circuit-broken', (event) => { const pluginId = event.payload?.pluginId || 'unknown'; BB.ui.showErrorWithRetry( 'Feed "' + pluginId + '" disabled after repeated failures', async () => { try { await BB.api.feeds.resetCircuitBreaker(pluginId); BB.ui.showToast('Feed re-enabled'); BB.sources.load(); BB.items.load(); } catch (err) { BB.ui.showToast('Failed to reset: ' + BB.utils.getErrorMessage(err), 'error'); } } ); BB.sources.load(); }); listen('menu:refresh', () => BB.feeds.refresh()); listen('menu:add_feed', () => BB.feeds.openAddFeed()); listen('menu:import_opml', () => BB.feeds.importOpml()); listen('menu:export_opml', () => BB.feeds.exportOpml()); listen('menu:view_all', async () => { BB.state.set('currentSource', ''); BB.state.resetPagination(); await BB.items.load(); BB.sources.render(BB.state.sources); }); listen('menu:view_unread', () => { BB.state.set('currentOrder', 'unread'); document.getElementById('sort-select').value = 'unread'; BB.state.resetPagination(); BB.items.load(); }); listen('menu:view_starred', () => { BB.state.set('currentOrder', 'starred'); document.getElementById('sort-select').value = 'starred'; BB.state.resetPagination(); BB.items.load(); }); // Session summary on close window.addEventListener('beforeunload', () => { const read = BB.state.sessionArticlesRead; const starred = BB.state.sessionArticlesStarred; if (read > 0 || starred > 0) { const parts = []; if (read > 0) parts.push(read + ' read'); if (starred > 0) parts.push(starred + ' starred'); BB.ui.showToast('This session: ' + parts.join(', ')); } }); } /** Show the settings modal with theme selector and data management. */ function showSettings() { const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Settings'; body.innerHTML = ''; const content = document.createElement('div'); content.className = 'settings-content'; // Theme section const themeGroup = document.createElement('div'); themeGroup.className = 'form-group'; const themeLabel = document.createElement('label'); themeLabel.textContent = 'Theme'; const themeContainer = document.createElement('div'); themeContainer.id = 'settings-theme-container'; themeGroup.appendChild(themeLabel); themeGroup.appendChild(themeContainer); // F4 (2026-06-02): inline layout retired in favor of .theme-actions class. const themeActions = document.createElement('div'); themeActions.className = 'theme-actions'; const themeImportBtn = document.createElement('button'); themeImportBtn.className = 'btn btn-small'; themeImportBtn.textContent = 'Import Theme'; themeImportBtn.onclick = () => BB.themes.importTheme(); themeActions.appendChild(themeImportBtn); const themeExportBtn = document.createElement('button'); themeExportBtn.className = 'btn btn-small'; themeExportBtn.textContent = 'Export Current'; themeExportBtn.onclick = () => BB.themes.exportTheme(); themeActions.appendChild(themeExportBtn); themeGroup.appendChild(themeActions); content.appendChild(themeGroup); // Data management section const dataGroup = document.createElement('div'); dataGroup.className = 'form-group'; const dataLabel = document.createElement('label'); dataLabel.textContent = 'Data'; dataGroup.appendChild(dataLabel); // F4 (2026-06-02): inline layout retired in favor of .form-row primitive. const dataActions = document.createElement('div'); dataActions.className = 'form-row'; const importBtn = document.createElement('button'); importBtn.className = 'btn btn-small'; importBtn.textContent = 'Import OPML'; importBtn.onclick = () => { BB.ui.closeModal(); BB.feeds.importOpml(); }; dataActions.appendChild(importBtn); const exportBtn = document.createElement('button'); exportBtn.className = 'btn btn-small'; exportBtn.textContent = 'Export OPML'; exportBtn.onclick = () => { BB.ui.closeModal(); BB.feeds.exportOpml(); }; dataActions.appendChild(exportBtn); dataGroup.appendChild(dataActions); content.appendChild(dataGroup); // Sync section (if available) if (BB.sync) { const syncGroup = document.createElement('div'); syncGroup.className = 'form-group'; const syncLabel = document.createElement('label'); syncLabel.textContent = 'Cloud Sync'; syncGroup.appendChild(syncLabel); const syncBtn = document.createElement('button'); syncBtn.className = 'btn btn-small'; syncBtn.textContent = 'Sync Settings'; syncBtn.onclick = () => { BB.ui.closeModal(); BB.sync.openSettings(); }; syncGroup.appendChild(syncBtn); content.appendChild(syncGroup); } const actions = document.createElement('div'); actions.className = 'form-actions'; const closeBtn = document.createElement('button'); closeBtn.className = 'btn'; closeBtn.textContent = 'Close'; closeBtn.addEventListener('click', BB.ui.closeModal); actions.appendChild(closeBtn); content.appendChild(actions); body.appendChild(content); BB.themes.buildSelector(themeContainer); BB.ui.openModal(); } /** Show the keyboard shortcuts help modal. */ function showHelp() { const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Keyboard Shortcuts'; body.innerHTML = `

Navigation

jNext item
kPrevious item
o / EnterOpen in browser
/Focus search
EscClose detail panel

Actions

sStar / unstar
rToggle read / unread
uToggle unread-only filter
Shift AMark all as read
?Show this help

Menu Shortcuts

\u2318RRefresh all feeds
\u2318NAdd new feed
\u2318IImport OPML
\u2318EExport OPML
`; BB.ui.openModal(); } /** Show the first-run welcome modal. Sets user_config flag to prevent re-showing. */ function showWelcome() { const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Welcome to Balanced Breakfast'; body.innerHTML = ''; const content = document.createElement('div'); content.className = 'welcome-content'; content.innerHTML = '

Your personal feed reader for RSS, Atom, podcasts, and more.

' + '

Getting Started

' + '

Subscribe to feeds with the + Add Feed button, or import existing subscriptions via File > Import OPML.

' + '

Keyboard Shortcuts

' + '

Press ? anytime to see all shortcuts. Navigate with j/k, star with s, toggle read with r.

' + '

Themes & Settings

' + '

Click the gear icon () in the header to change themes and configure preferences.

'; const cta = document.createElement('div'); cta.className = 'welcome-cta'; const addBtn = document.createElement('button'); addBtn.className = 'btn btn-success'; addBtn.textContent = 'Add Your First Feed'; addBtn.addEventListener('click', BB.feeds.openAddFeed); const exploreBtn = document.createElement('button'); exploreBtn.className = 'btn'; exploreBtn.textContent = 'Explore First'; exploreBtn.addEventListener('click', BB.ui.closeModal); cta.appendChild(addBtn); cta.appendChild(exploreBtn); content.appendChild(cta); body.appendChild(content); BB.ui.openModal(); invoke('set_config', { key: 'bb-welcomed', value: '1' }); } BB.app = { init, showHelp, showSettings, showWelcome }; // Auto-init when DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();