Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
021b6621f1
  1. 15
      src/components/RssFeedItem/index.tsx
  2. 23
      src/components/RssFeedList/ArticleUrlsSection.tsx
  3. 35
      src/components/RssFeedList/RssEntriesSection.tsx
  4. 30
      src/components/RssFeedList/RssFeedDisplayPrefsContext.tsx
  5. 443
      src/components/RssFeedList/index.tsx
  6. 3
      src/components/RssWebFeedCard/index.tsx
  7. 4
      src/constants.ts
  8. 17
      src/i18n/locales/en.ts
  9. 62
      src/lib/rss-article.ts
  10. 359
      src/lib/rss-web-feed.ts
  11. 3
      src/services/note-stats.service.ts

15
src/components/RssFeedItem/index.tsx

@ -17,6 +17,8 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/u @@ -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({ @@ -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()
@ -455,8 +458,18 @@ export default function RssFeedItem({ @@ -455,8 +458,18 @@ export default function RssFeedItem({
// 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

23
src/components/RssFeedList/ArticleUrlsSection.tsx

@ -0,0 +1,23 @@ @@ -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 (
<section className="space-y-3" aria-labelledby="jumble-article-urls-heading">
<div className="space-y-1 px-0.5">
<h2
id="jumble-article-urls-heading"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{t('Article URLs')}
</h2>
<p className="text-[11px] leading-snug text-muted-foreground/90">
{t('Article URLs subtitle')}
</p>
</div>
<div className="space-y-4">{children}</div>
</section>
)
}

35
src/components/RssFeedList/RssEntriesSection.tsx

@ -0,0 +1,35 @@ @@ -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 (
<section className="space-y-3" aria-labelledby="jumble-rss-entries-heading">
<div className="space-y-1 px-0.5">
<h2
id="jumble-rss-entries-heading"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{t('RSS timeline')}
</h2>
<p className="text-[11px] leading-snug text-muted-foreground/90">
{t('RSS timeline subtitle')}
</p>
</div>
<div className="space-y-0 divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{items.map((item) => (
<RssFeedItem
key={`${item.feedUrl}-${item.guid}`}
item={item}
layout="list"
sourceStrip="rss"
className="rounded-none border-0 bg-transparent shadow-none"
/>
))}
</div>
</section>
)
}

30
src/components/RssFeedList/RssFeedDisplayPrefsContext.tsx

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
import { createContext, useContext, type ReactNode } from 'react'
export type RssFeedDisplayPrefs = {
suppressClawstrLinks: boolean
}
const outsideProviderDefaults: RssFeedDisplayPrefs = {
suppressClawstrLinks: false
}
const RssFeedDisplayPrefsContext = createContext<RssFeedDisplayPrefs | null>(null)
export function RssFeedDisplayPrefsProvider({
value,
children
}: {
value: RssFeedDisplayPrefs
children: ReactNode
}) {
return (
<RssFeedDisplayPrefsContext.Provider value={value}>
{children}
</RssFeedDisplayPrefsContext.Provider>
)
}
/** Outside {@link RssFeedDisplayPrefsProvider}, Clawstr suppression is off (e.g. full article page). */
export function useRssFeedDisplayPrefs(): RssFeedDisplayPrefs {
return useContext(RssFeedDisplayPrefsContext) ?? outsideProviderDefaults
}

443
src/components/RssFeedList/index.tsx

@ -1,28 +1,28 @@ @@ -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({ @@ -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<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
@ -153,6 +154,8 @@ export default function RssFeedList() { @@ -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<ManualRssWebUrlEntry[]>([])
/** Latest relay discovery (in-memory); URLs appear as faux cards even before IndexedDB merge. */
const [relayDiscoveredUrls, setRelayDiscoveredUrls] = useState<ManualRssWebUrlEntry[]>([])
const refreshManualWebUrls = useCallback(() => {
void loadManualRssWebUrls().then(setManualWebEntries)
@ -162,16 +165,8 @@ export default function RssFeedList() { @@ -162,16 +165,8 @@ export default function RssFeedList() {
void loadManualRssWebUrls().then(setManualWebEntries)
}, [])
const webDiscoveryAuthorPubkeys = useMemo(() => {
if (!pubkey) return []
const set = new Set<string>([pubkey])
if (followListEvent) {
for (const pk of getPubkeysFromPTags(followListEvent.tags)) {
set.add(pk)
}
}
return [...set]
}, [pubkey, followListEvent])
/** 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,19 +476,17 @@ export default function RssFeedList() { @@ -481,19 +476,17 @@ 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
@ -513,146 +506,158 @@ export default function RssFeedList() { @@ -513,146 +506,158 @@ export default function RssFeedList() {
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])
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 FeedRow =
type CombinedFeedRow =
| { kind: 'web'; canonicalUrl: string; rssItems: TRssFeedItem[]; latestPub: number }
| { kind: 'rss'; item: TRssFeedItem }
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('webAndRss')
const [myWebReactionTs, setMyWebReactionTs] = useState<Map<string, number>>(() => new Map())
type UnifiedFeedRow =
| { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] }
| { kind: 'rssEntry'; item: TRssFeedItem }
const loadMyWebReactions = useCallback(() => {
if (!pubkey || feedScope === 'rssOnly') {
setMyWebReactionTs(new Map())
return
}
void fetchPubkeyWebExternalReactionUrls(pubkey).then(setMyWebReactionTs)
}, [pubkey, feedScope])
useEffect(() => {
loadMyWebReactions()
}, [loadMyWebReactions])
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('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<string, { rssItems: TRssFeedItem[]; latestPub: number }>()
for (const g of groups) {
webByUrl.set(g.canonicalUrl, { rssItems: g.items, latestPub: g.latestPub })
}
for (const [canonicalUrl, ts] of myWebReactionTs) {
const cur = webByUrl.get(canonicalUrl)
if (cur) {
webByUrl.set(canonicalUrl, {
...cur,
latestPub: Math.max(cur.latestPub, ts)
})
} else {
webByUrl.set(canonicalUrl, { rssItems: [], latestPub: ts })
}
}
for (const { url, addedAt } of manualWebEntries) {
if (!isHttpArticleUrl(url)) continue
const cur = webByUrl.get(url)
if (cur) {
webByUrl.set(url, {
...cur,
latestPub: Math.max(cur.latestPub, addedAt)
})
} else {
webByUrl.set(url, { rssItems: [], latestPub: addedAt })
}
}
const web: Extract<FeedRow, { kind: 'web' }>[] = Array.from(webByUrl.entries()).map(
([canonicalUrl, v]) => ({
kind: 'web' as const,
canonicalUrl,
rssItems: v.rssItems,
latestPub: v.latestPub
})
const 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
})
}, [baseFilteredItems, manualWebEntries, relayDiscoveredUrls])
// One merged list: Web+RSS shows all; Only Web hides rss rows; Only RSS hides web rows.
if (feedScope === 'webOnly') {
return combined.filter((r): r is Extract<FeedRow, { kind: 'web' }> => r.kind === 'web')
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 (feedScope === 'rssOnly') {
return combined.filter((r): r is Extract<FeedRow, { kind: 'rss' }> => r.kind === 'rss')
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<string>()
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 === 'urls') {
const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch
.filter((r): r is Extract<CombinedFeedRow, { kind: 'web' }> => 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<FeedRow, { kind: 'web' }> => 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<NostrWebActivityByUrl>(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() { @@ -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)
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() { @@ -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() { @@ -782,69 +772,77 @@ export default function RssFeedList() {
}
return (
<RssFeedDisplayPrefsProvider value={{ suppressClawstrLinks }}>
<div className="space-y-3">
{/* Feed header — Nostr filter, counts */}
{/* Feed header — view mode, display prefs, counts */}
<div className="sticky top-0 z-10 space-y-1.5 border-b bg-background px-4 py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div
className="inline-flex max-w-full flex-wrap rounded-md border border-border bg-muted/30 p-0.5 sm:flex-nowrap"
role="group"
aria-label={t('RSS + Web feed scope')}
aria-label={t('RSS feed view mode')}
>
<Button
type="button"
variant={feedScope === 'webOnly' ? 'secondary' : 'ghost'}
variant={feedScope === 'urls' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('webOnly')}
onClick={() => persistFeedScope('urls')}
>
{t('Only Web')}
{t('URLs')}
</Button>
<Button
type="button"
variant={feedScope === 'webAndRss' ? 'secondary' : 'ghost'}
variant={feedScope === 'both' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('webAndRss')}
onClick={() => persistFeedScope('both')}
>
{t('Web + RSS')}
{t('Both')}
</Button>
<Button
type="button"
variant={feedScope === 'rssOnly' ? 'secondary' : 'ghost'}
variant={feedScope === 'rss' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('rssOnly')}
onClick={() => persistFeedScope('rss')}
>
{t('Only RSS')}
{t('RSS')}
</Button>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="only-my-web-events"
checked={onlyMyWebEvents}
disabled={!pubkey || feedScope === 'rssOnly'}
onCheckedChange={(c) => persistOnlyMine(c === true)}
id="suppress-clawstr-links"
checked={suppressClawstrLinks}
onCheckedChange={(c) => persistSuppressClawstr(c === true)}
/>
<Label
htmlFor="only-my-web-events"
className={`cursor-pointer text-xs text-muted-foreground ${!pubkey || feedScope === 'rssOnly' ? 'opacity-50' : ''}`}
htmlFor="suppress-clawstr-links"
className="cursor-pointer text-xs text-muted-foreground"
>
{t('Only my web events')}
{t('Suppress Clawstr links in RSS previews')}
</Label>
</div>
</div>
<p className="text-xs text-muted-foreground sm:text-right">
{t('Showing {{filtered}} of {{total}} entries', {
filtered: displayedRows.length,
total: mergedRowsForFeed.length
filtered: displayedCount,
total: feedTotalCount
})}
</p>
</div>
{nostrLoading ? (
<p className="text-xs text-muted-foreground">{t('Fetching web activity from Nostr…')}</p>
) : null}
<div className="relative w-full max-w-xl">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground sm:h-4 sm:w-4" />
<Input
type="search"
placeholder={t('Search...')}
value={searchQuery}
onChange={(e) => 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...')}
/>
</div>
</div>
{/* Filter Bar - Collapsible */}
@ -922,18 +920,6 @@ export default function RssFeedList() { @@ -922,18 +920,6 @@ export default function RssFeedList() {
<SelectItem value="month">{t('Last month')}</SelectItem>
</SelectContent>
</Select>
{/* Search Box */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 md:h-4 md:w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('Search...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 md:h-9 pl-7 md:pl-8 text-xs md:text-sm w-full"
/>
</div>
</div>
</div>
)}
@ -948,7 +934,7 @@ export default function RssFeedList() { @@ -948,7 +934,7 @@ export default function RssFeedList() {
</div>
)}
{displayedRows.length === 0 ? (
{feedTotalCount === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
@ -956,24 +942,60 @@ export default function RssFeedList() { @@ -956,24 +942,60 @@ export default function RssFeedList() {
: t('No RSS feed items available')}
</p>
</div>
) : (
) : displayedFeed.view === 'rss' ? (
<>
{displayedRows.map((row) =>
row.kind === 'web' ? (
<RssEntriesSection items={displayedFeed.items} />
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
) : null}
</>
) : feedScope === 'urls' ? (
<>
<ArticleUrlsSection>
{displayedFeed.rows
.filter((r): r is Extract<UnifiedFeedRow, { kind: 'url' }> => r.kind === 'url')
.map((row) => (
<RssWebFeedCard
key={row.canonicalUrl}
canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems}
/>
))}
</ArticleUrlsSection>
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
) : null}
</>
) : (
<>
<div className="space-y-4">
{displayedFeed.rows.map((row) =>
row.kind === 'url' ? (
<RssWebFeedCard
key={row.canonicalUrl}
canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems}
/>
) : (
<div
key={`${row.item.feedUrl}-${row.item.guid}`}
canonicalUrl={row.item.link}
rssItems={[row.item]}
className="overflow-hidden rounded-xl border border-border bg-card"
>
<RssFeedItem
item={row.item}
layout="list"
sourceStrip="rss"
className="rounded-none border-0 bg-transparent shadow-none"
/>
</div>
)
)}
{displayedRows.length < mergedRowsForFeed.length ? (
</div>
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
@ -982,6 +1004,7 @@ export default function RssFeedList() { @@ -982,6 +1004,7 @@ export default function RssFeedList() {
)}
</div>
</div>
</RssFeedDisplayPrefsProvider>
)
}

3
src/components/RssWebFeedCard/index.tsx

@ -79,8 +79,7 @@ export default function RssWebFeedCard({ @@ -79,8 +79,7 @@ export default function RssWebFeedCard({
<RssFeedItem
key={`${item.feedUrl}-${item.guid}`}
item={item}
layout="detail"
expandBodyFully
layout="list"
className="rounded-none border-0 shadow-none bg-transparent"
/>
))}

4
src/constants.ts

@ -313,7 +313,9 @@ export const ExtendedKind = { @@ -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). */

17
src/i18n/locales/en.ts

@ -1255,19 +1255,24 @@ export default { @@ -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',

62
src/lib/rss-article.ts

@ -81,17 +81,77 @@ export function getArticleUrlFromCommentITags(event: Event): string | undefined @@ -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<Event, 'kind' | 'tags'>): 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<Event, 'tags'>): string | undefined {
for (const t of event.tags) {
if (t[0] !== 'r' || !t[1]) continue
const u = t[1].trim()
if (!u.startsWith('http://') && !u.startsWith('https://')) continue
if (t[2] === 'source' || !t[2]) return canonicalizeRssArticleUrl(u)
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<string>([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.

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

@ -1,10 +1,14 @@ @@ -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' @@ -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 &lt;a href&gt; 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 @@ -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<ManualRssWebUrlEntry[]> {
const raw = await indexedDb.getSetting(RSS_WEB_MANUAL_URLS_SETTING)
@ -102,9 +117,6 @@ export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry @@ -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[]): { @@ -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<string, { rssItems: RssFeedItem[]; latestPub: number }>()
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<string>()
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<string[]> {
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 { @@ -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<ManualRssWebUrlEntry[]> {
const unique = [...new Set(pubkeys.filter(Boolean))].slice(0, MAX_WEB_DISCOVERY_AUTHORS)
if (unique.length === 0) return []
export async function fetchDiscoveredWebUrlsFromRelays(options: {
accountPubkey: string | null
favoriteRelays: string[]
blockedRelays: string[]
}): Promise<ManualRssWebUrlEntry[]> {
const relayUrls = await buildRssWebNostrQueryRelayUrls(options)
if (relayUrls.length === 0) {
logger.info('[RssWebFeed] Relay URL discovery skipped (no relays)')
return []
}
const relayUrls = buildStatsRelayList()
if (relayUrls.length === 0) return []
logger.info('[RssWebFeed] Relay URL discovery starting', {
relayCount: relayUrls.length,
kinds: RSS_WEB_RELAY_DISCOVERY_KINDS,
perKindLimit: RSS_WEB_NOSTR_PER_KIND_LIMIT
})
const latestByUrl = new Map<string, number>()
const webKinds = [
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.EXTERNAL_REACTION,
kinds.Highlights
] as number[]
for (let i = 0; i < unique.length; i += WEB_DISCOVERY_AUTHORS_CHUNK) {
const chunk = unique.slice(i, i + WEB_DISCOVERY_AUTHORS_CHUNK)
try {
await queryService.fetchEvents(
relayUrls,
[{ kinds: webKinds, authors: chunk, limit: WEB_DISCOVERY_EVENTS_LIMIT }],
{
onevent: (evt: Event) => {
const onEvent = (evt: Event) => {
const url = extractArticleUrlFromWebActivityEvent(evt)
if (!url) return
const prev = latestByUrl.get(url) ?? 0
if (evt.created_at > prev) latestByUrl.set(url, evt.created_at)
},
eoseTimeout: 5000,
globalTimeout: 15000
}
)
} catch {
/* ignore chunk */
}
}
return [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
}
export type NostrWebActivityByUrl = Map<
string,
{
comments: Event[]
highlights: Event[]
externalReactions: Event[]
}
>
/**
* Pull kind 1111 (i-tag) comments, kind 17 (i-tag web) reactions, and kind 9802 (r-tag URL) highlights.
*/
export async function fetchNostrWebActivityForUrls(urls: string[]): Promise<NostrWebActivityByUrl> {
const out: NostrWebActivityByUrl = new Map()
const httpUrls = [...new Set(urls.filter((u) => isHttpArticleUrl(u)).map((u) => canonicalizeRssArticleUrl(u)))]
if (httpUrls.length === 0) return out
const relayUrls = buildStatsRelayList()
if (relayUrls.length === 0) return out
const urlSet = new Set(httpUrls)
const commentById = new Map<string, Event>()
const highlightById = new Map<string, Event>()
const externalReactionById = new Map<string, Event>()
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)
}
},
eoseTimeout: 4000,
globalTimeout: 12000
const key = canonicalizeRssArticleUrl(url)
const prev = latestByUrl.get(key) ?? 0
if (evt.created_at > prev) latestByUrl.set(key, evt.created_at)
}
for (let i = 0; i < httpUrls.length; i += URL_CHUNK) {
const chunk = httpUrls.slice(i, i + URL_CHUNK)
await Promise.all(
RSS_WEB_RELAY_DISCOVERY_KINDS.map(async (kind) => {
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)
[
{
kinds: [kind],
limit: RSS_WEB_NOSTR_PER_KIND_LIMIT,
since: Math.floor(Date.now() / 1000) - RSS_WEB_RELAY_DISCOVERY_SINCE_SEC
}
return out
}
/**
* Latest kind-17 web reaction time per canonical URL for this pubkey (for feed rows not in RSS).
*/
export async function fetchPubkeyWebExternalReactionUrls(pubkey: string): Promise<Map<string, number>> {
const out = new Map<string, number>()
const relayUrls = buildStatsRelayList()
if (!pubkey || relayUrls.length === 0) return out
try {
await queryService.fetchEvents(
relayUrls,
[{ kinds: [ExtendedKind.EXTERNAL_REACTION], authors: [pubkey], limit: 500 }],
],
{
onevent: (evt: Event) => {
const url = getWebExternalReactionTargetUrl(evt)
if (!url) return
const key = canonicalizeRssArticleUrl(url)
const prev = out.get(key) ?? 0
if (evt.created_at > prev) out.set(key, evt.created_at)
},
onevent: onEvent,
eoseTimeout: 5000,
globalTimeout: 15000
}
)
} catch {
/* ignore */
/* per-kind */
}
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<boolean> {
const v = await indexedDb.getSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING)
return v === '1' || v === 'true'
export async function loadRssWebSuppressClawstrPreference(): Promise<boolean> {
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<void> {
await indexedDb.setSetting(RSS_WEB_ONLY_MY_EVENTS_SETTING, onlyMine ? '1' : '0')
export async function saveRssWebSuppressClawstrPreference(suppress: boolean): Promise<void> {
await indexedDb.setSetting(RSS_WEB_SUPPRESS_CLAWSTR_SETTING, suppress ? '1' : '0')
}
export async function loadRssWebFeedScopePreference(): Promise<RssWebFeedScope> {
const v = await indexedDb.getSetting(RSS_WEB_FEED_SCOPE_SETTING)
if (v === 'webOnly' || v === 'webAndRss' || v === 'rssOnly') return v
if (v === 'all') return 'webAndRss'
return 'webAndRss'
return parseRssWebFeedScope(v)
}
export async function saveRssWebFeedScopePreference(scope: RssWebFeedScope): Promise<void> {

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

@ -10,6 +10,7 @@ import { getZapInfoFromEvent } from '@/lib/event-metadata' @@ -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 { @@ -308,7 +309,7 @@ class NoteStatsService {
limit: interactionLimit
},
{
'#r': [canonical],
'#r': computeRTagFilterValuesForArticleThread(canonical),
kinds: [kinds.Highlights],
limit: interactionLimit
},

Loading…
Cancel
Save