+ {/* Feed Source and Date */}
+
+ {feedSourceName}
+ {pubDateTimestamp && (
+
+ )}
+
+
+ {/* Title */}
+
+
+ {/* Description with text selection support */}
+
+
{
+ // Allow text selection
+ e.stopPropagation()
+ }}
+ style={{
+ userSelect: 'text',
+ WebkitUserSelect: 'text',
+ MozUserSelect: 'text',
+ msUserSelect: 'text'
+ }}
+ />
+
+ {/* Highlight Button */}
+ {showHighlightButton && selectedText && selectionPosition && (
+
+
+
+ )}
+
+
+ {/* Link to original article */}
+
+
+ {/* Post Editor for highlights */}
+
{
+ setIsPostEditorOpen(open)
+ if (!open) {
+ setHighlightData(undefined)
+ setHighlightText('')
+ }
+ }}
+ defaultContent={highlightText}
+ initialHighlightData={highlightData}
+ />
+
+ )
+}
+
diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx
new file mode 100644
index 0000000..b621486
--- /dev/null
+++ b/src/components/RssFeedList/index.tsx
@@ -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
([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(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 (
+
+
+
{t('Loading RSS feeds...')}
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (items.length === 0) {
+ return (
+
+
{t('No RSS feed items available')}
+
+ )
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
+
diff --git a/src/constants.ts b/src/constants.ts
index 9e4f234..7ee1175 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -53,6 +53,7 @@ export const StorageKey = {
DEFAULT_QUIET_DAYS: 'defaultQuietDays',
RESPECT_QUIET_TAGS: 'respectQuietTags',
GLOBAL_QUIET_MODE: 'globalQuietMode',
+ SHOW_RSS_FEED: 'showRssFeed',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
@@ -149,6 +150,7 @@ export const ExtendedKind = {
WIKI_ARTICLE: 30818,
WIKI_ARTICLE_MARKDOWN: 30817,
PUBLICATION_CONTENT: 30041,
+ RSS_FEED_LIST: 30895,
// NIP-89 Application Handlers
APPLICATION_HANDLER_RECOMMENDATION: 31989,
APPLICATION_HANDLER_INFO: 31990
@@ -230,3 +232,5 @@ export const MEDIA_AUTO_LOAD_POLICY = {
WIFI_ONLY: 'wifi-only',
NEVER: 'never'
} as const
+
+export const DEFAULT_RSS_FEEDS = ['https://divineoffice.org/feed/']
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index 1ecc254..9d419f0 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -438,6 +438,15 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf
}
}
+export function createRssFeedListDraftEvent(feedUrls: string[]): TDraftEvent {
+ return {
+ kind: ExtendedKind.RSS_FEED_LIST,
+ content: JSON.stringify(feedUrls),
+ tags: [],
+ created_at: dayjs().unix()
+ }
+}
+
export function createCacheRelaysDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
return {
kind: ExtendedKind.CACHE_RELAYS,
diff --git a/src/lib/link.ts b/src/lib/link.ts
index 63647d2..8e0e4ba 100644
--- a/src/lib/link.ts
+++ b/src/lib/link.ts
@@ -70,6 +70,7 @@ export const toWallet = () => '/settings/wallet'
export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation'
+export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx
new file mode 100644
index 0000000..aed69b2
--- /dev/null
+++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx
@@ -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([])
+ 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 (
+
+
+
+
+
+ )
+ }
+
+ 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
+
diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx
index d5bb677..1b07608 100644
--- a/src/pages/secondary/SettingsPage/index.tsx
+++ b/src/pages/secondary/SettingsPage/index.tsx
@@ -5,7 +5,8 @@ import {
toPostSettings,
toRelaySettings,
toTranslation,
- toWallet
+ toWallet,
+ toRssFeedSettings
} from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSmartSettingsNavigation } from '@/PageManager'
@@ -18,6 +19,7 @@ import {
KeyRound,
Languages,
PencilLine,
+ Rss,
Server,
Settings2,
Wallet
@@ -75,6 +77,15 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
)}
+ {!!pubkey && (
+ navigateToSettings(toRssFeedSettings())}>
+
+
+
{t('RSS Feed Settings')}
+
+
+
+ )}
{!!nsec && (
},
{ path: '/settings/general', element: },
{ path: '/settings/translation', element: },
+ { path: '/settings/rss-feeds', element: },
{ path: '/profile-editor', element: },
{ path: '/mutes', element: }
]
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index 6b4078c..f68b7b5 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -27,6 +27,7 @@ const StoreNames = {
FAVORITE_RELAYS: 'favoriteRelays',
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents',
CACHE_RELAYS_EVENTS: 'cacheRelaysEvents',
+ RSS_FEED_LIST_EVENTS: 'rssFeedListEvents',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
@@ -50,7 +51,7 @@ class IndexedDbService {
init(): Promise {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
- const request = window.indexedDB.open('jumble', 14)
+ const request = window.indexedDB.open('jumble', 15)
request.onerror = (event) => {
reject(event)
@@ -120,6 +121,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.CACHE_RELAYS_EVENTS, { keyPath: 'key' })
}
+ if (!db.objectStoreNames.contains(StoreNames.RSS_FEED_LIST_EVENTS)) {
+ db.createObjectStore(StoreNames.RSS_FEED_LIST_EVENTS, { keyPath: 'key' })
+ }
}
})
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
@@ -512,6 +516,8 @@ class IndexedDbService {
return StoreNames.BLOCKED_RELAYS_EVENTS
case ExtendedKind.CACHE_RELAYS:
return StoreNames.CACHE_RELAYS_EVENTS
+ case ExtendedKind.RSS_FEED_LIST:
+ return StoreNames.RSS_FEED_LIST_EVENTS
case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
@@ -1077,11 +1083,12 @@ class IndexedDbService {
if (storeName === StoreNames.RELAY_SETS) return kinds.Relaysets
if (storeName === StoreNames.FAVORITE_RELAYS) return ExtendedKind.FAVORITE_RELAYS
if (storeName === StoreNames.BLOCKED_RELAYS_EVENTS) return ExtendedKind.BLOCKED_RELAYS
- if (storeName === StoreNames.CACHE_RELAYS_EVENTS) return ExtendedKind.CACHE_RELAYS
- if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList
- if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets
- // PUBLICATION_EVENTS is not replaceable, so we don't handle it here
- return undefined
+ if (storeName === StoreNames.CACHE_RELAYS_EVENTS) return ExtendedKind.CACHE_RELAYS
+ if (storeName === StoreNames.RSS_FEED_LIST_EVENTS) return ExtendedKind.RSS_FEED_LIST
+ if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList
+ if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets
+ // PUBLICATION_EVENTS is not replaceable, so we don't handle it here
+ return undefined
}
private isReplaceableEventKind(kind: number): boolean {
@@ -1096,7 +1103,8 @@ class IndexedDbService {
kind === ExtendedKind.FAVORITE_RELAYS ||
kind === ExtendedKind.BLOCKED_RELAYS ||
kind === ExtendedKind.CACHE_RELAYS ||
- kind === ExtendedKind.BLOSSOM_SERVER_LIST
+ kind === ExtendedKind.BLOSSOM_SERVER_LIST ||
+ kind === ExtendedKind.RSS_FEED_LIST
)
}
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 02df46e..3c8eb8a 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -59,6 +59,7 @@ class LocalStorageService {
private defaultQuietDays: number = 7
private respectQuietTags: boolean = true
private globalQuietMode: boolean = false
+ private showRssFeed: boolean = true
constructor() {
if (!LocalStorageService.instance) {
@@ -276,6 +277,9 @@ class LocalStorageService {
const globalQuietModeStr = window.localStorage.getItem(StorageKey.GLOBAL_QUIET_MODE)
this.globalQuietMode = globalQuietModeStr === 'true'
+ const showRssFeedStr = window.localStorage.getItem(StorageKey.SHOW_RSS_FEED)
+ this.showRssFeed = showRssFeedStr === null ? true : showRssFeedStr === 'true' // Default to true
+
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -646,6 +650,15 @@ class LocalStorageService {
this.globalQuietMode = enabled
window.localStorage.setItem(StorageKey.GLOBAL_QUIET_MODE, enabled.toString())
}
+
+ getShowRssFeed() {
+ return this.showRssFeed
+ }
+
+ setShowRssFeed(show: boolean) {
+ this.showRssFeed = show
+ window.localStorage.setItem(StorageKey.SHOW_RSS_FEED, show.toString())
+ }
}
const instance = new LocalStorageService()
diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts
index 1e4d29a..e574205 100644
--- a/src/services/navigation.service.ts
+++ b/src/services/navigation.service.ts
@@ -14,6 +14,7 @@ import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage'
+import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import NotePage from '@/pages/secondary/NotePage'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import FollowingListPage from '@/pages/secondary/FollowingListPage'
@@ -64,6 +65,7 @@ export class URLParser {
if (url.includes('/wallet')) return 'wallet'
if (url.includes('/posts')) return 'posts'
if (url.includes('/translation')) return 'translation'
+ if (url.includes('/rss-feeds')) return 'rss-feeds'
return 'general'
}
}
@@ -116,6 +118,8 @@ export class ComponentFactory {
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
case 'translation':
return React.createElement(TranslationPage, { index: 0, hideTitlebar: true })
+ case 'rss-feeds':
+ return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true })
default:
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
}
diff --git a/src/services/rss-feed.service.ts b/src/services/rss-feed.service.ts
new file mode 100644
index 0000000..5768ede
--- /dev/null
+++ b/src/services/rss-feed.service.ts
@@ -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 = 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 {
+ // 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 {
+ 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
+