import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { forwardRef, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import storage from '@/services/local-storage.service' import { createRssFeedListDraftEvent } from '@/lib/draft-event' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { CloudUpload, Loader, Trash2, Plus, Download, Upload } from 'lucide-react' import logger from '@/lib/logger' import { ExtendedKind } from '@/constants' import indexedDb from '@/services/indexed-db.service' import rssFeedService from '@/services/rss-feed.service' import { parseOpml, generateOpml, downloadFile } from '@/lib/opml' import { toast } from 'sonner' import { normalizeHttpUrl } from '@/lib/url' // Helper function to normalize and deduplicate feed URLs const normalizeAndDeduplicateUrls = (urls: string[]): string[] => { const normalizedUrls = urls .map(url => normalizeHttpUrl(url.trim())) .filter((url): url is string => url.length > 0) // Filter out invalid URLs // Deduplicate by creating a Set of normalized URLs, preserving order const seen = new Set() const unique: string[] = [] for (const url of normalizedUrls) { if (!seen.has(url)) { seen.add(url) unique.push(url) } } return unique } const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { pubkey, publish, checkLogin, rssFeedListEvent, updateRssFeedListEvent } = useNostr() const [feedUrls, setFeedUrls] = useState([]) const [newFeedUrl, setNewFeedUrl] = useState('') const [showRssFeed, setShowRssFeed] = useState(true) const [hasChange, setHasChange] = useState(false) const [pushing, setPushing] = useState(false) const [loading, setLoading] = useState(true) // Load RSS feed list from context (which is loaded from cache first, then relays if stale) useEffect(() => { // Load show RSS feed setting setShowRssFeed(storage.getShowRssFeed()) // Load RSS feed list from context event (which comes from cache) if (!pubkey) { setLoading(false) return } if (rssFeedListEvent) { try { // Extract URLs from "u" tags and normalize them 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('[RssFeedSettingsPage] Invalid RSS feed URL (not a string)', { url, type: typeof url }) return false } const trimmed = url.trim() if (trimmed.length === 0) { logger.warn('[RssFeedSettingsPage] Empty RSS feed URL found') return false } return true }) .map(url => url.trim()) // Normalize and deduplicate URLs const normalizedUrls = normalizeAndDeduplicateUrls(urls) if (normalizedUrls.length > 0) { setFeedUrls(normalizedUrls) logger.info('[RssFeedSettingsPage] Loaded RSS feed list from context', { count: normalizedUrls.length, urls: normalizedUrls, originalCount: urls.length }) } else { logger.info('[RssFeedSettingsPage] RSS feed list is empty or contains no valid URLs') } } catch (e) { logger.error('[RssFeedSettingsPage] Failed to parse RSS feed list from tags', { error: e, tags: rssFeedListEvent.tags }) } } else { logger.info('[RssFeedSettingsPage] No RSS feed list event in context (user may not have created one yet)') } setLoading(false) }, [pubkey, rssFeedListEvent]) const handleShowRssFeedChange = (checked: boolean) => { setShowRssFeed(checked) storage.setShowRssFeed(checked) // No need to set hasChange here as this is a local storage setting, not a Nostr event } const handleAddFeed = () => { const url = newFeedUrl.trim() if (!url) return // Normalize and deduplicate all URLs (including the new one) const allUrls = [...feedUrls, url] const normalizedUrls = normalizeAndDeduplicateUrls(allUrls) // Check if the new URL was actually added (not a duplicate) const normalizedExistingUrls = normalizeAndDeduplicateUrls(feedUrls) const normalizedNewUrl = normalizeHttpUrl(url) if (!normalizedNewUrl) { // Invalid URL return } if (normalizedExistingUrls.includes(normalizedNewUrl)) { // Feed already exists return } setFeedUrls(normalizedUrls) setNewFeedUrl('') setHasChange(true) } const handleRemoveFeed = (url: string) => { setFeedUrls(feedUrls.filter(u => u !== url)) setHasChange(true) } const handleImportOpml = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return // Reset input event.target.value = '' try { const text = await file.text() const feeds = parseOpml(text) if (feeds.length === 0) { toast.error(t('No RSS feeds found in OPML file')) return } // Extract URLs from OPML feeds const opmlUrls = feeds .map(feed => feed.xmlUrl) .filter((url): url is string => { try { new URL(url) return true } catch { return false } }) if (opmlUrls.length === 0) { toast.error(t('No valid RSS feed URLs found in OPML file')) return } // Merge with existing feeds and normalize/deduplicate everything const allUrls = [...feedUrls, ...opmlUrls] const normalizedUrls = normalizeAndDeduplicateUrls(allUrls) // Check how many new URLs were actually added const normalizedExistingUrls = new Set(normalizeAndDeduplicateUrls(feedUrls)) const newUrls = normalizedUrls.filter(url => !normalizedExistingUrls.has(url)) if (newUrls.length === 0) { toast.info(t('All feeds from OPML file are already added')) return } // Update with normalized and deduplicated URLs setFeedUrls(normalizedUrls) setHasChange(true) toast.success(t('Imported {{count}} feed(s) from OPML file', { count: newUrls.length })) } catch (error) { logger.error('[RssFeedSettingsPage] Failed to import OPML file', { error }) toast.error(t('Failed to import OPML file: {{error}}', { error: error instanceof Error ? error.message : String(error) })) } } const handleExportOpml = () => { // Normalize and deduplicate before exporting const normalizedUrls = normalizeAndDeduplicateUrls(feedUrls) if (normalizedUrls.length === 0) { toast.error(t('No feeds to export')) return } try { const opmlContent = generateOpml(normalizedUrls, 'Jumble RSS Feeds') const filename = `jumble-rss-feeds-${new Date().toISOString().split('T')[0]}.opml` downloadFile(opmlContent, filename, 'application/xml') toast.success(t('RSS feeds exported to OPML file')) } catch (error) { logger.error('[RssFeedSettingsPage] Failed to export OPML file', { error }) toast.error(t('Failed to export OPML file')) } } const handleSave = async () => { if (!pubkey) { logger.error('[RssFeedSettingsPage] Cannot save: no pubkey') return } setPushing(true) try { // Normalize and deduplicate URLs before saving const normalizedUrls = normalizeAndDeduplicateUrls(feedUrls) logger.info('[RssFeedSettingsPage] Creating RSS feed list event', { pubkey: pubkey.substring(0, 8), feedCount: normalizedUrls.length, originalCount: feedUrls.length, feedUrls: normalizedUrls }) const event = createRssFeedListDraftEvent(normalizedUrls) // Validate the event structure before publishing logger.info('[RssFeedSettingsPage] Draft event created', { kind: event.kind, tagCount: event.tags.length, tags: event.tags, created_at: event.created_at }) console.log('✅ [RSS] Event created with tags', { kind: event.kind, tagCount: event.tags.length, tags: event.tags }) console.log('🔵 [RSS] About to call publish()') let result try { result = await publish(event) console.log('✅ [RSS] Event published successfully!', { id: result.id, kind: result.kind, pubkey: result.pubkey?.substring(0, 8), content: result.content }) } catch (publishError) { console.error('❌ [RSS] Publish failed!', publishError) throw publishError } logger.info('[RssFeedSettingsPage] Event published', { eventId: result.id, kind: result.kind, pubkey: result.pubkey, created_at: result.created_at, content: result.content }) // Cache the event in IndexedDB for immediate access console.log('🔵 [RSS] About to cache event in IndexedDB', { eventId: result.id, kind: result.kind, pubkey: result.pubkey?.substring(0, 8) }) try { logger.info('[RssFeedSettingsPage] Attempting to cache event in IndexedDB', { eventId: result.id, kind: result.kind, pubkey: result.pubkey }) console.log('🔵 [RSS] Calling indexedDb.putReplaceableEvent()...') const savedEvent = await indexedDb.putReplaceableEvent(result) console.log('✅ [RSS] Successfully cached to IndexedDB!', { eventId: savedEvent.id, kind: savedEvent.kind, pubkey: savedEvent.pubkey?.substring(0, 8), content: savedEvent.content }) logger.info('[RssFeedSettingsPage] Successfully cached RSS feed list event to IndexedDB', { eventId: savedEvent.id, kind: savedEvent.kind, pubkey: savedEvent.pubkey, feedCount: feedUrls.length }) } catch (cacheError) { console.error('❌ [RSS] Failed to cache to IndexedDB!', { error: cacheError, errorMessage: cacheError instanceof Error ? cacheError.message : String(cacheError), errorStack: cacheError instanceof Error ? cacheError.stack : undefined, eventId: result.id, kind: result.kind }) logger.error('[RssFeedSettingsPage] Failed to cache RSS feed list event', { error: cacheError, eventId: result.id, kind: result.kind }) // Don't fail the save if caching fails, but log the error } // Verify the event was saved by reading it back console.log('🔵 [RSS] Verifying event was saved...') try { logger.info('[RssFeedSettingsPage] Verifying event was saved to IndexedDB', { pubkey: pubkey.substring(0, 8), kind: ExtendedKind.RSS_FEED_LIST }) const savedEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) if (savedEvent) { console.log('✅ [RSS] Event found in IndexedDB!', { eventId: savedEvent.id, expectedId: result.id, match: savedEvent.id === result.id, content: savedEvent.content }) logger.info('[RssFeedSettingsPage] Event found in IndexedDB', { eventId: savedEvent.id, expectedId: result.id, match: savedEvent.id === result.id, created_at: savedEvent.created_at, content: savedEvent.content }) if (savedEvent.id === result.id) { console.log('✅ [RSS] Event IDs match! Verification successful!') logger.info('[RssFeedSettingsPage] Verified RSS feed list event in IndexedDB', { eventId: savedEvent.id }) } else { console.warn('⚠️ [RSS] Event ID mismatch!', { expectedId: result.id, foundId: savedEvent.id }) logger.warn('[RssFeedSettingsPage] RSS feed list event ID mismatch', { expectedId: result.id, foundId: savedEvent.id, expectedCreatedAt: result.created_at, foundCreatedAt: savedEvent.created_at }) } } else { console.error('❌ [RSS] Event NOT found in IndexedDB after save!', { expectedId: result.id, pubkey: pubkey.substring(0, 8), kind: ExtendedKind.RSS_FEED_LIST }) logger.error('[RssFeedSettingsPage] RSS feed list event not found in IndexedDB after save', { expectedId: result.id, pubkey: pubkey.substring(0, 8), kind: ExtendedKind.RSS_FEED_LIST }) } } catch (verifyError) { console.error('❌ [RSS] Error verifying event in IndexedDB!', verifyError) logger.error('[RssFeedSettingsPage] Failed to verify RSS feed list event in IndexedDB', { error: verifyError, pubkey: pubkey.substring(0, 8), kind: ExtendedKind.RSS_FEED_LIST }) } // Update the context with the new event await updateRssFeedListEvent(result) // Dispatch custom event to notify other components (like RssFeedList) to refresh window.dispatchEvent(new CustomEvent('rssFeedListUpdated', { detail: { pubkey, feedUrls: normalizedUrls, eventId: result.id } })) // Trigger background refresh of feeds (don't wait for it) logger.info('[RssFeedSettingsPage] Triggering background refresh of RSS feeds', { feedCount: normalizedUrls.length }) rssFeedService.backgroundRefreshFeeds(normalizedUrls).catch(err => { logger.error('[RssFeedSettingsPage] Background refresh failed', { error: err }) }) // Update local state with normalized URLs if they changed if (normalizedUrls.length !== feedUrls.length || JSON.stringify(normalizedUrls.sort()) !== JSON.stringify(feedUrls.sort())) { setFeedUrls(normalizedUrls) } // Read relayStatuses immediately before it might be deleted const relayStatuses = (result as any).relayStatuses logger.info('[RssFeedSettingsPage] Publishing complete', { eventId: result.id, relayStatusCount: relayStatuses?.length || 0, successCount: relayStatuses?.filter((s: any) => s.success).length || 0 }) setHasChange(false) // Show publishing feedback if (relayStatuses && relayStatuses.length > 0) { showPublishingFeedback({ success: true, relayStatuses: relayStatuses, successCount: relayStatuses.filter((s: any) => s.success).length, totalCount: relayStatuses.length }, { message: t('RSS feeds saved'), duration: 6000 }) } else { showSimplePublishSuccess(t('RSS feeds saved')) } } catch (error) { logger.error('[RssFeedSettingsPage] Failed to save RSS feed list', { error, errorMessage: error instanceof Error ? error.message : String(error), errorStack: error instanceof Error ? error.stack : undefined }) // Show error feedback with relay statuses if available if (error instanceof Error && (error as any).relayStatuses) { const errorRelayStatuses = (error as any).relayStatuses showPublishingFeedback({ success: false, relayStatuses: errorRelayStatuses, successCount: errorRelayStatuses.filter((s: any) => s.success).length, totalCount: errorRelayStatuses.length }, { message: error.message || t('Failed to save RSS feeds'), duration: 6000 }) } else { showPublishingError(error instanceof Error ? error : new Error(t('Failed to save RSS feeds'))) } } finally { setPushing(false) } } if (!pubkey) { return (
) } if (loading) { return (
{t('loading...')}
) } return (
{/* Show RSS Feed Toggle */}
{t('Show or hide the RSS feed tab in the main feed')}
{/* RSS Feed List */}
{t('Add RSS feed URLs to subscribe to. If no feeds are configured, the default feed will be used.')}
{/* Add Feed Input */}
setNewFeedUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleAddFeed() } }} className="flex-1" />
{/* Feed List */}
{feedUrls.length === 0 ? (
{t('No feeds configured. Default feed will be used.')}
) : ( feedUrls.map((url) => (
{url}
)) )}
{/* Save Button */}
) }) RssFeedSettingsPage.displayName = 'RssFeedSettingsPage' export default RssFeedSettingsPage