import { useEffect, useState, useMemo, useRef } 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, Search, Plus } from 'lucide-react' import logger from '@/lib/logger' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { Check, ChevronDown } from 'lucide-react' import { useSmartRssArticleNavigation } from '@/PageManager' import { normalizeHttpArticleUrl } from '@/lib/rss-article' function ManualRssUrlAddRow({ className }: { className?: string }) { const { t } = useTranslation() const { navigateToRssArticle } = useSmartRssArticleNavigation() const [open, setOpen] = useState(false) const [value, setValue] = useState('') const [error, setError] = useState('') const submit = () => { setError('') const url = normalizeHttpArticleUrl(value) if (!url) { setError(t('Enter a valid http(s) URL')) return } setOpen(false) setValue('') navigateToRssArticle(url) } return ( <> {t('Add a web URL')} {t('Open any https page in the side panel to reply, react, and discuss on Nostr.')} { setValue(e.target.value) setError('') }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() submit() } }} autoFocus /> {error ?

{error}

: null}
) } export default function RssFeedList() { const { t } = useTranslation() const { pubkey, rssFeedListEvent } = useNostr() const { isSmallScreen } = useScreenSize() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [refreshing, setRefreshing] = useState(false) // Filter states const [selectedFeeds, setSelectedFeeds] = useState(['all']) const [timeFilter, setTimeFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [showFilters, setShowFilters] = useState(false) const [isCompactView, setIsCompactView] = useState(true) const [feedPopoverOpen, setFeedPopoverOpen] = useState(false) // Pagination state const [showCount, setShowCount] = useState(25) const bottomRef = useRef(null) // Listen for filter toggle events useEffect(() => { const handleToggleFilters = () => { setShowFilters(prev => !prev) } window.addEventListener('toggleRssFilters', handleToggleFilters) return () => { window.removeEventListener('toggleRssFilters', handleToggleFilters) } }, []) 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[] = [] if (pubkey && rssFeedListEvent) { // User has an event - use only feeds from that event (even if empty) 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 }) feedUrls = urls if (urls.length > 0) { logger.info('[RssFeedList] Loaded RSS feed list from context', { feedCount: urls.length, eventId: rssFeedListEvent.id, urls }) } else { logger.info('[RssFeedList] RSS feed list event exists but is empty - will show empty feed') } } catch (e) { logger.error('[RssFeedList] Failed to parse RSS feed list from tags', { error: e, tags: rssFeedListEvent.tags }) // On parse error, treat as empty event (don't use defaults) feedUrls = [] } } else if (pubkey) { // No event exists - use default feeds for demo logger.info('[RssFeedList] No RSS feed list event in context, using default feeds') feedUrls = DEFAULT_RSS_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 }) } }) } else { // No pubkey - use default feeds feedUrls = DEFAULT_RSS_FEEDS } // 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]) // Normalize feed URL to prevent duplicates (e.g., with/without trailing slash) // This matches the normalization used in rss-feed.service.ts const normalizeFeedUrl = (url: string): string => { return url.trim().replace(/\/$/, '') } // Get unique feed URLs and titles from items // Normalize URLs to prevent duplicates (e.g., with/without trailing slash) const availableFeeds = useMemo(() => { const feedMap = new Map() items.forEach(item => { const normalizedUrl = normalizeFeedUrl(item.feedUrl) if (!feedMap.has(normalizedUrl)) { feedMap.set(normalizedUrl, { url: normalizedUrl, title: item.feedTitle || item.feedUrl }) } }) return Array.from(feedMap.values()) }, [items]) // Helper function to truncate text const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text return text.slice(0, maxLength) + '...' } // Handle feed selection change const handleFeedToggle = (feedUrl: string, checked: boolean) => { if (feedUrl === 'all') { // If "all" is checked, clear all other selections setSelectedFeeds(checked ? ['all'] : []) } else { // If a specific feed is checked, remove "all" if present setSelectedFeeds(prev => { const newSelection = checked ? [...prev.filter(f => f !== 'all'), feedUrl] : prev.filter(f => f !== feedUrl) // If nothing is selected, default to "all" return newSelection.length === 0 ? ['all'] : newSelection }) } } // Filter items based on selected filters const filteredItems = useMemo(() => { let filtered = items // Filter by feed if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { const normalizedSelectedFeeds = selectedFeeds.map(f => normalizeFeedUrl(f)) filtered = filtered.filter(item => normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl)) ) } // Filter by time if (timeFilter !== 'all') { const now = Date.now() let cutoffTime = 0 switch (timeFilter) { case 'hour': cutoffTime = now - 60 * 60 * 1000 break case 'day': cutoffTime = now - 24 * 60 * 60 * 1000 break case 'week': cutoffTime = now - 7 * 24 * 60 * 60 * 1000 break case 'month': cutoffTime = now - 30 * 24 * 60 * 60 * 1000 break } filtered = filtered.filter(item => { if (!item.pubDate) return false return item.pubDate.getTime() >= cutoffTime }) } // Filter by search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase().trim() filtered = filtered.filter(item => { const titleMatch = item.title.toLowerCase().includes(query) const descMatch = item.description.toLowerCase().includes(query) const feedMatch = (item.feedTitle || '').toLowerCase().includes(query) return titleMatch || descMatch || feedMatch }) } return filtered }, [items, selectedFeeds, timeFilter, searchQuery]) // Reset showCount when filters change useEffect(() => { setShowCount(25) }, [selectedFeeds, timeFilter, searchQuery]) // Pagination: slice to showCount for display const displayedItems = useMemo(() => { return filteredItems.slice(0, showCount) }, [filteredItems, showCount]) // IntersectionObserver for infinite scroll useEffect(() => { if (!bottomRef.current || displayedItems.length >= filteredItems.length) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && displayedItems.length < filteredItems.length) { setShowCount((prev) => Math.min(prev + 25, filteredItems.length)) } }, { root: null, rootMargin: '100px', threshold: 0.1 } ) observer.observe(bottomRef.current) return () => { observer.disconnect() } }, [displayedItems.length, filteredItems.length]) // Get display text for feed selector const feedSelectorText = useMemo(() => { if (selectedFeeds.includes('all') || selectedFeeds.length === 0) { return t('All feeds') } if (selectedFeeds.length === 1) { const feed = availableFeeds.find(f => f.url === selectedFeeds[0]) return feed ? truncateText(feed.title, 50) : t('All feeds') } return t('{{count}} feeds', { count: selectedFeeds.length }) }, [selectedFeeds, availableFeeds, t]) if (loading) { return (

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

) } if (error) { return (

{error}

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

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

) } return (
{/* Feed Counter Header - Always visible */}

{t('Showing {{filtered}} of {{total}} items', { filtered: displayedItems.length, total: filteredItems.length })}

{/* Filter Bar - Collapsible */} {showFilters && (
{/* Feed Selector - Multi-select with Popover */}
{/* All feeds option */}
{ const isAllSelected = selectedFeeds.includes('all') handleFeedToggle('all', !isAllSelected) }} >
{selectedFeeds.includes('all') && }
{/* Individual feed options */} {availableFeeds.map((feed) => { const isChecked = selectedFeeds.includes(feed.url) return (
handleFeedToggle(feed.url, !isChecked)} >
{isChecked && }
) })}
{/* Time Filter */} {/* Search Box */}
setSearchQuery(e.target.value)} className="h-8 md:h-9 pl-7 md:pl-8 text-xs md:text-sm w-full" />
)} {/* Content */}
{refreshing && (
{t('Refreshing feeds...')}
)} {displayedItems.length === 0 ? (

{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all' ? t('No items match your filters') : t('No RSS feed items available')}

) : ( <> {displayedItems.map((item) => ( ))} {/* Bottom ref for infinite scroll */} {displayedItems.length < filteredItems.length && (
)} )}
) }