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.
 
 
 

593 lines
21 KiB

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<string>()
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<string[]>([])
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<HTMLInputElement>) => {
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 (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}>
<div className="flex flex-col w-full items-center py-8">
<Button size="lg" onClick={() => checkLogin()}>
{t('Login to configure RSS feeds')}
</Button>
</div>
</SecondaryPageLayout>
)
}
if (loading) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}>
<div className="text-center text-sm text-muted-foreground py-8">{t('loading...')}</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}>
<div className="px-4 pt-3 space-y-6">
{/* Show RSS Feed Toggle */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="show-rss-feed">{t('Show RSS Feed')}</Label>
<Switch
id="show-rss-feed"
checked={showRssFeed}
onCheckedChange={handleShowRssFeedChange}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('Show or hide the RSS feed tab in the main feed')}
</div>
</div>
{/* RSS Feed List */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t('RSS Feeds')}</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleExportOpml}
disabled={feedUrls.length === 0}
className="text-xs"
>
<Download className="h-3 w-3 mr-1" />
{t('Export OPML')}
</Button>
<label>
<Button
variant="outline"
size="sm"
asChild
className="text-xs cursor-pointer"
>
<span>
<Upload className="h-3 w-3 mr-1" />
{t('Import OPML')}
</span>
</Button>
<input
type="file"
accept=".opml,application/xml,text/xml"
onChange={handleImportOpml}
className="hidden"
/>
</label>
</div>
</div>
<div className="text-muted-foreground text-xs">
{t('Add RSS feed URLs to subscribe to. If no feeds are configured, the default feed will be used.')}
</div>
</div>
{/* Add Feed Input */}
<div className="flex gap-2">
<Input
type="url"
placeholder="https://example.com/feed.xml"
value={newFeedUrl}
onChange={(e) => setNewFeedUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFeed()
}
}}
className="flex-1"
/>
<Button onClick={handleAddFeed} size="icon" variant="outline">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Feed List */}
<div className="space-y-2">
{feedUrls.length === 0 ? (
<div className="text-sm text-muted-foreground py-4 text-center">
{t('No feeds configured. Default feed will be used.')}
</div>
) : (
feedUrls.map((url) => (
<div key={url} className="flex items-center justify-between p-3 border rounded-lg">
<span className="text-sm break-all flex-1 mr-2">{url}</span>
<Button
onClick={() => handleRemoveFeed(url)}
size="icon"
variant="ghost"
className="flex-shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</div>
{/* Save Button */}
<Button
className="w-full"
disabled={pushing || !hasChange}
onClick={handleSave}
>
{pushing ? <Loader className="animate-spin mr-2" /> : <CloudUpload className="mr-2" />}
{t('Save')}
</Button>
</div>
</div>
</SecondaryPageLayout>
)
})
RssFeedSettingsPage.displayName = 'RssFeedSettingsPage'
export default RssFeedSettingsPage