diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx index 74cde3c..e75f9c0 100644 --- a/src/components/RssFeedItem/index.tsx +++ b/src/components/RssFeedItem/index.tsx @@ -36,7 +36,7 @@ function htmlToPlainText(html: string): string { return text } -export default function RssFeedItem({ item, className }: { item: TRssFeedItem; className?: string }) { +export default function RssFeedItem({ item, className, compact = false }: { item: TRssFeedItem; className?: string; compact?: boolean }) { const { t } = useTranslation() const { pubkey, checkLogin } = useNostr() const { isSmallScreen } = useScreenSize() @@ -238,14 +238,14 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c return } - // Wait for selection to be stable (no changes for 500ms) before showing drawer + // Wait for selection to be stable (no changes for 1500ms) before showing drawer selectionStableTimeoutRef.current = setTimeout(() => { const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current - // Only show if selection hasn't changed in the last 500ms - if (timeSinceLastChange >= 500 && !isSelectingRef.current) { + // Only show if selection hasn't changed in the last 1500ms (3x original delay) + if (timeSinceLastChange >= 2000 && !isSelectingRef.current) { handleSelection(true) } - }, 500) + }, 1500) } else { // Desktop: shorter delay if (selectionTimeoutRef.current) { @@ -454,7 +454,7 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c }, [descriptionHtml, isExpanded]) return ( -
+
{/* Feed Header with Metadata */}
{/* Feed Image/Logo */} @@ -491,206 +491,238 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c
{/* Title */} -
+
e.stopPropagation()} > - {item.title} + {item.title}
- {/* Media (Images) */} - {item.media && item.media.length > 0 && ( -
- {item.media - .filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image') - .map((media, index) => { - const hasThumbnail = !!media.thumbnail - const imageUrl = media.thumbnail || media.url - return ( -
- {item.title} { - e.stopPropagation() - // Open full image in new tab - window.open(media.url, '_blank', 'noopener,noreferrer') - }} - onError={(e) => { - // Hide image on error - e.currentTarget.style.display = 'none' - }} - /> - {media.credit && ( -
- {t('Photo')}: {media.credit} + {/* Compact view: Hide media and description when compact and not expanded */} + {!compact || isExpanded ? ( + <> + {/* Media (Images) */} + {item.media && item.media.length > 0 && ( +
+ {item.media + .filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image') + .map((media, index) => { + const hasThumbnail = !!media.thumbnail + const imageUrl = media.thumbnail || media.url + return ( +
+ {item.title} { + e.stopPropagation() + // Open full image in new tab + window.open(media.url, '_blank', 'noopener,noreferrer') + }} + onError={(e) => { + // Hide image on error + e.currentTarget.style.display = 'none' + }} + /> + {media.credit && ( +
+ {t('Photo')}: {media.credit} +
+ )}
- )} + ) + })} +
+ )} + + {/* Audio/Video Enclosure */} + {item.enclosure && (item.enclosure.type.startsWith('audio/') || item.enclosure.type.startsWith('video/')) && ( +
+
+
+
+ {item.enclosure.type.startsWith('audio/') ? t('Audio') : t('Video')} + {item.enclosure.duration && ( + ({item.enclosure.duration}) + )} +
- ) - })} -
- )} - - {/* Audio/Video Enclosure */} - {item.enclosure && (item.enclosure.type.startsWith('audio/') || item.enclosure.type.startsWith('video/')) && ( -
-
-
-
- {item.enclosure.type.startsWith('audio/') ? t('Audio') : t('Video')} - {item.enclosure.duration && ( - ({item.enclosure.duration}) - )} +
- -
-
- )} - - {/* Description with text selection support and collapse/expand */} -
-
{ - // Allow text selection - e.stopPropagation() - }} - /> - - {/* Gradient overlay when collapsed */} - {needsCollapse && !isExpanded && ( -
- )} - - {/* Collapse/Expand Button */} - {needsCollapse && ( -
- -
- )} - - {/* Highlight Drawer (Mobile) */} - {isSmallScreen && ( - { - setShowHighlightDrawer(open) - if (!open) { - // Clear selection when drawer closes - window.getSelection()?.removeAllRanges() - setSelectedText('') - setShowHighlightButton(false) - } - }} - > - - - {t('Create Highlight')} - -
-
- {t('Selected text')}: -
-
- "{selectedText}" -
+ /> + + {/* Gradient overlay when collapsed */} + {needsCollapse && !isExpanded && ( +
+ )} + + {/* Collapse/Expand Button - Only show in full view */} + {!compact && needsCollapse && ( +
+
+ )} + + {/* Highlight Button (Desktop) */} + {!isSmallScreen && showHighlightButton && selectedText && selectionPosition && ( +
+
- - - )} -
+ )} + + {/* Highlight Drawer (Mobile) */} + {isSmallScreen && ( + { + setShowHighlightDrawer(open) + if (!open) { + // Clear selection when drawer closes + window.getSelection()?.removeAllRanges() + setSelectedText('') + setShowHighlightButton(false) + } + }} + > + + + {t('Create Highlight')} + +
+
+ {t('Selected text')}: +
+
+ "{selectedText}" +
+ +
+
+
+ )} +
+ + ) : null} - {/* Link to original article */} -
+ {/* Link to original article and expand button */} +
e.stopPropagation()} > - {t('Read full article')} - + {t('Read full article')} + + {compact && ( + + )}
{/* Post Editor for highlights */} diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index aebb86a..840aaef 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react' +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' @@ -8,7 +8,12 @@ import { Loader, AlertCircle, Search } 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 { useScreenSize } from '@/providers/ScreenSizeProvider' +import { Check, ChevronDown } from 'lucide-react' export default function RssFeedList() { const { t } = useTranslation() @@ -20,10 +25,16 @@ export default function RssFeedList() { const [refreshing, setRefreshing] = useState(false) // Filter states - const [selectedFeed, setSelectedFeed] = useState('all') + const [selectedFeeds, setSelectedFeeds] = useState(['all']) const [timeFilter, setTimeFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [showFilters, setShowFilters] = useState(false) + const [isCompactView, setIsCompactView] = useState(true) + const [feedPopoverOpen, setFeedPopoverOpen] = useState(false) + + // Pagination state + const [showCount, setShowCount] = useState(25) + const bottomRef = useRef(null) // Listen for filter toggle events useEffect(() => { @@ -85,7 +96,6 @@ export default function RssFeedList() { try { // Get feed URLs from event or use default let feedUrls: string[] = [] - let useDefaultFeeds = false if (pubkey && rssFeedListEvent) { // User has an event - use only feeds from that event (even if empty) @@ -129,7 +139,6 @@ export default function RssFeedList() { // 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 - useDefaultFeeds = true // Trigger background refresh for default feeds when no event exists rssFeedService.backgroundRefreshFeeds(feedUrls, abortController.signal).catch(err => { if (!(err instanceof DOMException && err.name === 'AbortError')) { @@ -139,7 +148,6 @@ export default function RssFeedList() { } else { // No pubkey - use default feeds feedUrls = DEFAULT_RSS_FEEDS - useDefaultFeeds = true } // Check if aborted before fetching @@ -306,14 +314,39 @@ export default function RssFeedList() { 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 (selectedFeed !== 'all') { - const normalizedSelectedFeed = normalizeFeedUrl(selectedFeed) - filtered = filtered.filter(item => normalizeFeedUrl(item.feedUrl) === normalizedSelectedFeed) + 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 @@ -354,7 +387,49 @@ export default function RssFeedList() { } return filtered - }, [items, selectedFeed, timeFilter, searchQuery]) + }, [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 ( @@ -384,24 +459,85 @@ export default function RssFeedList() { return (
+ {/* Feed Counter Header - Always visible */} +
+
+
+ + +
+

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

+
+
+ {/* Filter Bar - Collapsible */} {showFilters && ( -
+
- {/* Feed Selector */} - + {/* 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 */}