Browse Source

add rss feeds

imwald
Silberengel 4 months ago
parent
commit
fecf41951e
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 4
      src/PageManager.tsx
  4. 107
      src/components/NormalFeed/index.tsx
  5. 244
      src/components/RssFeedItem/index.tsx
  6. 95
      src/components/RssFeedList/index.tsx
  7. 4
      src/constants.ts
  8. 9
      src/lib/draft-event.ts
  9. 1
      src/lib/link.ts
  10. 256
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  11. 13
      src/pages/secondary/SettingsPage/index.tsx
  12. 2
      src/routes.tsx
  13. 22
      src/services/indexed-db.service.ts
  14. 13
      src/services/local-storage.service.ts
  15. 4
      src/services/navigation.service.ts
  16. 301
      src/services/rss-feed.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "12.3", "version": "13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "12.3", "version": "13.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "12.3", "version": "13.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

4
src/PageManager.tsx

@ -13,6 +13,7 @@ import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage' import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage' import TranslationPage from '@/pages/secondary/TranslationPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import NotePage from '@/pages/secondary/NotePage' import NotePage from '@/pages/secondary/NotePage'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage' import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import FollowingListPage from '@/pages/secondary/FollowingListPage' import FollowingListPage from '@/pages/secondary/FollowingListPage'
@ -298,6 +299,9 @@ export function useSmartSettingsNavigation() {
} else if (url === '/settings/translation') { } else if (url === '/settings/translation') {
window.history.pushState(null, '', url) window.history.pushState(null, '', url)
setPrimaryNoteView(<TranslationPage index={0} hideTitlebar={true} />, 'settings-sub') setPrimaryNoteView(<TranslationPage index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/rss-feeds') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<RssFeedSettingsPage index={0} hideTitlebar={true} />, 'settings-sub')
} }
} }

107
src/components/NormalFeed/index.tsx

@ -6,9 +6,10 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { forwardRef, useMemo, useRef, useState } from 'react' import { forwardRef, useMemo, useRef, useState, useEffect } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
import RssFeedList from '../RssFeedList'
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
@ -37,11 +38,41 @@ const NormalFeed = forwardRef<TNoteListRef, {
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const internalNoteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref || internalNoteListRef const noteListRef = ref || internalNoteListRef
const [showRssFeed, setShowRssFeed] = useState(() => storage.getShowRssFeed())
const [activeTab, setActiveTab] = useState<string>(listMode)
const handleListModeChange = (mode: TNoteListMode) => { // Sync activeTab with listMode when listMode changes (but not when switching to RSS)
setListMode(mode) useEffect(() => {
if (activeTab !== 'rss' && activeTab !== listMode) {
setActiveTab(listMode)
}
}, [listMode, activeTab])
// Check showRssFeed setting on mount
useEffect(() => {
const currentShowRssFeed = storage.getShowRssFeed()
setShowRssFeed(currentShowRssFeed)
}, [])
// Handle RSS tab visibility when showRssFeed changes
useEffect(() => {
// If RSS tab is hidden while it's active, switch to posts
if (!showRssFeed && activeTab === 'rss') {
setActiveTab('posts')
setListMode('posts')
}
}, [showRssFeed, activeTab])
const handleListModeChange = (mode: TNoteListMode | string) => {
if (mode === 'rss') {
setActiveTab('rss')
return
}
const noteListMode = mode as TNoteListMode
setListMode(noteListMode)
setActiveTab(noteListMode)
if (isMainFeed) { if (isMainFeed) {
storage.setNoteListMode(mode) storage.setNoteListMode(noteListMode)
} }
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth') noteListRef.current?.scrollToTop('smooth')
@ -55,37 +86,57 @@ const NormalFeed = forwardRef<TNoteListRef, {
} }
} }
// Build tabs array conditionally
const tabs = useMemo(() => {
const baseTabs = [
{ value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' }
]
if (showRssFeed) {
baseTabs.push({ value: 'rss', label: 'RSS' })
}
return baseTabs
}, [showRssFeed])
// Determine current tab value
const currentTabValue = activeTab
return ( return (
<> <>
<Tabs <Tabs
value={listMode} value={currentTabValue}
tabs={[ tabs={tabs}
{ value: 'posts', label: 'Notes' }, onTabChange={(tab) => {
{ value: 'postsAndReplies', label: 'Replies' } handleListModeChange(tab)
]}
onTabChange={(listMode) => {
handleListModeChange(listMode as TNoteListMode)
}} }}
options={ options={
<> activeTab !== 'rss' ? (
{!supportTouch && <RefreshButton onClick={() => { <>
if (noteListRef && typeof noteListRef !== 'function') { {!supportTouch && <RefreshButton onClick={() => {
noteListRef.current?.refresh() if (noteListRef && typeof noteListRef !== 'function') {
} noteListRef.current?.refresh()
}} />} }
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} /> }} />}
</> <KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
</>
) : null
} }
/> />
<NoteList {activeTab === 'rss' ? (
ref={noteListRef} <RssFeedList />
showKinds={temporaryShowKinds} ) : (
subRequests={subRequests} <NoteList
hideReplies={listMode === 'posts'} ref={noteListRef}
hideUntrustedNotes={hideUntrustedNotes} showKinds={temporaryShowKinds}
areAlgoRelays={areAlgoRelays} subRequests={subRequests}
showRelayCloseReason={showRelayCloseReason} hideReplies={listMode === 'posts'}
/> hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason}
/>
)}
</> </>
) )
}) })

244
src/components/RssFeedItem/index.tsx

@ -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>
)
}

95
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<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>
)
}

4
src/constants.ts

@ -53,6 +53,7 @@ export const StorageKey = {
DEFAULT_QUIET_DAYS: 'defaultQuietDays', DEFAULT_QUIET_DAYS: 'defaultQuietDays',
RESPECT_QUIET_TAGS: 'respectQuietTags', RESPECT_QUIET_TAGS: 'respectQuietTags',
GLOBAL_QUIET_MODE: 'globalQuietMode', GLOBAL_QUIET_MODE: 'globalQuietMode',
SHOW_RSS_FEED: 'showRssFeed',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
@ -149,6 +150,7 @@ export const ExtendedKind = {
WIKI_ARTICLE: 30818, WIKI_ARTICLE: 30818,
WIKI_ARTICLE_MARKDOWN: 30817, WIKI_ARTICLE_MARKDOWN: 30817,
PUBLICATION_CONTENT: 30041, PUBLICATION_CONTENT: 30041,
RSS_FEED_LIST: 30895,
// NIP-89 Application Handlers // NIP-89 Application Handlers
APPLICATION_HANDLER_RECOMMENDATION: 31989, APPLICATION_HANDLER_RECOMMENDATION: 31989,
APPLICATION_HANDLER_INFO: 31990 APPLICATION_HANDLER_INFO: 31990
@ -230,3 +232,5 @@ export const MEDIA_AUTO_LOAD_POLICY = {
WIFI_ONLY: 'wifi-only', WIFI_ONLY: 'wifi-only',
NEVER: 'never' NEVER: 'never'
} as const } as const
export const DEFAULT_RSS_FEEDS = ['https://divineoffice.org/feed/']

9
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 { export function createCacheRelaysDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
return { return {
kind: ExtendedKind.CACHE_RELAYS, kind: ExtendedKind.CACHE_RELAYS,

1
src/lib/link.ts

@ -70,6 +70,7 @@ export const toWallet = () => '/settings/wallet'
export const toPostSettings = () => '/settings/posts' export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general' export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation' export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toProfileEditor = () => '/profile-editor' export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews` export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`

256
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<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

13
src/pages/secondary/SettingsPage/index.tsx

@ -5,7 +5,8 @@ import {
toPostSettings, toPostSettings,
toRelaySettings, toRelaySettings,
toTranslation, toTranslation,
toWallet toWallet,
toRssFeedSettings
} from '@/lib/link' } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartSettingsNavigation } from '@/PageManager' import { useSmartSettingsNavigation } from '@/PageManager'
@ -18,6 +19,7 @@ import {
KeyRound, KeyRound,
Languages, Languages,
PencilLine, PencilLine,
Rss,
Server, Server,
Settings2, Settings2,
Wallet Wallet
@ -75,6 +77,15 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
<ChevronRight /> <ChevronRight />
</SettingItem> </SettingItem>
)} )}
{!!pubkey && (
<SettingItem className="clickable" onClick={() => navigateToSettings(toRssFeedSettings())}>
<div className="flex items-center gap-4">
<Rss />
<div>{t('RSS Feed Settings')}</div>
</div>
<ChevronRight />
</SettingItem>
)}
{!!nsec && ( {!!nsec && (
<SettingItem <SettingItem
className="clickable" className="clickable"

2
src/routes.tsx

@ -13,6 +13,7 @@ import ProfilePage from './pages/secondary/ProfilePage'
import RelayPage from './pages/secondary/RelayPage' import RelayPage from './pages/secondary/RelayPage'
import RelayReviewsPage from './pages/secondary/RelayReviewsPage' import RelayReviewsPage from './pages/secondary/RelayReviewsPage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
import RssFeedSettingsPage from './pages/secondary/RssFeedSettingsPage'
import SearchPage from './pages/secondary/SearchPage' import SearchPage from './pages/secondary/SearchPage'
import SettingsPage from './pages/secondary/SettingsPage' import SettingsPage from './pages/secondary/SettingsPage'
import TranslationPage from './pages/secondary/TranslationPage' import TranslationPage from './pages/secondary/TranslationPage'
@ -34,6 +35,7 @@ const ROUTES = [
{ path: '/settings/posts', element: <PostSettingsPage /> }, { path: '/settings/posts', element: <PostSettingsPage /> },
{ path: '/settings/general', element: <GeneralSettingsPage /> }, { path: '/settings/general', element: <GeneralSettingsPage /> },
{ path: '/settings/translation', element: <TranslationPage /> }, { path: '/settings/translation', element: <TranslationPage /> },
{ path: '/settings/rss-feeds', element: <RssFeedSettingsPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> }, { path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> } { path: '/mutes', element: <MuteListPage /> }
] ]

22
src/services/indexed-db.service.ts

@ -27,6 +27,7 @@ const StoreNames = {
FAVORITE_RELAYS: 'favoriteRelays', FAVORITE_RELAYS: 'favoriteRelays',
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents', BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents',
CACHE_RELAYS_EVENTS: 'cacheRelaysEvents', CACHE_RELAYS_EVENTS: 'cacheRelaysEvents',
RSS_FEED_LIST_EVENTS: 'rssFeedListEvents',
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays', FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos', RELAY_INFOS: 'relayInfos',
@ -50,7 +51,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 14) const request = window.indexedDB.open('jumble', 15)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@ -120,6 +121,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.CACHE_RELAYS_EVENTS, { keyPath: 'key' }) 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 setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
@ -512,6 +516,8 @@ class IndexedDbService {
return StoreNames.BLOCKED_RELAYS_EVENTS return StoreNames.BLOCKED_RELAYS_EVENTS
case ExtendedKind.CACHE_RELAYS: case ExtendedKind.CACHE_RELAYS:
return StoreNames.CACHE_RELAYS_EVENTS return StoreNames.CACHE_RELAYS_EVENTS
case ExtendedKind.RSS_FEED_LIST:
return StoreNames.RSS_FEED_LIST_EVENTS
case kinds.UserEmojiList: case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets: case kinds.Emojisets:
@ -1077,11 +1083,12 @@ class IndexedDbService {
if (storeName === StoreNames.RELAY_SETS) return kinds.Relaysets if (storeName === StoreNames.RELAY_SETS) return kinds.Relaysets
if (storeName === StoreNames.FAVORITE_RELAYS) return ExtendedKind.FAVORITE_RELAYS if (storeName === StoreNames.FAVORITE_RELAYS) return ExtendedKind.FAVORITE_RELAYS
if (storeName === StoreNames.BLOCKED_RELAYS_EVENTS) return ExtendedKind.BLOCKED_RELAYS if (storeName === StoreNames.BLOCKED_RELAYS_EVENTS) return ExtendedKind.BLOCKED_RELAYS
if (storeName === StoreNames.CACHE_RELAYS_EVENTS) return ExtendedKind.CACHE_RELAYS if (storeName === StoreNames.CACHE_RELAYS_EVENTS) return ExtendedKind.CACHE_RELAYS
if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList if (storeName === StoreNames.RSS_FEED_LIST_EVENTS) return ExtendedKind.RSS_FEED_LIST
if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList
// PUBLICATION_EVENTS is not replaceable, so we don't handle it here if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets
return undefined // PUBLICATION_EVENTS is not replaceable, so we don't handle it here
return undefined
} }
private isReplaceableEventKind(kind: number): boolean { private isReplaceableEventKind(kind: number): boolean {
@ -1096,7 +1103,8 @@ class IndexedDbService {
kind === ExtendedKind.FAVORITE_RELAYS || kind === ExtendedKind.FAVORITE_RELAYS ||
kind === ExtendedKind.BLOCKED_RELAYS || kind === ExtendedKind.BLOCKED_RELAYS ||
kind === ExtendedKind.CACHE_RELAYS || kind === ExtendedKind.CACHE_RELAYS ||
kind === ExtendedKind.BLOSSOM_SERVER_LIST kind === ExtendedKind.BLOSSOM_SERVER_LIST ||
kind === ExtendedKind.RSS_FEED_LIST
) )
} }

13
src/services/local-storage.service.ts

@ -59,6 +59,7 @@ class LocalStorageService {
private defaultQuietDays: number = 7 private defaultQuietDays: number = 7
private respectQuietTags: boolean = true private respectQuietTags: boolean = true
private globalQuietMode: boolean = false private globalQuietMode: boolean = false
private showRssFeed: boolean = true
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@ -276,6 +277,9 @@ class LocalStorageService {
const globalQuietModeStr = window.localStorage.getItem(StorageKey.GLOBAL_QUIET_MODE) const globalQuietModeStr = window.localStorage.getItem(StorageKey.GLOBAL_QUIET_MODE)
this.globalQuietMode = globalQuietModeStr === 'true' 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 // Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@ -646,6 +650,15 @@ class LocalStorageService {
this.globalQuietMode = enabled this.globalQuietMode = enabled
window.localStorage.setItem(StorageKey.GLOBAL_QUIET_MODE, enabled.toString()) 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() const instance = new LocalStorageService()

4
src/services/navigation.service.ts

@ -14,6 +14,7 @@ import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage' import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage' import TranslationPage from '@/pages/secondary/TranslationPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import NotePage from '@/pages/secondary/NotePage' import NotePage from '@/pages/secondary/NotePage'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage' import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import FollowingListPage from '@/pages/secondary/FollowingListPage' import FollowingListPage from '@/pages/secondary/FollowingListPage'
@ -64,6 +65,7 @@ export class URLParser {
if (url.includes('/wallet')) return 'wallet' if (url.includes('/wallet')) return 'wallet'
if (url.includes('/posts')) return 'posts' if (url.includes('/posts')) return 'posts'
if (url.includes('/translation')) return 'translation' if (url.includes('/translation')) return 'translation'
if (url.includes('/rss-feeds')) return 'rss-feeds'
return 'general' return 'general'
} }
} }
@ -116,6 +118,8 @@ export class ComponentFactory {
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
case 'translation': case 'translation':
return React.createElement(TranslationPage, { index: 0, hideTitlebar: true }) return React.createElement(TranslationPage, { index: 0, hideTitlebar: true })
case 'rss-feeds':
return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true })
default: default:
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
} }

301
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<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…
Cancel
Save