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 { RssUnifiedScopeSection } from './RssUnifiedScopeSection' import { canonicalizeRssArticleUrl, isClawstrDotComHttpUrl } from '@/lib/rss-article' import { addManualRssWebUrl, fetchDiscoveredWebUrlsFromRelays, loadManualRssWebUrls, loadPromotedRssThreadUrls, loadRssWebFeedScopePreference, loadRssWebHideUnifiedClutterPreference, loadRssWebSuppressClawstrPreference, buildArticleUrlFeedRows, isHttpArticleUrl, isRssWebUnifiedClutterUrl, mergeDiscoveredRssWebUrls, rssWebRowHasRealFeedItems, saveRssWebFeedScopePreference, saveRssWebHideUnifiedClutterPreference, 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' /** Cutoff timestamp (ms) for RSS+Web time filters; `null` = all time. */ function getRssTimeFilterCutoffMs(timeFilter: string): number | null { if (timeFilter === 'all') return null const now = Date.now() switch (timeFilter) { case 'hour': return now - 60 * 60 * 1000 case 'hours24': return now - 24 * 60 * 60 * 1000 case 'hours48': return now - 48 * 60 * 60 * 1000 case 'week': return now - 7 * 24 * 60 * 60 * 1000 case 'month': return now - 30 * 24 * 60 * 60 * 1000 default: return null } } 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}
) } function rssFeedItemArticleIsClawstrHost(item: TRssFeedItem): boolean { const l = item.link?.trim() if (!l || (!l.startsWith('http://') && !l.startsWith('https://'))) return false return isClawstrDotComHttpUrl(l) || isClawstrDotComHttpUrl(canonicalizeRssArticleUrl(l)) } 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 [suppressClawstrLinks, setSuppressClawstrLinks] = useState(true) const [hideUnifiedClutter, setHideUnifiedClutter] = useState(true) const refreshManualWebUrls = useCallback(() => { void loadManualRssWebUrls().then(setManualWebEntries) }, []) const [promotedThreadUrls, setPromotedThreadUrls] = useState([]) const promotedThreadUrlSet = useMemo(() => new Set(promotedThreadUrls), [promotedThreadUrls]) const refreshPromotedThreadUrls = useCallback(() => { void loadPromotedRssThreadUrls().then(setPromotedThreadUrls) }, []) useEffect(() => { void loadManualRssWebUrls().then(setManualWebEntries) }, []) useEffect(() => { void loadPromotedRssThreadUrls().then(setPromotedThreadUrls) }, []) /** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */ const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0) /** Subscribed feed URLs (same rules as RSS load effect) — used for IndexedDB snapshot. */ const resolvedFeedUrls = useMemo((): string[] => { if (pubkey && rssFeedListEvent) { try { return rssFeedListEvent.tags .filter((tag) => tag[0] === 'u' && tag[1]) .map((tag) => tag[1] as string) .filter((url): url is string => { if (typeof url !== 'string') return false return url.trim().length > 0 }) } catch { return [] } } return DEFAULT_RSS_FEEDS }, [pubkey, rssFeedListEvent]) /** Full IndexedDB slice for current subscriptions; merged into the filter pipeline so search/time/feed use all cached rows. */ const [idbRssSnapshot, setIdbRssSnapshot] = useState([]) const refreshIdbRssSnapshot = useCallback(() => { if (resolvedFeedUrls.length === 0) { setIdbRssSnapshot([]) return } void rssFeedService.getCachedItemsForFeedUrls(resolvedFeedUrls).then(setIdbRssSnapshot) }, [resolvedFeedUrls]) useEffect(() => { refreshIdbRssSnapshot() }, [refreshIdbRssSnapshot, items]) useEffect(() => { const id = window.setInterval(() => refreshIdbRssSnapshot(), 20_000) return () => clearInterval(id) }, [refreshIdbRssSnapshot]) const mergedRssItems = useMemo( () => rssFeedService.mergeRssFeedItemLists([items, idbRssSnapshot]), [items, idbRssSnapshot] ) // 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() mergedRssItems.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()) }, [mergedRssItems, 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 }) } } /** Single-select value for the RSS tab source dropdown (multi-select from filter popover → `__multi__`). */ const rssSourceDropdownValue = useMemo(() => { if (selectedFeeds.includes('all') || selectedFeeds.length === 0) return 'all' const urls = selectedFeeds.filter((f) => f !== 'all').map((f) => normalizeFeedUrl(f)) if (urls.length === 1) return urls[0]! return '__multi__' }, [selectedFeeds]) const onRssSourceSelect = useCallback((value: string) => { if (value === 'all') setSelectedFeeds(['all']) else setSelectedFeeds([value.trim().replace(/\/$/, '')]) }, []) /** Feed + time only (search is applied after merge so URL rows and links match too). */ const baseFilteredItems = useMemo(() => { let filtered = mergedRssItems if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f)) filtered = filtered.filter((item) => normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl)) ) } const cutoffTime = getRssTimeFilterCutoffMs(timeFilter) if (cutoffTime !== null) { filtered = filtered.filter((item) => { if (!item.pubDate) return false return item.pubDate.getTime() >= cutoffTime }) } return filtered }, [mergedRssItems, selectedFeeds, timeFilter]) /** When “hide clutter” is on, drop those entries from the feed (not only from URL cards). */ const rssWebItemsRespectingClutterPref = useMemo(() => { if (!hideUnifiedClutter) return baseFilteredItems return baseFilteredItems.filter((item) => { const link = item.link?.trim() if (!link || !isHttpArticleUrl(link)) return true return !isRssWebUnifiedClutterUrl(link) }) }, [baseFilteredItems, hideUnifiedClutter]) 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) || (item.feedUrl || '').toLowerCase().includes(query) || (item.feedDescription || '').toLowerCase().includes(query) || (item.feedImage || '').toLowerCase().includes(query) ) }, []) /** Match article / preview URLs and hostname (e.g. "spiegel" → www.spiegel.de paths). */ const articleHttpUrlMatchesSearch = useCallback((url: string, q: string) => { const query = q.toLowerCase().trim() if (!query) return true const lower = url.toLowerCase() if (lower.includes(query)) return true try { const host = new URL(url).hostname.toLowerCase() if (host.includes(query)) return true } catch { /* ignore */ } return false }, []) 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('urls') useEffect(() => { const handler = () => { setRelayDiscoveryTick((n) => n + 1) refreshManualWebUrls() refreshPromotedThreadUrls() } window.addEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler) return () => window.removeEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler) }, [refreshManualWebUrls, refreshPromotedThreadUrls]) useEffect(() => { let cancelled = false void (async () => { try { const discovered = await fetchDiscoveredWebUrlsFromRelays({ accountPubkey: pubkey, favoriteRelays: favoriteRelays ?? [], blockedRelays: blockedRelays ?? [], excludeClutterUrls: hideUnifiedClutter }) if (cancelled) return setRelayDiscoveredUrls(discovered) const didMerge = await mergeDiscoveredRssWebUrls(discovered) if (didMerge && !cancelled) refreshManualWebUrls() } catch { if (!cancelled) setRelayDiscoveredUrls([]) } })() return () => { cancelled = true } }, [pubkey, favoriteRelays, blockedRelays, refreshManualWebUrls, relayDiscoveryTick, hideUnifiedClutter]) const combinedFeedRows = useMemo((): CombinedFeedRow[] => { const { webRows, nonHttpItems } = buildArticleUrlFeedRows( rssWebItemsRespectingClutterPref, manualWebEntries, relayDiscoveredUrls, { excludeClutterLinks: hideUnifiedClutter } ) const rest: CombinedFeedRow[] = nonHttpItems.map((item) => ({ kind: 'rss' as const, item })) const merged = [...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 }) if (!suppressClawstrLinks) return merged return merged.filter((row) => { if (row.kind === 'web') return !isClawstrDotComHttpUrl(row.canonicalUrl) return !rssFeedItemArticleIsClawstrHost(row.item) }) }, [ rssWebItemsRespectingClutterPref, manualWebEntries, relayDiscoveredUrls, suppressClawstrLinks, hideUnifiedClutter ]) /** Time window applies to URL cards too (latestPub / item pubDate), not only RSS rows. */ const combinedFeedRowsInTimeRange = useMemo((): CombinedFeedRow[] => { const cutoff = getRssTimeFilterCutoffMs(timeFilter) if (cutoff === null) return combinedFeedRows return combinedFeedRows.filter((row) => { if (row.kind === 'rss') { const ts = row.item.pubDate?.getTime() ?? 0 return ts >= cutoff } return row.latestPub >= cutoff }) }, [combinedFeedRows, timeFilter]) const combinedFeedRowsForSearch = useMemo((): CombinedFeedRow[] => { const q = searchQuery.trim() if (!q) return combinedFeedRowsInTimeRange return combinedFeedRowsInTimeRange.filter((row) => { if (row.kind === 'rss') { return rssItemMatchesSearch(row.item, q) } if (articleHttpUrlMatchesSearch(row.canonicalUrl, q)) return true return row.rssItems.some((it) => rssItemMatchesSearch(it, q)) }) }, [combinedFeedRowsInTimeRange, searchQuery, rssItemMatchesSearch, articleHttpUrlMatchesSearch]) const urlScopeRows = useMemo((): UnifiedFeedRow[] => { return combinedFeedRowsForSearch .filter( (r): r is Extract => r.kind === 'web' && (!rssWebRowHasRealFeedItems(r.rssItems) || promotedThreadUrlSet.has(r.canonicalUrl)) ) .sort((a, b) => b.latestPub - a.latestPub) .map((r) => ({ kind: 'url' as const, canonicalUrl: r.canonicalUrl, rssItems: r.rssItems })) }, [combinedFeedRowsForSearch, promotedThreadUrlSet]) const rssScopeRows = useMemo((): UnifiedFeedRow[] => { const picked = combinedFeedRowsForSearch.filter((r) => { if (r.kind === 'rss') { const link = r.item.link?.trim() if (link && isHttpArticleUrl(link)) { if (promotedThreadUrlSet.has(canonicalizeRssArticleUrl(link))) return false } return true } if (r.kind === 'web' && rssWebRowHasRealFeedItems(r.rssItems)) { return !promotedThreadUrlSet.has(r.canonicalUrl) } return false }) const sorted = [...picked].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 }) return sorted.map((r) => r.kind === 'web' ? { kind: 'url' as const, canonicalUrl: r.canonicalUrl, rssItems: r.rssItems } : { kind: 'rssEntry' as const, item: r.item } ) }, [combinedFeedRowsForSearch, promotedThreadUrlSet]) const feedDisplayBase = useMemo( () => ({ rows: feedScope === 'urls' ? urlScopeRows : rssScopeRows }), [feedScope, urlScopeRows, rssScopeRows] ) const persistSuppressClawstr = useCallback((checked: boolean) => { rssWebPrefsUserTouchedRef.current = true setSuppressClawstrLinks(checked) void saveRssWebSuppressClawstrPreference(checked) }, []) const persistHideUnifiedClutter = useCallback((checked: boolean) => { rssWebPrefsUserTouchedRef.current = true setHideUnifiedClutter(checked) void saveRssWebHideUnifiedClutterPreference(checked) }, []) const persistFeedScope = useCallback((scope: RssWebFeedScope) => { rssWebPrefsUserTouchedRef.current = true setFeedScope(scope) void saveRssWebFeedScopePreference(scope) }, []) useEffect(() => { let cancelled = false void (async () => { const [suppressClawstr, hideClutter, scope] = await Promise.all([ loadRssWebSuppressClawstrPreference(), loadRssWebHideUnifiedClutterPreference(), loadRssWebFeedScopePreference() ]) if (cancelled || rssWebPrefsUserTouchedRef.current) return setSuppressClawstrLinks(suppressClawstr) setHideUnifiedClutter(hideClutter) setFeedScope(scope) })() return () => { cancelled = true } }, []) const feedTotalCount = feedDisplayBase.rows.length // Reset pagination when filters change useEffect(() => { setShowRowCount(20) }, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks, hideUnifiedClutter]) const displayedFeed = useMemo( () => ({ rows: feedDisplayBase.rows.slice(0, showRowCount) }), [feedDisplayBase, showRowCount] ) const displayedCount = 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 (mergedRssItems.length === 0 && manualWebEntries.length === 0) { return (

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

) } return (
{/* Feed header — view mode, display prefs, counts */}
persistSuppressClawstr(c === true)} />
persistHideUnifiedClutter(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...')} />
{feedScope === 'rss' ? (
) : null}
{/* 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 && }
) })}
)} {/* Content */}
{refreshing && (
)} {feedTotalCount === 0 ? (

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

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