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. 32
      src/components/PostEditor/PostContent.tsx
  4. 33
      src/components/ReplyNoteList/index.tsx
  5. 130
      src/components/RssFeedItem/index.tsx
  6. 391
      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. 73
      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 | @@ -226,6 +226,30 @@ function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName |
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). */
export function useSmartRssArticleNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage()
@ -1600,9 +1624,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1600,9 +1624,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// In double-pane mode, never open drawer - just pop from stack
if (panelMode === 'double' && !isSmallScreen) {
if (secondaryStack.length === 1) {
// Just close the panel - DO NOT change the main page or URL
// Closing panel should NEVER affect the main page
const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? ''
setSecondaryStack([])
if (secondaryUrlIsRssArticle(closingUrl)) {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
@ -1646,18 +1675,22 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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
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)
setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
if (secondaryUrlIsRssArticle(closingUrl)) {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
}, 350)
// Clear stack
setSecondaryStack([])
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Mobile/Single-pane - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {
@ -1669,13 +1702,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1669,13 +1702,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (secondaryStack.length === 1) {
// Just close the panel - DO NOT change the main page or URL
// Closing panel should NEVER affect the main page
const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? ''
setSecondaryStack([])
if (secondaryUrlIsRssArticle(closingUrl)) {
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
// Restore tab state first
if (savedFeedState?.tab) {
logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab })
window.dispatchEvent(new CustomEvent('restorePageTab', {

10
src/components/NoteStats/LikeButton.tsx

@ -36,6 +36,7 @@ import EmojiPicker from '../EmojiPicker' @@ -36,6 +36,7 @@ import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils'
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 }) {
const { t } = useTranslation()
@ -139,6 +140,12 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; @@ -139,6 +140,12 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
} else {
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 {
@ -164,6 +171,9 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; @@ -164,6 +171,9 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
noteStatsService.updateNoteStatsByEvents([evt], undefined, {
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) {
logger.error('Like failed', { error, eventId: event.id })

32
src/components/PostEditor/PostContent.tsx

@ -37,7 +37,7 @@ import { isTouchDevice } from '@/lib/utils' @@ -37,7 +37,7 @@ import { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
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 logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
@ -122,15 +122,27 @@ export default function PostContent({ @@ -122,15 +122,27 @@ export default function PostContent({
undefined,
isQuotePost ? undefined : { replyParentNoteId: parentEvent.id }
)
const rootInfo = !isReplaceableEvent(parentEvent.kind)
? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey }
: {
type: 'A' as const,
id: getReplaceableCoordinateFromEvent(parentEvent),
eventId: parentEvent.id,
pubkey: parentEvent.pubkey,
relay: client.getEventHint(parentEvent.id)
}
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: 'A' as const,
id: getReplaceableCoordinateFromEvent(parentEvent),
eventId: parentEvent.id,
pubkey: parentEvent.pubkey,
relay: client.getEventHint(parentEvent.id)
}
const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? []
const next = cached.filter((r) => r.id !== clean.id).concat([clean])
discussionFeedCache.setCachedReplies(rootInfo, next)

33
src/components/ReplyNoteList/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants'
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import {
eventReferencesEventId,
getParentETag,
@ -141,6 +141,16 @@ function ReplyNoteList({ @@ -141,6 +141,16 @@ function ReplyNoteList({
if (isReplaceableEvent(event.kind) && currentEventKey !== 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
@ -220,7 +230,14 @@ function ReplyNoteList({ @@ -220,7 +230,14 @@ function ReplyNoteList({
default:
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])
/** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */
@ -261,9 +278,9 @@ function ReplyNoteList({ @@ -261,9 +278,9 @@ function ReplyNoteList({
useEffect(() => {
const fetchRootEvent = async () => {
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) {
setRootInfo({ type: 'I', id: url })
setRootInfo({ type: 'I', id: canonicalizeRssArticleUrl(url) })
}
return
}
@ -320,7 +337,7 @@ function ReplyNoteList({ @@ -320,7 +337,7 @@ function ReplyNoteList({
}
const rootArticleUrl = getArticleUrlFromCommentITags(event)
if (rootArticleUrl) {
root = { type: 'I', id: rootArticleUrl }
root = { type: 'I', id: canonicalizeRssArticleUrl(rootArticleUrl) }
}
}
setRootInfo(root)
@ -653,10 +670,14 @@ function ReplyNoteList({ @@ -653,10 +670,14 @@ function ReplyNoteList({
const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
const replyRootId = getRootEventHexId(reply)
const replyUrlForIThread =
rootInfo?.type === 'I' ? getArticleUrlFromCommentITags(reply) : undefined
const belongsToSameThread = rootInfo && (
(rootInfo.type === 'E' && replyRootId === 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 (

130
src/components/RssFeedItem/index.tsx

@ -1,6 +1,10 @@ @@ -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 { ExternalLink, Highlighter } from 'lucide-react'
import { ExternalLink, Globe, Highlighter, Rss } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@ -40,17 +44,24 @@ function htmlToPlainText(html: string): string { @@ -40,17 +44,24 @@ function htmlToPlainText(html: string): string {
export default function RssFeedItem({
item,
className,
layout = 'detail'
layout = 'detail',
expandBodyFully = false,
sourceStrip
}: {
item: TRssFeedItem
className?: string
/** `list`: title row + actions (open full article in side panel). `detail`: full body (secondary panel). */
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 { pubkey, checkLogin } = useNostr()
const { isSmallScreen } = useScreenSize()
const { navigateToRssArticle } = useSmartRssArticleNavigation()
const isWebFaux = isWebOnlyFauxRssItem(item)
const isListLayout = layout === 'list'
const showFullBody = layout === 'detail'
const [selectedText, setSelectedText] = useState('')
@ -392,13 +403,14 @@ export default function RssFeedItem({ @@ -392,13 +403,14 @@ export default function RssFeedItem({
// Format feed source name from URL
const feedSourceName = useMemo(() => {
if (isWebFaux) return ''
try {
const url = new URL(item.feedUrl)
return url.hostname.replace(/^www\./, '')
} catch {
return item.feedTitle || 'RSS Feed'
}
}, [item.feedUrl, item.feedTitle])
}, [item.feedUrl, item.feedTitle, isWebFaux])
// Clean and parse HTML description safely
// Decode HTML entities and remove any XML artifacts that might have leaked through
@ -440,9 +452,23 @@ export default function RssFeedItem({ @@ -440,9 +452,23 @@ export default function RssFeedItem({
// Check if content exceeds 400px height (detail layout only)
const [needsCollapse, setNeedsCollapse] = useState(false)
const [longBodyExpanded, setLongBodyExpanded] = useState(false)
const [longBodyExpanded, setLongBodyExpanded] = useState(() => expandBodyFully && layout === 'detail')
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) {
setNeedsCollapse(false)
return
@ -480,7 +506,7 @@ export default function RssFeedItem({ @@ -480,7 +506,7 @@ export default function RssFeedItem({
// Use ResizeObserver to detect when content changes
const resizeObserver = new ResizeObserver(() => {
if (!longBodyExpanded) {
if (!longBodyExpanded && !expandBodyFully) {
checkHeight()
}
})
@ -494,7 +520,7 @@ export default function RssFeedItem({ @@ -494,7 +520,7 @@ export default function RssFeedItem({
clearTimeout(timeoutId2)
resizeObserver.disconnect()
}
}, [descriptionHtml, longBodyExpanded, isListLayout])
}, [descriptionHtml, longBodyExpanded, isListLayout, expandBodyFully, showFullBody, isWebFaux])
return (
<div
@ -519,10 +545,29 @@ export default function RssFeedItem({ @@ -519,10 +545,29 @@ export default function RssFeedItem({
: 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 */}
<div className="flex items-start gap-3 pb-3 border-b">
{/* Feed Image/Logo */}
{item.feedImage && (
{item.feedImage && !isWebFaux && (
<img
src={item.feedImage}
alt={item.feedTitle || feedSourceName}
@ -539,7 +584,7 @@ export default function RssFeedItem({ @@ -539,7 +584,7 @@ export default function RssFeedItem({
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">
{item.feedTitle || feedSourceName}
{isWebFaux ? t('Web page') : item.feedTitle || feedSourceName}
</h3>
{item.feedDescription && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
@ -588,7 +633,7 @@ export default function RssFeedItem({ @@ -588,7 +633,7 @@ export default function RssFeedItem({
{showFullBody ? (
<>
{/* Media (Images) */}
{item.media && item.media.length > 0 && (
{!isWebFaux && item.media && item.media.length > 0 && (
<div className="space-y-2 overflow-hidden">
{item.media
.filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image')
@ -623,7 +668,9 @@ export default function RssFeedItem({ @@ -623,7 +668,9 @@ export default function RssFeedItem({
)}
{/* 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="rounded-lg border bg-muted/50 p-4">
<div className="flex items-center gap-3 mb-3">
@ -643,35 +690,50 @@ export default function RssFeedItem({ @@ -643,35 +690,50 @@ export default function RssFeedItem({
</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
ref={contentRef}
className={cn(
'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',
'[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg',
'[&_*]:max-w-full'
)}
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
onMouseUp={(e) => {
// Allow text selection
e.stopPropagation()
}}
/>
{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
ref={contentRef}
className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200 overflow-wrap-anywhere',
needsCollapse && !longBodyExpanded && !expandBodyFully && 'max-h-[400px] overflow-hidden',
'[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg',
'[&_*]:max-w-full'
)}
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
onMouseUp={(e) => {
e.stopPropagation()
}}
/>
)}
{/* 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" />
)}
{showFullBody && needsCollapse && (
{!isWebFaux && showFullBody && needsCollapse && !expandBodyFully && (
<div className="flex justify-center mt-2 relative z-10">
<Button
variant="ghost"

391
src/components/RssFeedList/index.tsx

@ -1,15 +1,34 @@ @@ -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 { useNostr } from '@/providers/NostrProvider'
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
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 { AlertCircle, Search, Plus } from 'lucide-react'
import logger from '@/lib/logger'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
@ -23,15 +42,20 @@ import { @@ -23,15 +42,20 @@ import {
} from '@/components/ui/dialog'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Check, ChevronDown } from 'lucide-react'
import { useSmartRssArticleNavigation } from '@/PageManager'
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 { navigateToRssArticle } = useSmartRssArticleNavigation()
const [open, setOpen] = useState(false)
const [value, setValue] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const submit = () => {
setError('')
@ -40,9 +64,17 @@ function ManualRssUrlAddRow({ className }: { className?: string }) { @@ -40,9 +64,17 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
setError(t('Enter a valid http(s) URL'))
return
}
setOpen(false)
setValue('')
navigateToRssArticle(url)
setSaving(true)
void (async () => {
try {
await addManualRssWebUrl(url)
setOpen(false)
setValue('')
await Promise.resolve(onUrlAdded())
} finally {
setSaving(false)
}
})()
}
return (
@ -61,7 +93,7 @@ function ManualRssUrlAddRow({ className }: { className?: string }) { @@ -61,7 +93,7 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
<DialogHeader>
<DialogTitle>{t('Add a web URL')}</DialogTitle>
<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>
</DialogHeader>
<Input
@ -84,8 +116,8 @@ function ManualRssUrlAddRow({ className }: { className?: string }) { @@ -84,8 +116,8 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
{t('Cancel')}
</Button>
<Button type="button" onClick={submit}>
{t('Open')}
<Button type="button" disabled={saving} onClick={submit}>
{t('Add to feed')}
</Button>
</DialogFooter>
</DialogContent>
@ -96,7 +128,7 @@ function ManualRssUrlAddRow({ className }: { className?: string }) { @@ -96,7 +128,7 @@ function ManualRssUrlAddRow({ className }: { className?: string }) {
export default function RssFeedList() {
const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr()
const { pubkey, rssFeedListEvent, followListEvent } = useNostr()
const { isSmallScreen } = useScreenSize()
const [items, setItems] = useState<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
@ -108,12 +140,33 @@ export default function RssFeedList() { @@ -108,12 +140,33 @@ export default function RssFeedList() {
const [timeFilter, setTimeFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState<string>('')
const [showFilters, setShowFilters] = useState<boolean>(false)
const [isCompactView, setIsCompactView] = useState<boolean>(true)
const [feedPopoverOpen, setFeedPopoverOpen] = useState<boolean>(false)
// Pagination state
const [showCount, setShowCount] = useState<number>(25)
// Pagination state (merged RSS+Web rows)
const [showRowCount, setShowRowCount] = useState<number>(20)
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
useEffect(() => {
@ -288,7 +341,7 @@ export default function RssFeedList() { @@ -288,7 +341,7 @@ export default function RssFeedList() {
})
}
}
} catch (err) {
} catch {
if (isMounted) {
setRefreshing(false)
}
@ -468,24 +521,201 @@ export default function RssFeedList() { @@ -468,24 +521,201 @@ export default function RssFeedList() {
return filtered
}, [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(() => {
setShowCount(25)
}, [selectedFeeds, timeFilter, searchQuery])
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(() => {
setShowRowCount(20)
}, [selectedFeeds, timeFilter, searchQuery, feedScope, onlyMyWebEvents])
// Pagination: slice to showCount for display
const displayedItems = useMemo(() => {
return filteredItems.slice(0, showCount)
}, [filteredItems, showCount])
const displayedRows = useMemo(() => {
return mergedRowsForFeed.slice(0, showRowCount)
}, [mergedRowsForFeed, showRowCount])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (!bottomRef.current || displayedItems.length >= filteredItems.length) return
if (!bottomRef.current || displayedRows.length >= mergedRowsForFeed.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedItems.length < filteredItems.length) {
setShowCount((prev) => Math.min(prev + 25, filteredItems.length))
if (entries[0].isIntersecting && displayedRows.length < mergedRowsForFeed.length) {
setShowRowCount((prev) => Math.min(prev + 20, mergedRowsForFeed.length))
}
},
{ root: null, rootMargin: '100px', threshold: 0.1 }
@ -496,7 +726,7 @@ export default function RssFeedList() { @@ -496,7 +726,7 @@ export default function RssFeedList() {
return () => {
observer.disconnect()
}
}, [displayedItems.length, filteredItems.length])
}, [displayedRows.length, mergedRowsForFeed.length])
// Get display text for feed selector
const feedSelectorText = useMemo(() => {
@ -530,10 +760,10 @@ export default function RssFeedList() { @@ -530,10 +760,10 @@ export default function RssFeedList() {
)
}
if (items.length === 0) {
if (items.length === 0 && manualWebEntries.length === 0) {
return (
<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>
</div>
)
@ -541,26 +771,68 @@ export default function RssFeedList() { @@ -541,26 +771,68 @@ export default function RssFeedList() {
return (
<div className="space-y-3">
{/* Feed Counter Header - Always visible */}
<div className="sticky top-0 z-10 bg-background border-b px-4 py-1.5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Switch
id="compact-view"
checked={isCompactView}
onCheckedChange={setIsCompactView}
/>
<Label htmlFor="compact-view" className="text-xs text-muted-foreground cursor-pointer">
{isCompactView ? t('Compact') : t('Full')}
</Label>
{/* Feed header — Nostr filter, counts */}
<div className="sticky top-0 z-10 space-y-1.5 border-b bg-background px-4 py-2">
<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">
<Checkbox
id="only-my-web-events"
checked={onlyMyWebEvents}
disabled={!pubkey || feedScope === 'rssOnly'}
onCheckedChange={(c) => persistOnlyMine(c === true)}
/>
<Label
htmlFor="only-my-web-events"
className={`cursor-pointer text-xs text-muted-foreground ${!pubkey || feedScope === 'rssOnly' ? 'opacity-50' : ''}`}
>
{t('Only my web events')}
</Label>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: displayedItems.length,
total: filteredItems.length
<p className="text-xs text-muted-foreground sm:text-right">
{t('Showing {{filtered}} of {{total}} entries', {
filtered: displayedRows.length,
total: mergedRowsForFeed.length
})}
</p>
</div>
{nostrLoading ? (
<p className="text-xs text-muted-foreground">{t('Fetching web activity from Nostr…')}</p>
) : null}
</div>
{/* Filter Bar - Collapsible */}
@ -652,7 +924,7 @@ export default function RssFeedList() { @@ -652,7 +924,7 @@ export default function RssFeedList() {
{/* Content */}
<div className="space-y-4 px-4 py-3">
<ManualRssUrlAddRow />
<ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
{refreshing && (
<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 />
@ -660,7 +932,7 @@ export default function RssFeedList() { @@ -660,7 +932,7 @@ export default function RssFeedList() {
</div>
)}
{displayedItems.length === 0 ? (
{displayedRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
@ -670,15 +942,26 @@ export default function RssFeedList() { @@ -670,15 +942,26 @@ export default function RssFeedList() {
</div>
) : (
<>
{displayedItems.map((item) => (
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} layout={isCompactView ? 'list' : 'detail'} />
))}
{/* Bottom ref for infinite scroll */}
{displayedItems.length < filteredItems.length && (
{displayedRows.map((row) =>
row.kind === 'web' ? (
<RssWebFeedCard
key={row.canonicalUrl}
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">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
)}
) : null}
</>
)}
</div>

86
src/components/RssUrlThreadStatsBar/index.tsx

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

24
src/i18n/locales/en.ts

@ -397,6 +397,9 @@ export default { @@ -397,6 +397,9 @@ export default {
'Jumble Imwald synthetic event': 'Jumble Imwald synthetic event',
'+ Add a URL to this list': 'Add a URL to this list',
'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.',
'Enter a valid http(s) URL': 'Enter a valid http(s) URL',
@ -1249,6 +1252,27 @@ export default { @@ -1249,6 +1252,27 @@ export default {
'Publisher name (optional)': 'Publisher name (optional)',
'Quiet Tags': 'Quiet Tags',
'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 Feeds': 'RSS Feeds',
'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 @@ -81,6 +81,17 @@ export function getArticleUrlFromCommentITags(event: Event): string | undefined
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.
* 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 @@ @@ -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 @@ @@ -1,5 +1,5 @@
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'
/** Matches `ReplyNoteList` / discussion thread root shapes. */
@ -11,7 +11,9 @@ export type TThreadRootRef = @@ -11,7 +11,9 @@ export type TThreadRootRef =
/** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */
export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): boolean {
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') {
const coord = getRootATag(evt)?.[1]

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

@ -63,7 +63,7 @@ const RssPage = forwardRef<TPageRef>((_, ref) => { @@ -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 items-center gap-2 pl-3">
<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 className="flex items-center gap-1">
<Button

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

@ -1,18 +1,33 @@ @@ -1,18 +1,33 @@
import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats'
import RssFeedItem from '@/components/RssFeedItem'
import WebPreview from '@/components/WebPreview'
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 indexedDb from '@/services/indexed-db.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 { 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 { 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(
(
@ -30,10 +45,12 @@ const RssArticlePage = forwardRef( @@ -30,10 +45,12 @@ const RssArticlePage = forwardRef(
ref
) => {
const { t } = useTranslation()
const { rssFeedListEvent } = useNostr()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const [item, setItem] = useState<TRssFeedItem | null>(initialItem ?? null)
const [loading, setLoading] = useState(!initialItem)
const [allCachedItems, setAllCachedItems] = useState<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedSource, setSelectedSource] = useState<'all' | string>('all')
const articleUrl = useMemo(() => {
try {
@ -43,18 +60,81 @@ const RssArticlePage = forwardRef( @@ -43,18 +60,81 @@ const RssArticlePage = forwardRef(
}
}, [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(() => {
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)
return
}
let cancelled = false
;(async () => {
setLoading(true)
try {
const items = await indexedDb.getRssFeedItems()
if (cancelled) return
const found = items.find((i) => i.link === articleUrl) ?? null
setItem(found)
setAllCachedItems(items)
} finally {
if (!cancelled) setLoading(false)
}
@ -62,41 +142,37 @@ const RssArticlePage = forwardRef( @@ -62,41 +142,37 @@ const RssArticlePage = forwardRef(
return () => {
cancelled = true
}
}, [articleUrl, initialItem])
}, [articleUrl])
const syntheticRoot = useMemo(
() => (articleUrl ? createRssThreadRootEvent(articleUrl) : null),
[articleUrl]
)
const primaryRssItem = itemsToRender[0] ?? null
useEffect(() => {
if (hideTitlebar) {
sessionStorage.setItem('notePageTitle', item ? t('RSS article') : t('Web page'))
sessionStorage.setItem('notePageTitle', primaryRssItem ? t('RSS article') : t('Web page'))
}
return () => {
if (hideTitlebar) {
sessionStorage.removeItem('notePageTitle')
}
}
}, [hideTitlebar, t, item])
}, [hideTitlebar, t, primaryRssItem])
const refreshArticle = useCallback(async () => {
setContentKey((k) => k + 1)
if (!articleUrl) return
if (initialItem) {
setItem(initialItem)
setLoading(false)
return
}
setLoading(true)
try {
const items = await indexedDb.getRssFeedItems()
const found = items.find((i) => i.link === articleUrl) ?? null
setItem(found)
setAllCachedItems(items)
} finally {
setLoading(false)
}
}, [articleUrl, initialItem])
}, [articleUrl])
useEffect(() => {
if (!hideTitlebar) {
@ -126,7 +202,7 @@ const RssArticlePage = forwardRef( @@ -126,7 +202,7 @@ const RssArticlePage = forwardRef(
)
}
if (loading) {
if (loading && matchingItems.length === 0) {
return (
<SecondaryPageLayout
ref={ref}
@ -141,7 +217,7 @@ const RssArticlePage = forwardRef( @@ -141,7 +217,7 @@ const RssArticlePage = forwardRef(
)
}
if (!item) {
if (matchingItems.length === 0) {
return (
<SecondaryPageLayout
ref={ref}
@ -154,18 +230,9 @@ const RssArticlePage = forwardRef( @@ -154,18 +230,9 @@ const RssArticlePage = forwardRef(
<p className="text-xs text-muted-foreground">
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
</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 && (
<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>
)}
<Separator />
@ -193,12 +260,48 @@ const RssArticlePage = forwardRef( @@ -193,12 +260,48 @@ const RssArticlePage = forwardRef(
displayScrollToTopButton
>
<div key={contentKey} className="min-w-0">
<div className="px-4 pt-3 w-full">
<RssFeedItem item={item} layout="detail" />
<div className="px-4 pt-3 w-full space-y-3">
{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>
{syntheticRoot && (
<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>
)}
<Separator className="mt-4" />

6
src/providers/ReplyProvider.tsx

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

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

@ -11,6 +11,7 @@ import logger from '@/lib/logger' @@ -11,6 +11,7 @@ import logger from '@/lib/logger'
import {
canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl,
getWebExternalReactionTargetUrl,
rssArticleStableEventId
} from '@/lib/rss-article'
@ -34,6 +35,8 @@ export type TNoteStats = { @@ -34,6 +35,8 @@ export type TNoteStats = {
quotes: { id: string; pubkey: string; created_at: number }[]
highlightIdSet: Set<string>
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
}
@ -54,6 +57,8 @@ class NoteStatsService { @@ -54,6 +57,8 @@ class NoteStatsService {
private processBatchRunning = false
private readonly BATCH_DELAY = 200
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() {
if (!NoteStatsService.instance) {
@ -102,6 +107,9 @@ class NoteStatsService { @@ -102,6 +107,9 @@ class NoteStatsService {
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingEvents.add(eventId)
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
this.pendingSyntheticRootById.set(eventId, event)
}
this.armStatsBatchTimer()
if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) {
@ -155,8 +163,10 @@ class NoteStatsService { @@ -155,8 +163,10 @@ class NoteStatsService {
this.pendingFetchFavoriteRelays.delete(eventId)
try {
// Get the event from cache or fetch it
const event = await eventService.fetchEvent(eventId)
// Synthetic RSS/Web thread parents are not published; use the instance from fetchNoteStats.
const synthetic = this.pendingSyntheticRootById.get(eventId)
this.pendingSyntheticRootById.delete(eventId)
const event = synthetic ?? (await eventService.fetchEvent(eventId))
if (!event) {
logger.debug('[NoteStats] Event not found:', eventId.substring(0, 8))
return
@ -284,13 +294,30 @@ class NoteStatsService { @@ -284,13 +294,30 @@ class NoteStatsService {
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const url = getArticleUrlFromCommentITags(event)
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
if (url) {
const canonical = canonicalizeRssArticleUrl(url)
filters.push({
'#i': [canonical],
kinds: [ExtendedKind.EXTERNAL_REACTION],
limit: reactionLimit
})
filters.push(
{
'#i': [canonical],
kinds: [ExtendedKind.EXTERNAL_REACTION],
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 { @@ -431,6 +458,8 @@ class NoteStatsService {
}
} else if (evt.kind === kinds.Highlights) {
updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor)
} else if (evt.kind === kinds.BookmarkList) {
this.addBookmarkListRefsByEvent(evt)
}
return updatedEventId
@ -578,6 +607,12 @@ class NoteStatsService { @@ -578,6 +607,12 @@ class NoteStatsService {
if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const eTag = evt.tags.find(tagNameEquals('e')) ?? evt.tags.find(tagNameEquals('E'))
originalEventId = eTag?.[1]
if (!originalEventId) {
const scopeUrl = getArticleUrlFromCommentITags(evt)
if (scopeUrl) {
originalEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(scopeUrl))
}
}
} else if (evt.kind === kinds.ShortTextNote) {
const parentETag = evt.tags.find(([tagName, , , marker]) => {
return tagName === 'e' && (marker === 'reply' || marker === 'root')
@ -648,7 +683,13 @@ class NoteStatsService { @@ -648,7 +683,13 @@ class NoteStatsService {
}
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
const old = this.noteStatsMap.get(highlightedEventId) || {}
@ -666,6 +707,20 @@ class NoteStatsService { @@ -666,6 +707,20 @@ class NoteStatsService {
this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights })
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()

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { DEFAULT_RSS_FEEDS } from '@/constants'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { cleanUrl } from '@/lib/url'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
@ -53,6 +54,26 @@ export interface RssFeed { @@ -53,6 +54,26 @@ export interface RssFeed {
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 {
static instance: RssFeedService
private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map()
@ -60,6 +81,8 @@ class RssFeedService { @@ -60,6 +81,8 @@ class RssFeedService {
private backgroundRefreshController: AbortController | null = null
private monthMapCache: Record<string, string> | null = null
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() {
if (!RssFeedService.instance) {
@ -68,6 +91,63 @@ class RssFeedService { @@ -68,6 +91,63 @@ class RssFeedService {
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
*/
@ -1327,18 +1407,17 @@ class RssFeedService { @@ -1327,18 +1407,17 @@ class RssFeedService {
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) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
// Write merged items back to IndexedDB
try {
await indexedDb.putRssFeedItems(mergedItems)
await this.persistGlobalRssCacheAfterMerge(mergedItems, feedUrls)
logger.info('[RssFeedService] Updated IndexedDB cache with merged items', {
totalItems: mergedItems.length,
mergedFromThisRefresh: mergedItems.length,
newItems: newItems.length,
cachedItems: cachedItems.length
})
@ -1527,18 +1606,16 @@ class RssFeedService { @@ -1527,18 +1606,16 @@ class RssFeedService {
const mergedItems = Array.from(itemMap.values())
// Sort by publication date (newest first)
mergedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
// Write merged items back to IndexedDB
try {
await indexedDb.putRssFeedItems(mergedItems)
await this.persistGlobalRssCacheAfterMerge(mergedItems, feedUrls)
logger.info('[RssFeedService] Background refresh: updated IndexedDB cache', {
totalItems: mergedItems.length,
mergedFromThisRefresh: mergedItems.length,
newItems: newItems.length,
cachedItems: cachedItems.length
})

Loading…
Cancel
Save