16 changed files with 1042 additions and 39 deletions
@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
|
||||
import { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
||||
import { FormattedTimestamp } from '../FormattedTimestamp' |
||||
import { ExternalLink, Highlighter } from 'lucide-react' |
||||
import { useState, useRef, useEffect, useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '@/components/ui/button' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import PostEditor from '@/components/PostEditor' |
||||
import { HighlightData } from '@/components/PostEditor/HighlightEditor' |
||||
|
||||
export default function RssFeedItem({ item, className }: { item: TRssFeedItem; className?: string }) { |
||||
const { t } = useTranslation() |
||||
const { pubkey, checkLogin } = useNostr() |
||||
const [selectedText, setSelectedText] = useState('') |
||||
const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor
|
||||
const [showHighlightButton, setShowHighlightButton] = useState(false) |
||||
const [selectionPosition, setSelectionPosition] = useState<{ x: number; y: number } | null>(null) |
||||
const [isPostEditorOpen, setIsPostEditorOpen] = useState(false) |
||||
const [highlightData, setHighlightData] = useState<HighlightData | undefined>(undefined) |
||||
const contentRef = useRef<HTMLDivElement>(null) |
||||
const selectionTimeoutRef = useRef<NodeJS.Timeout>() |
||||
|
||||
// Handle text selection
|
||||
useEffect(() => { |
||||
const handleSelection = () => { |
||||
const selection = window.getSelection() |
||||
if (!selection || selection.isCollapsed || !contentRef.current) { |
||||
setShowHighlightButton(false) |
||||
setSelectedText('') |
||||
return |
||||
} |
||||
|
||||
// Check if selection is within this item's content
|
||||
const range = selection.getRangeAt(0) |
||||
if (!contentRef.current.contains(range.commonAncestorContainer)) { |
||||
setShowHighlightButton(false) |
||||
setSelectedText('') |
||||
return |
||||
} |
||||
|
||||
const text = selection.toString().trim() |
||||
if (text.length > 0) { |
||||
setSelectedText(text) |
||||
|
||||
// Get selection position for button placement
|
||||
const rect = range.getBoundingClientRect() |
||||
setSelectionPosition({ |
||||
x: rect.left + rect.width / 2, |
||||
y: rect.top - 10 |
||||
}) |
||||
setShowHighlightButton(true) |
||||
} else { |
||||
setShowHighlightButton(false) |
||||
setSelectedText('') |
||||
} |
||||
} |
||||
|
||||
const handleMouseUp = () => { |
||||
// Delay to allow selection to complete
|
||||
if (selectionTimeoutRef.current) { |
||||
clearTimeout(selectionTimeoutRef.current) |
||||
} |
||||
selectionTimeoutRef.current = setTimeout(handleSelection, 100) |
||||
} |
||||
|
||||
const handleClick = (e: MouseEvent) => { |
||||
// Hide button if clicking outside the selection area
|
||||
if (showHighlightButton && !(e.target as HTMLElement).closest('.highlight-button-container')) { |
||||
setShowHighlightButton(false) |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('mouseup', handleMouseUp) |
||||
document.addEventListener('click', handleClick) |
||||
|
||||
return () => { |
||||
document.removeEventListener('mouseup', handleMouseUp) |
||||
document.removeEventListener('click', handleClick) |
||||
if (selectionTimeoutRef.current) { |
||||
clearTimeout(selectionTimeoutRef.current) |
||||
} |
||||
} |
||||
}, [showHighlightButton]) |
||||
|
||||
const handleCreateHighlight = () => { |
||||
const currentSelection = window.getSelection() |
||||
const text = currentSelection?.toString().trim() || selectedText |
||||
|
||||
if (!text) { |
||||
return |
||||
} |
||||
|
||||
// Store the text to highlight
|
||||
setHighlightText(text) |
||||
|
||||
if (!pubkey) { |
||||
checkLogin(() => { |
||||
// After login, create highlight data and open editor
|
||||
const data: HighlightData = { |
||||
sourceType: 'url', |
||||
sourceValue: item.link, |
||||
context: item.description |
||||
} |
||||
setHighlightData(data) |
||||
setIsPostEditorOpen(true) |
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges() |
||||
setShowHighlightButton(false) |
||||
setSelectedText('') |
||||
}) |
||||
return |
||||
} |
||||
|
||||
// Create highlight data
|
||||
const data: HighlightData = { |
||||
sourceType: 'url', |
||||
sourceValue: item.link, |
||||
context: item.description |
||||
} |
||||
|
||||
// Open PostEditor in highlight mode
|
||||
setHighlightData(data) |
||||
setIsPostEditorOpen(true) |
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges() |
||||
setShowHighlightButton(false) |
||||
setSelectedText('') |
||||
} |
||||
|
||||
// Format feed source name from URL
|
||||
const feedSourceName = useMemo(() => { |
||||
try { |
||||
const url = new URL(item.feedUrl) |
||||
return url.hostname.replace(/^www\./, '') |
||||
} catch { |
||||
return item.feedTitle || 'RSS Feed' |
||||
} |
||||
}, [item.feedUrl, item.feedTitle]) |
||||
|
||||
// Parse HTML description safely
|
||||
const descriptionHtml = item.description |
||||
|
||||
// Format publication date
|
||||
const pubDateTimestamp = item.pubDate ? Math.floor(item.pubDate.getTime() / 1000) : null |
||||
|
||||
return ( |
||||
<div className={`border rounded-lg bg-background p-4 space-y-3 ${className || ''}`}> |
||||
{/* Feed Source and Date */} |
||||
<div className="flex items-center justify-between text-sm text-muted-foreground"> |
||||
<span className="font-medium">{feedSourceName}</span> |
||||
{pubDateTimestamp && ( |
||||
<FormattedTimestamp timestamp={pubDateTimestamp} className="shrink-0" short /> |
||||
)} |
||||
</div> |
||||
|
||||
{/* Title */} |
||||
<div> |
||||
<a |
||||
href={item.link} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="text-lg font-semibold hover:text-primary transition-colors inline-flex items-center gap-2" |
||||
onClick={(e) => e.stopPropagation()} |
||||
> |
||||
{item.title} |
||||
<ExternalLink className="h-4 w-4 shrink-0" /> |
||||
</a> |
||||
</div> |
||||
|
||||
{/* Description with text selection support */} |
||||
<div className="relative"> |
||||
<div |
||||
ref={contentRef} |
||||
className="prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content" |
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }} |
||||
onMouseUp={(e) => { |
||||
// Allow text selection
|
||||
e.stopPropagation() |
||||
}} |
||||
style={{ |
||||
userSelect: 'text', |
||||
WebkitUserSelect: 'text', |
||||
MozUserSelect: 'text', |
||||
msUserSelect: 'text' |
||||
}} |
||||
/> |
||||
|
||||
{/* Highlight Button */} |
||||
{showHighlightButton && selectedText && selectionPosition && ( |
||||
<div |
||||
className="highlight-button-container fixed z-50" |
||||
style={{ |
||||
left: `${selectionPosition.x}px`, |
||||
top: `${selectionPosition.y}px`, |
||||
transform: 'translateX(-50%) translateY(-100%)' |
||||
}} |
||||
> |
||||
<Button |
||||
size="sm" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
handleCreateHighlight() |
||||
}} |
||||
className="shadow-lg" |
||||
> |
||||
<Highlighter className="h-4 w-4 mr-2" /> |
||||
{t('Create Highlight')} |
||||
</Button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
{/* Link to original article */} |
||||
<div className="flex items-center gap-2 text-sm"> |
||||
<a |
||||
href={item.link} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="text-primary hover:underline inline-flex items-center gap-1" |
||||
onClick={(e) => e.stopPropagation()} |
||||
> |
||||
{t('Read full article')} |
||||
<ExternalLink className="h-3 w-3" /> |
||||
</a> |
||||
</div> |
||||
|
||||
{/* Post Editor for highlights */} |
||||
<PostEditor |
||||
open={isPostEditorOpen} |
||||
setOpen={(open) => { |
||||
setIsPostEditorOpen(open) |
||||
if (!open) { |
||||
setHighlightData(undefined) |
||||
setHighlightText('') |
||||
} |
||||
}} |
||||
defaultContent={highlightText} |
||||
initialHighlightData={highlightData} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
||||
import { ExtendedKind, DEFAULT_RSS_FEEDS } from '@/constants' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import RssFeedItem from '../RssFeedItem' |
||||
import { Loader, AlertCircle } from 'lucide-react' |
||||
import logger from '@/lib/logger' |
||||
|
||||
export default function RssFeedList() { |
||||
const { t } = useTranslation() |
||||
const { pubkey } = useNostr() |
||||
const [items, setItems] = useState<TRssFeedItem[]>([]) |
||||
const [loading, setLoading] = useState(true) |
||||
const [error, setError] = useState<string | null>(null) |
||||
|
||||
useEffect(() => { |
||||
const loadRssFeeds = async () => { |
||||
setLoading(true) |
||||
setError(null) |
||||
|
||||
try { |
||||
// Get feed URLs from event or use default
|
||||
let feedUrls: string[] = DEFAULT_RSS_FEEDS |
||||
|
||||
if (pubkey) { |
||||
try { |
||||
const event = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) |
||||
if (event && event.content) { |
||||
try { |
||||
const urls = JSON.parse(event.content) as string[] |
||||
if (Array.isArray(urls) && urls.length > 0) { |
||||
feedUrls = urls |
||||
} |
||||
} catch (e) { |
||||
logger.error('[RssFeedList] Failed to parse RSS feed list', { error: e }) |
||||
// Use default feeds on parse error
|
||||
} |
||||
} |
||||
} catch (e) { |
||||
logger.error('[RssFeedList] Failed to load RSS feed list event', { error: e }) |
||||
// Use default feeds on error
|
||||
} |
||||
} |
||||
|
||||
// Fetch and merge feeds
|
||||
const fetchedItems = await rssFeedService.fetchMultipleFeeds(feedUrls) |
||||
setItems(fetchedItems) |
||||
} catch (err) { |
||||
logger.error('[RssFeedList] Error loading RSS feeds', { error: err }) |
||||
setError(err instanceof Error ? err.message : t('Failed to load RSS feeds')) |
||||
} finally { |
||||
setLoading(false) |
||||
} |
||||
} |
||||
|
||||
loadRssFeeds() |
||||
}, [pubkey, 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="flex flex-col items-center justify-center py-12"> |
||||
<p className="text-sm text-muted-foreground">{t('No RSS feed items available')}</p> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-4 px-4 py-3"> |
||||
{items.map((item) => ( |
||||
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,256 @@
@@ -0,0 +1,256 @@
|
||||
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 } from 'lucide-react' |
||||
import logger from '@/lib/logger' |
||||
import { ExtendedKind } from '@/constants' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
|
||||
const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { pubkey, publish, checkLogin } = 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) |
||||
|
||||
useEffect(() => { |
||||
// Load show RSS feed setting
|
||||
setShowRssFeed(storage.getShowRssFeed()) |
||||
|
||||
// Load RSS feed list from event
|
||||
const loadRssFeedList = async () => { |
||||
if (!pubkey) { |
||||
setLoading(false) |
||||
return |
||||
} |
||||
|
||||
try { |
||||
const event = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) |
||||
if (event && event.content) { |
||||
try { |
||||
const urls = JSON.parse(event.content) as string[] |
||||
if (Array.isArray(urls)) { |
||||
setFeedUrls(urls) |
||||
} |
||||
} catch (e) { |
||||
logger.error('[RssFeedSettingsPage] Failed to parse RSS feed list', { error: e }) |
||||
} |
||||
} |
||||
} catch (error) { |
||||
logger.error('[RssFeedSettingsPage] Failed to load RSS feed list', { error }) |
||||
} finally { |
||||
setLoading(false) |
||||
} |
||||
} |
||||
|
||||
loadRssFeedList() |
||||
}, [pubkey]) |
||||
|
||||
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 |
||||
|
||||
// Basic URL validation
|
||||
try { |
||||
new URL(url) |
||||
} catch { |
||||
// Invalid URL
|
||||
return |
||||
} |
||||
|
||||
if (feedUrls.includes(url)) { |
||||
// Feed already exists
|
||||
return |
||||
} |
||||
|
||||
setFeedUrls([...feedUrls, url]) |
||||
setNewFeedUrl('') |
||||
setHasChange(true) |
||||
} |
||||
|
||||
const handleRemoveFeed = (url: string) => { |
||||
setFeedUrls(feedUrls.filter(u => u !== url)) |
||||
setHasChange(true) |
||||
} |
||||
|
||||
const handleSave = async () => { |
||||
if (!pubkey) return |
||||
|
||||
setPushing(true) |
||||
try { |
||||
const event = createRssFeedListDraftEvent(feedUrls) |
||||
const result = await publish(event) |
||||
|
||||
// Cache the event in IndexedDB for immediate access
|
||||
try { |
||||
await indexedDb.putReplaceableEvent(result) |
||||
} catch (cacheError) { |
||||
logger.warn('[RssFeedSettingsPage] Failed to cache RSS feed list event', { error: cacheError }) |
||||
// Don't fail the save if caching fails
|
||||
} |
||||
|
||||
// Read relayStatuses immediately before it might be deleted
|
||||
const relayStatuses = (result as any).relayStatuses |
||||
|
||||
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 }) |
||||
// 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"> |
||||
<Label>{t('RSS Feeds')}</Label> |
||||
<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 |
||||
|
||||
@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
|
||||
import { DEFAULT_RSS_FEEDS } from '@/constants' |
||||
import logger from '@/lib/logger' |
||||
|
||||
export interface RssFeedItem { |
||||
title: string |
||||
link: string |
||||
description: string |
||||
pubDate: Date | null |
||||
guid: string |
||||
feedUrl: string |
||||
feedTitle?: string |
||||
} |
||||
|
||||
export interface RssFeed { |
||||
title: string |
||||
link: string |
||||
description: string |
||||
items: RssFeedItem[] |
||||
feedUrl: string |
||||
} |
||||
|
||||
class RssFeedService { |
||||
static instance: RssFeedService |
||||
private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map() |
||||
private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
constructor() { |
||||
if (!RssFeedService.instance) { |
||||
RssFeedService.instance = this |
||||
} |
||||
return RssFeedService.instance |
||||
} |
||||
|
||||
/** |
||||
* Fetch and parse an RSS/Atom feed from a URL |
||||
*/ |
||||
async fetchFeed(url: string): Promise<RssFeed> { |
||||
// Check cache first
|
||||
const cached = this.feedCache.get(url) |
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) { |
||||
return cached.feed |
||||
} |
||||
|
||||
try { |
||||
// Check if we should use proxy server to avoid CORS issues
|
||||
const proxyServer = import.meta.env.VITE_PROXY_SERVER |
||||
const isProxyUrl = url.includes('/sites/') |
||||
|
||||
// If proxy is configured and URL isn't already proxied, use proxy
|
||||
let fetchUrl = url |
||||
if (proxyServer && !isProxyUrl) { |
||||
fetchUrl = `${proxyServer}/sites/${encodeURIComponent(url)}` |
||||
} |
||||
|
||||
const controller = new AbortController() |
||||
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||
|
||||
const res = await fetch(fetchUrl, { |
||||
signal: controller.signal, |
||||
mode: 'cors', |
||||
credentials: 'omit', |
||||
headers: { |
||||
'Accept': 'application/rss+xml, application/xml, application/atom+xml, text/xml, */*' |
||||
} |
||||
}) |
||||
|
||||
clearTimeout(timeoutId) |
||||
|
||||
if (!res.ok) { |
||||
throw new Error(`Failed to fetch feed: ${res.status} ${res.statusText}`) |
||||
} |
||||
|
||||
const xmlText = await res.text() |
||||
const feed = this.parseFeed(xmlText, url) |
||||
|
||||
// Cache the feed
|
||||
this.feedCache.set(url, { feed, timestamp: Date.now() }) |
||||
|
||||
return feed |
||||
} catch (error) { |
||||
logger.error('[RssFeedService] Error fetching feed', { url, error }) |
||||
throw error |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse RSS/Atom XML into structured data |
||||
*/ |
||||
private parseFeed(xmlText: string, feedUrl: string): RssFeed { |
||||
const parser = new DOMParser() |
||||
const doc = parser.parseFromString(xmlText, 'text/xml') |
||||
|
||||
// Check for parsing errors
|
||||
const parserError = doc.querySelector('parsererror') |
||||
if (parserError) { |
||||
throw new Error('Failed to parse XML feed') |
||||
} |
||||
|
||||
// Determine if it's RSS or Atom
|
||||
const isAtom = doc.documentElement.tagName === 'feed' || doc.documentElement.namespaceURI === 'http://www.w3.org/2005/Atom' |
||||
|
||||
if (isAtom) { |
||||
return this.parseAtomFeed(doc, feedUrl) |
||||
} else { |
||||
return this.parseRssFeed(doc, feedUrl) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse RSS 2.0 feed |
||||
*/ |
||||
private parseRssFeed(doc: Document, feedUrl: string): RssFeed { |
||||
const channel = doc.querySelector('channel') |
||||
if (!channel) { |
||||
throw new Error('Invalid RSS feed: no channel element found') |
||||
} |
||||
|
||||
const title = this.getTextContent(channel, 'title') || 'Untitled Feed' |
||||
const link = this.getTextContent(channel, 'link') || feedUrl |
||||
const description = this.getTextContent(channel, 'description') || '' |
||||
|
||||
const items: RssFeedItem[] = [] |
||||
const itemElements = channel.querySelectorAll('item') |
||||
|
||||
itemElements.forEach((item) => { |
||||
const itemTitle = this.getTextContent(item, 'title') || '' |
||||
let itemLink = this.getTextContent(item, 'link') || '' |
||||
// Convert relative URLs to absolute
|
||||
if (itemLink && !itemLink.startsWith('http://') && !itemLink.startsWith('https://')) { |
||||
try { |
||||
const baseUrl = new URL(feedUrl) |
||||
itemLink = new URL(itemLink, baseUrl.origin).href |
||||
} catch { |
||||
// If URL parsing fails, keep the original link
|
||||
} |
||||
} |
||||
// For description, preserve HTML content
|
||||
const itemDescription = this.getHtmlContent(item, 'description') || '' |
||||
const itemPubDate = this.parseDate(this.getTextContent(item, 'pubDate')) |
||||
const itemGuid = this.getTextContent(item, 'guid') || itemLink || '' |
||||
|
||||
items.push({ |
||||
title: itemTitle, |
||||
link: itemLink, |
||||
description: itemDescription, |
||||
pubDate: itemPubDate, |
||||
guid: itemGuid, |
||||
feedUrl, |
||||
feedTitle: title |
||||
}) |
||||
}) |
||||
|
||||
return { |
||||
title, |
||||
link, |
||||
description, |
||||
items, |
||||
feedUrl |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse Atom 1.0 feed |
||||
*/ |
||||
private parseAtomFeed(doc: Document, feedUrl: string): RssFeed { |
||||
const feed = doc.documentElement |
||||
|
||||
const title = this.getTextContent(feed, 'title') || 'Untitled Feed' |
||||
const linkElement = feed.querySelector('link[rel="alternate"], link:not([rel])') |
||||
const link = linkElement?.getAttribute('href') || feedUrl |
||||
const description = this.getTextContent(feed, 'subtitle') || this.getTextContent(feed, 'description') || '' |
||||
|
||||
const items: RssFeedItem[] = [] |
||||
const entryElements = feed.querySelectorAll('entry') |
||||
|
||||
entryElements.forEach((entry) => { |
||||
const entryTitle = this.getTextContent(entry, 'title') || '' |
||||
const entryLinkElement = entry.querySelector('link[rel="alternate"], link:not([rel])') |
||||
let entryLink = entryLinkElement?.getAttribute('href') || '' |
||||
// Convert relative URLs to absolute
|
||||
if (entryLink && !entryLink.startsWith('http://') && !entryLink.startsWith('https://')) { |
||||
try { |
||||
const baseUrl = new URL(feedUrl) |
||||
entryLink = new URL(entryLink, baseUrl.origin).href |
||||
} catch { |
||||
// If URL parsing fails, keep the original link
|
||||
} |
||||
} |
||||
// For content/summary, preserve HTML content
|
||||
const entryContent = this.getHtmlContent(entry, 'content') || this.getHtmlContent(entry, 'summary') || '' |
||||
const entryPublished = this.getTextContent(entry, 'published') || this.getTextContent(entry, 'updated') |
||||
const entryPubDate = this.parseDate(entryPublished) |
||||
const entryId = this.getTextContent(entry, 'id') || entryLink || '' |
||||
|
||||
items.push({ |
||||
title: entryTitle, |
||||
link: entryLink, |
||||
description: entryContent, |
||||
pubDate: entryPubDate, |
||||
guid: entryId, |
||||
feedUrl, |
||||
feedTitle: title |
||||
}) |
||||
}) |
||||
|
||||
return { |
||||
title, |
||||
link, |
||||
description, |
||||
items, |
||||
feedUrl |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get text content from an element, handling CDATA and nested elements |
||||
*/ |
||||
private getTextContent(element: Element | null, tagName: string): string { |
||||
if (!element) return '' |
||||
const child = element.querySelector(tagName) |
||||
if (!child) return '' |
||||
// Get text content which automatically decodes HTML entities
|
||||
return child.textContent?.trim() || '' |
||||
} |
||||
|
||||
/** |
||||
* Get HTML content from an element (for descriptions that may contain HTML) |
||||
*/ |
||||
private getHtmlContent(element: Element | null, tagName: string): string { |
||||
if (!element) return '' |
||||
const child = element.querySelector(tagName) |
||||
if (!child) return '' |
||||
// Return innerHTML to preserve HTML formatting
|
||||
return child.innerHTML?.trim() || child.textContent?.trim() || '' |
||||
} |
||||
|
||||
/** |
||||
* Parse date string into Date object |
||||
*/ |
||||
private parseDate(dateString: string | null): Date | null { |
||||
if (!dateString) return null |
||||
try { |
||||
return new Date(dateString) |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get feed URLs to use (from event or default) |
||||
*/ |
||||
getFeedUrls(eventFeedUrls: string[] | null | undefined): string[] { |
||||
if (eventFeedUrls && eventFeedUrls.length > 0) { |
||||
return eventFeedUrls |
||||
} |
||||
return DEFAULT_RSS_FEEDS |
||||
} |
||||
|
||||
/** |
||||
* Fetch multiple feeds and merge items |
||||
*/ |
||||
async fetchMultipleFeeds(feedUrls: string[]): Promise<RssFeedItem[]> { |
||||
const results = await Promise.allSettled( |
||||
feedUrls.map(url => this.fetchFeed(url)) |
||||
) |
||||
|
||||
const allItems: RssFeedItem[] = [] |
||||
|
||||
results.forEach((result, index) => { |
||||
if (result.status === 'fulfilled') { |
||||
allItems.push(...result.value.items) |
||||
} else { |
||||
logger.warn('[RssFeedService] Failed to fetch feed', { url: feedUrls[index], error: result.reason }) |
||||
} |
||||
}) |
||||
|
||||
// Sort by publication date (newest first)
|
||||
allItems.sort((a, b) => { |
||||
const dateA = a.pubDate?.getTime() || 0 |
||||
const dateB = b.pubDate?.getTime() || 0 |
||||
return dateB - dateA |
||||
}) |
||||
|
||||
return allItems |
||||
} |
||||
|
||||
/** |
||||
* Clear cache for a specific feed or all feeds |
||||
*/ |
||||
clearCache(url?: string) { |
||||
if (url) { |
||||
this.feedCache.delete(url) |
||||
} else { |
||||
this.feedCache.clear() |
||||
} |
||||
} |
||||
} |
||||
|
||||
const instance = new RssFeedService() |
||||
export default instance |
||||
|
||||
Loading…
Reference in new issue