diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 2a97715d..5087ac65 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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 | 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,12 +1624,17 @@ 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) - + // Restore tab state first if (savedFeedState?.tab) { logger.info('PageManager: Desktop - Restoring tab state', { page: currentPrimaryPage, tab: savedFeedState.tab }) @@ -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 }) { } 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', { diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 5c5d1701..ae87d3a8 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -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; } 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; 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 }) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d6b7acdb..cc6edd49 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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({ 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) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 60a45f15..446e2c69 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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({ 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() // Prevent infinite loops @@ -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({ 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({ } 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({ 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 ( diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx index 9dd680f7..b8723cf1 100644 --- a/src/components/RssFeedItem/index.tsx +++ b/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 { 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 { 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({ // 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({ // 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({ // 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({ clearTimeout(timeoutId2) resizeObserver.disconnect() } - }, [descriptionHtml, longBodyExpanded, isListLayout]) + }, [descriptionHtml, longBodyExpanded, isListLayout, expandBodyFully, showFullBody, isWebFaux]) return (
+ {sourceStrip ? ( +
+ {sourceStrip === 'rss' ? ( + + ) : ( + + )} + + {sourceStrip === 'rss' + ? t('RSS feed item label') + : t('Web URL item label')} + +
+ ) : null} {/* Feed Header with Metadata */}
{/* Feed Image/Logo */} - {item.feedImage && ( + {item.feedImage && !isWebFaux && ( {item.feedTitle

- {item.feedTitle || feedSourceName} + {isWebFaux ? t('Web page') : item.feedTitle || feedSourceName}

{item.feedDescription && (

@@ -588,7 +633,7 @@ export default function RssFeedItem({ {showFullBody ? ( <> {/* Media (Images) */} - {item.media && item.media.length > 0 && ( + {!isWebFaux && item.media && item.media.length > 0 && (

{item.media .filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image') @@ -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/')) && (
@@ -643,35 +690,50 @@ export default function RssFeedItem({
)} - {/* Description with text selection support and collapse/expand */} + {/* RSS HTML body or OpenGraph web preview for URL-only faux items */}
-
{ - // Allow text selection - e.stopPropagation() - }} - /> + {isWebFaux ? ( +
e.stopPropagation()} + > + +
+ ) : ( +
{ + e.stopPropagation() + }} + /> + )} {/* Gradient overlay when collapsed */} - {needsCollapse && !longBodyExpanded && ( + {!isWebFaux && needsCollapse && !longBodyExpanded && !expandBodyFully && (
)} - {showFullBody && needsCollapse && ( + {!isWebFaux && showFullBody && needsCollapse && !expandBodyFully && (
- @@ -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([]) const [loading, setLoading] = useState(true) @@ -108,12 +140,33 @@ export default function RssFeedList() { const [timeFilter, setTimeFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [showFilters, setShowFilters] = useState(false) - const [isCompactView, setIsCompactView] = useState(true) const [feedPopoverOpen, setFeedPopoverOpen] = useState(false) - // Pagination state - const [showCount, setShowCount] = useState(25) + // Pagination state (merged RSS+Web rows) + const [showRowCount, setShowRowCount] = useState(20) const bottomRef = useRef(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([]) + + const refreshManualWebUrls = useCallback(() => { + void loadManualRssWebUrls().then(setManualWebEntries) + }, []) + + useEffect(() => { + void loadManualRssWebUrls().then(setManualWebEntries) + }, []) + + const webDiscoveryAuthorPubkeys = useMemo(() => { + if (!pubkey) return [] + const set = new Set([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() { }) } } - } catch (err) { + } catch { if (isMounted) { setRefreshing(false) } @@ -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('webAndRss') + const [myWebReactionTs, setMyWebReactionTs] = useState>(() => 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() + 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[] = 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 => r.kind === 'web') + } + if (feedScope === 'rssOnly') { + return combined.filter((r): r is Extract => r.kind === 'rss') + } + + return combined + }, [filteredItems, feedScope, myWebReactionTs, manualWebEntries]) + + const webUrlsKey = useMemo( + () => + mergedRows + .filter((r): r is Extract => r.kind === 'web') + .map((r) => r.canonicalUrl) + .sort() + .join('\n'), + [mergedRows] + ) + + const [onlyMyWebEvents, setOnlyMyWebEvents] = useState(false) + const [nostrActivity, setNostrActivity] = useState(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() { 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() { ) } - if (items.length === 0) { + if (items.length === 0 && manualWebEntries.length === 0) { return (
- +

{t('No RSS feed items available')}

) @@ -541,26 +771,68 @@ export default function RssFeedList() { return (
- {/* Feed Counter Header - Always visible */} -
-
-
- - + {/* Feed header — Nostr filter, counts */} +
+
+
+
+ + + +
+
+ persistOnlyMine(c === true)} + /> + +
-

- {t('Showing {{filtered}} of {{total}} items', { - filtered: displayedItems.length, - total: filteredItems.length +

+ {t('Showing {{filtered}} of {{total}} entries', { + filtered: displayedRows.length, + total: mergedRowsForFeed.length })}

+ {nostrLoading ? ( +

{t('Fetching web activity from Nostr…')}

+ ) : null}
{/* Filter Bar - Collapsible */} @@ -652,7 +924,7 @@ export default function RssFeedList() { {/* Content */}
- + {refreshing && (
@@ -660,7 +932,7 @@ export default function RssFeedList() {
)} - {displayedItems.length === 0 ? ( + {displayedRows.length === 0 ? (

{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all' @@ -670,15 +942,26 @@ export default function RssFeedList() {

) : ( <> - {displayedItems.map((item) => ( - - ))} - {/* Bottom ref for infinite scroll */} - {displayedItems.length < filteredItems.length && ( + {displayedRows.map((row) => + row.kind === 'web' ? ( + + ) : ( + + ) + )} + {displayedRows.length < mergedRowsForFeed.length ? (
- )} + ) : null} )}
diff --git a/src/components/RssUrlThreadStatsBar/index.tsx b/src/components/RssUrlThreadStatsBar/index.tsx new file mode 100644 index 00000000..5635f6aa --- /dev/null +++ b/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 ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="group" + aria-label={t('URL thread activity')} + > + + + {fmt(replyCount)} + + + + {fmt(reactionCount)} + + + + {fmt(bookmarkCount)} + + + + {fmt(highlightCount)} + +
+ ) +} diff --git a/src/components/RssWebFeedCard/index.tsx b/src/components/RssWebFeedCard/index.tsx new file mode 100644 index 00000000..e4b11093 --- /dev/null +++ b/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 ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + openArticle() + } + }} + > +
+ {hasRealRss ? ( + + ) : ( + + )} + {hasRealRss ? t('RSS feed item label') : t('Web URL item label')} +
+ +
+ {displayRssItems.length > 0 ? ( +
+ {displayRssItems.map((item) => ( + + ))} +
+ ) : ( + + )} +
+ + {displayRssItems.length === 0 ? ( +

+ {canonicalUrl} +

+ ) : null} + {rssItems.length > 1 ? ( +

+ {t('{{count}} RSS entries for this URL', { count: rssItems.length })} +

+ ) : null} + + +
+ ) +} diff --git a/src/components/Sidebar/RssButton.tsx b/src/components/Sidebar/RssButton.tsx index f249c6b8..7d50e409 100644 --- a/src/components/Sidebar/RssButton.tsx +++ b/src/components/Sidebar/RssButton.tsx @@ -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() { const isActive = display && current === 'rss' && primaryViewType === null return ( - navigate('rss')} active={isActive}> + navigate('rss')} active={isActive}> ) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 63652feb..0211a2ed 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { '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', diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts index 04c581a4..3b9ce7e3 100644 --- a/src/lib/rss-article.ts +++ b/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] } +/** HTTP(S) page URL from kind 9802 `r` tags (`source` marker or bare `r`). */ +export function getHighlightSourceHttpUrl(event: Pick): 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. diff --git a/src/lib/rss-web-feed.ts b/src/lib/rss-web-feed.ts new file mode 100644 index 00000000..69b9248c --- /dev/null +++ b/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 { + 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 + 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 { + 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 { + if (discovered.length === 0) return false + const existing = await loadManualRssWebUrls() + const byUrl = new Map() + 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() + 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() + 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 { + 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() + 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 { + 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() + const highlightById = new Map() + const externalReactionById = new Map() + + 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> { + const out = new Map() + 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 { + const v = await indexedDb.getSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING) + return v === '1' || v === 'true' +} + +export async function saveRssWebOnlyMyEventsPreference(onlyMine: boolean): Promise { + await indexedDb.setSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING, onlyMine ? '1' : '0') +} + +export async function loadRssWebFeedScopePreference(): Promise { + 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 { + 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) +} diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index faff9fcb..b89131c5 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -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 = /** 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] diff --git a/src/pages/primary/RssPage/index.tsx b/src/pages/primary/RssPage/index.tsx index cfefb695..db670932 100644 --- a/src/pages/primary/RssPage/index.tsx +++ b/src/pages/primary/RssPage/index.tsx @@ -63,7 +63,7 @@ const RssPage = forwardRef((_, ref) => {
-
{t('RSS Feed')}
+
{t('RSS + Web')}
{syntheticRoot && (
- +
)} @@ -193,12 +260,48 @@ const RssArticlePage = forwardRef( displayScrollToTopButton >
-
- +
+ {sourceOptions.length > 1 ? ( +
+ + +
+ ) : null} +
1 ? 'divide-y divide-border rounded-lg border border-border overflow-hidden' : '' + } + > + {itemsToRender.map((it) => ( + 1 ? 'rounded-none border-0' : ''} + /> + ))} +
{syntheticRoot && (
- +
)} diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 8812ca0b..fc6f0d13 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -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 { 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 }) { } else { const articleUrl = getArticleUrlFromCommentITags(reply) if (articleUrl) { - rootId = articleUrl + rootId = canonicalizeRssArticleUrl(articleUrl) } } } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 8892ccbc..6dc37979 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -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 = { quotes: { id: string; pubkey: string; created_at: number }[] highlightIdSet: Set highlights: { id: string; pubkey: string; created_at: number }[] + /** Pubkeys whose NIP-51 bookmark list includes this note id (`e` tag). */ + bookmarkPubkeySet?: Set updatedAt?: number } @@ -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() constructor() { if (!NoteStatsService.instance) { @@ -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 { 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 { 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 { } } 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 { 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 { } 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 { 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() + if (bookmarkPubkeySet.has(evt.pubkey)) continue + bookmarkPubkeySet.add(evt.pubkey) + this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet }) + this.notifyNoteStats(targetId) + } + } } const instance = new NoteStatsService() diff --git a/src/services/rss-feed.service.ts b/src/services/rss-feed.service.ts index 549c243c..c0aefebc 100644 --- a/src/services/rss-feed.service.ts +++ b/src/services/rss-feed.service.ts @@ -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 { 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): 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 = new Map() @@ -60,6 +81,8 @@ class RssFeedService { private backgroundRefreshController: AbortController | null = null private monthMapCache: Record | null = null private activeFetchPromises: Map> = 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 { 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 { + 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() + 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 */ @@ -1326,19 +1406,18 @@ 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) - logger.info('[RssFeedService] Updated IndexedDB cache with merged items', { - totalItems: mergedItems.length, + await this.persistGlobalRssCacheAfterMerge(mergedItems, feedUrls) + logger.info('[RssFeedService] Updated IndexedDB cache with merged items', { + mergedFromThisRefresh: mergedItems.length, newItems: newItems.length, cachedItems: cachedItems.length }) @@ -1526,19 +1605,17 @@ 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) - logger.info('[RssFeedService] Background refresh: updated IndexedDB cache', { - totalItems: mergedItems.length, + await this.persistGlobalRssCacheAfterMerge(mergedItems, feedUrls) + logger.info('[RssFeedService] Background refresh: updated IndexedDB cache', { + mergedFromThisRefresh: mergedItems.length, newItems: newItems.length, cachedItems: cachedItems.length })