diff --git a/package-lock.json b/package-lock.json index c7f3420..6726d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "12.3", + "version": "13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "12.3", + "version": "13.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index b86c4d1..205196f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 5572388..2009ced 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -13,6 +13,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' @@ -298,6 +299,9 @@ export function useSmartSettingsNavigation() { } else if (url === '/settings/translation') { window.history.pushState(null, '', url) setPrimaryNoteView(, 'settings-sub') + } else if (url === '/settings/rss-feeds') { + window.history.pushState(null, '', url) + setPrimaryNoteView(, 'settings-sub') } } diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 053e731..4c1b01f 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -6,9 +6,10 @@ import { useKindFilter } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import storage from '@/services/local-storage.service' 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 { RefreshButton } from '../RefreshButton' +import RssFeedList from '../RssFeedList' const NormalFeed = forwardRef isTouchDevice(), []) const internalNoteListRef = useRef(null) const noteListRef = ref || internalNoteListRef + const [showRssFeed, setShowRssFeed] = useState(() => storage.getShowRssFeed()) + const [activeTab, setActiveTab] = useState(listMode) - const handleListModeChange = (mode: TNoteListMode) => { - setListMode(mode) + // Sync activeTab with listMode when listMode changes (but not when switching to RSS) + 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) { - storage.setNoteListMode(mode) + storage.setNoteListMode(noteListMode) } if (noteListRef && typeof noteListRef !== 'function') { noteListRef.current?.scrollToTop('smooth') @@ -55,37 +86,57 @@ const NormalFeed = forwardRef { + 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 ( <> { - handleListModeChange(listMode as TNoteListMode) + value={currentTabValue} + tabs={tabs} + onTabChange={(tab) => { + handleListModeChange(tab) }} options={ - <> - {!supportTouch && { - if (noteListRef && typeof noteListRef !== 'function') { - noteListRef.current?.refresh() - } - }} />} - - + activeTab !== 'rss' ? ( + <> + {!supportTouch && { + if (noteListRef && typeof noteListRef !== 'function') { + noteListRef.current?.refresh() + } + }} />} + + + ) : null } /> - + {activeTab === 'rss' ? ( + + ) : ( + + )} ) }) diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx new file mode 100644 index 0000000..9fadfb1 --- /dev/null +++ b/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(undefined) + const contentRef = useRef(null) + const selectionTimeoutRef = useRef() + + // 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 ( +
+ {/* 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 ( +
+ +

{error}

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