/* ============================================ IVY'S RSS HUB — Main Application Orchestrates the RSS feed aggregator ============================================ */ /** * Shared Utilities — Used by both App and Sidebar * Avoids code duplication across modules */ window.IvyUtils = { /** * Escape HTML to prevent XSS */ escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; }, /** * Escape text for use in HTML attributes */ escapeAttr(text) { if (!text) return ""; return text .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .replace(//g, ">"); }, /** * Truncate text with ellipsis */ truncate(text, maxLength) { if (!text || text.length <= maxLength) return text; return text.substring(0, maxLength) + "..."; } }; /** * RSSHub Application */ class RSSHubApp { constructor() { // Initialize parser this.parser = new RSSParser(); // State this.feeds = []; this.feedResults = []; this.currentCategory = "all"; this.currentLang = "all"; // Language filter: "all", "en", "fr" this.settings = this.loadSettings(); this.isRefreshing = false; // Prevent double-refresh race conditions this.autoRefreshTimer = null; // Auto-refresh interval timer this.nextRefreshTime = null; // Timestamp of next auto-refresh this.countdownTimer = null; // Countdown display timer // DOM Elements this.elements = { feedsContainer: document.getElementById("feeds-container"), statusText: document.getElementById("status-text"), statusTime: document.getElementById("status-time"), btnRefresh: document.getElementById("btn-refresh"), btnTheme: document.getElementById("btn-theme"), btnSettings: document.getElementById("btn-settings"), btnAbout: document.getElementById("btn-about"), modalAbout: document.getElementById("modal-about"), modalSettings: document.getElementById("modal-settings"), modalCloseAbout: document.getElementById("modal-close-about"), modalCloseSettings: document.getElementById("modal-close-settings"), sourcesList: document.getElementById("sources-list"), navButtons: document.querySelectorAll(".nav-btn"), langButtons: document.querySelectorAll(".lang-btn"), sidebar: document.getElementById("sidebar") }; // Initialize theme this.initTheme(); // Initialize this.init(); } /** * Initialize theme from localStorage or system preference */ initTheme() { const savedTheme = localStorage.getItem("ivy-rss-hub-theme"); if (savedTheme) { // User has a saved preference document.documentElement.setAttribute("data-theme", savedTheme); this.updateThemeIcon(savedTheme); } else { // Use system preference (CSS handles it, but we update the icon) const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; this.updateThemeIcon(prefersDark ? "dark" : "light"); } // Listen for system theme changes window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => { if (!localStorage.getItem("ivy-rss-hub-theme")) { this.updateThemeIcon(e.matches ? "dark" : "light"); } }); } /** * Toggle between light and dark theme */ toggleTheme() { const currentTheme = document.documentElement.getAttribute("data-theme"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; // Determine current effective theme let effectiveTheme; if (currentTheme) { effectiveTheme = currentTheme; } else { effectiveTheme = prefersDark ? "dark" : "light"; } // Toggle const newTheme = effectiveTheme === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", newTheme); localStorage.setItem("ivy-rss-hub-theme", newTheme); this.updateThemeIcon(newTheme); this.showToast(`${newTheme === "dark" ? "🌙 Dark" : "☀️ Light"} theme activated`, "info"); } /** * Update theme toggle button icon */ updateThemeIcon(theme) { if (this.elements.btnTheme) { this.elements.btnTheme.textContent = theme === "dark" ? "🌙" : "☀️"; this.elements.btnTheme.title = `Switch to ${theme === "dark" ? "light" : "dark"} theme`; } } /** * Initialize the application */ init() { // Load feeds from settings or defaults this.feeds = this.settings.feeds || [...window.FeedsConfig.DEFAULT_FEEDS]; // Save if migration occurred if (this._needsMigrationSave) { console.log("[Migration] Saving migrated feed configuration"); this.saveSettings(); this._needsMigrationSave = false; } // Setup event listeners this.setupEventListeners(); // Setup scroll propagation for nested scrollable containers this.setupScrollPropagation(); // Render settings this.renderSourcesList(); // Initialize sidebar this.sidebar = new SidebarManager(this); window.sidebar = this.sidebar; // Expose for event handlers // Initialize settings UI from saved values this.initSettingsUI(); // Initial fetch this.refreshFeeds(); // Start auto-refresh if enabled if (this.settings.autoRefresh) { this.startAutoRefresh(); } } /** * Initialize settings UI elements from saved settings */ initSettingsUI() { const groupBySourceCheckbox = document.getElementById("opt-group-by-source"); const showDescriptionsCheckbox = document.getElementById("opt-show-descriptions"); const maxItemsInput = document.getElementById("opt-max-items"); const autoRefreshCheckbox = document.getElementById("opt-auto-refresh"); const refreshIntervalSelect = document.getElementById("opt-refresh-interval"); if (groupBySourceCheckbox) { groupBySourceCheckbox.checked = this.settings.groupBySource !== false; } if (showDescriptionsCheckbox) { showDescriptionsCheckbox.checked = this.settings.showDescriptions !== false; } if (maxItemsInput) { maxItemsInput.value = this.settings.maxItems || 10; } if (autoRefreshCheckbox) { autoRefreshCheckbox.checked = this.settings.autoRefresh === true; } if (refreshIntervalSelect) { refreshIntervalSelect.value = this.settings.refreshInterval || 10; } this.updateAutoRefreshStatus(); } /** * Setup event listeners */ setupEventListeners() { // Refresh button - normal click uses cache, shift+click forces refresh this.elements.btnRefresh.addEventListener("click", async e => { // Prevent double-click while already loading if (this.isRefreshing) { return; } if (e.shiftKey) { // Force refresh - clear all cache including IndexedDB await this.parser.clearAllCache(); this.showToast("Cache cleared! Refreshing...", "info"); console.log("🔄 Force refresh - cache cleared"); } // Normal refresh uses cache if available this.refreshFeeds(); }); // Add tooltip hint this.elements.btnRefresh.title = "Refresh feeds (Shift+Click to force refresh)"; // Theme toggle this.elements.btnTheme?.addEventListener("click", () => { this.toggleTheme(); }); // About modal this.elements.btnAbout.addEventListener("click", () => { this.elements.modalAbout.classList.add("active"); // Focus the close button for accessibility setTimeout(() => this.elements.modalCloseAbout.focus(), 100); }); this.elements.modalCloseAbout.addEventListener("click", () => { this.elements.modalAbout.classList.remove("active"); }); // Settings modal this.elements.btnSettings.addEventListener("click", () => { this.renderSourcesList(); this.elements.modalSettings.classList.add("active"); // Focus the close button for accessibility setTimeout(() => this.elements.modalCloseSettings.focus(), 100); }); this.elements.modalCloseSettings.addEventListener("click", () => { this.elements.modalSettings.classList.remove("active"); }); // Close modals on overlay click [this.elements.modalAbout, this.elements.modalSettings].forEach(modal => { modal.addEventListener("click", e => { if (e.target === modal) { modal.classList.remove("active"); } }); }); // Focus trap for modals (accessibility) document.addEventListener("keydown", e => { if (e.key !== "Tab") return; // Check if a modal is open const activeModal = document.querySelector(".modal-overlay.active .modal"); if (!activeModal) return; // Get all focusable elements in the modal const focusableElements = activeModal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; // Trap focus within modal if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } }); // Keyboard shortcuts document.addEventListener("keydown", e => { // Don't trigger shortcuts when typing in inputs if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { // Escape to blur inputs if (e.key === "Escape") e.target.blur(); return; } // Escape to close modals and sidebar if (e.key === "Escape") { this.elements.modalAbout.classList.remove("active"); this.elements.modalSettings.classList.remove("active"); // Also close mobile sidebar this.elements.sidebar?.classList.remove("open"); } // R to refresh (Shift+R for force refresh) if (e.key === "r" || e.key === "R") { e.preventDefault(); if (e.shiftKey) { this.parser.clearAllCache().then(() => { this.showToast("Cache cleared! Refreshing...", "info"); this.refreshFeeds(); }); } else { this.refreshFeeds(); } } // / to focus search if (e.key === "/") { e.preventDefault(); const searchInput = document.getElementById("search-input"); if (searchInput) { searchInput.focus(); // Open sidebar on mobile if (window.innerWidth <= 1100) { this.elements.sidebar?.classList.add("open"); } } } // S to toggle sidebar on desktop if (e.key === "s" && !e.ctrlKey && !e.metaKey) { const sidebar = document.getElementById("sidebar"); if (sidebar) sidebar.classList.toggle("hidden-desktop"); } // T to toggle theme if (e.key === "t" && !e.ctrlKey && !e.metaKey) { this.toggleTheme(); } // Home or ArrowUp (when not in input) to scroll to top if (e.key === "Home" || (e.key === "ArrowUp" && e.ctrlKey)) { e.preventDefault(); this.scrollToTop(); } }); // Back to top button this.setupBackToTop(); // Category navigation this.elements.navButtons.forEach(btn => { btn.addEventListener("click", () => { this.setCategory(btn.dataset.category); }); }); // Add custom feed (use AbortController pattern to prevent duplicate listeners) const addFeedBtn = document.getElementById("btn-add-feed"); if (addFeedBtn && !addFeedBtn.dataset.listenerAttached) { addFeedBtn.addEventListener("click", () => this.addCustomFeed()); addFeedBtn.dataset.listenerAttached = "true"; } // Settings options document.getElementById("opt-group-by-source")?.addEventListener("change", e => { this.settings.groupBySource = e.target.checked; this.saveSettings(); this.renderFeeds(); }); document.getElementById("opt-show-descriptions")?.addEventListener("change", e => { this.settings.showDescriptions = e.target.checked; this.saveSettings(); this.renderFeeds(); }); document.getElementById("opt-max-items")?.addEventListener("change", e => { this.settings.maxItems = parseInt(e.target.value) || 20; this.saveSettings(); this.renderFeeds(); }); // Auto-refresh settings document.getElementById("opt-auto-refresh")?.addEventListener("change", e => { this.settings.autoRefresh = e.target.checked; this.saveSettings(); if (e.target.checked) { this.startAutoRefresh(); this.showToast("Auto-refresh enabled! ⏰", "success"); } else { this.stopAutoRefresh(); this.showToast("Auto-refresh disabled", "info"); } }); document.getElementById("opt-refresh-interval")?.addEventListener("change", e => { this.settings.refreshInterval = parseInt(e.target.value) || 10; this.saveSettings(); // Restart timer with new interval if auto-refresh is enabled if (this.settings.autoRefresh) { this.startAutoRefresh(); this.showToast(`Refresh interval set to ${e.target.value} minutes`, "success"); } }); // Enable/Disable all feeds buttons document.getElementById("btn-enable-all")?.addEventListener("click", () => { this.feeds.forEach(f => (f.enabled = true)); this.saveSettings(); this.renderSourcesList(); this.showToast("All feeds enabled!", "success"); }); document.getElementById("btn-disable-all")?.addEventListener("click", () => { this.feeds.forEach(f => (f.enabled = false)); this.saveSettings(); this.renderSourcesList(); this.showToast("All feeds disabled", "info"); }); // Language filter buttons this.elements.langButtons.forEach(btn => { btn.addEventListener("click", () => { this.setLanguage(btn.dataset.lang); }); }); // Reset all data button document.getElementById("btn-reset-all")?.addEventListener("click", () => { this.resetAllData(); }); } /** * Set active category filter */ setCategory(category) { this.currentCategory = category; // Update nav buttons this.elements.navButtons.forEach(btn => { btn.classList.toggle("active", btn.dataset.category === category); }); // Re-render feeds this.renderFeeds(); } /** * Setup scroll propagation for nested scrollable containers * Fixes the issue where scroll gets "trapped" inside .source-articles * when reaching the top or bottom of the container */ setupScrollPropagation() { // Use event delegation on the feeds container this.elements.feedsContainer.addEventListener( "wheel", e => { const target = e.target.closest(".source-articles"); if (!target) return; const { scrollTop, scrollHeight, clientHeight } = target; const isAtTop = scrollTop <= 0; const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; // If scrolling up and at top, or scrolling down and at bottom, // let the event propagate to the parent if ((e.deltaY < 0 && isAtTop) || (e.deltaY > 0 && isAtBottom)) { // Don't prevent default - let parent scroll return; } // Otherwise, prevent the event from reaching the parent // This keeps scroll inside the container when not at bounds if (scrollHeight > clientHeight) { e.stopPropagation(); } }, { passive: true } ); } /** * Setup Back to Top button behavior */ setupBackToTop() { const backToTopBtn = document.getElementById("back-to-top"); if (!backToTopBtn) return; // Show/hide button based on scroll position let ticking = false; window.addEventListener( "scroll", () => { if (!ticking) { requestAnimationFrame(() => { const scrollY = window.scrollY || document.documentElement.scrollTop; // Show button after scrolling 400px if (scrollY > 400) { backToTopBtn.classList.add("visible"); } else { backToTopBtn.classList.remove("visible"); } ticking = false; }); ticking = true; } }, { passive: true } ); // Click handler backToTopBtn.addEventListener("click", () => { this.scrollToTop(); }); } /** * Smooth scroll to top of page */ scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } /* ============================================ AUTO-REFRESH SYSTEM ============================================ */ /** * Start auto-refresh timer */ startAutoRefresh() { // Clear any existing timer this.stopAutoRefresh(); const intervalMinutes = this.settings.refreshInterval || 10; const intervalMs = intervalMinutes * 60 * 1000; // Set next refresh time this.nextRefreshTime = Date.now() + intervalMs; // Start main refresh timer this.autoRefreshTimer = setInterval(() => { console.log("[RSS] Auto-refresh triggered"); this.refreshFeeds(); // Reset next refresh time this.nextRefreshTime = Date.now() + intervalMs; }, intervalMs); // Start countdown display timer (update every 10 seconds) this.countdownTimer = setInterval(() => { this.updateAutoRefreshStatus(); }, 10000); // Update status immediately this.updateAutoRefreshStatus(); console.log(`[RSS] Auto-refresh started: every ${intervalMinutes} minutes`); } /** * Stop auto-refresh timer */ stopAutoRefresh() { if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; } if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } this.nextRefreshTime = null; this.updateAutoRefreshStatus(); } /** * Update auto-refresh status display */ updateAutoRefreshStatus() { const statusEl = document.getElementById("auto-refresh-status"); if (!statusEl) return; if (!this.settings.autoRefresh || !this.nextRefreshTime) { statusEl.textContent = "Auto-refresh is disabled"; statusEl.style.color = ""; return; } const remaining = this.nextRefreshTime - Date.now(); if (remaining <= 0) { statusEl.textContent = "Refreshing now..."; statusEl.style.color = "var(--ivy-green)"; return; } const minutes = Math.floor(remaining / 60000); const seconds = Math.floor((remaining % 60000) / 1000); if (minutes > 0) { statusEl.textContent = `⏰ Next refresh in ${minutes}m ${seconds}s`; } else { statusEl.textContent = `⏰ Next refresh in ${seconds}s`; } statusEl.style.color = "var(--ivy-green)"; } /** * Set active language filter */ setLanguage(lang) { this.currentLang = lang; // Update lang buttons this.elements.langButtons.forEach(btn => { btn.classList.toggle("active", btn.dataset.lang === lang); }); // Re-render feeds this.renderFeeds(); } /** * Refresh all feeds */ async refreshFeeds() { // Prevent concurrent refresh calls if (this.isRefreshing) { console.debug("[RSS] Refresh already in progress, skipping"); return; } this.isRefreshing = true; try { // Show loading state this.elements.btnRefresh.classList.add("spinning"); this.elements.btnRefresh.setAttribute("aria-busy", "true"); this.setStatus("Loading feeds...", ""); this.renderLoading(); // Get enabled feeds const enabledFeeds = this.feeds.filter(f => f.enabled); if (enabledFeeds.length === 0) { this.setStatus("No feeds enabled", ""); this.renderEmpty("No feeds enabled. Go to Settings to enable some feeds."); return; } // Use progressive loading with callback this.feedResults = []; await this.parser.fetchAllFeedsProgressive(enabledFeeds, (results, loaded, total) => { // Update results this.feedResults = results; // Calculate stats const successful = results.filter(r => r.status === "success").length; const fromCache = results.filter(r => r.fromCache).length; const maxItems = this.settings.maxItems || 10; // Count DISPLAYED articles (limited by maxItems per source) const displayedArticles = results .filter(r => r.feed) .reduce((sum, r) => sum + Math.min(r.feed.items.length, maxItems), 0); // Update status with progress const cacheInfo = fromCache > 0 ? ` (${fromCache} cached)` : ""; this.setStatus( `${successful}/${loaded} sources loaded${cacheInfo} • ~${displayedArticles} articles`, loaded < total ? `Loading ${loaded}/${total}...` : `Last updated: ${this.formatTime(new Date())}` ); // Render feeds progressively this.renderFeeds(); }); // Final update const successful = this.feedResults.filter(r => r.status === "success").length; const total = this.feedResults.length; const fromCache = this.feedResults.filter(r => r.fromCache).length; const maxItems = this.settings.maxItems || 10; const displayedArticles = this.feedResults .filter(r => r.feed) .reduce((sum, r) => sum + Math.min(r.feed.items.length, maxItems), 0); const cacheInfo = fromCache > 0 ? ` (${fromCache} ⚡)` : ""; this.setStatus( `${successful}/${total} sources${cacheInfo} • ${displayedArticles} articles`, `Last updated: ${this.formatTime(new Date())}` ); // Render feeds this.renderFeeds(); // Update sidebar with article data if (this.sidebar) { this.sidebar.updateWithArticles(this.feedResults); } } catch (error) { console.error("[RSS] Error during refresh:", error); this.setStatus("Error loading feeds", ""); this.showToast("Failed to load some feeds. Please try again.", "error"); } finally { // Always cleanup - stop spinner and reset flag this.elements.btnRefresh.classList.remove("spinning"); this.elements.btnRefresh.setAttribute("aria-busy", "false"); this.isRefreshing = false; } } /** * Render loading state */ renderLoading() { const enabledCount = this.feeds.filter(f => f.enabled).length; this.elements.feedsContainer.innerHTML = `
Fetching ${enabledCount} feeds...
First load may take a moment. Shift+R to force refresh.
${message}
Try changing filters or enabling more sources in Settings.