Browse Source

fix thumbnail display and rss feed refreshing

imwald
Silberengel 4 months ago
parent
commit
b96429b1db
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 22
      src/components/CacheRelaysSetting/index.tsx
  4. 48
      src/components/NormalFeed/index.tsx
  5. 14
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 50
      src/components/RssFeedItem/index.tsx
  7. 115
      src/components/RssFeedList/index.tsx
  8. 12
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  9. 9
      src/providers/NostrProvider/index.tsx
  10. 141
      src/services/indexed-db.service.ts
  11. 386
      src/services/rss-feed.service.ts

4
package-lock.json generated

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

2
package.json

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

22
src/components/CacheRelaysSetting/index.tsx

@ -404,8 +404,22 @@ export default function CacheRelaysSetting() { @@ -404,8 +404,22 @@ export default function CacheRelaysSetting() {
}
// Check if an event is invalid
const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }): boolean => {
if (!item || !item.value) return true
const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => {
if (!item) return true
// RSS feed items are not Nostr events, so skip validation for that store
// Handle both old format (with item property) and new format (with value property)
if (storeName === 'rssFeedItems') {
// Old format has item property, new format has value property - both are valid for RSS items
if (item.value || (item as any).item) {
return false
}
// If neither exists, it's invalid
return true
}
// For other stores, check if value exists
if (!item.value) return true
const event = item.value as Event
// Check for required Nostr event fields
@ -690,7 +704,7 @@ export default function CacheRelaysSetting() { @@ -690,7 +704,7 @@ export default function CacheRelaysSetting() {
) : (
filteredStoreItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
const invalid = isInvalidEvent(item)
const invalid = isInvalidEvent(item, selectedStore)
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : ''
return (
<div key={item.key || index} className="border rounded-lg p-3 break-words relative">
@ -870,7 +884,7 @@ export default function CacheRelaysSetting() { @@ -870,7 +884,7 @@ export default function CacheRelaysSetting() {
) : (
filteredStoreItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
const invalid = isInvalidEvent(item)
const invalid = isInvalidEvent(item, selectedStore)
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : ''
return (
<div key={item.key || index} className="border rounded-lg p-3 break-words relative">

48
src/components/NormalFeed/index.tsx

@ -10,6 +10,9 @@ import { forwardRef, useMemo, useRef, useState, useEffect } from 'react' @@ -10,6 +10,9 @@ import { forwardRef, useMemo, useRef, useState, useEffect } from 'react'
import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton'
import RssFeedList from '../RssFeedList'
import { useNostr } from '@/providers/NostrProvider'
import rssFeedService from '@/services/rss-feed.service'
import { DEFAULT_RSS_FEEDS } from '@/constants'
const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[]
@ -40,6 +43,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -40,6 +43,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
const noteListRef = ref || internalNoteListRef
const [showRssFeed, setShowRssFeed] = useState(() => storage.getShowRssFeed())
const [activeTab, setActiveTab] = useState<string>(listMode)
const [rssRefreshKey, setRssRefreshKey] = useState(0)
const { pubkey, rssFeedListEvent } = useNostr()
// Sync activeTab with listMode when listMode changes (but not when switching to RSS)
useEffect(() => {
@ -121,11 +126,50 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -121,11 +126,50 @@ const NormalFeed = forwardRef<TNoteListRef, {
}} />}
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
</>
) : null
) : (
<>
{!supportTouch && <RefreshButton onClick={() => {
// Get feed URLs from event or use default
let feedUrls: string[] = DEFAULT_RSS_FEEDS
if (pubkey && rssFeedListEvent) {
try {
const urls = rssFeedListEvent.tags
.filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1] as string)
.filter((url): url is string => {
if (typeof url !== 'string') return false
const trimmed = url.trim()
return trimmed.length > 0
})
if (urls.length > 0) {
feedUrls = urls
}
} catch (e) {
// Use default feeds on error
}
}
// Trigger background refresh and UI update
logger.info('[NormalFeed] Manual refresh: triggering background refresh', { feedCount: feedUrls.length })
// Start background refresh (don't wait for it)
rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => {
logger.error('[NormalFeed] Manual refresh: background refresh failed', { error: err })
})
// Immediately trigger UI update (will show cached items, then update when background refresh completes)
if (pubkey) {
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: 'manual-refresh' }
}))
}
// Also force re-render by updating key
setRssRefreshKey(prev => prev + 1)
}} />}
</>
)
}
/>
{activeTab === 'rss' ? (
<RssFeedList />
<RssFeedList key={rssRefreshKey} />
) : (
<NoteList
ref={noteListRef}

14
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1368,14 +1368,15 @@ function parseMarkdownContent( @@ -1368,14 +1368,15 @@ function parseMarkdownContent(
}
}
const displayUrl = thumbnailUrl || url
const hasThumbnail = !!thumbnailUrl
parts.push(
<div key={`img-${patternIdx}`} className="my-2 block max-w-[400px] mx-auto">
<div key={`img-${patternIdx}`} className={`my-2 block ${hasThumbnail ? 'max-w-[120px]' : 'max-w-[400px]'}`}>
<Image
image={{ url: displayUrl, pubkey: eventPubkey }}
className="w-full rounded-lg cursor-zoom-in"
className={`${hasThumbnail ? 'h-auto' : 'w-full'} rounded-lg cursor-zoom-in`}
classNames={{
wrapper: 'rounded-lg block w-full',
wrapper: `rounded-lg block ${hasThumbnail ? '' : 'w-full'}`,
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
@ -3064,14 +3065,15 @@ export default function MarkdownArticle({ @@ -3064,14 +3065,15 @@ export default function MarkdownArticle({
}
}
const displayUrl = thumbnailUrl || media.url
const hasThumbnail = !!thumbnailUrl
return (
<div key={`tag-media-${cleaned}`} className="my-2">
<div key={`tag-media-${cleaned}`} className={`my-2 ${hasThumbnail ? 'max-w-[120px]' : 'max-w-[400px]'}`}>
<Image
image={{ url: displayUrl, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in"
className={`${hasThumbnail ? 'h-auto' : 'w-full'} rounded-lg cursor-zoom-in`}
classNames={{
wrapper: 'rounded-lg',
wrapper: `rounded-lg ${hasThumbnail ? '' : 'w-full'}`,
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {

50
src/components/RssFeedItem/index.tsx

@ -398,29 +398,33 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c @@ -398,29 +398,33 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c
<div className="space-y-2">
{item.media
.filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image')
.map((media, index) => (
<div key={index} className="relative">
<img
src={media.thumbnail || media.url}
alt={item.title}
className="w-full rounded-lg object-cover max-h-96 cursor-pointer hover:opacity-90 transition-opacity"
onClick={(e) => {
e.stopPropagation()
// Open image in new tab
window.open(media.url, '_blank', 'noopener,noreferrer')
}}
onError={(e) => {
// Hide image on error
e.currentTarget.style.display = 'none'
}}
/>
{media.credit && (
<div className="text-xs text-muted-foreground mt-1">
{t('Photo')}: {media.credit}
</div>
)}
</div>
))}
.map((media, index) => {
const hasThumbnail = !!media.thumbnail
const imageUrl = media.thumbnail || media.url
return (
<div key={index} className="relative">
<img
src={imageUrl}
alt={item.title}
className={`${hasThumbnail ? 'max-w-[120px] h-auto' : 'w-full max-h-96'} rounded-lg ${hasThumbnail ? 'object-contain' : 'object-cover'} cursor-pointer hover:opacity-90 transition-opacity`}
onClick={(e) => {
e.stopPropagation()
// Open full image in new tab
window.open(media.url, '_blank', 'noopener,noreferrer')
}}
onError={(e) => {
// Hide image on error
e.currentTarget.style.display = 'none'
}}
/>
{media.credit && (
<div className="text-xs text-muted-foreground mt-1">
{t('Photo')}: {media.credit}
</div>
)}
</div>
)
})}
</div>
)}

115
src/components/RssFeedList/index.tsx

@ -13,22 +13,52 @@ export default function RssFeedList() { @@ -13,22 +13,52 @@ export default function RssFeedList() {
const [items, setItems] = useState<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
useEffect(() => {
// Create AbortController for this effect
const abortController = new AbortController()
let abortController = new AbortController()
let isMounted = true
let isLoading = false
let timeoutId: NodeJS.Timeout | null = null
const loadRssFeeds = async (forceNewController = false) => {
// If forced, create a new controller (for manual refreshes)
if (forceNewController) {
abortController.abort() // Abort old one
abortController = new AbortController()
}
const loadRssFeeds = async () => {
// Check if already aborted or if a load is already in progress
if (abortController.signal.aborted || isLoading) {
logger.debug('[RssFeedList] Skipping load - already aborted or loading', {
aborted: abortController.signal.aborted,
isLoading
})
return
}
// Clear any existing timeout
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
isLoading = true
setLoading(true)
setError(null)
// Set a timeout to prevent infinite loading (30 seconds)
timeoutId = setTimeout(() => {
if (isMounted && isLoading) {
logger.warn('[RssFeedList] Feed loading timeout - aborting and showing partial results')
abortController.abort()
isLoading = false
if (isMounted) {
setLoading(false)
}
}
}, 30000)
try {
// Get feed URLs from event or use default
@ -72,6 +102,12 @@ export default function RssFeedList() { @@ -72,6 +102,12 @@ export default function RssFeedList() {
}
} else if (pubkey) {
logger.info('[RssFeedList] No RSS feed list event in context, using default feeds')
// Trigger background refresh for default feeds when no event exists
rssFeedService.backgroundRefreshFeeds(feedUrls, abortController.signal).catch(err => {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
logger.error('[RssFeedList] Background refresh of default feeds failed', { error: err })
}
})
}
// Check if aborted before fetching
@ -79,11 +115,19 @@ export default function RssFeedList() { @@ -79,11 +115,19 @@ export default function RssFeedList() {
return
}
// Fetch and merge feeds (this handles errors gracefully and returns partial results)
// Fetch and merge feeds (cache-first: returns cached items immediately, background-refreshes)
// Show refreshing indicator (background refresh will run in background, or we'll wait if cache is empty)
if (isMounted) {
setRefreshing(true)
}
const fetchedItems = await rssFeedService.fetchMultipleFeeds(feedUrls, abortController.signal)
// Check if aborted after fetching
if (abortController.signal.aborted || !isMounted) {
if (isMounted) {
setRefreshing(false)
}
return
}
@ -94,6 +138,40 @@ export default function RssFeedList() { @@ -94,6 +138,40 @@ export default function RssFeedList() {
}
setItems(fetchedItems)
// Set up a listener for cache updates (background refresh may add new items)
// Re-check cache after a delay to see if background refresh added items
const checkForUpdates = async () => {
if (abortController.signal.aborted || !isMounted) {
if (isMounted) {
setRefreshing(false)
}
return
}
try {
const updatedItems = await rssFeedService.fetchMultipleFeeds(feedUrls, abortController.signal)
if (!abortController.signal.aborted && isMounted) {
setRefreshing(false)
if (updatedItems.length > fetchedItems.length) {
// New items were added by background refresh
setItems(updatedItems)
logger.info('[RssFeedList] Updated items from background refresh', {
previousCount: fetchedItems.length,
newCount: updatedItems.length
})
}
}
} catch (err) {
if (isMounted) {
setRefreshing(false)
}
// Ignore errors in update check
}
}
// Check for updates after 5 seconds (background refresh should be done by then)
setTimeout(checkForUpdates, 5000)
} catch (err) {
// Don't handle abort errors - they're expected during cleanup
if (err instanceof DOMException && err.name === 'AbortError') {
@ -116,9 +194,17 @@ export default function RssFeedList() { @@ -116,9 +194,17 @@ export default function RssFeedList() {
}
} finally {
isLoading = false
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
// Only update loading state if still mounted
if (isMounted) {
setLoading(false)
// If we had no cached items, background refresh was awaited, so stop refreshing indicator
if (items.length === 0) {
setRefreshing(false)
}
}
}
}
@ -134,7 +220,16 @@ export default function RssFeedList() { @@ -134,7 +220,16 @@ export default function RssFeedList() {
eventId: detail.eventId,
feedCount: detail.feedUrls.length
})
loadRssFeeds()
// For manual refresh, show refreshing indicator
if (detail.eventId === 'manual-refresh' && isMounted) {
setRefreshing(true)
}
// For manual refresh, the background refresh is already triggered by the button
// Just reload to show updated items (background refresh will update cache in the background)
// For other updates (like event changes), also just reload
loadRssFeeds(true)
}
}
@ -143,7 +238,11 @@ export default function RssFeedList() { @@ -143,7 +238,11 @@ export default function RssFeedList() {
return () => {
isMounted = false
isLoading = false
abortController.abort() // Cancel all in-flight requests
if (timeoutId) {
clearTimeout(timeoutId)
}
// Abort any in-flight requests
abortController.abort()
window.removeEventListener('rssFeedListUpdated', handleRssFeedListUpdate as EventListener)
}
}, [pubkey, rssFeedListEvent, t])
@ -176,6 +275,12 @@ export default function RssFeedList() { @@ -176,6 +275,12 @@ export default function RssFeedList() {
return (
<div className="space-y-4 px-4 py-3">
{refreshing && (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-muted-foreground border-b">
<Loader className="h-4 w-4 animate-spin" />
<span>{t('Refreshing feeds...')}</span>
</div>
)}
{items.map((item) => (
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} />
))}

12
src/pages/secondary/RssFeedSettingsPage/index.tsx

@ -13,10 +13,11 @@ import { CloudUpload, Loader, Trash2, Plus } from 'lucide-react' @@ -13,10 +13,11 @@ import { CloudUpload, Loader, Trash2, Plus } from 'lucide-react'
import logger from '@/lib/logger'
import { ExtendedKind } from '@/constants'
import indexedDb from '@/services/indexed-db.service'
import rssFeedService from '@/services/rss-feed.service'
const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { pubkey, publish, checkLogin, rssFeedListEvent } = useNostr()
const { pubkey, publish, checkLogin, rssFeedListEvent, updateRssFeedListEvent } = useNostr()
const [feedUrls, setFeedUrls] = useState<string[]>([])
const [newFeedUrl, setNewFeedUrl] = useState('')
const [showRssFeed, setShowRssFeed] = useState(true)
@ -262,11 +263,20 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -262,11 +263,20 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
})
}
// Update the context with the new event
await updateRssFeedListEvent(result)
// Dispatch custom event to notify other components (like RssFeedList) to refresh
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: result.id }
}))
// Trigger background refresh of feeds (don't wait for it)
logger.info('[RssFeedSettingsPage] Triggering background refresh of RSS feeds', { feedCount: feedUrls.length })
rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => {
logger.error('[RssFeedSettingsPage] Background refresh failed', { error: err })
})
// Read relayStatuses immediately before it might be deleted
const relayStatuses = (result as any).relayStatuses
logger.info('[RssFeedSettingsPage] Publishing complete', {

9
src/providers/NostrProvider/index.tsx

@ -94,6 +94,7 @@ type TNostrContext = { @@ -94,6 +94,7 @@ type TNostrContext = {
updateInterestListEvent: (interestListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
}
@ -1094,6 +1095,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1094,6 +1095,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setBlockedRelaysEvent(newBlockedRelaysEvent)
}
const updateRssFeedListEvent = async (rssFeedListEvent: Event) => {
const newRssFeedListEvent = await indexedDb.putReplaceableEvent(rssFeedListEvent)
if (newRssFeedListEvent.id !== rssFeedListEvent.id) return
setRssFeedListEvent(newRssFeedListEvent)
}
const updateNotificationsSeenAt = async (skipPublish = false) => {
if (!account) return
@ -1163,6 +1171,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1163,6 +1171,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateInterestListEvent,
updateFavoriteRelaysEvent,
updateBlockedRelaysEvent,
updateRssFeedListEvent,
updateNotificationsSeenAt
}}
>

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

@ -28,6 +28,7 @@ const StoreNames = { @@ -28,6 +28,7 @@ const StoreNames = {
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents',
CACHE_RELAYS_EVENTS: 'cacheRelaysEvents',
RSS_FEED_LIST_EVENTS: 'rssFeedListEvents',
RSS_FEED_ITEMS: 'rssFeedItems',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
@ -51,7 +52,7 @@ class IndexedDbService { @@ -51,7 +52,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 16)
const request = window.indexedDB.open('jumble', 17)
request.onerror = (event) => {
reject(event)
@ -124,6 +125,11 @@ class IndexedDbService { @@ -124,6 +125,11 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.RSS_FEED_LIST_EVENTS)) {
db.createObjectStore(StoreNames.RSS_FEED_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RSS_FEED_ITEMS)) {
const store = db.createObjectStore(StoreNames.RSS_FEED_ITEMS, { keyPath: 'key' })
store.createIndex('feedUrl', 'feedUrl', { unique: false })
store.createIndex('pubDate', 'pubDate', { unique: false })
}
}
})
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
@ -1301,6 +1307,139 @@ class IndexedDbService { @@ -1301,6 +1307,139 @@ class IndexedDbService {
})
)
}
/**
* Store RSS feed items in IndexedDB
*/
async putRssFeedItems(items: import('./rss-feed.service').RssFeedItem[]): Promise<void> {
await this.initPromise
const storeName = StoreNames.RSS_FEED_ITEMS
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
logger.warn('[IndexedDB] RSS feed items store not found', { storeName })
return
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
let completed = 0
let errors = 0
items.forEach((item) => {
// Create a unique key from feedUrl and guid
const key = `${item.feedUrl}:${item.guid}`
// Store in TValue format for consistency with other stores
const value: TValue<import('./rss-feed.service').RssFeedItem> = {
key,
value: item,
addedAt: Date.now()
}
const request = store.put(value)
request.onsuccess = () => {
completed++
if (completed + errors === items.length) {
resolve()
}
}
request.onerror = () => {
errors++
if (completed + errors === items.length) {
resolve() // Don't reject, just log
}
}
})
if (items.length === 0) {
resolve()
}
})
}
/**
* Get all RSS feed items from IndexedDB
*/
async getRssFeedItems(): Promise<import('./rss-feed.service').RssFeedItem[]> {
await this.initPromise
const storeName = StoreNames.RSS_FEED_ITEMS
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
logger.warn('[IndexedDB] RSS feed items store not found', { storeName })
return []
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onsuccess = () => {
const items = request.result.map((entry: TValue<import('./rss-feed.service').RssFeedItem> | any) => {
let item: import('./rss-feed.service').RssFeedItem | null = null
// Handle new format (with value property)
if (entry.value) {
item = entry.value
}
// Fallback for old format (with item property)
else if ((entry as any).item) {
item = (entry as any).item as import('./rss-feed.service').RssFeedItem
}
if (!item) {
return null
}
// Ensure pubDate is properly handled (IndexedDB may serialize Date as string)
if (item.pubDate && typeof item.pubDate === 'string') {
item.pubDate = new Date(item.pubDate)
} else if (item.pubDate && typeof item.pubDate === 'number') {
item.pubDate = new Date(item.pubDate)
}
return item
}).filter((item): item is import('./rss-feed.service').RssFeedItem => item !== null)
logger.debug('[IndexedDB] Retrieved RSS feed items', {
totalRetrieved: request.result.length,
validItems: items.length
})
resolve(items)
}
request.onerror = () => {
reject(request.error)
}
})
}
/**
* Clear RSS feed items from IndexedDB
*/
async clearRssFeedItems(): Promise<void> {
await this.initPromise
const storeName = StoreNames.RSS_FEED_ITEMS
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => {
resolve()
}
request.onerror = () => {
reject(request.error)
}
})
}
}
const instance = IndexedDbService.getInstance()

386
src/services/rss-feed.service.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { DEFAULT_RSS_FEEDS } from '@/constants'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
export interface RssFeedItemMedia {
url: string
@ -55,6 +56,7 @@ class RssFeedService { @@ -55,6 +56,7 @@ class RssFeedService {
static instance: RssFeedService
private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map()
private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
private backgroundRefreshController: AbortController | null = null
constructor() {
if (!RssFeedService.instance) {
@ -925,7 +927,7 @@ class RssFeedService { @@ -925,7 +927,7 @@ class RssFeedService {
/**
* Fetch multiple feeds and merge items
* This method gracefully handles failures - if some feeds fail, it returns items from successful feeds
* Cache-first: reads from IndexedDB, displays immediately, then background-refreshes to merge new items
*/
async fetchMultipleFeeds(feedUrls: string[], signal?: AbortSignal): Promise<RssFeedItem[]> {
if (feedUrls.length === 0) {
@ -937,70 +939,345 @@ class RssFeedService { @@ -937,70 +939,345 @@ class RssFeedService {
throw new DOMException('The operation was aborted.', 'AbortError')
}
const results = await Promise.allSettled(
feedUrls.map(url => this.fetchFeed(url, signal))
)
// Check if aborted after fetching
if (signal?.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError')
// Step 1: Read from IndexedDB cache first (cache-first strategy)
let cachedItems: RssFeedItem[] = []
try {
const allCachedItems = await indexedDb.getRssFeedItems()
logger.info('[RssFeedService] Retrieved all cached items from IndexedDB', {
totalCached: allCachedItems.length
})
// Filter to only items from the requested feeds
// Normalize URLs for comparison (remove trailing slashes, ensure consistent format)
const normalizeUrl = (url: string) => url.trim().replace(/\/$/, '')
const normalizedRequestedUrls = new Set(feedUrls.map(normalizeUrl))
cachedItems = allCachedItems.filter(item => {
const normalizedItemUrl = normalizeUrl(item.feedUrl)
const matches = normalizedRequestedUrls.has(normalizedItemUrl)
if (!matches && allCachedItems.length > 0 && allCachedItems.length < 10) {
// Only log for small sets to avoid spam
logger.debug('[RssFeedService] Item filtered out (feed URL not in requested list)', {
itemFeedUrl: item.feedUrl,
normalizedItemUrl,
requestedFeeds: feedUrls,
normalizedRequestedUrls: Array.from(normalizedRequestedUrls),
itemGuid: item.guid?.substring(0, 20)
})
}
return matches
})
logger.info('[RssFeedService] Filtered cached items by feed URLs', {
beforeFilter: allCachedItems.length,
afterFilter: cachedItems.length,
requestedFeedCount: feedUrls.length,
uniqueCachedFeedUrls: [...new Set(allCachedItems.map(i => i.feedUrl))],
requestedFeedUrls: feedUrls
})
// Convert pubDate back to Date objects (handle both Date objects and timestamps/strings)
cachedItems = cachedItems.map(item => {
let pubDate: Date | null = null
if (item.pubDate) {
if (item.pubDate instanceof Date) {
pubDate = item.pubDate
} else if (typeof item.pubDate === 'number') {
pubDate = new Date(item.pubDate)
} else if (typeof item.pubDate === 'string') {
pubDate = new Date(item.pubDate)
}
}
return {
...item,
pubDate
}
})
logger.info('[RssFeedService] Loaded cached items from IndexedDB', {
cachedCount: cachedItems.length,
feedCount: feedUrls.length,
filteredCount: cachedItems.length,
feedUrls: feedUrls
})
} catch (error) {
logger.warn('[RssFeedService] Failed to load cached items from IndexedDB', { error })
}
const allItems: RssFeedItem[] = []
let successCount = 0
let failureCount = 0
let abortCount = 0
const cacheWasEmpty = cachedItems.length === 0
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
allItems.push(...result.value.items)
successCount++
logger.debug('[RssFeedService] Successfully fetched feed', { url: feedUrls[index], itemCount: result.value.items.length })
} else {
failureCount++
const error = result.reason
// Don't log abort errors - they're expected during cleanup
if (error instanceof DOMException && error.name === 'AbortError') {
abortCount++
// Silently skip aborted requests
// Step 2: Background refresh to merge new items
// If cache is empty, we'll wait a bit for the refresh to complete
const backgroundRefresh = async () => {
if (signal?.aborted) {
return
}
try {
const results = await Promise.allSettled(
feedUrls.map(url => this.fetchFeed(url, signal))
)
if (signal?.aborted) {
return
}
// Log warning but don't throw - we want to return partial results
const errorMessage = error instanceof Error ? error.message : String(error)
logger.warn('[RssFeedService] Failed to fetch feed after trying all strategies', {
url: feedUrls[index],
error: errorMessage
const newItems: RssFeedItem[] = []
let successCount = 0
let failureCount = 0
let abortCount = 0
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
newItems.push(...result.value.items)
successCount++
logger.debug('[RssFeedService] Successfully fetched feed', { url: feedUrls[index], itemCount: result.value.items.length })
} else {
failureCount++
const error = result.reason
if (error instanceof DOMException && error.name === 'AbortError') {
abortCount++
return
}
const errorMessage = error instanceof Error ? error.message : String(error)
logger.warn('[RssFeedService] Failed to fetch feed after trying all strategies', {
url: feedUrls[index],
error: errorMessage
})
}
})
if (!signal?.aborted && successCount > 0) {
// Merge new items with cached items (deduplicate by feedUrl:guid)
const itemMap = new Map<string, RssFeedItem>()
// Add cached items first
cachedItems.forEach(item => {
const key = `${item.feedUrl}:${item.guid}`
itemMap.set(key, item)
})
// Add/update with new items (newer items replace older ones)
newItems.forEach(item => {
const key = `${item.feedUrl}:${item.guid}`
const existing = itemMap.get(key)
// Keep the newer item, or add if it doesn't exist
if (!existing || (item.pubDate && existing.pubDate && item.pubDate > existing.pubDate)) {
itemMap.set(key, item)
}
})
const mergedItems = Array.from(itemMap.values())
// Sort by publication date (newest first)
mergedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
// Write merged items back to IndexedDB
try {
await indexedDb.putRssFeedItems(mergedItems)
logger.info('[RssFeedService] Updated IndexedDB cache with merged items', {
totalItems: mergedItems.length,
newItems: newItems.length,
cachedItems: cachedItems.length
})
} catch (error) {
logger.error('[RssFeedService] Failed to update IndexedDB cache', { error })
}
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
logger.error('[RssFeedService] Background refresh failed', { error })
}
}
})
}
// Log summary (only if not aborted)
if (!signal?.aborted) {
if (successCount > 0) {
logger.info('[RssFeedService] Feed fetch summary', {
total: feedUrls.length,
successful: successCount,
failed: failureCount - abortCount, // Don't count aborts as failures
aborted: abortCount,
itemsFound: allItems.length
})
} else if (failureCount > abortCount) {
// Only log error if there were actual failures (not just aborts)
logger.error('[RssFeedService] All feeds failed to fetch', {
total: feedUrls.length,
urls: feedUrls
})
// If cache is empty, wait a bit for background refresh to populate it
if (cacheWasEmpty) {
logger.info('[RssFeedService] Cache is empty, waiting for background refresh to complete', { feedCount: feedUrls.length })
try {
// Wait up to 10 seconds for background refresh to complete
await Promise.race([
backgroundRefresh(),
new Promise(resolve => setTimeout(resolve, 10000))
])
// Re-read from cache after background refresh
try {
const refreshedItems = await indexedDb.getRssFeedItems()
const feedUrlSet = new Set(feedUrls)
cachedItems = refreshedItems
.filter(item => feedUrlSet.has(item.feedUrl))
.map(item => ({
...item,
pubDate: item.pubDate ? new Date(item.pubDate) : null
}))
logger.info('[RssFeedService] Loaded items after background refresh', {
itemCount: cachedItems.length,
feedCount: feedUrls.length
})
} catch (error) {
logger.warn('[RssFeedService] Failed to reload cached items after background refresh', { error })
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
logger.error('[RssFeedService] Background refresh error during initial load', { error })
}
}
} else {
// Cache has items, start background refresh in background (don't wait)
backgroundRefresh().catch(err => {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
logger.error('[RssFeedService] Background refresh error', { error: err })
}
})
}
// Return cached items (now potentially updated from background refresh)
// Sort by publication date (newest first)
allItems.sort((a, b) => {
cachedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
return allItems
return cachedItems
}
/**
* Trigger a background refresh for specific feed URLs (without returning cached items)
* This is useful when you want to force a refresh after updating the feed list
* Aborts any existing background refresh before starting a new one
*/
async backgroundRefreshFeeds(feedUrls: string[], signal?: AbortSignal): Promise<void> {
if (feedUrls.length === 0) {
return
}
// Abort any existing background refresh
if (this.backgroundRefreshController) {
logger.info('[RssFeedService] Aborting existing background refresh before starting new one')
this.backgroundRefreshController.abort()
this.backgroundRefreshController = null
}
// Create a new AbortController for this refresh
const controller = new AbortController()
this.backgroundRefreshController = controller
// Combine with external signal if provided
if (signal) {
if (signal.aborted) {
controller.abort()
this.backgroundRefreshController = null
return
}
signal.addEventListener('abort', () => {
controller.abort()
this.backgroundRefreshController = null
}, { once: true })
}
const combinedSignal = signal ? (() => {
const combined = new AbortController()
const abort = () => combined.abort()
signal.addEventListener('abort', abort, { once: true })
controller.signal.addEventListener('abort', abort, { once: true })
return combined.signal
})() : controller.signal
try {
const results = await Promise.allSettled(
feedUrls.map(url => this.fetchFeed(url, combinedSignal))
)
if (combinedSignal.aborted || controller.signal.aborted) {
this.backgroundRefreshController = null
return
}
const newItems: RssFeedItem[] = []
let successCount = 0
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
newItems.push(...result.value.items)
successCount++
logger.debug('[RssFeedService] Background refresh: successfully fetched feed', {
url: feedUrls[index],
itemCount: result.value.items.length
})
}
})
if (!combinedSignal.aborted && !controller.signal.aborted && successCount > 0) {
// Get existing cached items
let cachedItems: RssFeedItem[] = []
try {
cachedItems = await indexedDb.getRssFeedItems()
const feedUrlSet = new Set(feedUrls)
cachedItems = cachedItems.filter(item => feedUrlSet.has(item.feedUrl))
cachedItems = cachedItems.map(item => ({
...item,
pubDate: item.pubDate ? new Date(item.pubDate) : null
}))
} catch (error) {
logger.warn('[RssFeedService] Failed to load cached items for background refresh', { error })
}
// Merge new items with cached items (deduplicate by feedUrl:guid)
const itemMap = new Map<string, RssFeedItem>()
// Add cached items first
cachedItems.forEach(item => {
const key = `${item.feedUrl}:${item.guid}`
itemMap.set(key, item)
})
// Add/update with new items (newer items replace older ones)
newItems.forEach(item => {
const key = `${item.feedUrl}:${item.guid}`
const existing = itemMap.get(key)
if (!existing || (item.pubDate && existing.pubDate && item.pubDate > existing.pubDate)) {
itemMap.set(key, item)
}
})
const mergedItems = Array.from(itemMap.values())
// Sort by publication date (newest first)
mergedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
// Write merged items back to IndexedDB
try {
await indexedDb.putRssFeedItems(mergedItems)
logger.info('[RssFeedService] Background refresh: updated IndexedDB cache', {
totalItems: mergedItems.length,
newItems: newItems.length,
cachedItems: cachedItems.length
})
} catch (error) {
logger.error('[RssFeedService] Background refresh: failed to update IndexedDB cache', { error })
}
}
// Clear the controller when done
this.backgroundRefreshController = null
} catch (error) {
// Clear the controller on error
this.backgroundRefreshController = null
if (!(error instanceof DOMException && error.name === 'AbortError')) {
logger.error('[RssFeedService] Background refresh failed', { error })
}
}
}
/**
@ -1009,8 +1286,21 @@ class RssFeedService { @@ -1009,8 +1286,21 @@ class RssFeedService {
clearCache(url?: string) {
if (url) {
this.feedCache.delete(url)
// Also clear from IndexedDB (filter by feedUrl)
indexedDb.getRssFeedItems().then(items => {
const filtered = items.filter(item => item.feedUrl !== url)
indexedDb.putRssFeedItems(filtered).catch(err => {
logger.error('[RssFeedService] Failed to clear feed from IndexedDB', { url, error: err })
})
}).catch(err => {
logger.error('[RssFeedService] Failed to get items for cache clear', { url, error: err })
})
} else {
this.feedCache.clear()
// Clear all from IndexedDB
indexedDb.clearRssFeedItems().catch(err => {
logger.error('[RssFeedService] Failed to clear IndexedDB cache', { error: err })
})
}
}
}

Loading…
Cancel
Save