diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx index 45196696..d48e64f7 100644 --- a/src/components/RssFeedItem/index.tsx +++ b/src/components/RssFeedItem/index.tsx @@ -17,6 +17,8 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/u import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useSmartRssArticleNavigation } from '@/PageManager' import { getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url' +import { useRssFeedDisplayPrefs } from '@/components/RssFeedList/RssFeedDisplayPrefsContext' +import { isClawstrDotComHttpHref } from '@/lib/rss-article' /** * Convert HTML to plain text by extracting text content and cleaning up whitespace @@ -59,6 +61,7 @@ export default function RssFeedItem({ sourceStrip?: 'rss' | 'web' }) { const { t } = useTranslation() + const { suppressClawstrLinks } = useRssFeedDisplayPrefs() const { pubkey, checkLogin } = useNostr() const { isSmallScreen } = useScreenSize() const { navigateToRssArticle } = useSmartRssArticleNavigation() @@ -454,9 +457,19 @@ export default function RssFeedItem({ html = html.replace(/javascript:/gi, '') // Remove data: URLs that might contain javascript (basic protection) html = html.replace(/data:\s*text\/html/gi, '') - + + if (suppressClawstrLinks && html) { + const wrap = document.createElement('div') + wrap.innerHTML = html + wrap.querySelectorAll('a[href]').forEach((el) => { + const h = el.getAttribute('href') || '' + if (isClawstrDotComHttpHref(h)) el.remove() + }) + html = wrap.innerHTML + } + return html - }, [item.description]) + }, [item.description, suppressClawstrLinks]) // Format publication date const pubDateTimestamp = item.pubDate ? Math.floor(item.pubDate.getTime() / 1000) : null diff --git a/src/components/RssFeedList/ArticleUrlsSection.tsx b/src/components/RssFeedList/ArticleUrlsSection.tsx new file mode 100644 index 00000000..f88bd44f --- /dev/null +++ b/src/components/RssFeedList/ArticleUrlsSection.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' + +/** Section chrome for the article-URL card list (Nostr threads + merged RSS by URL). */ +export function ArticleUrlsSection({ children }: { children: ReactNode }) { + const { t } = useTranslation() + return ( +
+
+

+ {t('Article URLs')} +

+

+ {t('Article URLs subtitle')} +

+
+
{children}
+
+ ) +} diff --git a/src/components/RssFeedList/RssEntriesSection.tsx b/src/components/RssFeedList/RssEntriesSection.tsx new file mode 100644 index 00000000..adbc522b --- /dev/null +++ b/src/components/RssFeedList/RssEntriesSection.tsx @@ -0,0 +1,35 @@ +import RssFeedItem from '@/components/RssFeedItem' +import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' +import { useTranslation } from 'react-i18next' + +/** Classic RSS reader: one row per feed item, chronological. */ +export function RssEntriesSection({ items }: { items: TRssFeedItem[] }) { + const { t } = useTranslation() + if (items.length === 0) return null + return ( +
+
+

+ {t('RSS timeline')} +

+

+ {t('RSS timeline subtitle')} +

+
+
+ {items.map((item) => ( + + ))} +
+
+ ) +} diff --git a/src/components/RssFeedList/RssFeedDisplayPrefsContext.tsx b/src/components/RssFeedList/RssFeedDisplayPrefsContext.tsx new file mode 100644 index 00000000..04fda30b --- /dev/null +++ b/src/components/RssFeedList/RssFeedDisplayPrefsContext.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, type ReactNode } from 'react' + +export type RssFeedDisplayPrefs = { + suppressClawstrLinks: boolean +} + +const outsideProviderDefaults: RssFeedDisplayPrefs = { + suppressClawstrLinks: false +} + +const RssFeedDisplayPrefsContext = createContext(null) + +export function RssFeedDisplayPrefsProvider({ + value, + children +}: { + value: RssFeedDisplayPrefs + children: ReactNode +}) { + return ( + + {children} + + ) +} + +/** Outside {@link RssFeedDisplayPrefsProvider}, Clawstr suppression is off (e.g. full article page). */ +export function useRssFeedDisplayPrefs(): RssFeedDisplayPrefs { + return useContext(RssFeedDisplayPrefsContext) ?? outsideProviderDefaults +} diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index 8bcfe102..2f5f2464 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -1,28 +1,28 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 { ArticleUrlsSection } from './ArticleUrlsSection' +import { RssEntriesSection } from './RssEntriesSection' import { addManualRssWebUrl, - fetchDiscoveredWebUrlsFromAuthorPubkeys, - fetchNostrWebActivityForUrls, - fetchPubkeyWebExternalReactionUrls, - isHttpArticleUrl, + fetchDiscoveredWebUrlsFromRelays, loadManualRssWebUrls, loadRssWebFeedScopePreference, - loadRssWebOnlyMyEventsPreference, + loadRssWebSuppressClawstrPreference, + buildArticleUrlFeedRows, mergeDiscoveredRssWebUrls, - partitionRssItemsForWebFeed, saveRssWebFeedScopePreference, - saveRssWebOnlyMyEventsPreference, + saveRssWebSuppressClawstrPreference, WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, type ManualRssWebUrlEntry, - type NostrWebActivityByUrl, type RssWebFeedScope } from '@/lib/rss-web-feed' -import { getPubkeysFromPTags } from '@/lib/tag' +import { RssFeedDisplayPrefsProvider } from './RssFeedDisplayPrefsContext' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { AlertCircle, Search, Plus } from 'lucide-react' @@ -133,7 +133,8 @@ function ManualRssUrlAddRow({ export default function RssFeedList() { const { t } = useTranslation() - const { pubkey, rssFeedListEvent, followListEvent } = useNostr() + const { pubkey, rssFeedListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { isSmallScreen } = useScreenSize() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) @@ -153,6 +154,8 @@ export default function RssFeedList() { /** 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([]) + /** Latest relay discovery (in-memory); URLs appear as faux cards even before IndexedDB merge. */ + const [relayDiscoveredUrls, setRelayDiscoveredUrls] = useState([]) const refreshManualWebUrls = useCallback(() => { void loadManualRssWebUrls().then(setManualWebEntries) @@ -162,16 +165,8 @@ export default function RssFeedList() { 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]) + /** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */ + const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0) // Listen for filter toggle events useEffect(() => { @@ -481,23 +476,21 @@ export default function RssFeedList() { } } - // Filter items based on selected filters - const filteredItems = useMemo(() => { + /** Feed + time only (search is applied after merge so URL rows and links match too). */ + const baseFilteredItems = useMemo(() => { let filtered = items - // Filter by feed if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { - const normalizedSelectedFeeds = selectedFeeds.map(f => normalizeFeedUrl(f)) - filtered = filtered.filter(item => + const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f)) + filtered = filtered.filter((item) => normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl)) ) } - // Filter by time if (timeFilter !== 'all') { const now = Date.now() let cutoffTime = 0 - + switch (timeFilter) { case 'hour': cutoffTime = now - 60 * 60 * 1000 @@ -512,147 +505,159 @@ export default function RssFeedList() { cutoffTime = now - 30 * 24 * 60 * 60 * 1000 break } - - filtered = filtered.filter(item => { + + filtered = filtered.filter((item) => { if (!item.pubDate) return false return item.pubDate.getTime() >= cutoffTime }) } - // Filter by search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim() - filtered = filtered.filter(item => { - const titleMatch = item.title.toLowerCase().includes(query) - const descMatch = item.description.toLowerCase().includes(query) - const feedMatch = (item.feedTitle || '').toLowerCase().includes(query) - return titleMatch || descMatch || feedMatch - }) - } - return filtered - }, [items, selectedFeeds, timeFilter, searchQuery]) + }, [items, selectedFeeds, timeFilter]) - type FeedRow = + const rssItemMatchesSearch = useCallback((item: TRssFeedItem, q: string) => { + const query = q.toLowerCase().trim() + if (!query) return true + return ( + item.title.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + (item.feedTitle || '').toLowerCase().includes(query) || + (item.link || '').toLowerCase().includes(query) || + (item.guid || '').toLowerCase().includes(query) + ) + }, []) + + /** RSS-only view: flat timeline with full-text search. */ + const rssScopeItems = useMemo(() => { + const q = searchQuery.trim() + let list = baseFilteredItems + if (q) { + list = list.filter((item) => rssItemMatchesSearch(item, q)) + } + return [...list].sort( + (a, b) => (b.pubDate?.getTime() ?? 0) - (a.pubDate?.getTime() ?? 0) + ) + }, [baseFilteredItems, searchQuery, rssItemMatchesSearch]) + + type CombinedFeedRow = | { 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]) + type UnifiedFeedRow = + | { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] } + | { kind: 'rssEntry'; item: TRssFeedItem } - useEffect(() => { - loadMyWebReactions() - }, [loadMyWebReactions]) + const [feedScope, setFeedScope] = useState('both') useEffect(() => { - const handler = () => loadMyWebReactions() + const handler = () => setRelayDiscoveryTick((n) => n + 1) 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 + if (feedScope === 'rss') return let cancelled = false void (async () => { try { - const discovered = await fetchDiscoveredWebUrlsFromAuthorPubkeys(webDiscoveryAuthorPubkeys) + const discovered = await fetchDiscoveredWebUrlsFromRelays({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays: blockedRelays ?? [] + }) if (cancelled) return + setRelayDiscoveredUrls(discovered) const didMerge = await mergeDiscoveredRssWebUrls(discovered) if (didMerge && !cancelled) refreshManualWebUrls() } catch { - /* ignore */ + if (!cancelled) setRelayDiscoveredUrls([]) } })() return () => { cancelled = true } - }, [feedScope, pubkey, webDiscoveryAuthorPubkeys, refreshManualWebUrls]) + }, [feedScope, pubkey, favoriteRelays, blockedRelays, refreshManualWebUrls, relayDiscoveryTick]) - 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 combinedFeedRows = useMemo((): CombinedFeedRow[] => { + const { webRows, nonHttpItems } = buildArticleUrlFeedRows( + baseFilteredItems, + manualWebEntries, + relayDiscoveredUrls ) - - const rest: FeedRow[] = nonHttpItems.map((item) => ({ + const rest: CombinedFeedRow[] = nonHttpItems.map((item) => ({ kind: 'rss' as const, item })) - const combined: FeedRow[] = [...web, ...rest].sort((a, b) => { + return [...webRows, ...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') + }, [baseFilteredItems, manualWebEntries, relayDiscoveredUrls]) + + const combinedFeedRowsForSearch = useMemo((): CombinedFeedRow[] => { + const q = searchQuery.trim() + if (!q) return combinedFeedRows + return combinedFeedRows.filter((row) => { + if (row.kind === 'rss') { + return rssItemMatchesSearch(row.item, q) + } + if (row.canonicalUrl.toLowerCase().includes(q.toLowerCase())) return true + return row.rssItems.some((it) => rssItemMatchesSearch(it, q)) + }) + }, [combinedFeedRows, searchQuery, rssItemMatchesSearch]) + + /** Canonical URLs we know from Nostr (relay discovery or user-added), not RSS-only grouping. */ + const urlKeysWithNostrFootprint = useMemo(() => { + const s = new Set() + for (const e of manualWebEntries) s.add(e.url) + for (const e of relayDiscoveredUrls) s.add(e.url) + return s + }, [manualWebEntries, relayDiscoveredUrls]) + + /** What to show before “only my web events” (used for Nostr URL list). */ + const feedDisplayBase = useMemo((): + | { view: 'rss'; items: TRssFeedItem[] } + | { view: 'unified'; rows: UnifiedFeedRow[] } => { + if (feedScope === 'rss') { + return { view: 'rss', items: rssScopeItems } } - if (feedScope === 'rssOnly') { - return combined.filter((r): r is Extract => r.kind === 'rss') + + if (feedScope === 'urls') { + const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch + .filter((r): r is Extract => r.kind === 'web') + .filter((r) => { + const hasRss = r.rssItems.length > 0 + const hasNostr = urlKeysWithNostrFootprint.has(r.canonicalUrl) + if (hasRss && !hasNostr) return false + return true + }) + .map((r) => ({ + kind: 'url' as const, + canonicalUrl: r.canonicalUrl, + rssItems: [] + })) + return { view: 'unified', rows } } - 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 rows: UnifiedFeedRow[] = combinedFeedRowsForSearch.map((r) => + r.kind === 'web' + ? { + kind: 'url' as const, + canonicalUrl: r.canonicalUrl, + rssItems: r.rssItems + } + : { kind: 'rssEntry' as const, item: r.item } + ) + return { view: 'unified', rows } + }, [feedScope, rssScopeItems, combinedFeedRowsForSearch, urlKeysWithNostrFootprint]) - const [onlyMyWebEvents, setOnlyMyWebEvents] = useState(false) - const [nostrActivity, setNostrActivity] = useState(new Map()) - const [nostrLoading, setNostrLoading] = useState(false) + const [suppressClawstrLinks, setSuppressClawstrLinks] = useState(true) - const persistOnlyMine = useCallback((checked: boolean) => { + const persistSuppressClawstr = useCallback((checked: boolean) => { rssWebPrefsUserTouchedRef.current = true - setOnlyMyWebEvents(checked) - void saveRssWebOnlyMyEventsPreference(checked) + setSuppressClawstrLinks(checked) + void saveRssWebSuppressClawstrPreference(checked) }, []) const persistFeedScope = useCallback((scope: RssWebFeedScope) => { @@ -662,72 +667,57 @@ export default function RssFeedList() { }, []) useEffect(() => { + let cancelled = false void (async () => { - const [onlyMine, scope] = await Promise.all([ - loadRssWebOnlyMyEventsPreference(), + const [suppressClawstr, scope] = await Promise.all([ + loadRssWebSuppressClawstrPreference(), loadRssWebFeedScopePreference() ]) - if (!rssWebPrefsUserTouchedRef.current) { - setOnlyMyWebEvents(onlyMine) - setFeedScope(scope) - } + if (cancelled || rssWebPrefsUserTouchedRef.current) return + setSuppressClawstrLinks(suppressClawstr) + setFeedScope(scope) })() - }, []) - - useEffect(() => { - if (!webUrlsKey) { - setNostrActivity(new Map()) - setNostrLoading(false) - return - } - const urls = webUrlsKey.split('\n').filter(Boolean) - let cancelled = false - setNostrLoading(true) - void fetchNostrWebActivityForUrls(urls) - .then((m) => { - if (!cancelled) setNostrActivity(m) - }) - .catch(() => { - if (!cancelled) setNostrActivity(new Map()) - }) - .finally(() => { - if (!cancelled) setNostrLoading(false) - }) return () => { cancelled = true } - }, [webUrlsKey]) - - const mergedRowsForFeed = useMemo(() => { - if (!onlyMyWebEvents || !pubkey) return mergedRows - return mergedRows.filter((row) => { - if (row.kind !== 'web') return true - const act = nostrActivity.get(row.canonicalUrl) - const myComments = act?.comments.filter((e) => e.pubkey === pubkey).length ?? 0 - const myHighlights = act?.highlights.filter((e) => e.pubkey === pubkey).length ?? 0 - const myReactions = - act?.externalReactions?.filter((e) => e.pubkey === pubkey).length ?? 0 - return myComments > 0 || myHighlights > 0 || myReactions > 0 - }) - }, [mergedRows, onlyMyWebEvents, pubkey, nostrActivity]) + }, []) + + const feedTotalCount = + feedDisplayBase.view === 'rss' + ? feedDisplayBase.items.length + : feedDisplayBase.rows.length // Reset pagination when filters change useEffect(() => { setShowRowCount(20) - }, [selectedFeeds, timeFilter, searchQuery, feedScope, onlyMyWebEvents]) + }, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks]) + + const displayedFeed = useMemo((): + | { view: 'rss'; items: TRssFeedItem[] } + | { view: 'unified'; rows: UnifiedFeedRow[] } => { + if (feedDisplayBase.view === 'rss') { + return { + view: 'rss' as const, + items: feedDisplayBase.items.slice(0, showRowCount) + } + } + return { + view: 'unified' as const, + rows: feedDisplayBase.rows.slice(0, showRowCount) + } + }, [feedDisplayBase, showRowCount]) - const displayedRows = useMemo(() => { - return mergedRowsForFeed.slice(0, showRowCount) - }, [mergedRowsForFeed, showRowCount]) + const displayedCount = + displayedFeed.view === 'rss' ? displayedFeed.items.length : displayedFeed.rows.length // IntersectionObserver for infinite scroll useEffect(() => { - if (!bottomRef.current || displayedRows.length >= mergedRowsForFeed.length) return + if (!bottomRef.current || displayedCount >= feedTotalCount) return const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && displayedRows.length < mergedRowsForFeed.length) { - setShowRowCount((prev) => Math.min(prev + 20, mergedRowsForFeed.length)) + if (entries[0].isIntersecting && displayedCount < feedTotalCount) { + setShowRowCount((prev) => Math.min(prev + 20, feedTotalCount)) } }, { root: null, rootMargin: '100px', threshold: 0.1 } @@ -738,7 +728,7 @@ export default function RssFeedList() { return () => { observer.disconnect() } - }, [displayedRows.length, mergedRowsForFeed.length]) + }, [displayedCount, feedTotalCount]) // Get display text for feed selector const feedSelectorText = useMemo(() => { @@ -782,69 +772,77 @@ export default function RssFeedList() { } return ( +
- {/* Feed header — Nostr filter, counts */} + {/* Feed header — view mode, display prefs, counts */}
persistOnlyMine(c === true)} + id="suppress-clawstr-links" + checked={suppressClawstrLinks} + onCheckedChange={(c) => persistSuppressClawstr(c === true)} />

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

- {nostrLoading ? ( -

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

- ) : null} +
+ + setSearchQuery(e.target.value)} + className="h-8 w-full pl-8 text-xs sm:h-9 sm:pl-9 sm:text-sm" + aria-label={t('Search...')} + /> +
{/* Filter Bar - Collapsible */} @@ -922,18 +920,6 @@ export default function RssFeedList() { {t('Last month')} - - {/* Search Box */} -
- - setSearchQuery(e.target.value)} - className="h-8 md:h-9 pl-7 md:pl-8 text-xs md:text-sm w-full" - /> -
)} @@ -948,7 +934,7 @@ export default function RssFeedList() { )} - {displayedRows.length === 0 ? ( + {feedTotalCount === 0 ? (

{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all' @@ -956,24 +942,60 @@ export default function RssFeedList() { : t('No RSS feed items available')}

+ ) : displayedFeed.view === 'rss' ? ( + <> + + {displayedCount < feedTotalCount ? ( +
+ +
+ ) : null} + + ) : feedScope === 'urls' ? ( + <> + + {displayedFeed.rows + .filter((r): r is Extract => r.kind === 'url') + .map((row) => ( + + ))} + + {displayedCount < feedTotalCount ? ( +
+ +
+ ) : null} + ) : ( <> - {displayedRows.map((row) => - row.kind === 'web' ? ( - - ) : ( - - ) - )} - {displayedRows.length < mergedRowsForFeed.length ? ( +
+ {displayedFeed.rows.map((row) => + row.kind === 'url' ? ( + + ) : ( +
+ +
+ ) + )} +
+ {displayedCount < feedTotalCount ? (
@@ -982,6 +1004,7 @@ export default function RssFeedList() { )} +
) } diff --git a/src/components/RssWebFeedCard/index.tsx b/src/components/RssWebFeedCard/index.tsx index e4b11093..f64fcd44 100644 --- a/src/components/RssWebFeedCard/index.tsx +++ b/src/components/RssWebFeedCard/index.tsx @@ -79,8 +79,7 @@ export default function RssWebFeedCard({ ))} diff --git a/src/constants.ts b/src/constants.ts index c29a9706..413b4723 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -313,7 +313,9 @@ export const ExtendedKind = { /** NIP-58 Badges: profile badges list (addressable, d=profile_badges) */ PROFILE_BADGES: 30008, /** NIP-58 Badges: badge definition (addressable) */ - BADGE_DEFINITION: 30009 + BADGE_DEFINITION: 30009, + /** Web page bookmark (URL in i/I or r tags); used in RSS+Web relay discovery */ + WEB_BOOKMARK: 39701 } /** Event kinds that show “Read this note aloud” in note options (Web Speech API). */ diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4e4ba98c..c227d2e8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1255,19 +1255,24 @@ export default { '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 view mode': 'RSS feed view mode', + 'Article URLs': 'Article URLs', + 'Article URLs subtitle': + 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', + 'RSS timeline': 'RSS timeline', + 'RSS timeline subtitle': 'Every item from your subscribed feeds, newest first — classic RSS reader.', + URLs: 'URLs', + RSS: 'RSS', + Both: 'Both', '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', + 'Suppress Clawstr links in RSS previews': + 'Hide links to clawstr.com in RSS previews', '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', diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts index 3b9ce7e3..bab00e81 100644 --- a/src/lib/rss-article.ts +++ b/src/lib/rss-article.ts @@ -81,17 +81,77 @@ export function getArticleUrlFromCommentITags(event: Event): string | undefined return event.tags.find((t) => t[0] === 'i')?.[1] } +/** HTTP(S) URL from kind 39701 web bookmarks (`i`/`I`/`r` tags). */ +export function getWebBookmarkArticleUrl(event: Pick): string | undefined { + if (event.kind !== ExtendedKind.WEB_BOOKMARK) return undefined + const fromII = getArticleUrlFromCommentITags(event as Event) + if (fromII && (fromII.startsWith('http://') || fromII.startsWith('https://'))) { + return canonicalizeRssArticleUrl(fromII) + } + const fromR = getHighlightSourceHttpUrl(event as Event) + if (fromR) return fromR + for (const t of event.tags) { + if (t[0] === 'r' && t[1]?.trim()) { + const u = t[1].trim() + if (u.startsWith('http://') || u.startsWith('https://')) return canonicalizeRssArticleUrl(u) + } + } + return undefined +} + /** 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) + const marker = (t[2] ?? '').trim().toLowerCase() + // NIP-84: non-source URL refs use `mention`; only `source` (any casing) or legacy bare `r` is the page. + if (marker === 'mention') continue + if (marker === 'source' || marker === '') return canonicalizeRssArticleUrl(u) } return undefined } +/** + * Values for a REQ `#r` filter on kind 9802 when the thread key is a canonical article URL. + * Relay matching is exact on the tag string, so we include common variants (slash, stripped query). + */ +export function computeRTagFilterValuesForArticleThread(canonicalUrl: string): string[] { + const s = canonicalUrl.trim() + if (!s.startsWith('http://') && !s.startsWith('https://')) return [] + const out = new Set([s]) + try { + const u = new URL(s) + if (u.search) { + out.add(`${u.origin}${u.pathname}`) + } + const p = u.pathname + if (p.length > 1 && p.endsWith('/')) { + out.add(`${u.origin}${p.slice(0, -1)}${u.search}`) + } else if (p.length > 0 && !p.endsWith('/')) { + out.add(`${u.origin}${p}/${u.search}`) + } + } catch { + /* ignore */ + } + return [...out] +} + +/** Strip anchors whose href targets https://clawstr.com/… (incl. subdomains, http(s), protocol-relative). */ +export function isClawstrDotComHttpHref(href: string): boolean { + const t = href.trim() + if (!t) return false + try { + const u = t.startsWith('//') ? new URL(`https:${t}`) : new URL(t) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return false + const host = u.hostname.toLowerCase() + return host === 'clawstr.com' || host.endsWith('.clawstr.com') + } catch { + return false + } +} + /** * 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 index 30f2ae6d..b377ff13 100644 --- a/src/lib/rss-web-feed.ts +++ b/src/lib/rss-web-feed.ts @@ -1,10 +1,14 @@ -import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl, + getWebBookmarkArticleUrl, getWebExternalReactionTargetUrl } from '@/lib/rss-article' +import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' import { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' @@ -12,16 +16,26 @@ 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: `'1'` (default) = strip <a href> to clawstr.com from RSS HTML in the feed list. */ +export const RSS_WEB_SUPPRESS_CLAWSTR_SETTING = 'rssWebSuppressClawstrLinks' -/** IndexedDB: merged RSS+Web cards + Nostr vs flat RSS-only list. */ +/** IndexedDB: feed view — article URL cards, flat RSS timeline, or both interleaved. */ 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' +/** `urls` = one card per article URL (Nostr + RSS merge). `rss` = classic chronological RSS list. `both` = mixed timeline with distinct row UIs. */ +export type RssWebFeedScope = 'urls' | 'rss' | 'both' + +/** Normalize stored scope (legacy `webOnly` / `rssOnly` / `webAndRss` included). */ +export function parseRssWebFeedScope(raw: string | null | undefined): RssWebFeedScope { + if (raw === 'urls' || raw === 'rss' || raw === 'both') return raw + if (raw === 'webOnly') return 'urls' + if (raw === 'rssOnly') return 'rss' + if (raw === 'webAndRss' || raw === 'all') return 'both' + return 'both' +} export type ManualRssWebUrlEntry = { url: string; addedAt: number } @@ -35,10 +49,11 @@ function trimManualRssWebUrlsToLimit(entries: ManualRssWebUrlEntry[]): ManualRss .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 = 10 -const WEB_DISCOVERY_EVENTS_LIMIT = 400 +/** Per-kind REQ limit for RSS+Web relay URL discovery (no `authors` filter). */ +export const RSS_WEB_NOSTR_PER_KIND_LIMIT = 100 + +/** Relay discovery: only events in this window (some relays reject unbounded kind-only REQs). */ +const RSS_WEB_RELAY_DISCOVERY_SINCE_SEC = 365 * 24 * 60 * 60 export async function loadManualRssWebUrls(): Promise { const raw = await indexedDb.getSetting(RSS_WEB_MANUAL_URLS_SETTING) @@ -102,9 +117,6 @@ export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry return true } -/** Small chunks keep each Nostr filter JSON under relay limits ("filter item too large"). */ -const URL_CHUNK = 5 - /** Dispatched after publishing a kind 17 web URL reaction so RSS+Web can refetch. */ export const WEB_EXTERNAL_REACTION_PUBLISHED_EVENT = 'jumble:webExternalReactionPublished' @@ -159,25 +171,114 @@ export function partitionRssItemsForWebFeed(items: RssFeedItem[]): { return { groups, nonHttpItems } } -function buildStatsRelayList(): string[] { +/** + * One row per article URL for the “web” side of RSS+Web. + * + * **Sources (Nostr-first):** URLs from relay discovery (`relayDiscoveredEntries`) and persisted manual + * URLs (`manualEntries`) are merged in so each becomes a card even when RSS has no row for that URL + * — {@link RssWebFeedCard} then uses a faux RSS item / preview for empty `rssItems`. + * + * **RSS** only *enriches* rows: items from feeds are grouped by canonical link; Nostr-only URLs keep + * `rssItems: []`. + */ +export type ArticleUrlFeedWebRow = { + kind: 'web' + canonicalUrl: string + rssItems: RssFeedItem[] + latestPub: number +} + +export function buildArticleUrlFeedRows( + filteredItems: RssFeedItem[], + manualEntries: ManualRssWebUrlEntry[], + relayDiscoveredEntries: ManualRssWebUrlEntry[] +): { webRows: ArticleUrlFeedWebRow[]; nonHttpItems: RssFeedItem[] } { + const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems) + const webByUrl = new Map() + + for (const g of groups) { + webByUrl.set(g.canonicalUrl, { rssItems: g.items, latestPub: g.latestPub }) + } + + const mergeNostrTimestamp = (url: string, ts: number) => { + const cur = webByUrl.get(url) + if (cur) { + webByUrl.set(url, { + ...cur, + latestPub: Math.max(cur.latestPub, ts) + }) + } else { + webByUrl.set(url, { rssItems: [], latestPub: ts }) + } + } + + for (const { url, addedAt } of manualEntries) { + if (!isHttpArticleUrl(url)) continue + mergeNostrTimestamp(canonicalizeRssArticleUrl(url), addedAt) + } + for (const { url, addedAt } of relayDiscoveredEntries) { + if (!isHttpArticleUrl(url)) continue + mergeNostrTimestamp(canonicalizeRssArticleUrl(url), addedAt) + } + + const webRows: ArticleUrlFeedWebRow[] = Array.from(webByUrl.entries()).map( + ([canonicalUrl, v]) => ({ + kind: 'web' as const, + canonicalUrl, + rssItems: v.rssItems, + latestPub: v.latestPub + }) + ) + webRows.sort((a, b) => b.latestPub - a.latestPub) + return { webRows, nonHttpItems } +} + +function highlightSourceUrl(evt: Event): string | undefined { + const u = getHighlightSourceHttpUrl(evt) + return u && isHttpArticleUrl(u) ? u : undefined +} + +function dedupeRelayUrlsForRssWeb(urls: string[]): string[] { const seen = new Set() const out: string[] = [] - const add = (u: string) => { + for (const u of urls) { const n = normalizeUrl(u) || u - if (!n || seen.has(n)) return + if (!n || seen.has(n)) continue 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 +/** + * Inbox + favorites + fast read: one normalized list for RSS+Web relay queries. + * Logged-out users get favorites tier + fast read only. + */ +export async function buildRssWebNostrQueryRelayUrls(options: { + accountPubkey: string | null + favoriteRelays: string[] + blockedRelays: string[] +}): Promise { + const { accountPubkey, favoriteRelays, blockedRelays } = options + const inboxAndFavorites: string[] = accountPubkey + ? await buildAccountListRelayUrlsForMerge({ + accountPubkey, + favoriteRelays, + blockedRelays + }) + : getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + return dedupeRelayUrlsForRssWeb([...inboxAndFavorites, ...FAST_READ_RELAY_URLS]) } +/** Kinds 1111, 17, 9802, 1244, 39701 — one REQ each in {@link fetchDiscoveredWebUrlsFromRelays}. */ +const RSS_WEB_RELAY_DISCOVERY_KINDS: number[] = [ + ExtendedKind.COMMENT, + ExtendedKind.EXTERNAL_REACTION, + kinds.Highlights, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.WEB_BOOKMARK +] + function extractArticleUrlFromWebActivityEvent(evt: Event): string | undefined { if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) { const u = getArticleUrlFromCommentITags(evt) @@ -191,205 +292,87 @@ function extractArticleUrlFromWebActivityEvent(evt: Event): string | undefined { if (evt.kind === kinds.Highlights) { return highlightSourceUrl(evt) } + if (evt.kind === ExtendedKind.WEB_BOOKMARK) { + const u = getWebBookmarkArticleUrl(evt) + return u ? canonicalizeRssArticleUrl(u) : undefined + } 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. + * One REQ per kind, no `authors` filter: latest events from aggregated relays, grouped by canonical URL. */ -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 */ - } +export async function fetchDiscoveredWebUrlsFromRelays(options: { + accountPubkey: string | null + favoriteRelays: string[] + blockedRelays: string[] +}): Promise { + const relayUrls = await buildRssWebNostrQueryRelayUrls(options) + if (relayUrls.length === 0) { + logger.info('[RssWebFeed] Relay URL discovery skipped (no relays)') + return [] } - return [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt })) -} + logger.info('[RssWebFeed] Relay URL discovery starting', { + relayCount: relayUrls.length, + kinds: RSS_WEB_RELAY_DISCOVERY_KINDS, + perKindLimit: RSS_WEB_NOSTR_PER_KIND_LIMIT + }) -export type NostrWebActivityByUrl = Map< - string, - { - comments: Event[] - highlights: Event[] - externalReactions: Event[] + const latestByUrl = new Map() + const onEvent = (evt: Event) => { + const url = extractArticleUrlFromWebActivityEvent(evt) + if (!url) return + const key = canonicalizeRssArticleUrl(url) + const prev = latestByUrl.get(key) ?? 0 + if (evt.created_at > prev) latestByUrl.set(key, evt.created_at) } -> -/** - * 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() - - const webActivityOpts = { - 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) + await Promise.all( + RSS_WEB_RELAY_DISCOVERY_KINDS.map(async (kind) => { + try { + await queryService.fetchEvents( + relayUrls, + [ + { + kinds: [kind], + limit: RSS_WEB_NOSTR_PER_KIND_LIMIT, + since: Math.floor(Date.now() / 1000) - RSS_WEB_RELAY_DISCOVERY_SINCE_SEC + } + ], + { + onevent: onEvent, + eoseTimeout: 5000, + globalTimeout: 15000 + } + ) + } catch { + /* per-kind */ } - }, - eoseTimeout: 4000, - globalTimeout: 12000 - } - - for (let i = 0; i < httpUrls.length; i += URL_CHUNK) { - const chunk = httpUrls.slice(i, i + URL_CHUNK) - try { - // One filter per REQ — multiple large #i/#r arrays in one subscription hit relay size limits. - await queryService.fetchEvents( - relayUrls, - [{ kinds: [ExtendedKind.COMMENT], '#i': chunk, limit: 120 }], - webActivityOpts - ) - await queryService.fetchEvents( - relayUrls, - [{ kinds: [ExtendedKind.EXTERNAL_REACTION], '#i': chunk, limit: 120 }], - webActivityOpts - ) - await queryService.fetchEvents( - relayUrls, - [{ kinds: [kinds.Highlights], '#r': chunk, limit: 120 }], - webActivityOpts - ) - } 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 + const entries = [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt })) + logger.info('[RssWebFeed] Relay URL discovery finished', { + uniqueUrls: entries.length + }) + return entries } -export async function loadRssWebOnlyMyEventsPreference(): Promise { - const v = await indexedDb.getSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING) - return v === '1' || v === 'true' +export async function loadRssWebSuppressClawstrPreference(): Promise { + const v = await indexedDb.getSetting(RSS_WEB_SUPPRESS_CLAWSTR_SETTING) + if (v === '0' || v === 'false') return false + if (v === '1' || v === 'true') return true + return true } -export async function saveRssWebOnlyMyEventsPreference(onlyMine: boolean): Promise { - await indexedDb.setSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING, onlyMine ? '1' : '0') +export async function saveRssWebSuppressClawstrPreference(suppress: boolean): Promise { + await indexedDb.setSetting(RSS_WEB_SUPPRESS_CLAWSTR_SETTING, suppress ? '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' + return parseRssWebFeedScope(v) } export async function saveRssWebFeedScopePreference(scope: RssWebFeedScope): Promise { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 6dc37979..9115ba1d 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -10,6 +10,7 @@ import { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { canonicalizeRssArticleUrl, + computeRTagFilterValuesForArticleThread, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl, getWebExternalReactionTargetUrl, @@ -308,7 +309,7 @@ class NoteStatsService { limit: interactionLimit }, { - '#r': [canonical], + '#r': computeRTagFilterValuesForArticleThread(canonical), kinds: [kinds.Highlights], limit: interactionLimit },