import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import { DEFAULT_RSS_FEEDS } from '@/constants' import RssFeedItem from '../RssFeedItem' import { Loader, AlertCircle } from 'lucide-react' import logger from '@/lib/logger' export default function RssFeedList() { const { t } = useTranslation() const { pubkey, rssFeedListEvent } = useNostr() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [refreshing, setRefreshing] = useState(false) useEffect(() => { // Create AbortController for this effect let abortController = new AbortController() let isMounted = true let isLoading = false let timeoutId: NodeJS.Timeout | null = null const loadRssFeeds = async (forceNewController = false) => { // If forced, create a new controller (for manual refreshes) if (forceNewController) { abortController.abort() // Abort old one abortController = new AbortController() } // Check if already aborted or if a load is already in progress if (abortController.signal.aborted || isLoading) { logger.debug('[RssFeedList] Skipping load - already aborted or loading', { aborted: abortController.signal.aborted, isLoading }) return } // Clear any existing timeout if (timeoutId) { clearTimeout(timeoutId) timeoutId = null } isLoading = true setLoading(true) setError(null) // Set a timeout to prevent infinite loading (30 seconds) timeoutId = setTimeout(() => { if (isMounted && isLoading) { logger.warn('[RssFeedList] Feed loading timeout - aborting and showing partial results') abortController.abort() isLoading = false if (isMounted) { setLoading(false) } } }, 30000) try { // Get feed URLs from event or use default let feedUrls: string[] = DEFAULT_RSS_FEEDS if (pubkey && rssFeedListEvent) { try { // Extract URLs from "u" tags const urls = rssFeedListEvent.tags .filter(tag => tag[0] === 'u' && tag[1]) .map(tag => tag[1] as string) .filter((url): url is string => { if (typeof url !== 'string') { logger.warn('[RssFeedList] Invalid RSS feed URL (not a string)', { url, type: typeof url }) return false } const trimmed = url.trim() if (trimmed.length === 0) { logger.warn('[RssFeedList] Empty RSS feed URL found') return false } return true }) if (urls.length > 0) { feedUrls = urls logger.info('[RssFeedList] Loaded RSS feed list from context', { feedCount: urls.length, eventId: rssFeedListEvent.id, urls }) } else { logger.info('[RssFeedList] RSS feed list is empty or contains no valid URLs, using default feeds') } } catch (e) { logger.error('[RssFeedList] Failed to parse RSS feed list from tags', { error: e, tags: rssFeedListEvent.tags }) // Use default feeds on parse error } } else if (pubkey) { logger.info('[RssFeedList] No RSS feed list event in context, using default feeds') // Trigger background refresh for default feeds when no event exists rssFeedService.backgroundRefreshFeeds(feedUrls, abortController.signal).catch(err => { if (!(err instanceof DOMException && err.name === 'AbortError')) { logger.error('[RssFeedList] Background refresh of default feeds failed', { error: err }) } }) } // Check if aborted before fetching if (abortController.signal.aborted || !isMounted) { return } // Fetch and merge feeds (cache-first: returns cached items immediately, background-refreshes) // Show refreshing indicator (background refresh will run in background, or we'll wait if cache is empty) if (isMounted) { setRefreshing(true) } const fetchedItems = await rssFeedService.fetchMultipleFeeds(feedUrls, abortController.signal) // Always set items if we got them, even if signal was aborted (abort might happen after fetch completes) // Only skip setting items if component unmounted if (!isMounted) { setRefreshing(false) return } // Set items regardless of abort status (abort might have happened after fetch completed) if (fetchedItems.length === 0) { // No items were successfully fetched, but don't show error if we tried // The fetchMultipleFeeds already logs warnings for failed feeds setError(null) // Clear any previous error } setItems(fetchedItems) // Check if aborted after setting items (for cleanup) if (abortController.signal.aborted) { logger.debug('[RssFeedList] Signal was aborted after fetching, but items were set', { itemCount: fetchedItems.length }) } // Set up a listener for cache updates (background refresh may add new items) // Re-check cache after a delay to see if background refresh added items const checkForUpdates = async () => { if (abortController.signal.aborted || !isMounted) { if (isMounted) { setRefreshing(false) } return } try { const updatedItems = await rssFeedService.fetchMultipleFeeds(feedUrls, abortController.signal) if (!abortController.signal.aborted && isMounted) { setRefreshing(false) if (updatedItems.length > fetchedItems.length) { // New items were added by background refresh setItems(updatedItems) logger.info('[RssFeedList] Updated items from background refresh', { previousCount: fetchedItems.length, newCount: updatedItems.length }) } } } catch (err) { if (isMounted) { setRefreshing(false) } // Ignore errors in update check } } // Check for updates after 5 seconds (background refresh should be done by then) setTimeout(checkForUpdates, 5000) } catch (err) { // Don't handle abort errors - they're expected during cleanup if (err instanceof DOMException && err.name === 'AbortError') { return } // Check if still mounted before setting error if (!isMounted) { return } logger.error('[RssFeedList] Error loading RSS feeds', { error: err }) // Don't set error state - fetchMultipleFeeds handles individual feed failures gracefully // Only set error if there's a critical issue (like network completely down) if (err instanceof TypeError && err.message.includes('Failed to fetch')) { // Network error - might be temporary, don't show persistent error setError(null) } else { setError(err instanceof Error ? err.message : t('Failed to load RSS feeds')) } } finally { isLoading = false if (timeoutId) { clearTimeout(timeoutId) timeoutId = null } // Only update loading state if still mounted if (isMounted) { setLoading(false) // If we had no cached items, background refresh was awaited, so stop refreshing indicator if (items.length === 0) { setRefreshing(false) } } } } loadRssFeeds() // Listen for RSS feed list updates const handleRssFeedListUpdate = (event: CustomEvent) => { const detail = event.detail as { pubkey: string; feedUrls: string[]; eventId: string } // Only refresh if it's for the current user if (detail.pubkey === pubkey && isMounted) { logger.info('[RssFeedList] Received RSS feed list update event, refreshing...', { eventId: detail.eventId, feedCount: detail.feedUrls.length }) // For manual refresh, show refreshing indicator if (detail.eventId === 'manual-refresh' && isMounted) { setRefreshing(true) } // For manual refresh, the background refresh is already triggered by the button // Just reload to show updated items (background refresh will update cache in the background) // For other updates (like event changes), also just reload loadRssFeeds(true) } } window.addEventListener('rssFeedListUpdated', handleRssFeedListUpdate as EventListener) return () => { isMounted = false isLoading = false if (timeoutId) { clearTimeout(timeoutId) } // Abort any in-flight requests abortController.abort() window.removeEventListener('rssFeedListUpdated', handleRssFeedListUpdate as EventListener) } }, [pubkey, rssFeedListEvent, t]) if (loading) { return (

{t('Loading RSS feeds...')}

) } if (error) { return (

{error}

) } if (items.length === 0) { return (

{t('No RSS feed items available')}

) } return (
{refreshing && (
{t('Refreshing feeds...')}
)} {items.map((item) => ( ))}
) }