Browse Source

Change RSS feed to RSS + Web feed

imwald
Silberengel 1 month ago
parent
commit
d6a74d1460
  1. 53
      src/PageManager.tsx
  2. 10
      src/components/NoteStats/LikeButton.tsx
  3. 16
      src/components/PostEditor/PostContent.tsx
  4. 33
      src/components/ReplyNoteList/index.tsx
  5. 94
      src/components/RssFeedItem/index.tsx
  6. 381
      src/components/RssFeedList/index.tsx
  7. 86
      src/components/RssUrlThreadStatsBar/index.tsx
  8. 107
      src/components/RssWebFeedCard/index.tsx
  9. 4
      src/components/Sidebar/RssButton.tsx
  10. 24
      src/i18n/locales/en.ts
  11. 11
      src/lib/rss-article.ts
  12. 392
      src/lib/rss-web-feed.ts
  13. 6
      src/lib/thread-reply-root-match.ts
  14. 2
      src/pages/primary/RssPage/index.tsx
  15. 173
      src/pages/secondary/RssArticlePage/index.tsx
  16. 6
      src/providers/ReplyProvider.tsx
  17. 67
      src/services/note-stats.service.ts
  18. 93
      src/services/rss-feed.service.ts

53
src/PageManager.tsx

@ -226,6 +226,30 @@ function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName |
return `/rss-item/${key}` return `/rss-item/${key}`
} }
/** True for secondary routes that show an RSS / web article in the panel (contextual or bare). */
function secondaryUrlIsRssArticle(url: string): boolean {
let path = url.split('?')[0].split('#')[0]
try {
if (path.startsWith('http://') || path.startsWith('https://')) {
path = new URL(path).pathname
}
} catch {
/* keep path */
}
return (
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/rss-item\/[^/?#]+/.test(path) ||
/^\/rss-item\/[^/?#]+/.test(path)
)
}
function replaceHistoryWithPrimaryPageUrl(
page: TPrimaryPageName,
props?: { spell?: string } | Record<string, unknown> | null
) {
const pageUrl = buildPrimaryPageUrl(page, props as { spell?: string } | undefined)
window.history.replaceState(null, '', pageUrl)
}
/** Open an RSS article in the secondary panel (same routing pattern as contextual note URLs). */ /** Open an RSS article in the secondary panel (same routing pattern as contextual note URLs). */
export function useSmartRssArticleNavigation() { export function useSmartRssArticleNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage() const { push: pushSecondaryPage } = useSecondaryPage()
@ -1600,9 +1624,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// In double-pane mode, never open drawer - just pop from stack // In double-pane mode, never open drawer - just pop from stack
if (panelMode === 'double' && !isSmallScreen) { if (panelMode === 'double' && !isSmallScreen) {
if (secondaryStack.length === 1) { if (secondaryStack.length === 1) {
// Just close the panel - DO NOT change the main page or URL const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? ''
// Closing panel should NEVER affect the main page
setSecondaryStack([]) setSecondaryStack([])
if (secondaryUrlIsRssArticle(closingUrl)) {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
@ -1646,18 +1675,22 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack // On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack
if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) { if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) {
// Close drawer (this will restore the URL to the correct primary page) const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? ''
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => { setTimeout(() => {
setDrawerNoteId(null) setDrawerNoteId(null)
setDrawerInitialEvent(null) setDrawerInitialEvent(null)
if (secondaryUrlIsRssArticle(closingUrl)) {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
}, 350) }, 350)
// Clear stack
setSecondaryStack([]) setSecondaryStack([])
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) { if (savedFeedState?.tab) {
logger.info('PageManager: Mobile/Single-pane - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab }) logger.info('PageManager: Mobile/Single-pane - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', { window.dispatchEvent(new CustomEvent('restorePageTab', {
@ -1669,13 +1702,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
if (secondaryStack.length === 1) { if (secondaryStack.length === 1) {
// Just close the panel - DO NOT change the main page or URL const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? ''
// Closing panel should NEVER affect the main page
setSecondaryStack([]) setSecondaryStack([])
if (secondaryUrlIsRssArticle(closingUrl)) {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) { if (savedFeedState?.tab) {
logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab }) logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', { window.dispatchEvent(new CustomEvent('restorePageTab', {

10
src/components/NoteStats/LikeButton.tsx

@ -36,6 +36,7 @@ import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis' import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils' import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed'
export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -139,6 +140,12 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
} else { } else {
showSimplePublishSuccess(t('Reaction removed')) showSimplePublishSuccess(t('Reaction removed'))
} }
if (
event.kind === ExtendedKind.RSS_THREAD_ROOT &&
reactionEvent?.kind === ExtendedKind.EXTERNAL_REACTION
) {
window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT))
}
} }
} }
} else { } else {
@ -164,6 +171,9 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
noteStatsService.updateNoteStatsByEvents([evt], undefined, { noteStatsService.updateNoteStatsByEvents([evt], undefined, {
interactionTargetNoteId: event.id interactionTargetNoteId: event.id
}) })
if (event.kind === ExtendedKind.RSS_THREAD_ROOT && evt.kind === ExtendedKind.EXTERNAL_REACTION) {
window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT))
}
} }
} catch (error) { } catch (error) {
logger.error('Like failed', { error, eventId: event.id }) logger.error('Like failed', { error, eventId: event.id })

16
src/components/PostEditor/PostContent.tsx

@ -37,7 +37,7 @@ import { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { normalizeUrl, cleanUrl } from '@/lib/url' import { normalizeUrl, cleanUrl } from '@/lib/url'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
@ -122,7 +122,19 @@ export default function PostContent({
undefined, undefined,
isQuotePost ? undefined : { replyParentNoteId: parentEvent.id } isQuotePost ? undefined : { replyParentNoteId: parentEvent.id }
) )
const rootInfo = !isReplaceableEvent(parentEvent.kind) const rootInfo =
parentEvent.kind === ExtendedKind.RSS_THREAD_ROOT
? (() => {
const articleUrl = getArticleUrlFromCommentITags(parentEvent)
if (articleUrl) {
return {
type: 'I' as const,
id: canonicalizeRssArticleUrl(articleUrl)
}
}
return { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey }
})()
: !isReplaceableEvent(parentEvent.kind)
? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey }
: { : {
type: 'A' as const, type: 'A' as const,

33
src/components/ReplyNoteList/index.tsx

@ -1,6 +1,6 @@
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants' import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants'
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { import {
eventReferencesEventId, eventReferencesEventId,
getParentETag, getParentETag,
@ -141,6 +141,16 @@ function ReplyNoteList({
if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) { if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) {
parentEventKeys.push(eventIdKey) parentEventKeys.push(eventIdKey)
} }
// Web article threads: kind 1111 replies use #i (URL) only — ReplyProvider keys them by canonical URL, not synthetic root id.
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const u = getArticleUrlFromCommentITags(event)
if (u) {
const canon = canonicalizeRssArticleUrl(u)
if (!parentEventKeys.includes(canon)) {
parentEventKeys = [canon, ...parentEventKeys]
}
}
}
const processedEventIds = new Set<string>() // Prevent infinite loops const processedEventIds = new Set<string>() // Prevent infinite loops
@ -220,7 +230,14 @@ function ReplyNoteList({
default: default:
return replyEvents.sort((a, b) => b.created_at - a.created_at) return replyEvents.sort((a, b) => b.created_at - a.created_at)
} }
}, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort]) }, [
event.id,
event.kind,
repliesMap,
mutePubkeySet,
hideContentMentioningMutedUsers,
sort
])
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies])
/** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */ /** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */
@ -261,9 +278,9 @@ function ReplyNoteList({
useEffect(() => { useEffect(() => {
const fetchRootEvent = async () => { const fetchRootEvent = async () => {
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const url = event.tags.find((t) => t[0] === 'i' || t[0] === 'I')?.[1] const url = getArticleUrlFromCommentITags(event)
if (url) { if (url) {
setRootInfo({ type: 'I', id: url }) setRootInfo({ type: 'I', id: canonicalizeRssArticleUrl(url) })
} }
return return
} }
@ -320,7 +337,7 @@ function ReplyNoteList({
} }
const rootArticleUrl = getArticleUrlFromCommentITags(event) const rootArticleUrl = getArticleUrlFromCommentITags(event)
if (rootArticleUrl) { if (rootArticleUrl) {
root = { type: 'I', id: rootArticleUrl } root = { type: 'I', id: canonicalizeRssArticleUrl(rootArticleUrl) }
} }
} }
setRootInfo(root) setRootInfo(root)
@ -653,10 +670,14 @@ function ReplyNoteList({
const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
const replyRootId = getRootEventHexId(reply) const replyRootId = getRootEventHexId(reply)
const replyUrlForIThread =
rootInfo?.type === 'I' ? getArticleUrlFromCommentITags(reply) : undefined
const belongsToSameThread = rootInfo && ( const belongsToSameThread = rootInfo && (
(rootInfo.type === 'E' && replyRootId === rootInfo.id) || (rootInfo.type === 'E' && replyRootId === rootInfo.id) ||
(rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) || (rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) ||
(rootInfo.type === 'I' && getArticleUrlFromCommentITags(reply) === rootInfo.id) (rootInfo.type === 'I' &&
!!replyUrlForIThread &&
canonicalizeRssArticleUrl(replyUrlForIThread) === canonicalizeRssArticleUrl(rootInfo.id))
) )
return ( return (

94
src/components/RssFeedItem/index.tsx

@ -1,6 +1,10 @@
import { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import {
RssFeedItem as TRssFeedItem,
isWebOnlyFauxRssItem
} from '@/services/rss-feed.service'
import WebPreview from '../WebPreview'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import { ExternalLink, Highlighter } from 'lucide-react' import { ExternalLink, Globe, Highlighter, Rss } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -40,17 +44,24 @@ function htmlToPlainText(html: string): string {
export default function RssFeedItem({ export default function RssFeedItem({
item, item,
className, className,
layout = 'detail' layout = 'detail',
expandBodyFully = false,
sourceStrip
}: { }: {
item: TRssFeedItem item: TRssFeedItem
className?: string className?: string
/** `list`: title row + actions (open full article in side panel). `detail`: full body (secondary panel). */ /** `list`: title row + actions (open full article in side panel). `detail`: full body (secondary panel). */
layout?: 'list' | 'detail' layout?: 'list' | 'detail'
/** When `layout` is `detail`, show full article HTML without height cap or “Show more”. */
expandBodyFully?: boolean
/** Optional RSS vs Web URL hint for feed rows (combined cards use their own strip). */
sourceStrip?: 'rss' | 'web'
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { navigateToRssArticle } = useSmartRssArticleNavigation() const { navigateToRssArticle } = useSmartRssArticleNavigation()
const isWebFaux = isWebOnlyFauxRssItem(item)
const isListLayout = layout === 'list' const isListLayout = layout === 'list'
const showFullBody = layout === 'detail' const showFullBody = layout === 'detail'
const [selectedText, setSelectedText] = useState('') const [selectedText, setSelectedText] = useState('')
@ -392,13 +403,14 @@ export default function RssFeedItem({
// Format feed source name from URL // Format feed source name from URL
const feedSourceName = useMemo(() => { const feedSourceName = useMemo(() => {
if (isWebFaux) return ''
try { try {
const url = new URL(item.feedUrl) const url = new URL(item.feedUrl)
return url.hostname.replace(/^www\./, '') return url.hostname.replace(/^www\./, '')
} catch { } catch {
return item.feedTitle || 'RSS Feed' return item.feedTitle || 'RSS Feed'
} }
}, [item.feedUrl, item.feedTitle]) }, [item.feedUrl, item.feedTitle, isWebFaux])
// Clean and parse HTML description safely // Clean and parse HTML description safely
// Decode HTML entities and remove any XML artifacts that might have leaked through // Decode HTML entities and remove any XML artifacts that might have leaked through
@ -440,9 +452,23 @@ export default function RssFeedItem({
// Check if content exceeds 400px height (detail layout only) // Check if content exceeds 400px height (detail layout only)
const [needsCollapse, setNeedsCollapse] = useState(false) const [needsCollapse, setNeedsCollapse] = useState(false)
const [longBodyExpanded, setLongBodyExpanded] = useState(false) const [longBodyExpanded, setLongBodyExpanded] = useState(() => expandBodyFully && layout === 'detail')
useEffect(() => { useEffect(() => {
if (expandBodyFully && layout === 'detail') {
setLongBodyExpanded(true)
}
}, [expandBodyFully, layout])
useEffect(() => {
if (isWebFaux) {
setNeedsCollapse(false)
return
}
if (expandBodyFully && showFullBody) {
setNeedsCollapse(false)
return
}
if (isListLayout || !contentRef.current || !descriptionHtml) { if (isListLayout || !contentRef.current || !descriptionHtml) {
setNeedsCollapse(false) setNeedsCollapse(false)
return return
@ -480,7 +506,7 @@ export default function RssFeedItem({
// Use ResizeObserver to detect when content changes // Use ResizeObserver to detect when content changes
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (!longBodyExpanded) { if (!longBodyExpanded && !expandBodyFully) {
checkHeight() checkHeight()
} }
}) })
@ -494,7 +520,7 @@ export default function RssFeedItem({
clearTimeout(timeoutId2) clearTimeout(timeoutId2)
resizeObserver.disconnect() resizeObserver.disconnect()
} }
}, [descriptionHtml, longBodyExpanded, isListLayout]) }, [descriptionHtml, longBodyExpanded, isListLayout, expandBodyFully, showFullBody, isWebFaux])
return ( return (
<div <div
@ -519,10 +545,29 @@ export default function RssFeedItem({
: undefined : undefined
} }
> >
{sourceStrip ? (
<div
className="flex items-center gap-1.5 pb-2 mb-2 border-b border-border/40 text-[11px] sm:text-xs text-muted-foreground"
aria-label={
sourceStrip === 'rss' ? t('RSS feed item label') : t('Web URL item label')
}
>
{sourceStrip === 'rss' ? (
<Rss className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
) : (
<Globe className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
)}
<span>
{sourceStrip === 'rss'
? t('RSS feed item label')
: t('Web URL item label')}
</span>
</div>
) : null}
{/* Feed Header with Metadata */} {/* Feed Header with Metadata */}
<div className="flex items-start gap-3 pb-3 border-b"> <div className="flex items-start gap-3 pb-3 border-b">
{/* Feed Image/Logo */} {/* Feed Image/Logo */}
{item.feedImage && ( {item.feedImage && !isWebFaux && (
<img <img
src={item.feedImage} src={item.feedImage}
alt={item.feedTitle || feedSourceName} alt={item.feedTitle || feedSourceName}
@ -539,7 +584,7 @@ export default function RssFeedItem({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate"> <h3 className="font-semibold text-sm truncate">
{item.feedTitle || feedSourceName} {isWebFaux ? t('Web page') : item.feedTitle || feedSourceName}
</h3> </h3>
{item.feedDescription && ( {item.feedDescription && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5"> <p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
@ -588,7 +633,7 @@ export default function RssFeedItem({
{showFullBody ? ( {showFullBody ? (
<> <>
{/* Media (Images) */} {/* Media (Images) */}
{item.media && item.media.length > 0 && ( {!isWebFaux && item.media && item.media.length > 0 && (
<div className="space-y-2 overflow-hidden"> <div className="space-y-2 overflow-hidden">
{item.media {item.media
.filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image') .filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image')
@ -623,7 +668,9 @@ export default function RssFeedItem({
)} )}
{/* Audio/Video Enclosure */} {/* Audio/Video Enclosure */}
{item.enclosure && (item.enclosure.type.startsWith('audio/') || item.enclosure.type.startsWith('video/')) && ( {!isWebFaux &&
item.enclosure &&
(item.enclosure.type.startsWith('audio/') || item.enclosure.type.startsWith('video/')) && (
<div className="space-y-2"> <div className="space-y-2">
<div className="rounded-lg border bg-muted/50 p-4"> <div className="rounded-lg border bg-muted/50 p-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@ -643,13 +690,28 @@ export default function RssFeedItem({
</div> </div>
)} )}
{/* Description with text selection support and collapse/expand */} {/* RSS HTML body or OpenGraph web preview for URL-only faux items */}
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{isWebFaux ? (
<div
ref={contentRef}
className="not-prose max-w-full rss-feed-content"
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
onMouseUp={(e) => e.stopPropagation()}
>
<WebPreview url={item.link} className="w-full" />
</div>
) : (
<div <div
ref={contentRef} ref={contentRef}
className={cn( className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200 overflow-wrap-anywhere', 'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200 overflow-wrap-anywhere',
needsCollapse && !longBodyExpanded && 'max-h-[400px] overflow-hidden', needsCollapse && !longBodyExpanded && !expandBodyFully && 'max-h-[400px] overflow-hidden',
'[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg', '[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg',
'[&_*]:max-w-full' '[&_*]:max-w-full'
)} )}
@ -661,17 +723,17 @@ export default function RssFeedItem({
}} }}
dangerouslySetInnerHTML={{ __html: descriptionHtml }} dangerouslySetInnerHTML={{ __html: descriptionHtml }}
onMouseUp={(e) => { onMouseUp={(e) => {
// Allow text selection
e.stopPropagation() e.stopPropagation()
}} }}
/> />
)}
{/* Gradient overlay when collapsed */} {/* Gradient overlay when collapsed */}
{needsCollapse && !longBodyExpanded && ( {!isWebFaux && needsCollapse && !longBodyExpanded && !expandBodyFully && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/60 to-background pointer-events-none" /> <div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/60 to-background pointer-events-none" />
)} )}
{showFullBody && needsCollapse && ( {!isWebFaux && showFullBody && needsCollapse && !expandBodyFully && (
<div className="flex justify-center mt-2 relative z-10"> <div className="flex justify-center mt-2 relative z-10">
<Button <Button
variant="ghost" variant="ghost"

381
src/components/RssFeedList/index.tsx

@ -1,15 +1,34 @@
import { useEffect, useState, useMemo, useRef } from 'react' import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import { DEFAULT_RSS_FEEDS } from '@/constants' import { DEFAULT_RSS_FEEDS } from '@/constants'
import RssFeedItem from '../RssFeedItem' import RssWebFeedCard from '../RssWebFeedCard'
import {
addManualRssWebUrl,
fetchDiscoveredWebUrlsFromAuthorPubkeys,
fetchNostrWebActivityForUrls,
fetchPubkeyWebExternalReactionUrls,
isHttpArticleUrl,
loadManualRssWebUrls,
loadRssWebFeedScopePreference,
loadRssWebOnlyMyEventsPreference,
mergeDiscoveredRssWebUrls,
partitionRssItemsForWebFeed,
saveRssWebFeedScopePreference,
saveRssWebOnlyMyEventsPreference,
WEB_EXTERNAL_REACTION_PUBLISHED_EVENT,
type ManualRssWebUrlEntry,
type NostrWebActivityByUrl,
type RssWebFeedScope
} from '@/lib/rss-web-feed'
import { getPubkeysFromPTags } from '@/lib/tag'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, Search, Plus } from 'lucide-react' import { AlertCircle, Search, Plus } from 'lucide-react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -23,15 +42,20 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import { useSmartRssArticleNavigation } from '@/PageManager'
import { normalizeHttpArticleUrl } from '@/lib/rss-article' import { normalizeHttpArticleUrl } from '@/lib/rss-article'
function ManualRssUrlAddRow({ className }: { className?: string }) { function ManualRssUrlAddRow({
className,
onUrlAdded
}: {
className?: string
onUrlAdded: () => void | Promise<void>
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRssArticle } = useSmartRssArticleNavigation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const submit = () => { const submit = () => {
setError('') setError('')
@ -40,9 +64,17 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
setError(t('Enter a valid http(s) URL')) setError(t('Enter a valid http(s) URL'))
return return
} }
setSaving(true)
void (async () => {
try {
await addManualRssWebUrl(url)
setOpen(false) setOpen(false)
setValue('') setValue('')
navigateToRssArticle(url) await Promise.resolve(onUrlAdded())
} finally {
setSaving(false)
}
})()
} }
return ( return (
@ -61,7 +93,7 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
<DialogHeader> <DialogHeader>
<DialogTitle>{t('Add a web URL')}</DialogTitle> <DialogTitle>{t('Add a web URL')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t('Open any https page in the side panel to reply, react, and discuss on Nostr.')} {t('Add web URL to feed description')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Input <Input
@ -84,8 +116,8 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
<Button type="button" variant="secondary" onClick={() => setOpen(false)}> <Button type="button" variant="secondary" onClick={() => setOpen(false)}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button type="button" onClick={submit}> <Button type="button" disabled={saving} onClick={submit}>
{t('Open')} {t('Add to feed')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -96,7 +128,7 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
export default function RssFeedList() { export default function RssFeedList() {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr() const { pubkey, rssFeedListEvent, followListEvent } = useNostr()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [items, setItems] = useState<TRssFeedItem[]>([]) const [items, setItems] = useState<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -108,12 +140,33 @@ export default function RssFeedList() {
const [timeFilter, setTimeFilter] = useState<string>('all') const [timeFilter, setTimeFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState<string>('') const [searchQuery, setSearchQuery] = useState<string>('')
const [showFilters, setShowFilters] = useState<boolean>(false) const [showFilters, setShowFilters] = useState<boolean>(false)
const [isCompactView, setIsCompactView] = useState<boolean>(true)
const [feedPopoverOpen, setFeedPopoverOpen] = useState<boolean>(false) const [feedPopoverOpen, setFeedPopoverOpen] = useState<boolean>(false)
// Pagination state // Pagination state (merged RSS+Web rows)
const [showCount, setShowCount] = useState<number>(25) const [showRowCount, setShowRowCount] = useState<number>(20)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
/** True after user changes RSS+Web scope or “only my web events”; blocks async prefs from overwriting. */
const rssWebPrefsUserTouchedRef = useRef(false)
const [manualWebEntries, setManualWebEntries] = useState<ManualRssWebUrlEntry[]>([])
const refreshManualWebUrls = useCallback(() => {
void loadManualRssWebUrls().then(setManualWebEntries)
}, [])
useEffect(() => {
void loadManualRssWebUrls().then(setManualWebEntries)
}, [])
const webDiscoveryAuthorPubkeys = useMemo(() => {
if (!pubkey) return []
const set = new Set<string>([pubkey])
if (followListEvent) {
for (const pk of getPubkeysFromPTags(followListEvent.tags)) {
set.add(pk)
}
}
return [...set]
}, [pubkey, followListEvent])
// Listen for filter toggle events // Listen for filter toggle events
useEffect(() => { useEffect(() => {
@ -288,7 +341,7 @@ export default function RssFeedList() {
}) })
} }
} }
} catch (err) { } catch {
if (isMounted) { if (isMounted) {
setRefreshing(false) setRefreshing(false)
} }
@ -468,24 +521,201 @@ export default function RssFeedList() {
return filtered return filtered
}, [items, selectedFeeds, timeFilter, searchQuery]) }, [items, selectedFeeds, timeFilter, searchQuery])
// Reset showCount when filters change type FeedRow =
| { kind: 'web'; canonicalUrl: string; rssItems: TRssFeedItem[]; latestPub: number }
| { kind: 'rss'; item: TRssFeedItem }
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('webAndRss')
const [myWebReactionTs, setMyWebReactionTs] = useState<Map<string, number>>(() => new Map())
const loadMyWebReactions = useCallback(() => {
if (!pubkey || feedScope === 'rssOnly') {
setMyWebReactionTs(new Map())
return
}
void fetchPubkeyWebExternalReactionUrls(pubkey).then(setMyWebReactionTs)
}, [pubkey, feedScope])
useEffect(() => {
loadMyWebReactions()
}, [loadMyWebReactions])
useEffect(() => {
const handler = () => loadMyWebReactions()
window.addEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler)
return () => window.removeEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler)
}, [loadMyWebReactions])
useEffect(() => {
if (feedScope === 'rssOnly' || !pubkey || webDiscoveryAuthorPubkeys.length === 0) return
let cancelled = false
void (async () => {
try {
const discovered = await fetchDiscoveredWebUrlsFromAuthorPubkeys(webDiscoveryAuthorPubkeys)
if (cancelled) return
const didMerge = await mergeDiscoveredRssWebUrls(discovered)
if (didMerge && !cancelled) refreshManualWebUrls()
} catch {
/* ignore */
}
})()
return () => {
cancelled = true
}
}, [feedScope, pubkey, webDiscoveryAuthorPubkeys, refreshManualWebUrls])
const mergedRows = useMemo(() => {
const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems)
const webByUrl = new Map<string, { rssItems: TRssFeedItem[]; latestPub: number }>()
for (const g of groups) {
webByUrl.set(g.canonicalUrl, { rssItems: g.items, latestPub: g.latestPub })
}
for (const [canonicalUrl, ts] of myWebReactionTs) {
const cur = webByUrl.get(canonicalUrl)
if (cur) {
webByUrl.set(canonicalUrl, {
...cur,
latestPub: Math.max(cur.latestPub, ts)
})
} else {
webByUrl.set(canonicalUrl, { rssItems: [], latestPub: ts })
}
}
for (const { url, addedAt } of manualWebEntries) {
if (!isHttpArticleUrl(url)) continue
const cur = webByUrl.get(url)
if (cur) {
webByUrl.set(url, {
...cur,
latestPub: Math.max(cur.latestPub, addedAt)
})
} else {
webByUrl.set(url, { rssItems: [], latestPub: addedAt })
}
}
const web: Extract<FeedRow, { kind: 'web' }>[] = Array.from(webByUrl.entries()).map(
([canonicalUrl, v]) => ({
kind: 'web' as const,
canonicalUrl,
rssItems: v.rssItems,
latestPub: v.latestPub
})
)
const rest: FeedRow[] = nonHttpItems.map((item) => ({
kind: 'rss' as const,
item
}))
const combined: FeedRow[] = [...web, ...rest].sort((a, b) => {
const ta = a.kind === 'web' ? a.latestPub : (a.item.pubDate?.getTime() ?? 0)
const tb = b.kind === 'web' ? b.latestPub : (b.item.pubDate?.getTime() ?? 0)
return tb - ta
})
// One merged list: Web+RSS shows all; Only Web hides rss rows; Only RSS hides web rows.
if (feedScope === 'webOnly') {
return combined.filter((r): r is Extract<FeedRow, { kind: 'web' }> => r.kind === 'web')
}
if (feedScope === 'rssOnly') {
return combined.filter((r): r is Extract<FeedRow, { kind: 'rss' }> => r.kind === 'rss')
}
return combined
}, [filteredItems, feedScope, myWebReactionTs, manualWebEntries])
const webUrlsKey = useMemo(
() =>
mergedRows
.filter((r): r is Extract<FeedRow, { kind: 'web' }> => r.kind === 'web')
.map((r) => r.canonicalUrl)
.sort()
.join('\n'),
[mergedRows]
)
const [onlyMyWebEvents, setOnlyMyWebEvents] = useState(false)
const [nostrActivity, setNostrActivity] = useState<NostrWebActivityByUrl>(new Map())
const [nostrLoading, setNostrLoading] = useState(false)
const persistOnlyMine = useCallback((checked: boolean) => {
rssWebPrefsUserTouchedRef.current = true
setOnlyMyWebEvents(checked)
void saveRssWebOnlyMyEventsPreference(checked)
}, [])
const persistFeedScope = useCallback((scope: RssWebFeedScope) => {
rssWebPrefsUserTouchedRef.current = true
setFeedScope(scope)
void saveRssWebFeedScopePreference(scope)
}, [])
useEffect(() => {
void (async () => {
const [onlyMine, scope] = await Promise.all([
loadRssWebOnlyMyEventsPreference(),
loadRssWebFeedScopePreference()
])
if (!rssWebPrefsUserTouchedRef.current) {
setOnlyMyWebEvents(onlyMine)
setFeedScope(scope)
}
})()
}, [])
useEffect(() => {
if (!webUrlsKey) {
setNostrActivity(new Map())
setNostrLoading(false)
return
}
const urls = webUrlsKey.split('\n').filter(Boolean)
let cancelled = false
setNostrLoading(true)
void fetchNostrWebActivityForUrls(urls)
.then((m) => {
if (!cancelled) setNostrActivity(m)
})
.catch(() => {
if (!cancelled) setNostrActivity(new Map())
})
.finally(() => {
if (!cancelled) setNostrLoading(false)
})
return () => {
cancelled = true
}
}, [webUrlsKey])
const mergedRowsForFeed = useMemo(() => {
if (!onlyMyWebEvents || !pubkey) return mergedRows
return mergedRows.filter((row) => {
if (row.kind !== 'web') return true
const act = nostrActivity.get(row.canonicalUrl)
const myComments = act?.comments.filter((e) => e.pubkey === pubkey).length ?? 0
const myHighlights = act?.highlights.filter((e) => e.pubkey === pubkey).length ?? 0
const myReactions =
act?.externalReactions?.filter((e) => e.pubkey === pubkey).length ?? 0
return myComments > 0 || myHighlights > 0 || myReactions > 0
})
}, [mergedRows, onlyMyWebEvents, pubkey, nostrActivity])
// Reset pagination when filters change
useEffect(() => { useEffect(() => {
setShowCount(25) setShowRowCount(20)
}, [selectedFeeds, timeFilter, searchQuery]) }, [selectedFeeds, timeFilter, searchQuery, feedScope, onlyMyWebEvents])
// Pagination: slice to showCount for display const displayedRows = useMemo(() => {
const displayedItems = useMemo(() => { return mergedRowsForFeed.slice(0, showRowCount)
return filteredItems.slice(0, showCount) }, [mergedRowsForFeed, showRowCount])
}, [filteredItems, showCount])
// IntersectionObserver for infinite scroll // IntersectionObserver for infinite scroll
useEffect(() => { useEffect(() => {
if (!bottomRef.current || displayedItems.length >= filteredItems.length) return if (!bottomRef.current || displayedRows.length >= mergedRowsForFeed.length) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && displayedItems.length < filteredItems.length) { if (entries[0].isIntersecting && displayedRows.length < mergedRowsForFeed.length) {
setShowCount((prev) => Math.min(prev + 25, filteredItems.length)) setShowRowCount((prev) => Math.min(prev + 20, mergedRowsForFeed.length))
} }
}, },
{ root: null, rootMargin: '100px', threshold: 0.1 } { root: null, rootMargin: '100px', threshold: 0.1 }
@ -496,7 +726,7 @@ export default function RssFeedList() {
return () => { return () => {
observer.disconnect() observer.disconnect()
} }
}, [displayedItems.length, filteredItems.length]) }, [displayedRows.length, mergedRowsForFeed.length])
// Get display text for feed selector // Get display text for feed selector
const feedSelectorText = useMemo(() => { const feedSelectorText = useMemo(() => {
@ -530,10 +760,10 @@ export default function RssFeedList() {
) )
} }
if (items.length === 0) { if (items.length === 0 && manualWebEntries.length === 0) {
return ( return (
<div className="space-y-4 px-4 py-6"> <div className="space-y-4 px-4 py-6">
<ManualRssUrlAddRow /> <ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
<p className="text-sm text-muted-foreground text-center">{t('No RSS feed items available')}</p> <p className="text-sm text-muted-foreground text-center">{t('No RSS feed items available')}</p>
</div> </div>
) )
@ -541,26 +771,68 @@ export default function RssFeedList() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Feed Counter Header - Always visible */} {/* Feed header — Nostr filter, counts */}
<div className="sticky top-0 z-10 bg-background border-b px-4 py-1.5"> <div className="sticky top-0 z-10 space-y-1.5 border-b bg-background px-4 py-2">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div
className="inline-flex max-w-full flex-wrap rounded-md border border-border bg-muted/30 p-0.5 sm:flex-nowrap"
role="group"
aria-label={t('RSS + Web feed scope')}
>
<Button
type="button"
variant={feedScope === 'webOnly' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('webOnly')}
>
{t('Only Web')}
</Button>
<Button
type="button"
variant={feedScope === 'webAndRss' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('webAndRss')}
>
{t('Web + RSS')}
</Button>
<Button
type="button"
variant={feedScope === 'rssOnly' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('rssOnly')}
>
{t('Only RSS')}
</Button>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Checkbox
id="compact-view" id="only-my-web-events"
checked={isCompactView} checked={onlyMyWebEvents}
onCheckedChange={setIsCompactView} disabled={!pubkey || feedScope === 'rssOnly'}
onCheckedChange={(c) => persistOnlyMine(c === true)}
/> />
<Label htmlFor="compact-view" className="text-xs text-muted-foreground cursor-pointer"> <Label
{isCompactView ? t('Compact') : t('Full')} htmlFor="only-my-web-events"
className={`cursor-pointer text-xs text-muted-foreground ${!pubkey || feedScope === 'rssOnly' ? 'opacity-50' : ''}`}
>
{t('Only my web events')}
</Label> </Label>
</div> </div>
<p className="text-xs text-muted-foreground"> </div>
{t('Showing {{filtered}} of {{total}} items', { <p className="text-xs text-muted-foreground sm:text-right">
filtered: displayedItems.length, {t('Showing {{filtered}} of {{total}} entries', {
total: filteredItems.length filtered: displayedRows.length,
total: mergedRowsForFeed.length
})} })}
</p> </p>
</div> </div>
{nostrLoading ? (
<p className="text-xs text-muted-foreground">{t('Fetching web activity from Nostr…')}</p>
) : null}
</div> </div>
{/* Filter Bar - Collapsible */} {/* Filter Bar - Collapsible */}
@ -652,7 +924,7 @@ export default function RssFeedList() {
{/* Content */} {/* Content */}
<div className="space-y-4 px-4 py-3"> <div className="space-y-4 px-4 py-3">
<ManualRssUrlAddRow /> <ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
{refreshing && ( {refreshing && (
<div className="flex items-center gap-2 border-b py-2" role="status" aria-busy="true"> <div className="flex items-center gap-2 border-b py-2" role="status" aria-busy="true">
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" aria-hidden /> <Skeleton className="h-4 w-4 shrink-0 rounded-sm" aria-hidden />
@ -660,7 +932,7 @@ export default function RssFeedList() {
</div> </div>
)} )}
{displayedItems.length === 0 ? ( {displayedRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all' {searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
@ -670,15 +942,26 @@ export default function RssFeedList() {
</div> </div>
) : ( ) : (
<> <>
{displayedItems.map((item) => ( {displayedRows.map((row) =>
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} layout={isCompactView ? 'list' : 'detail'} /> row.kind === 'web' ? (
))} <RssWebFeedCard
{/* Bottom ref for infinite scroll */} key={row.canonicalUrl}
{displayedItems.length < filteredItems.length && ( canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems}
/>
) : (
<RssWebFeedCard
key={`${row.item.feedUrl}-${row.item.guid}`}
canonicalUrl={row.item.link}
rssItems={[row.item]}
/>
)
)}
{displayedRows.length < mergedRowsForFeed.length ? (
<div ref={bottomRef} className="flex justify-center py-4"> <div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden /> <Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div> </div>
)} ) : null}
</> </>
)} )}
</div> </div>

86
src/components/RssUrlThreadStatsBar/index.tsx

@ -0,0 +1,86 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useUserTrust } from '@/contexts/user-trust-context'
import { cn } from '@/lib/utils'
import noteStatsService from '@/services/note-stats.service'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider'
import { Bookmark, Highlighter, MessageCircle, ThumbsUp } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
/** Compact reply / reaction / bookmark / highlight counts for RSS + Web URL threads. */
export default function RssUrlThreadStatsBar({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false))
}, [event.id, event.kind, event.created_at, event.sig, pubkey, statsRelaysKey])
const fmt = (n: number) => (n >= 100 ? '99+' : String(n))
const { replyCount, reactionCount, highlightCount, bookmarkCount } = useMemo(() => {
const replies = noteStats?.replies ?? []
const likes = noteStats?.likes ?? []
const highlights = noteStats?.highlights ?? []
const trustedReplyCount = hideUntrustedInteractions
? replies.filter((r) => isUserTrusted(r.pubkey)).length
: replies.length
const trustedReactionCount = hideUntrustedInteractions
? likes.filter((l) => isUserTrusted(l.pubkey)).length
: likes.length
const trustedHighlightCount = hideUntrustedInteractions
? highlights.filter((h) => isUserTrusted(h.pubkey)).length
: highlights.length
const bookmarkCountInner = noteStats?.bookmarkPubkeySet?.size ?? 0
return {
replyCount: trustedReplyCount,
reactionCount: trustedReactionCount,
highlightCount: trustedHighlightCount,
bookmarkCount: bookmarkCountInner
}
}, [noteStats, hideUntrustedInteractions, isUserTrusted])
return (
<div
className={cn(
'flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground',
loading ? 'animate-pulse' : '',
className
)}
data-rss-url-thread-stats
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="group"
aria-label={t('URL thread activity')}
>
<span className="inline-flex items-center gap-1" title={t('Comments')}>
<MessageCircle className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
<span className="tabular-nums">{fmt(replyCount)}</span>
</span>
<span className="inline-flex items-center gap-1" title={t('Reactions')}>
<ThumbsUp className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
<span className="tabular-nums">{fmt(reactionCount)}</span>
</span>
<span className="inline-flex items-center gap-1" title={t('Bookmarks')}>
<Bookmark className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
<span className="tabular-nums">{fmt(bookmarkCount)}</span>
</span>
<span className="inline-flex items-center gap-1" title={t('Highlights')}>
<Highlighter className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
<span className="tabular-nums">{fmt(highlightCount)}</span>
</span>
</div>
)
}

107
src/components/RssWebFeedCard/index.tsx

@ -0,0 +1,107 @@
import RssFeedItem from '@/components/RssFeedItem'
import RssUrlThreadStatsBar from '@/components/RssUrlThreadStatsBar'
import WebPreview from '@/components/WebPreview'
import { cn } from '@/lib/utils'
import { createRssThreadRootEvent } from '@/lib/rss-article'
import { isHttpArticleUrl } from '@/lib/rss-web-feed'
import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import {
createWebOnlyRssFeedItem,
isWebOnlyFauxRssItem
} from '@/services/rss-feed.service'
import { Globe, Rss } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSmartRssArticleNavigation } from '@/PageManager'
/**
* Single feed card for an article URL: RSS body and/or faux web item (OpenGraph), plus URL-thread stats.
* Opens {@link RssArticlePage} in the secondary panel when the card is activated.
*/
export default function RssWebFeedCard({
canonicalUrl,
rssItems,
className
}: {
canonicalUrl: string
rssItems: TRssFeedItem[]
className?: string
}) {
const { t } = useTranslation()
const { navigateToRssArticle } = useSmartRssArticleNavigation()
const syntheticRoot = useMemo(() => createRssThreadRootEvent(canonicalUrl), [canonicalUrl])
const displayRssItems = useMemo(() => {
if (rssItems.length > 0) return rssItems
if (isHttpArticleUrl(canonicalUrl)) return [createWebOnlyRssFeedItem(canonicalUrl)]
return []
}, [rssItems, canonicalUrl])
const hasRealRss = displayRssItems.some((i) => !isWebOnlyFauxRssItem(i))
const openArticle = () => {
navigateToRssArticle(canonicalUrl)
}
return (
<div
className={cn(
'rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
'cursor-pointer transition-colors hover:bg-muted/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
className
)}
role="link"
tabIndex={0}
onClick={openArticle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openArticle()
}
}}
>
<div
className="flex items-center gap-1.5 border-b border-border/40 px-3 py-1.5 text-[11px] sm:text-xs text-muted-foreground"
aria-label={hasRealRss ? t('RSS feed item label') : t('Web URL item label')}
>
{hasRealRss ? (
<Rss className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
) : (
<Globe className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
)}
<span>{hasRealRss ? t('RSS feed item label') : t('Web URL item label')}</span>
</div>
<div className="not-prose max-w-full border-b border-border/60 bg-muted/10 pointer-events-none">
{displayRssItems.length > 0 ? (
<div className="divide-y divide-border/60">
{displayRssItems.map((item) => (
<RssFeedItem
key={`${item.feedUrl}-${item.guid}`}
item={item}
layout="detail"
expandBodyFully
className="rounded-none border-0 shadow-none bg-transparent"
/>
))}
</div>
) : (
<WebPreview url={canonicalUrl} className="w-full" />
)}
</div>
{displayRssItems.length === 0 ? (
<p className="pointer-events-none border-b border-border/60 px-3 py-2 text-sm text-muted-foreground break-all">
{canonicalUrl}
</p>
) : null}
{rssItems.length > 1 ? (
<p className="pointer-events-none border-b border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
{t('{{count}} RSS entries for this URL', { count: rssItems.length })}
</p>
) : null}
<RssUrlThreadStatsBar event={syntheticRoot} />
</div>
)
}

4
src/components/Sidebar/RssButton.tsx

@ -1,10 +1,12 @@
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { Rss } from 'lucide-react' import { Rss } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
export default function RssButton() { export default function RssButton() {
const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const showRssFeed = storage.getShowRssFeed() const showRssFeed = storage.getShowRssFeed()
@ -14,7 +16,7 @@ export default function RssButton() {
const isActive = display && current === 'rss' && primaryViewType === null const isActive = display && current === 'rss' && primaryViewType === null
return ( return (
<SidebarItem title="RSS Feed" onClick={() => navigate('rss')} active={isActive}> <SidebarItem title={t('RSS + Web')} onClick={() => navigate('rss')} active={isActive}>
<Rss strokeWidth={3} /> <Rss strokeWidth={3} />
</SidebarItem> </SidebarItem>
) )

24
src/i18n/locales/en.ts

@ -397,6 +397,9 @@ export default {
'Jumble Imwald synthetic event': 'Jumble Imwald synthetic event', 'Jumble Imwald synthetic event': 'Jumble Imwald synthetic event',
'+ Add a URL to this list': 'Add a URL to this list', '+ Add a URL to this list': 'Add a URL to this list',
'Add a web URL': 'Add a web URL', 'Add a web URL': 'Add a web URL',
'Add web URL to feed description':
'Adds a card to this feed. Open the page from the card when you want to read, reply, react, or highlight.',
'Add to feed': 'Add to feed',
'Open any https page in the side panel to reply, react, and discuss on Nostr.': 'Open any https page in the side panel to reply, react, and discuss on Nostr.':
'Open any https page in the side panel to reply, react, and discuss on Nostr.', 'Open any https page in the side panel to reply, react, and discuss on Nostr.',
'Enter a valid http(s) URL': 'Enter a valid http(s) URL', 'Enter a valid http(s) URL': 'Enter a valid http(s) URL',
@ -1249,6 +1252,27 @@ export default {
'Publisher name (optional)': 'Publisher name (optional)', 'Publisher name (optional)': 'Publisher name (optional)',
'Quiet Tags': 'Quiet Tags', 'Quiet Tags': 'Quiet Tags',
'RSS Feed': 'RSS Feed', 'RSS Feed': 'RSS Feed',
'RSS + Web': 'RSS + Web',
'RSS feed source': 'RSS feed source',
'All feed sources': 'All feed sources',
'RSS + Web feed scope': 'RSS + Web feed scope',
'Only Web': 'Only Web',
'Web + RSS': 'Web + RSS',
'Only RSS': 'Only RSS',
'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',
'Only my web events': 'Only my web events',
'RSS articles': 'RSS articles',
'Web comments': 'Web comments',
'Web highlights': 'Web highlights',
'In your bookmarks': 'In your bookmarks',
'Fetching web activity from Nostr…': 'Fetching web activity from Nostr…',
'{{count}} RSS entries for this URL': '{{count}} RSS entries for this URL',
'No comments yet': 'No comments yet',
'No highlights yet': 'No highlights yet',
'Showing {{filtered}} of {{total}} entries':
'Showing {{filtered}} of {{total}} entries',
'RSS Feed Settings': 'RSS Feed Settings', 'RSS Feed Settings': 'RSS Feed Settings',
'RSS Feeds': 'RSS Feeds', 'RSS Feeds': 'RSS Feeds',
'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file', 'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file',

11
src/lib/rss-article.ts

@ -81,6 +81,17 @@ export function getArticleUrlFromCommentITags(event: Event): string | undefined
return event.tags.find((t) => t[0] === 'i')?.[1] return event.tags.find((t) => t[0] === 'i')?.[1]
} }
/** HTTP(S) page URL from kind 9802 `r` tags (`source` marker or bare `r`). */
export function getHighlightSourceHttpUrl(event: Pick<Event, 'tags'>): string | undefined {
for (const t of event.tags) {
if (t[0] !== 'r' || !t[1]) continue
const u = t[1].trim()
if (!u.startsWith('http://') && !u.startsWith('https://')) continue
if (t[2] === 'source' || !t[2]) return canonicalizeRssArticleUrl(u)
}
return undefined
}
/** /**
* NIP-25 kind 17 + NIP-73: resolve http(s) target URL for a `k: web` external reaction. * NIP-25 kind 17 + NIP-73: resolve http(s) target URL for a `k: web` external reaction.
* Stops at the next `k` tag so podcast-style multi-scope reactions are not mis-parsed as web. * Stops at the next `k` tag so podcast-style multi-scope reactions are not mis-parsed as web.

392
src/lib/rss-web-feed.ts

@ -0,0 +1,392 @@
import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl,
getWebExternalReactionTargetUrl
} from '@/lib/rss-article'
import { normalizeUrl } from '@/lib/url'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { RssFeedItem } from '@/services/rss-feed.service'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
/** IndexedDB settings key: `'1'` = show only current user’s web comments/highlights in RSS+Web feed. */
export const RSS_WEB_ONLY_MY_EVENTS_SETTING = 'rssWebOnlyMyEvents'
/** IndexedDB: merged RSS+Web cards + Nostr vs flat RSS-only list. */
export const RSS_WEB_FEED_SCOPE_SETTING = 'rssWebFeedScope'
/** IndexedDB: JSON array of `{ url, addedAt }` for URLs added from “Add URL” (no RSS row yet). */
export const RSS_WEB_MANUAL_URLS_SETTING = 'rssWebManualUrls'
export type RssWebFeedScope = 'webOnly' | 'webAndRss' | 'rssOnly'
export type ManualRssWebUrlEntry = { url: string; addedAt: number }
const MAX_MANUAL_WEB_URLS = 200
/** Keep newest URLs by `addedAt`; drops oldest when over limit. */
function trimManualRssWebUrlsToLimit(entries: ManualRssWebUrlEntry[]): ManualRssWebUrlEntry[] {
if (entries.length <= MAX_MANUAL_WEB_URLS) return entries
return [...entries]
.sort((a, b) => b.addedAt - a.addedAt)
.slice(0, MAX_MANUAL_WEB_URLS)
}
/** Cap how many pubkeys we scan (self + follows) per discovery pass. */
const MAX_WEB_DISCOVERY_AUTHORS = 400
const WEB_DISCOVERY_AUTHORS_CHUNK = 20
const WEB_DISCOVERY_EVENTS_LIMIT = 400
export async function loadManualRssWebUrls(): Promise<ManualRssWebUrlEntry[]> {
const raw = await indexedDb.getSetting(RSS_WEB_MANUAL_URLS_SETTING)
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
const out: ManualRssWebUrlEntry[] = []
for (const x of parsed) {
if (typeof x !== 'object' || x === null) continue
const rec = x as Record<string, unknown>
if (typeof rec.url !== 'string') continue
const url = canonicalizeRssArticleUrl(rec.url.trim())
if (!isHttpArticleUrl(url)) continue
const addedAt = typeof rec.addedAt === 'number' ? rec.addedAt : 0
out.push({ url, addedAt })
}
return out
} catch {
return []
}
}
/** Dedupes by canonical URL; newest first. Returns canonical URL. */
export async function addManualRssWebUrl(rawUrl: string): Promise<string> {
const canonical = canonicalizeRssArticleUrl(rawUrl.trim())
if (!isHttpArticleUrl(canonical)) return canonical
const existing = await loadManualRssWebUrls()
const filtered = existing.filter((e) => e.url !== canonical)
const next = trimManualRssWebUrlsToLimit([
{ url: canonical, addedAt: Date.now() },
...filtered
])
await indexedDb.setSetting(RSS_WEB_MANUAL_URLS_SETTING, JSON.stringify(next))
return canonical
}
/**
* Merge URLs learned from Nostr (follows + self) into the manual web URL list.
* Returns whether IndexedDB was updated (caller may refetch UI state).
*/
export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry[]): Promise<boolean> {
if (discovered.length === 0) return false
const existing = await loadManualRssWebUrls()
const byUrl = new Map<string, number>()
for (const e of existing) {
byUrl.set(e.url, e.addedAt)
}
let changed = false
for (const d of discovered) {
const prev = byUrl.get(d.url) ?? 0
const next = Math.max(prev, d.addedAt)
if (next !== prev) changed = true
byUrl.set(d.url, next)
}
if (!changed) return false
const merged = trimManualRssWebUrlsToLimit(
[...byUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
)
await indexedDb.setSetting(RSS_WEB_MANUAL_URLS_SETTING, JSON.stringify(merged))
return true
}
const URL_CHUNK = 14
/** Dispatched after publishing a kind 17 web URL reaction so RSS+Web can refetch. */
export const WEB_EXTERNAL_REACTION_PUBLISHED_EVENT = 'jumble:webExternalReactionPublished'
export type RssUrlGroup = {
canonicalUrl: string
items: RssFeedItem[]
/** Latest RSS pubDate in group for sorting */
latestPub: number
}
export function isHttpArticleUrl(url: string): boolean {
const t = url.trim()
return t.startsWith('http://') || t.startsWith('https://')
}
/**
* Group RSS entries by canonical article URL (NIP-22 / web thread key).
*/
export function groupRssItemsByCanonicalUrl(items: RssFeedItem[]): RssUrlGroup[] {
const { groups } = partitionRssItemsForWebFeed(items)
return groups
}
/** HTTP(S) article groups for combined cards; everything else stays as plain RSS rows. */
export function partitionRssItemsForWebFeed(items: RssFeedItem[]): {
groups: RssUrlGroup[]
nonHttpItems: RssFeedItem[]
} {
const map = new Map<string, RssFeedItem[]>()
const nonHttpItems: RssFeedItem[] = []
for (const item of items) {
const link = item.link?.trim()
if (!link || !isHttpArticleUrl(link)) {
nonHttpItems.push(item)
continue
}
const key = canonicalizeRssArticleUrl(link)
const list = map.get(key)
if (list) list.push(item)
else map.set(key, [item])
}
const groups: RssUrlGroup[] = []
for (const [canonicalUrl, groupItems] of map) {
let latestPub = 0
for (const it of groupItems) {
const t = it.pubDate?.getTime() ?? 0
if (t > latestPub) latestPub = t
}
groups.push({ canonicalUrl, items: groupItems, latestPub })
}
groups.sort((a, b) => b.latestPub - a.latestPub)
return { groups, nonHttpItems }
}
function buildStatsRelayList(): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (u: string) => {
const n = normalizeUrl(u) || u
if (!n || seen.has(n)) return
seen.add(n)
out.push(n)
}
SEARCHABLE_RELAY_URLS.forEach(add)
FAST_READ_RELAY_URLS.forEach(add)
return out
}
function highlightSourceUrl(evt: Event): string | undefined {
const u = getHighlightSourceHttpUrl(evt)
return u && isHttpArticleUrl(u) ? u : undefined
}
function extractArticleUrlFromWebActivityEvent(evt: Event): string | undefined {
if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const u = getArticleUrlFromCommentITags(evt)
if (!u || !isHttpArticleUrl(u)) return undefined
return canonicalizeRssArticleUrl(u)
}
if (evt.kind === ExtendedKind.EXTERNAL_REACTION) {
const u = getWebExternalReactionTargetUrl(evt)
return u && isHttpArticleUrl(u) ? canonicalizeRssArticleUrl(u) : undefined
}
if (evt.kind === kinds.Highlights) {
return highlightSourceUrl(evt)
}
return undefined
}
/**
* Recent kind 1111 / 1244 / 17 / 9802 from the given authors; returns canonical article URLs with latest event time.
* Used to seed manual URL cards so the RSS+Web feed can load thread stats and Nostr activity for pages not in RSS.
*/
export async function fetchDiscoveredWebUrlsFromAuthorPubkeys(pubkeys: string[]): Promise<ManualRssWebUrlEntry[]> {
const unique = [...new Set(pubkeys.filter(Boolean))].slice(0, MAX_WEB_DISCOVERY_AUTHORS)
if (unique.length === 0) return []
const relayUrls = buildStatsRelayList()
if (relayUrls.length === 0) return []
const latestByUrl = new Map<string, number>()
const webKinds = [
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.EXTERNAL_REACTION,
kinds.Highlights
] as number[]
for (let i = 0; i < unique.length; i += WEB_DISCOVERY_AUTHORS_CHUNK) {
const chunk = unique.slice(i, i + WEB_DISCOVERY_AUTHORS_CHUNK)
try {
await queryService.fetchEvents(
relayUrls,
[{ kinds: webKinds, authors: chunk, limit: WEB_DISCOVERY_EVENTS_LIMIT }],
{
onevent: (evt: Event) => {
const url = extractArticleUrlFromWebActivityEvent(evt)
if (!url) return
const prev = latestByUrl.get(url) ?? 0
if (evt.created_at > prev) latestByUrl.set(url, evt.created_at)
},
eoseTimeout: 5000,
globalTimeout: 15000
}
)
} catch {
/* ignore chunk */
}
}
return [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
}
export type NostrWebActivityByUrl = Map<
string,
{
comments: Event[]
highlights: Event[]
externalReactions: Event[]
}
>
/**
* Pull kind 1111 (i-tag) comments, kind 17 (i-tag web) reactions, and kind 9802 (r-tag URL) highlights.
*/
export async function fetchNostrWebActivityForUrls(urls: string[]): Promise<NostrWebActivityByUrl> {
const out: NostrWebActivityByUrl = new Map()
const httpUrls = [...new Set(urls.filter((u) => isHttpArticleUrl(u)).map((u) => canonicalizeRssArticleUrl(u)))]
if (httpUrls.length === 0) return out
const relayUrls = buildStatsRelayList()
if (relayUrls.length === 0) return out
const urlSet = new Set(httpUrls)
const commentById = new Map<string, Event>()
const highlightById = new Map<string, Event>()
const externalReactionById = new Map<string, Event>()
for (let i = 0; i < httpUrls.length; i += URL_CHUNK) {
const chunk = httpUrls.slice(i, i + URL_CHUNK)
try {
await queryService.fetchEvents(
relayUrls,
[
{ kinds: [ExtendedKind.COMMENT], '#i': chunk, limit: 120 },
{ kinds: [ExtendedKind.EXTERNAL_REACTION], '#i': chunk, limit: 120 },
{ kinds: [kinds.Highlights], '#r': chunk, limit: 120 }
],
{
onevent: (evt: Event) => {
if (evt.kind === ExtendedKind.COMMENT) {
commentById.set(evt.id, evt)
} else if (evt.kind === ExtendedKind.EXTERNAL_REACTION) {
externalReactionById.set(evt.id, evt)
} else if (evt.kind === kinds.Highlights) {
highlightById.set(evt.id, evt)
}
},
eoseTimeout: 4000,
globalTimeout: 12000
}
)
} catch {
/* ignore chunk */
}
}
const addTo = (
urlKey: string,
type: 'comments' | 'highlights' | 'externalReactions',
evt: Event
) => {
let bucket = out.get(urlKey)
if (!bucket) {
bucket = { comments: [], highlights: [], externalReactions: [] }
out.set(urlKey, bucket)
}
bucket[type].push(evt)
}
for (const evt of commentById.values()) {
const u = getArticleUrlFromCommentITags(evt)
if (!u || !isHttpArticleUrl(u)) continue
const key = canonicalizeRssArticleUrl(u)
if (!urlSet.has(key)) continue
addTo(key, 'comments', evt)
}
for (const evt of highlightById.values()) {
const u = highlightSourceUrl(evt)
if (!u) continue
const key = canonicalizeRssArticleUrl(u)
if (!urlSet.has(key)) continue
addTo(key, 'highlights', evt)
}
for (const evt of externalReactionById.values()) {
const u = getWebExternalReactionTargetUrl(evt)
if (!u) continue
const key = canonicalizeRssArticleUrl(u)
if (!urlSet.has(key)) continue
addTo(key, 'externalReactions', evt)
}
for (const [, bucket] of out) {
bucket.comments.sort((a, b) => b.created_at - a.created_at)
bucket.highlights.sort((a, b) => b.created_at - a.created_at)
bucket.externalReactions.sort((a, b) => b.created_at - a.created_at)
}
return out
}
/**
* Latest kind-17 web reaction time per canonical URL for this pubkey (for feed rows not in RSS).
*/
export async function fetchPubkeyWebExternalReactionUrls(pubkey: string): Promise<Map<string, number>> {
const out = new Map<string, number>()
const relayUrls = buildStatsRelayList()
if (!pubkey || relayUrls.length === 0) return out
try {
await queryService.fetchEvents(
relayUrls,
[{ kinds: [ExtendedKind.EXTERNAL_REACTION], authors: [pubkey], limit: 500 }],
{
onevent: (evt: Event) => {
const url = getWebExternalReactionTargetUrl(evt)
if (!url) return
const key = canonicalizeRssArticleUrl(url)
const prev = out.get(key) ?? 0
if (evt.created_at > prev) out.set(key, evt.created_at)
},
eoseTimeout: 5000,
globalTimeout: 15000
}
)
} catch {
/* ignore */
}
return out
}
export async function loadRssWebOnlyMyEventsPreference(): Promise<boolean> {
const v = await indexedDb.getSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING)
return v === '1' || v === 'true'
}
export async function saveRssWebOnlyMyEventsPreference(onlyMine: boolean): Promise<void> {
await indexedDb.setSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING, onlyMine ? '1' : '0')
}
export async function loadRssWebFeedScopePreference(): Promise<RssWebFeedScope> {
const v = await indexedDb.getSetting(RSS_WEB_FEED_SCOPE_SETTING)
if (v === 'webOnly' || v === 'webAndRss' || v === 'rssOnly') return v
if (v === 'all') return 'webAndRss'
return 'webAndRss'
}
export async function saveRssWebFeedScopePreference(scope: RssWebFeedScope): Promise<void> {
await indexedDb.setSetting(RSS_WEB_FEED_SCOPE_SETTING, scope)
}
export function filterEventsByPubkey(events: Event[], pubkey: string | null | undefined): Event[] {
if (!pubkey) return events
return events.filter((e) => e.pubkey === pubkey)
}

6
src/lib/thread-reply-root-match.ts

@ -1,5 +1,5 @@
import { getRootATag, getRootEventHexId } from '@/lib/event' import { getRootATag, getRootEventHexId } from '@/lib/event'
import { getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
/** Matches `ReplyNoteList` / discussion thread root shapes. */ /** Matches `ReplyNoteList` / discussion thread root shapes. */
@ -11,7 +11,9 @@ export type TThreadRootRef =
/** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */ /** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */
export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): boolean { export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): boolean {
if (root.type === 'I') { if (root.type === 'I') {
return getArticleUrlFromCommentITags(evt) === root.id const u = getArticleUrlFromCommentITags(evt)
if (!u) return false
return canonicalizeRssArticleUrl(u) === canonicalizeRssArticleUrl(root.id)
} }
if (root.type === 'A') { if (root.type === 'A') {
const coord = getRootATag(evt)?.[1] const coord = getRootATag(evt)?.[1]

2
src/pages/primary/RssPage/index.tsx

@ -63,7 +63,7 @@ const RssPage = forwardRef<TPageRef>((_, ref) => {
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> <div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3"> <div className="flex items-center gap-2 pl-3">
<Rss className="size-5" /> <Rss className="size-5" />
<div className="text-lg font-semibold">{t('RSS Feed')}</div> <div className="text-lg font-semibold">{t('RSS + Web')}</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button

173
src/pages/secondary/RssArticlePage/index.tsx

@ -1,18 +1,33 @@
import NoteInteractions from '@/components/NoteInteractions' import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import RssFeedItem from '@/components/RssFeedItem' import RssFeedItem from '@/components/RssFeedItem'
import WebPreview from '@/components/WebPreview'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import {
createWebOnlyRssFeedItem,
isWebOnlyFauxRssItem
} from '@/services/rss-feed.service'
import { isHttpArticleUrl } from '@/lib/rss-web-feed'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { decodeRssArticlePathSegment, createRssThreadRootEvent } from '@/lib/rss-article' import { useNostr } from '@/providers/NostrProvider'
import { decodeRssArticlePathSegment, createRssThreadRootEvent, canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button' function normalizeFeedUrl(url: string): string {
return url.trim().replace(/\/$/, '')
}
const RssArticlePage = forwardRef( const RssArticlePage = forwardRef(
( (
@ -30,10 +45,12 @@ const RssArticlePage = forwardRef(
ref ref
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const { rssFeedListEvent } = useNostr()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const [item, setItem] = useState<TRssFeedItem | null>(initialItem ?? null) const [allCachedItems, setAllCachedItems] = useState<TRssFeedItem[]>([])
const [loading, setLoading] = useState(!initialItem) const [loading, setLoading] = useState(true)
const [selectedSource, setSelectedSource] = useState<'all' | string>('all')
const articleUrl = useMemo(() => { const articleUrl = useMemo(() => {
try { try {
@ -43,18 +60,81 @@ const RssArticlePage = forwardRef(
} }
}, [articleKey]) }, [articleKey])
const subscribedFeedUrls = useMemo(() => {
if (!rssFeedListEvent?.tags?.length) return new Set<string>()
const s = new Set<string>()
for (const t of rssFeedListEvent.tags) {
if (t[0] === 'u' && t[1]) s.add(normalizeFeedUrl(String(t[1])))
}
return s
}, [rssFeedListEvent])
const matchingItems = useMemo(() => {
if (!articleUrl) return []
const canon = canonicalizeRssArticleUrl(articleUrl)
const fromDb = allCachedItems.filter((i) => canonicalizeRssArticleUrl(i.link) === canon)
let result =
subscribedFeedUrls.size === 0
? fromDb
: fromDb.filter((i) => subscribedFeedUrls.has(normalizeFeedUrl(i.feedUrl)))
if (initialItem && canonicalizeRssArticleUrl(initialItem.link) === canon) {
const norm = normalizeFeedUrl(initialItem.feedUrl)
const has = result.some(
(i) => normalizeFeedUrl(i.feedUrl) === norm && i.guid === initialItem.guid
)
if (!has) result = [initialItem, ...result]
}
if (!loading && result.length === 0 && isHttpArticleUrl(articleUrl)) {
return [createWebOnlyRssFeedItem(articleUrl)]
}
return result
}, [allCachedItems, articleUrl, subscribedFeedUrls, initialItem, loading])
const sourceOptions = useMemo(() => {
const m = new Map<string, string>()
for (const i of matchingItems) {
const u = normalizeFeedUrl(i.feedUrl)
if (!m.has(u)) {
m.set(
u,
isWebOnlyFauxRssItem(i) ? t('Web page') : (i.feedTitle?.trim() || u)
)
}
}
return [...m.entries()].map(([url, title]) => ({ url, title }))
}, [matchingItems, t])
const itemsToRender = useMemo(() => {
if (matchingItems.length === 0) return []
if (matchingItems.length === 1 || selectedSource === 'all') return matchingItems
return matchingItems.filter((i) => normalizeFeedUrl(i.feedUrl) === selectedSource)
}, [matchingItems, selectedSource])
useEffect(() => { useEffect(() => {
if (initialItem || !articleUrl) { if (sourceOptions.length <= 1) {
if (selectedSource !== 'all') setSelectedSource('all')
return
}
if (
selectedSource !== 'all' &&
!sourceOptions.some((o) => o.url === selectedSource)
) {
setSelectedSource('all')
}
}, [sourceOptions, selectedSource])
useEffect(() => {
if (!articleUrl) {
setLoading(false) setLoading(false)
return return
} }
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
setLoading(true)
try { try {
const items = await indexedDb.getRssFeedItems() const items = await indexedDb.getRssFeedItems()
if (cancelled) return if (cancelled) return
const found = items.find((i) => i.link === articleUrl) ?? null setAllCachedItems(items)
setItem(found)
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
} }
@ -62,41 +142,37 @@ const RssArticlePage = forwardRef(
return () => { return () => {
cancelled = true cancelled = true
} }
}, [articleUrl, initialItem]) }, [articleUrl])
const syntheticRoot = useMemo( const syntheticRoot = useMemo(
() => (articleUrl ? createRssThreadRootEvent(articleUrl) : null), () => (articleUrl ? createRssThreadRootEvent(articleUrl) : null),
[articleUrl] [articleUrl]
) )
const primaryRssItem = itemsToRender[0] ?? null
useEffect(() => { useEffect(() => {
if (hideTitlebar) { if (hideTitlebar) {
sessionStorage.setItem('notePageTitle', item ? t('RSS article') : t('Web page')) sessionStorage.setItem('notePageTitle', primaryRssItem ? t('RSS article') : t('Web page'))
} }
return () => { return () => {
if (hideTitlebar) { if (hideTitlebar) {
sessionStorage.removeItem('notePageTitle') sessionStorage.removeItem('notePageTitle')
} }
} }
}, [hideTitlebar, t, item]) }, [hideTitlebar, t, primaryRssItem])
const refreshArticle = useCallback(async () => { const refreshArticle = useCallback(async () => {
setContentKey((k) => k + 1) setContentKey((k) => k + 1)
if (!articleUrl) return if (!articleUrl) return
if (initialItem) {
setItem(initialItem)
setLoading(false)
return
}
setLoading(true) setLoading(true)
try { try {
const items = await indexedDb.getRssFeedItems() const items = await indexedDb.getRssFeedItems()
const found = items.find((i) => i.link === articleUrl) ?? null setAllCachedItems(items)
setItem(found)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [articleUrl, initialItem]) }, [articleUrl])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
@ -126,7 +202,7 @@ const RssArticlePage = forwardRef(
) )
} }
if (loading) { if (loading && matchingItems.length === 0) {
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
@ -141,7 +217,7 @@ const RssArticlePage = forwardRef(
) )
} }
if (!item) { if (matchingItems.length === 0) {
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
@ -154,18 +230,9 @@ const RssArticlePage = forwardRef(
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')} {t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
</p> </p>
<div className="not-prose max-w-full">
<WebPreview url={articleUrl} className="w-full" />
</div>
<Button variant="outline" size="sm" asChild>
<a href={articleUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2">
{t('Open in browser')}
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
{syntheticRoot && ( {syntheticRoot && (
<div className="px-0 w-full"> <div className="px-0 w-full">
<NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} /> <NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes={false} />
</div> </div>
)} )}
<Separator /> <Separator />
@ -193,12 +260,48 @@ const RssArticlePage = forwardRef(
displayScrollToTopButton displayScrollToTopButton
> >
<div key={contentKey} className="min-w-0"> <div key={contentKey} className="min-w-0">
<div className="px-4 pt-3 w-full"> <div className="px-4 pt-3 w-full space-y-3">
<RssFeedItem item={item} layout="detail" /> {sourceOptions.length > 1 ? (
<div className="space-y-1.5">
<Label htmlFor="rss-thread-feed-source" className="text-xs text-muted-foreground">
{t('RSS feed source')}
</Label>
<Select
value={selectedSource}
onValueChange={(v) => setSelectedSource(v === 'all' ? 'all' : v)}
>
<SelectTrigger id="rss-thread-feed-source" className="h-9 w-full max-w-md text-sm">
<SelectValue placeholder={t('RSS feed source')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All feed sources')}</SelectItem>
{sourceOptions.map(({ url, title }) => (
<SelectItem key={url} value={url}>
{title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div
className={
itemsToRender.length > 1 ? 'divide-y divide-border rounded-lg border border-border overflow-hidden' : ''
}
>
{itemsToRender.map((it) => (
<RssFeedItem
key={`${it.feedUrl}-${it.guid}`}
item={it}
layout="detail"
className={itemsToRender.length > 1 ? 'rounded-none border-0' : ''}
/>
))}
</div>
</div> </div>
{syntheticRoot && ( {syntheticRoot && (
<div className="px-4 w-full"> <div className="px-4 w-full">
<NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} /> <NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes={false} />
</div> </div>
)} )}
<Separator className="mt-4" /> <Separator className="mt-4" />

6
src/providers/ReplyProvider.tsx

@ -1,4 +1,4 @@
import { getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { import {
getParentATag, getParentATag,
getParentETag, getParentETag,
@ -7,7 +7,7 @@ import {
getRootETag, getRootETag,
isNip25ReactionKind isNip25ReactionKind
} from '@/lib/event' } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = { type TReplyContext = {
@ -49,7 +49,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
} else { } else {
const articleUrl = getArticleUrlFromCommentITags(reply) const articleUrl = getArticleUrlFromCommentITags(reply)
if (articleUrl) { if (articleUrl) {
rootId = articleUrl rootId = canonicalizeRssArticleUrl(articleUrl)
} }
} }
} }

67
src/services/note-stats.service.ts

@ -11,6 +11,7 @@ import logger from '@/lib/logger'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags, getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl,
getWebExternalReactionTargetUrl, getWebExternalReactionTargetUrl,
rssArticleStableEventId rssArticleStableEventId
} from '@/lib/rss-article' } from '@/lib/rss-article'
@ -34,6 +35,8 @@ export type TNoteStats = {
quotes: { id: string; pubkey: string; created_at: number }[] quotes: { id: string; pubkey: string; created_at: number }[]
highlightIdSet: Set<string> highlightIdSet: Set<string>
highlights: { id: string; pubkey: string; created_at: number }[] highlights: { id: string; pubkey: string; created_at: number }[]
/** Pubkeys whose NIP-51 bookmark list includes this note id (`e` tag). */
bookmarkPubkeySet?: Set<string>
updatedAt?: number updatedAt?: number
} }
@ -54,6 +57,8 @@ class NoteStatsService {
private processBatchRunning = false private processBatchRunning = false
private readonly BATCH_DELAY = 200 private readonly BATCH_DELAY = 200
private readonly MAX_BATCH_SIZE = 24 private readonly MAX_BATCH_SIZE = 24
/** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */
private pendingSyntheticRootById = new Map<string, Event>()
constructor() { constructor() {
if (!NoteStatsService.instance) { if (!NoteStatsService.instance) {
@ -102,6 +107,9 @@ class NoteStatsService {
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null) this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingEvents.add(eventId) this.pendingEvents.add(eventId)
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
this.pendingSyntheticRootById.set(eventId, event)
}
this.armStatsBatchTimer() this.armStatsBatchTimer()
if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) { if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) {
@ -155,8 +163,10 @@ class NoteStatsService {
this.pendingFetchFavoriteRelays.delete(eventId) this.pendingFetchFavoriteRelays.delete(eventId)
try { try {
// Get the event from cache or fetch it // Synthetic RSS/Web thread parents are not published; use the instance from fetchNoteStats.
const event = await eventService.fetchEvent(eventId) const synthetic = this.pendingSyntheticRootById.get(eventId)
this.pendingSyntheticRootById.delete(eventId)
const event = synthetic ?? (await eventService.fetchEvent(eventId))
if (!event) { if (!event) {
logger.debug('[NoteStats] Event not found:', eventId.substring(0, 8)) logger.debug('[NoteStats] Event not found:', eventId.substring(0, 8))
return return
@ -284,13 +294,30 @@ class NoteStatsService {
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const url = getArticleUrlFromCommentITags(event) const url = getArticleUrlFromCommentITags(event)
if (url && (url.startsWith('http://') || url.startsWith('https://'))) { if (url) {
const canonical = canonicalizeRssArticleUrl(url) const canonical = canonicalizeRssArticleUrl(url)
filters.push({ filters.push(
{
'#i': [canonical], '#i': [canonical],
kinds: [ExtendedKind.EXTERNAL_REACTION], kinds: [ExtendedKind.EXTERNAL_REACTION],
limit: reactionLimit limit: reactionLimit
}) },
{
'#i': [canonical],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: interactionLimit
},
{
'#r': [canonical],
kinds: [kinds.Highlights],
limit: interactionLimit
},
{
kinds: [kinds.BookmarkList],
'#e': [event.id],
limit: 200
}
)
} }
} }
@ -431,6 +458,8 @@ class NoteStatsService {
} }
} else if (evt.kind === kinds.Highlights) { } else if (evt.kind === kinds.Highlights) {
updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor) updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor)
} else if (evt.kind === kinds.BookmarkList) {
this.addBookmarkListRefsByEvent(evt)
} }
return updatedEventId return updatedEventId
@ -578,6 +607,12 @@ class NoteStatsService {
if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) { if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const eTag = evt.tags.find(tagNameEquals('e')) ?? evt.tags.find(tagNameEquals('E')) const eTag = evt.tags.find(tagNameEquals('e')) ?? evt.tags.find(tagNameEquals('E'))
originalEventId = eTag?.[1] originalEventId = eTag?.[1]
if (!originalEventId) {
const scopeUrl = getArticleUrlFromCommentITags(evt)
if (scopeUrl) {
originalEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(scopeUrl))
}
}
} else if (evt.kind === kinds.ShortTextNote) { } else if (evt.kind === kinds.ShortTextNote) {
const parentETag = evt.tags.find(([tagName, , , marker]) => { const parentETag = evt.tags.find(([tagName, , , marker]) => {
return tagName === 'e' && (marker === 'reply' || marker === 'root') return tagName === 'e' && (marker === 'reply' || marker === 'root')
@ -648,7 +683,13 @@ class NoteStatsService {
} }
private addHighlightByEvent(evt: Event, originalEventAuthor?: string) { private addHighlightByEvent(evt: Event, originalEventAuthor?: string) {
const highlightedEventId = evt.tags.find(tag => tag[0] === 'e')?.[1] let highlightedEventId = evt.tags.find((tag) => tag[0] === 'e')?.[1]
if (!highlightedEventId) {
const pageUrl = getHighlightSourceHttpUrl(evt)
if (pageUrl) {
highlightedEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl))
}
}
if (!highlightedEventId) return if (!highlightedEventId) return
const old = this.noteStatsMap.get(highlightedEventId) || {} const old = this.noteStatsMap.get(highlightedEventId) || {}
@ -666,6 +707,20 @@ class NoteStatsService {
this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights }) this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights })
return highlightedEventId return highlightedEventId
} }
/** Each bookmark list author counts once per target `e` id in that list. */
private addBookmarkListRefsByEvent(evt: Event) {
for (const tag of evt.tags) {
if (tag[0] !== 'e' || !tag[1]) continue
const targetId = tag[1]
const old = this.noteStatsMap.get(targetId) || {}
const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>()
if (bookmarkPubkeySet.has(evt.pubkey)) continue
bookmarkPubkeySet.add(evt.pubkey)
this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet })
this.notifyNoteStats(targetId)
}
}
} }
const instance = new NoteStatsService() const instance = new NoteStatsService()

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

@ -1,4 +1,5 @@
import { DEFAULT_RSS_FEEDS } from '@/constants' import { DEFAULT_RSS_FEEDS } from '@/constants'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
@ -53,6 +54,26 @@ export interface RssFeed {
lastBuildDate?: Date lastBuildDate?: Date
} }
/** Synthetic row for URL-only threads (Nostr activity on a link without an RSS cache hit). */
export const WEB_ONLY_FAUX_FEED_URL = 'nostr:jumble/web-faux-rss-item'
export function isWebOnlyFauxRssItem(item: Pick<RssFeedItem, 'feedUrl' | 'guid'>): boolean {
return item.feedUrl === WEB_ONLY_FAUX_FEED_URL || item.guid.startsWith('web-only:')
}
export function createWebOnlyRssFeedItem(articleUrl: string): RssFeedItem {
const canonical = canonicalizeRssArticleUrl(articleUrl.trim())
return {
title: canonical,
link: canonical,
description: '',
pubDate: null,
guid: `web-only:${canonical}`,
feedUrl: WEB_ONLY_FAUX_FEED_URL,
feedTitle: undefined
}
}
class RssFeedService { class RssFeedService {
static instance: RssFeedService static instance: RssFeedService
private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map() private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map()
@ -60,6 +81,8 @@ class RssFeedService {
private backgroundRefreshController: AbortController | null = null private backgroundRefreshController: AbortController | null = null
private monthMapCache: Record<string, string> | null = null private monthMapCache: Record<string, string> | null = null
private activeFetchPromises: Map<string, Promise<RssFeed>> = new Map() // Track active fetches by URL private activeFetchPromises: Map<string, Promise<RssFeed>> = new Map() // Track active fetches by URL
/** Global RSS item cap in IndexedDB; oldest by pubDate are removed when exceeded. */
private readonly MAX_CACHED_RSS_ITEMS = 5000
constructor() { constructor() {
if (!RssFeedService.instance) { if (!RssFeedService.instance) {
@ -68,6 +91,63 @@ class RssFeedService {
return RssFeedService.instance return RssFeedService.instance
} }
private normalizeRssFeedKeyUrl(url: string): string {
return url.trim().replace(/\/$/, '')
}
private parseItemPubDate(item: RssFeedItem): Date | null {
if (!item.pubDate) return null
if (item.pubDate instanceof Date) return item.pubDate
if (typeof item.pubDate === 'number') return new Date(item.pubDate)
if (typeof item.pubDate === 'string') return new Date(item.pubDate)
return null
}
/**
* Merge refreshed feeds into the full IndexedDB cache, trim oldest items when over the cap,
* and rewrite the store so pruned rows are removed (put-only would leave stale keys).
*/
private async persistGlobalRssCacheAfterMerge(
mergedFromRefresh: RssFeedItem[],
refreshedFeedUrls: string[]
): Promise<void> {
const refreshedSet = new Set(refreshedFeedUrls.map((u) => this.normalizeRssFeedKeyUrl(u)))
let all: RssFeedItem[] = []
try {
all = await indexedDb.getRssFeedItems()
} catch (e) {
logger.warn('[RssFeedService] persistGlobalRssCacheAfterMerge: read cache failed', { error: e })
}
const map = new Map<string, RssFeedItem>()
for (const item of all) {
const key = `${item.feedUrl}:${item.guid}`
if (!refreshedSet.has(this.normalizeRssFeedKeyUrl(item.feedUrl))) {
map.set(key, {
...item,
pubDate: this.parseItemPubDate(item)
})
}
}
for (const item of mergedFromRefresh) {
map.set(`${item.feedUrl}:${item.guid}`, item)
}
let combined = Array.from(map.values())
combined.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
if (combined.length > this.MAX_CACHED_RSS_ITEMS) {
combined = combined.slice(0, this.MAX_CACHED_RSS_ITEMS)
}
try {
await indexedDb.clearRssFeedItems()
await indexedDb.putRssFeedItems(combined)
} catch (error) {
logger.error('[RssFeedService] persistGlobalRssCacheAfterMerge failed', { error })
}
}
/** /**
* Fetch and parse an RSS/Atom feed from a URL * Fetch and parse an RSS/Atom feed from a URL
*/ */
@ -1327,18 +1407,17 @@ class RssFeedService {
const mergedItems = Array.from(itemMap.values()) const mergedItems = Array.from(itemMap.values())
// Sort by publication date (newest first) // Sort by publication date (newest first) before global merge + trim
mergedItems.sort((a, b) => { mergedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0 const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0 const dateB = b.pubDate?.getTime() || 0
return dateB - dateA return dateB - dateA
}) })
// Write merged items back to IndexedDB
try { try {
await indexedDb.putRssFeedItems(mergedItems) await this.persistGlobalRssCacheAfterMerge(mergedItems, feedUrls)
logger.info('[RssFeedService] Updated IndexedDB cache with merged items', { logger.info('[RssFeedService] Updated IndexedDB cache with merged items', {
totalItems: mergedItems.length, mergedFromThisRefresh: mergedItems.length,
newItems: newItems.length, newItems: newItems.length,
cachedItems: cachedItems.length cachedItems: cachedItems.length
}) })
@ -1527,18 +1606,16 @@ class RssFeedService {
const mergedItems = Array.from(itemMap.values()) const mergedItems = Array.from(itemMap.values())
// Sort by publication date (newest first)
mergedItems.sort((a, b) => { mergedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0 const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0 const dateB = b.pubDate?.getTime() || 0
return dateB - dateA return dateB - dateA
}) })
// Write merged items back to IndexedDB
try { try {
await indexedDb.putRssFeedItems(mergedItems) await this.persistGlobalRssCacheAfterMerge(mergedItems, feedUrls)
logger.info('[RssFeedService] Background refresh: updated IndexedDB cache', { logger.info('[RssFeedService] Background refresh: updated IndexedDB cache', {
totalItems: mergedItems.length, mergedFromThisRefresh: mergedItems.length,
newItems: newItems.length, newItems: newItems.length,
cachedItems: cachedItems.length cachedItems: cachedItems.length
}) })

Loading…
Cancel
Save