You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1010 lines
37 KiB

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<void>
}) {
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 (
<>
<Button
type="button"
variant="outline"
className={className ?? 'w-full justify-start gap-2 text-muted-foreground border-dashed'}
onClick={() => setOpen(true)}
>
<Plus className="h-4 w-4 shrink-0" />
{t('+ Add a URL to this list')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('Add a web URL')}</DialogTitle>
<DialogDescription>
{t('Add web URL to feed description')}
</DialogDescription>
</DialogHeader>
<Input
placeholder="https://example.com/article"
value={value}
onChange={(e) => {
setValue(e.target.value)
setError('')
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
}}
autoFocus
/>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
{t('Cancel')}
</Button>
<Button type="button" disabled={saving} onClick={submit}>
{t('Add to feed')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
export default function RssFeedList() {
const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { isSmallScreen } = useScreenSize()
const [items, setItems] = useState<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
// Filter states
const [selectedFeeds, setSelectedFeeds] = useState<string[]>(['all'])
const [timeFilter, setTimeFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState<string>('')
const [showFilters, setShowFilters] = useState<boolean>(false)
const [feedPopoverOpen, setFeedPopoverOpen] = useState<boolean>(false)
// Pagination state (merged RSS+Web rows)
const [showRowCount, setShowRowCount] = useState<number>(20)
const bottomRef = useRef<HTMLDivElement>(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<ManualRssWebUrlEntry[]>([])
/** Latest relay discovery (in-memory); URLs appear as faux cards even before IndexedDB merge. */
const [relayDiscoveredUrls, setRelayDiscoveredUrls] = useState<ManualRssWebUrlEntry[]>([])
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<string, { url: string; title: string }>()
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<RssWebFeedScope>('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<string>()
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<CombinedFeedRow, { kind: 'web' }> => 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 (
<div className="space-y-3 px-4 py-8" role="status" aria-busy="true" aria-live="polite">
<p className="text-sm text-muted-foreground">{t('Loading RSS feeds...')}</p>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-lg" />
))}
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4">
<AlertCircle className="h-8 w-8 text-destructive mb-4" />
<p className="text-sm text-destructive text-center">{error}</p>
</div>
)
}
if (items.length === 0 && manualWebEntries.length === 0) {
return (
<div className="space-y-4 px-4 py-6">
<ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
<p className="text-sm text-muted-foreground text-center">{t('No RSS feed items available')}</p>
</div>
)
}
return (
<RssFeedDisplayPrefsProvider value={{ suppressClawstrLinks }}>
<div className="space-y-3">
{/* Feed header — view mode, display prefs, counts */}
<div className="sticky top-0 z-10 space-y-1.5 border-b bg-background px-4 py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div
className="inline-flex max-w-full flex-wrap rounded-md border border-border bg-muted/30 p-0.5 sm:flex-nowrap"
role="group"
aria-label={t('RSS feed view mode')}
>
<Button
type="button"
variant={feedScope === 'urls' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('urls')}
>
{t('URLs')}
</Button>
<Button
type="button"
variant={feedScope === 'both' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('both')}
>
{t('Both')}
</Button>
<Button
type="button"
variant={feedScope === 'rss' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('rss')}
>
{t('RSS')}
</Button>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="suppress-clawstr-links"
checked={suppressClawstrLinks}
onCheckedChange={(c) => persistSuppressClawstr(c === true)}
/>
<Label
htmlFor="suppress-clawstr-links"
className="cursor-pointer text-xs text-muted-foreground"
>
{t('Suppress Clawstr links in RSS previews')}
</Label>
</div>
</div>
<p className="text-xs text-muted-foreground sm:text-right">
{t('Showing {{filtered}} of {{total}} entries', {
filtered: displayedCount,
total: feedTotalCount
})}
</p>
</div>
<div className="relative w-full max-w-xl">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground sm:h-4 sm:w-4" />
<Input
type="search"
placeholder={t('Search...')}
value={searchQuery}
onChange={(e) => 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...')}
/>
</div>
</div>
{/* Filter Bar - Collapsible */}
{showFilters && (
<div className="sticky top-[2.5rem] z-10 bg-background border-b px-4 py-2">
<div className={`flex ${isSmallScreen ? 'flex-col' : 'flex-row'} items-stretch gap-2`}>
{/* Feed Selector - Multi-select with Popover */}
<Popover open={feedPopoverOpen} onOpenChange={setFeedPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 text-xs md:text-sm md:h-9 flex-shrink-0 w-full md:w-auto justify-between"
style={{ minWidth: isSmallScreen ? '100%' : '300px' }}
>
<span className="truncate">{feedSelectorText}</span>
<ChevronDown className="ml-2 h-3 w-3 md:h-4 md:w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className={`${isSmallScreen ? 'w-[calc(100vw-2rem)]' : 'w-[calc(100vw-2rem)] max-w-[400px]'} p-0`} align="start">
<div className="max-h-[300px] overflow-y-auto">
<div className="p-2">
{/* All feeds option */}
<div
className="flex items-center space-x-2 p-2 rounded-sm hover:bg-accent cursor-pointer"
onClick={() => {
const isAllSelected = selectedFeeds.includes('all')
handleFeedToggle('all', !isAllSelected)
}}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
{selectedFeeds.includes('all') && <Check className="w-3 h-3" />}
</div>
<label className="text-sm cursor-pointer flex-1">
{t('All feeds')}
</label>
</div>
{/* Individual feed options */}
{availableFeeds.map((feed) => {
const isChecked = selectedFeeds.includes(feed.url)
return (
<div
key={feed.url}
className="flex items-center space-x-2 p-2 rounded-sm hover:bg-accent cursor-pointer"
onClick={() => handleFeedToggle(feed.url, !isChecked)}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
{isChecked && <Check className="w-3 h-3" />}
</div>
<label className="text-sm cursor-pointer flex-1 min-w-0" title={feed.title}>
<StandardRssFeedUrlInline
feedUrl={feed.url}
title={feed.title}
maxLength={50}
/>
</label>
</div>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Time Filter */}
<Select value={timeFilter} onValueChange={setTimeFilter}>
<SelectTrigger className="h-8 text-xs md:text-sm md:h-9 flex-shrink-0 w-full md:w-auto" style={{ minWidth: isSmallScreen ? '100%' : '120px' }}>
<SelectValue placeholder={t('All time')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All time')}</SelectItem>
<SelectItem value="hour">{t('Last hour')}</SelectItem>
<SelectItem value="day">{t('Last day')}</SelectItem>
<SelectItem value="week">{t('Last week')}</SelectItem>
<SelectItem value="month">{t('Last month')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Content */}
<div className="space-y-4 px-4 py-3">
<ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
{refreshing && (
<div className="flex items-center gap-2 border-b py-2" role="status" aria-busy="true">
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" aria-hidden />
<Skeleton className="h-4 flex-1 max-w-[200px]" />
</div>
)}
{feedTotalCount === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
? t('No items match your filters')
: t('No RSS feed items available')}
</p>
</div>
) : displayedFeed.view === 'rss' ? (
<>
<RssEntriesSection items={displayedFeed.items} />
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
) : null}
</>
) : feedScope === 'urls' ? (
<>
<ArticleUrlsSection>
{displayedFeed.rows
.filter((r): r is Extract<UnifiedFeedRow, { kind: 'url' }> => r.kind === 'url')
.map((row) => (
<RssWebFeedCard
key={row.canonicalUrl}
canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems}
/>
))}
</ArticleUrlsSection>
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
) : null}
</>
) : (
<>
<div className="space-y-4">
{displayedFeed.rows.map((row) =>
row.kind === 'url' ? (
<RssWebFeedCard
key={row.canonicalUrl}
canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems}
/>
) : (
<div
key={`${row.item.feedUrl}-${row.item.guid}`}
className="overflow-hidden rounded-xl border border-border bg-card"
>
<RssFeedItem
item={row.item}
layout="list"
sourceStrip="rss"
className="rounded-none border-0 bg-transparent shadow-none"
/>
</div>
)
)}
</div>
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
) : null}
</>
)}
</div>
</div>
</RssFeedDisplayPrefsProvider>
)
}