import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import { DEFAULT_RSS_FEEDS } from '@/constants' import RssFeedItem from '../RssFeedItem' import RssWebFeedCard from '../RssWebFeedCard' import { ArticleUrlsSection } from './ArticleUrlsSection' import { RssEntriesSection } from './RssEntriesSection' import { addManualRssWebUrl, fetchDiscoveredWebUrlsFromRelays, loadManualRssWebUrls, loadRssWebFeedScopePreference, loadRssWebSuppressClawstrPreference, buildArticleUrlFeedRows, mergeDiscoveredRssWebUrls, saveRssWebFeedScopePreference, saveRssWebSuppressClawstrPreference, WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, type ManualRssWebUrlEntry, type RssWebFeedScope } from '@/lib/rss-web-feed' import { RssFeedDisplayPrefsProvider } from './RssFeedDisplayPrefsContext' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { 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 { 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 { normalizeHttpArticleUrl } from '@/lib/rss-article' import { getRssFeedUrlHostname, getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url' import { StandardRssFeedUrlInline } from '@/components/StandardRssFeedUrlRow' function ManualRssUrlAddRow({ className, onUrlAdded }: { className?: string onUrlAdded: () => void | Promise }) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [value, setValue] = useState('') const [error, setError] = useState('') const [saving, setSaving] = useState(false) const submit = () => { setError('') const url = normalizeHttpArticleUrl(value) if (!url) { setError(t('Enter a valid http(s) URL')) return } setSaving(true) void (async () => { try { await addManualRssWebUrl(url) setOpen(false) setValue('') await Promise.resolve(onUrlAdded()) } finally { setSaving(false) } })() } return ( <> {t('Add a web URL')} {t('Add web URL to feed description')} { 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 { favoriteRelays, blockedRelays } = useFavoriteRelays() 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 [feedPopoverOpen, setFeedPopoverOpen] = useState(false) // Pagination state (merged RSS+Web rows) const [showRowCount, setShowRowCount] = useState(20) const bottomRef = useRef(null) /** True after user changes RSS+Web scope or “only my web events”; blocks async prefs from overwriting. */ const rssWebPrefsUserTouchedRef = useRef(false) const [manualWebEntries, setManualWebEntries] = useState([]) /** Latest relay discovery (in-memory); URLs appear as faux cards even before IndexedDB merge. */ const [relayDiscoveredUrls, setRelayDiscoveredUrls] = useState([]) const refreshManualWebUrls = useCallback(() => { void loadManualRssWebUrls().then(setManualWebEntries) }, []) useEffect(() => { void loadManualRssWebUrls().then(setManualWebEntries) }, []) /** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */ const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0) // 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 { 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)) { const profile = getStandardRssFeedProfile(normalizedUrl) const fallback = profile ? t(profile.labelKey, { defaultValue: profile.defaultLabel }) : getRssFeedUrlHostname(normalizedUrl) feedMap.set(normalizedUrl, { url: normalizedUrl, title: item.feedTitle?.trim() || fallback }) } }) return Array.from(feedMap.values()) }, [items, t]) // 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 }) } } /** Feed + time only (search is applied after merge so URL rows and links match too). */ const baseFilteredItems = useMemo(() => { let filtered = items if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f)) filtered = filtered.filter((item) => normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl)) ) } 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 }) } return filtered }, [items, selectedFeeds, timeFilter]) const rssItemMatchesSearch = useCallback((item: TRssFeedItem, q: string) => { const query = q.toLowerCase().trim() if (!query) return true return ( item.title.toLowerCase().includes(query) || item.description.toLowerCase().includes(query) || (item.feedTitle || '').toLowerCase().includes(query) || (item.link || '').toLowerCase().includes(query) || (item.guid || '').toLowerCase().includes(query) ) }, []) /** RSS-only view: flat timeline with full-text search. */ const rssScopeItems = useMemo(() => { const q = searchQuery.trim() let list = baseFilteredItems if (q) { list = list.filter((item) => rssItemMatchesSearch(item, q)) } return [...list].sort( (a, b) => (b.pubDate?.getTime() ?? 0) - (a.pubDate?.getTime() ?? 0) ) }, [baseFilteredItems, searchQuery, rssItemMatchesSearch]) type CombinedFeedRow = | { kind: 'web'; canonicalUrl: string; rssItems: TRssFeedItem[]; latestPub: number } | { kind: 'rss'; item: TRssFeedItem } type UnifiedFeedRow = | { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] } | { kind: 'rssEntry'; item: TRssFeedItem } const [feedScope, setFeedScope] = useState('both') useEffect(() => { const handler = () => setRelayDiscoveryTick((n) => n + 1) window.addEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler) return () => window.removeEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler) }, []) useEffect(() => { if (feedScope === 'rss') return let cancelled = false void (async () => { try { const discovered = await fetchDiscoveredWebUrlsFromRelays({ accountPubkey: pubkey, favoriteRelays: favoriteRelays ?? [], blockedRelays: blockedRelays ?? [] }) if (cancelled) return setRelayDiscoveredUrls(discovered) const didMerge = await mergeDiscoveredRssWebUrls(discovered) if (didMerge && !cancelled) refreshManualWebUrls() } catch { if (!cancelled) setRelayDiscoveredUrls([]) } })() return () => { cancelled = true } }, [feedScope, pubkey, favoriteRelays, blockedRelays, refreshManualWebUrls, relayDiscoveryTick]) const combinedFeedRows = useMemo((): CombinedFeedRow[] => { const { webRows, nonHttpItems } = buildArticleUrlFeedRows( baseFilteredItems, manualWebEntries, relayDiscoveredUrls ) const rest: CombinedFeedRow[] = nonHttpItems.map((item) => ({ kind: 'rss' as const, item })) return [...webRows, ...rest].sort((a, b) => { const ta = a.kind === 'web' ? a.latestPub : (a.item.pubDate?.getTime() ?? 0) const tb = b.kind === 'web' ? b.latestPub : (b.item.pubDate?.getTime() ?? 0) return tb - ta }) }, [baseFilteredItems, manualWebEntries, relayDiscoveredUrls]) const combinedFeedRowsForSearch = useMemo((): CombinedFeedRow[] => { const q = searchQuery.trim() if (!q) return combinedFeedRows return combinedFeedRows.filter((row) => { if (row.kind === 'rss') { return rssItemMatchesSearch(row.item, q) } if (row.canonicalUrl.toLowerCase().includes(q.toLowerCase())) return true return row.rssItems.some((it) => rssItemMatchesSearch(it, q)) }) }, [combinedFeedRows, searchQuery, rssItemMatchesSearch]) /** Canonical URLs we know from Nostr (relay discovery or user-added), not RSS-only grouping. */ const urlKeysWithNostrFootprint = useMemo(() => { const s = new Set() for (const e of manualWebEntries) s.add(e.url) for (const e of relayDiscoveredUrls) s.add(e.url) return s }, [manualWebEntries, relayDiscoveredUrls]) /** What to show before “only my web events” (used for Nostr URL list). */ const feedDisplayBase = useMemo((): | { view: 'rss'; items: TRssFeedItem[] } | { view: 'unified'; rows: UnifiedFeedRow[] } => { if (feedScope === 'rss') { return { view: 'rss', items: rssScopeItems } } if (feedScope === 'urls') { const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch .filter((r): r is Extract => r.kind === 'web') .filter((r) => { const hasRss = r.rssItems.length > 0 const hasNostr = urlKeysWithNostrFootprint.has(r.canonicalUrl) if (hasRss && !hasNostr) return false return true }) .map((r) => ({ kind: 'url' as const, canonicalUrl: r.canonicalUrl, rssItems: [] })) return { view: 'unified', rows } } const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch.map((r) => r.kind === 'web' ? { kind: 'url' as const, canonicalUrl: r.canonicalUrl, rssItems: r.rssItems } : { kind: 'rssEntry' as const, item: r.item } ) return { view: 'unified', rows } }, [feedScope, rssScopeItems, combinedFeedRowsForSearch, urlKeysWithNostrFootprint]) const [suppressClawstrLinks, setSuppressClawstrLinks] = useState(true) const persistSuppressClawstr = useCallback((checked: boolean) => { rssWebPrefsUserTouchedRef.current = true setSuppressClawstrLinks(checked) void saveRssWebSuppressClawstrPreference(checked) }, []) const persistFeedScope = useCallback((scope: RssWebFeedScope) => { rssWebPrefsUserTouchedRef.current = true setFeedScope(scope) void saveRssWebFeedScopePreference(scope) }, []) useEffect(() => { let cancelled = false void (async () => { const [suppressClawstr, scope] = await Promise.all([ loadRssWebSuppressClawstrPreference(), loadRssWebFeedScopePreference() ]) if (cancelled || rssWebPrefsUserTouchedRef.current) return setSuppressClawstrLinks(suppressClawstr) setFeedScope(scope) })() return () => { cancelled = true } }, []) const feedTotalCount = feedDisplayBase.view === 'rss' ? feedDisplayBase.items.length : feedDisplayBase.rows.length // Reset pagination when filters change useEffect(() => { setShowRowCount(20) }, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks]) const displayedFeed = useMemo((): | { view: 'rss'; items: TRssFeedItem[] } | { view: 'unified'; rows: UnifiedFeedRow[] } => { if (feedDisplayBase.view === 'rss') { return { view: 'rss' as const, items: feedDisplayBase.items.slice(0, showRowCount) } } return { view: 'unified' as const, rows: feedDisplayBase.rows.slice(0, showRowCount) } }, [feedDisplayBase, showRowCount]) const displayedCount = displayedFeed.view === 'rss' ? displayedFeed.items.length : displayedFeed.rows.length // IntersectionObserver for infinite scroll useEffect(() => { if (!bottomRef.current || displayedCount >= feedTotalCount) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && displayedCount < feedTotalCount) { setShowRowCount((prev) => Math.min(prev + 20, feedTotalCount)) } }, { root: null, rootMargin: '100px', threshold: 0.1 } ) observer.observe(bottomRef.current) return () => { observer.disconnect() } }, [displayedCount, feedTotalCount]) // 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...')}

{Array.from({ length: 6 }).map((_, i) => ( ))}
) } if (error) { return (

{error}

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

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

) } return (
{/* Feed header — view mode, display prefs, counts */}
persistSuppressClawstr(c === true)} />

{t('Showing {{filtered}} of {{total}} entries', { filtered: displayedCount, total: feedTotalCount })}

setSearchQuery(e.target.value)} className="h-8 w-full pl-8 text-xs sm:h-9 sm:pl-9 sm:text-sm" aria-label={t('Search...')} />
{/* 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 */}
)} {/* Content */}
{refreshing && (
)} {feedTotalCount === 0 ? (

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

) : displayedFeed.view === 'rss' ? ( <> {displayedCount < feedTotalCount ? (
) : null} ) : feedScope === 'urls' ? ( <> {displayedFeed.rows .filter((r): r is Extract => r.kind === 'url') .map((row) => ( ))} {displayedCount < feedTotalCount ? (
) : null} ) : ( <>
{displayedFeed.rows.map((row) => row.kind === 'url' ? ( ) : (
) )}
{displayedCount < feedTotalCount ? (
) : null} )}
) }