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.
 
 
 
 

685 lines
25 KiB

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 (
<>
<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('Open any https page in the side panel to reply, react, and discuss on Nostr.')}
</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" onClick={submit}>
{t('Open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
export default function RssFeedList() {
const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr()
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 [isCompactView, setIsCompactView] = useState<boolean>(true)
const [feedPopoverOpen, setFeedPopoverOpen] = useState<boolean>(false)
// Pagination state
const [showCount, setShowCount] = useState<number>(25)
const bottomRef = useRef<HTMLDivElement>(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<string, { url: string; title: string }>()
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 (
<div className="flex flex-col items-center justify-center py-12">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">{t('Loading RSS feeds...')}</p>
</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) {
return (
<div className="space-y-4 px-4 py-6">
<ManualRssUrlAddRow />
<p className="text-sm text-muted-foreground text-center">{t('No RSS feed items available')}</p>
</div>
)
}
return (
<div className="space-y-3">
{/* Feed Counter Header - Always visible */}
<div className="sticky top-0 z-10 bg-background border-b px-4 py-1.5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Switch
id="compact-view"
checked={isCompactView}
onCheckedChange={setIsCompactView}
/>
<Label htmlFor="compact-view" className="text-xs text-muted-foreground cursor-pointer">
{isCompactView ? t('Compact') : t('Full')}
</Label>
</div>
<p className="text-xs text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: displayedItems.length,
total: filteredItems.length
})}
</p>
</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 truncate" title={feed.title}>
{truncateText(feed.title, 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>
{/* Search Box */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 md:h-4 md:w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('Search...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 md:h-9 pl-7 md:pl-8 text-xs md:text-sm w-full"
/>
</div>
</div>
</div>
)}
{/* Content */}
<div className="space-y-4 px-4 py-3">
<ManualRssUrlAddRow />
{refreshing && (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-muted-foreground border-b">
<Loader className="h-4 w-4 animate-spin" />
<span>{t('Refreshing feeds...')}</span>
</div>
)}
{displayedItems.length === 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>
) : (
<>
{displayedItems.map((item) => (
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} layout={isCompactView ? 'list' : 'detail'} />
))}
{/* Bottom ref for infinite scroll */}
{displayedItems.length < filteredItems.length && (
<div ref={bottomRef} className="flex items-center justify-center py-4">
<Loader className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</>
)}
</div>
</div>
)
}