diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index ce85d60..825eaea 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -6,13 +6,15 @@ import { useUserTrust } from '@/providers/UserTrustProvider' import storage from '@/services/local-storage.service' import { TFeedSubRequest, TNoteListMode } from '@/types' import { forwardRef, useMemo, useRef, useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import KindFilter from '../KindFilter' import { RefreshButton } from '../RefreshButton' import RssFeedList from '../RssFeedList' import { useNostr } from '@/providers/NostrProvider' import rssFeedService from '@/services/rss-feed.service' import { DEFAULT_RSS_FEEDS } from '@/constants' -import { Rss } from 'lucide-react' +import { Rss, Search } from 'lucide-react' +import { Button } from '@/components/ui/button' const NormalFeed = forwardRef { - const currentShowRssFeed = storage.getShowRssFeed() - setShowRssFeed(currentShowRssFeed) + const checkShowRssFeed = () => { + const currentShowRssFeed = storage.getShowRssFeed() + setShowRssFeed(currentShowRssFeed) + } + + // Check on mount + checkShowRssFeed() + + // Listen for storage changes (polling approach - check every second) + const intervalId = setInterval(checkShowRssFeed, 1000) + + // Also listen for custom event if RSS setting changes + const handleRssSettingChange = () => { + checkShowRssFeed() + } + window.addEventListener('rssFeedSettingChanged', handleRssSettingChange) + + return () => { + clearInterval(intervalId) + window.removeEventListener('rssFeedSettingChanged', handleRssSettingChange) + } }, []) // Handle RSS tab visibility when showRssFeed changes @@ -72,6 +94,8 @@ const NormalFeed = forwardRef { if (showRssFeed) { setActiveTab('rss') + // Dispatch event to notify sidebar that RSS tab is active + window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: true } })) if (noteListRef && typeof noteListRef !== 'function') { noteListRef.current?.scrollToTop('smooth') } @@ -84,14 +108,47 @@ const NormalFeed = forwardRef { + const handleSwitchToNotes = () => { + // Switch to posts (Notes) tab + setListMode('posts') + setActiveTab('posts') + // Dispatch event to notify sidebar that RSS tab is not active + window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: false } })) + if (isMainFeed) { + storage.setNoteListMode('posts') + } + if (noteListRef && typeof noteListRef !== 'function') { + noteListRef.current?.scrollToTop('smooth') + } + } + + window.addEventListener('switchToNotesTab', handleSwitchToNotes) + return () => { + window.removeEventListener('switchToNotesTab', handleSwitchToNotes) + } + }, [isMainFeed, noteListRef]) + + // Dispatch initial RSS tab state on mount and when activeTab changes + useEffect(() => { + window.dispatchEvent(new CustomEvent('rssTabStateChanged', { + detail: { active: activeTab === 'rss' } + })) + }, [activeTab]) + const handleListModeChange = (mode: TNoteListMode | string) => { if (mode === 'rss') { setActiveTab('rss') + // Dispatch event to notify sidebar that RSS tab is active + window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: true } })) return } const noteListMode = mode as TNoteListMode setListMode(noteListMode) setActiveTab(noteListMode) + // Dispatch event to notify sidebar that RSS tab is not active + window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: false } })) if (isMainFeed) { storage.setNoteListMode(noteListMode) } @@ -134,12 +191,25 @@ const NormalFeed = forwardRef + {activeTab === 'rss' && showRssFeed && ( + + )} { if (activeTab === 'rss') { // Refresh RSS feeds // Get feed URLs from event or use default - let feedUrls: string[] = DEFAULT_RSS_FEEDS + let feedUrls: string[] = [] if (pubkey && rssFeedListEvent) { + // User has an event - use only feeds from that event (even if empty) try { const urls = rssFeedListEvent.tags .filter(tag => tag[0] === 'u' && tag[1]) @@ -149,12 +219,14 @@ const NormalFeed = forwardRef 0 }) - if (urls.length > 0) { - feedUrls = urls - } + feedUrls = urls // Use even if empty (respect user's choice) } catch (e) { - // Use default feeds on error + // On parse error, treat as empty event + feedUrls = [] } + } else { + // No event exists - use default feeds for demo + feedUrls = DEFAULT_RSS_FEEDS } // Trigger background refresh and UI update diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx index f82b24c..74cde3c 100644 --- a/src/components/RssFeedItem/index.tsx +++ b/src/components/RssFeedItem/index.tsx @@ -517,7 +517,7 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c {item.title} { e.stopPropagation() // Open full image in new tab @@ -566,7 +566,8 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c ref={contentRef} className={cn( 'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200', - needsCollapse && !isExpanded && 'max-h-[400px] overflow-hidden' + needsCollapse && !isExpanded && 'max-h-[400px] overflow-hidden', + '[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg' )} style={{ userSelect: 'text', diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index 75deeec..aebb86a 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -1,19 +1,41 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } 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 } from 'lucide-react' +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 { useScreenSize } from '@/providers/ScreenSizeProvider' export default function RssFeedList() { const { t } = useTranslation() const { pubkey, rssFeedListEvent } = useNostr() + 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 [selectedFeed, setSelectedFeed] = useState('all') + const [timeFilter, setTimeFilter] = useState('all') + const [searchQuery, setSearchQuery] = useState('') + const [showFilters, setShowFilters] = useState(false) + + // 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 @@ -62,9 +84,11 @@ export default function RssFeedList() { try { // Get feed URLs from event or use default - let feedUrls: string[] = DEFAULT_RSS_FEEDS + let feedUrls: string[] = [] + let useDefaultFeeds = false 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 @@ -83,31 +107,39 @@ export default function RssFeedList() { return true }) + feedUrls = urls if (urls.length > 0) { - feedUrls = urls logger.info('[RssFeedList] Loaded RSS feed list from context', { feedCount: urls.length, eventId: rssFeedListEvent.id, urls }) } else { - logger.info('[RssFeedList] RSS feed list is empty or contains no valid URLs, using default feeds') + 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 }) - // Use default feeds on parse error + // 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 + 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')) { logger.error('[RssFeedList] Background refresh of default feeds failed', { error: err }) } }) + } else { + // No pubkey - use default feeds + feedUrls = DEFAULT_RSS_FEEDS + useDefaultFeeds = true } // Check if aborted before fetching @@ -254,6 +286,76 @@ export default function RssFeedList() { } }, [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)) { + feedMap.set(normalizedUrl, { url: normalizedUrl, title: item.feedTitle || item.feedUrl }) + } + }) + return Array.from(feedMap.values()) + }, [items]) + + // 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) + } + + // 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, selectedFeed, timeFilter, searchQuery]) + if (loading) { return (
@@ -281,16 +383,78 @@ export default function RssFeedList() { } return ( -
- {refreshing && ( -
- - {t('Refreshing feeds...')} +
+ {/* Filter Bar - Collapsible */} + {showFilters && ( +
+
+ {/* Feed Selector */} + + + {/* Time Filter */} + + + {/* Search Box */} +
+ + setSearchQuery(e.target.value)} + className="h-8 md:h-9 pl-7 md:pl-8 text-xs md:text-sm w-full" + /> +
+
)} - {items.map((item) => ( - - ))} + + {/* Content */} +
+ {refreshing && ( +
+ + {t('Refreshing feeds...')} +
+ )} + + {filteredItems.length === 0 ? ( +
+

+ {searchQuery || selectedFeed !== 'all' || timeFilter !== 'all' + ? t('No items match your filters') + : t('No RSS feed items available')} +

+
+ ) : ( + filteredItems.map((item) => ( + + )) + )} +
) } diff --git a/src/components/Sidebar/HomeButton.tsx b/src/components/Sidebar/HomeButton.tsx index bde11df..67194d4 100644 --- a/src/components/Sidebar/HomeButton.tsx +++ b/src/components/Sidebar/HomeButton.tsx @@ -1,16 +1,55 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { Home } from 'lucide-react' import SidebarItem from './SidebarItem' +import storage from '@/services/local-storage.service' +import { useState, useEffect } from 'react' export default function HomeButton() { const { navigate, current, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() + const showRssFeed = storage.getShowRssFeed() + const [rssTabActive, setRssTabActive] = useState(false) + + // Listen for RSS tab state changes + useEffect(() => { + const handleRssTabStateChange = (event: CustomEvent<{ active: boolean }>) => { + setRssTabActive(event.detail.active) + } + + window.addEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) + + // Check initial state + setRssTabActive(false) // Default to false, will be updated by event + + return () => { + window.removeEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) + } + }, []) + + // Home is active when on home page, but NOT when RSS tab is active (RSS button handles that) + const isActive = display && current === 'home' && primaryViewType === null && !(showRssFeed && rssTabActive) + + const handleClick = () => { + // Navigate to home if not already there + if (current !== 'home' || primaryViewType !== null) { + navigate('home') + // Wait a bit for navigation to complete, then switch to Notes tab + setTimeout(() => { + window.dispatchEvent(new CustomEvent('switchToNotesTab')) + }, 100) + } else { + // Already on home, just switch to Notes tab (if RSS is active) + if (showRssFeed && rssTabActive) { + window.dispatchEvent(new CustomEvent('switchToNotesTab')) + } + } + } return ( navigate('home')} - active={display && current === 'home' && primaryViewType === null} + onClick={handleClick} + active={isActive} > diff --git a/src/components/Sidebar/RssButton.tsx b/src/components/Sidebar/RssButton.tsx index d09ac00..efcb5b9 100644 --- a/src/components/Sidebar/RssButton.tsx +++ b/src/components/Sidebar/RssButton.tsx @@ -2,15 +2,32 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { Rss } from 'lucide-react' import SidebarItem from './SidebarItem' import storage from '@/services/local-storage.service' +import { useState, useEffect } from 'react' export default function RssButton() { const { navigate, current, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() const showRssFeed = storage.getShowRssFeed() + const [rssTabActive, setRssTabActive] = useState(false) - // RSS is active when on home page and RSS tab would be active - // We can't directly check if RSS tab is active, so we'll just check if we're on home - const isActive = display && current === 'home' && primaryViewType === null && showRssFeed + // Listen for RSS tab state changes + useEffect(() => { + const handleRssTabStateChange = (event: CustomEvent<{ active: boolean }>) => { + setRssTabActive(event.detail.active) + } + + window.addEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) + + // Check initial state + setRssTabActive(false) // Default to false, will be updated by event + + return () => { + window.removeEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener) + } + }, []) + + // RSS is active when on home page, RSS tab is actually active, and RSS feed is enabled + const isActive = display && current === 'home' && primaryViewType === null && showRssFeed && rssTabActive const handleClick = () => { // Navigate to home if not already there diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2bce890..8feb91f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -470,6 +470,18 @@ export default { Connect: 'Connect', 'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!', 'Set up': 'Set up', - 'nested events': 'nested events' + 'nested events': 'nested events', + 'Loading RSS feeds...': 'Loading RSS feeds...', + 'No RSS feed items available': 'No RSS feed items available', + 'Refreshing feeds...': 'Refreshing feeds...', + 'All feeds': 'All feeds', + 'All time': 'All time', + 'Last hour': 'Last hour', + 'Last day': 'Last day', + 'Last week': 'Last week', + 'Last month': 'Last month', + 'No items match your filters': 'No items match your filters', + 'Search...': 'Search...', + 'Toggle filters': 'Toggle filters' } } diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx index dfd8323..c633239 100644 --- a/src/pages/secondary/RssFeedSettingsPage/index.tsx +++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx @@ -108,6 +108,8 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index const handleShowRssFeedChange = (checked: boolean) => { setShowRssFeed(checked) storage.setShowRssFeed(checked) + // Dispatch event to notify other components of the change + window.dispatchEvent(new CustomEvent('rssFeedSettingChanged')) // No need to set hasChange here as this is a local storage setting, not a Nostr event } diff --git a/src/services/rss-feed.service.ts b/src/services/rss-feed.service.ts index 6685cc6..2f65c1f 100644 --- a/src/services/rss-feed.service.ts +++ b/src/services/rss-feed.service.ts @@ -1088,11 +1088,16 @@ class RssFeedService { /** * Get feed URLs to use (from event or default) + * If eventFeedUrls is an empty array, return empty array (user has event but no feeds) + * If eventFeedUrls is null/undefined, return default feeds (no event exists) */ getFeedUrls(eventFeedUrls: string[] | null | undefined): string[] { - if (eventFeedUrls && eventFeedUrls.length > 0) { + // If eventFeedUrls is explicitly an array (even if empty), use it + // This means the user has an event, so respect their choice + if (Array.isArray(eventFeedUrls)) { return eventFeedUrls } + // If null/undefined, no event exists - use defaults for demo return DEFAULT_RSS_FEEDS }