From da583a4016d2afcffbc8a31a15cbc0cf0a7578b2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 13:17:47 +0100 Subject: [PATCH] bug-fixes --- src/components/RssFeedList/index.tsx | 23 ++---- .../RssUrlThreadEventsPreview/index.tsx | 18 ++--- src/components/RssUrlThreadStatsBar/index.tsx | 4 +- src/hooks/useRssUrlThreadQueryRelays.ts | 40 +++++++++++ src/lib/rss-article.ts | 27 ++++++- src/lib/rss-web-feed.ts | 28 ++++---- src/services/note-stats.service.ts | 70 ++++++++++++------- 7 files changed, 142 insertions(+), 68 deletions(-) create mode 100644 src/hooks/useRssUrlThreadQueryRelays.ts diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index 9ca70025..df9da019 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -654,15 +654,10 @@ export default function RssFeedList() { }) }, [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). */ + /** + * URLs-only view: one card per grouped article URL (same rows as “Both”), including RSS-sourced URLs + * and full `rssItems` for titles/snippets — previously RSS-only rows were hidden and `rssItems` was cleared. + */ const feedDisplayBase = useMemo((): | { view: 'rss'; items: TRssFeedItem[] } | { view: 'unified'; rows: UnifiedFeedRow[] } => { @@ -673,16 +668,10 @@ export default function RssFeedList() { 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: [] + rssItems: r.rssItems })) return { view: 'unified', rows } } @@ -697,7 +686,7 @@ export default function RssFeedList() { : { kind: 'rssEntry' as const, item: r.item } ) return { view: 'unified', rows } - }, [feedScope, rssScopeItems, combinedFeedRowsForSearch, urlKeysWithNostrFootprint]) + }, [feedScope, rssScopeItems, combinedFeedRowsForSearch]) const persistSuppressClawstr = useCallback((checked: boolean) => { rssWebPrefsUserTouchedRef.current = true diff --git a/src/components/RssUrlThreadEventsPreview/index.tsx b/src/components/RssUrlThreadEventsPreview/index.tsx index 3b764b32..7338a00b 100644 --- a/src/components/RssUrlThreadEventsPreview/index.tsx +++ b/src/components/RssUrlThreadEventsPreview/index.tsx @@ -1,14 +1,13 @@ import NoteCard from '@/components/NoteCard' import { Skeleton } from '@/components/ui/skeleton' -import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' -import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' +import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays' import { buildRssArticleUrlThreadInteractionFilterGroups, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { queryService } from '@/services/client.service' import type { Event } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' const PREVIEW_LIMIT = 5 const FETCH_LIMIT = 24 @@ -17,11 +16,7 @@ const FETCH_LIMIT = 24 * Compact Nostr thread rows (comments + highlights) for an article URL card in the RSS+Web feed. */ export default function RssUrlThreadEventsPreview({ canonicalUrl }: { canonicalUrl: string }) { - const { relays, key: relayHintsKey } = useNoteStatsRelayHints() - const relayUrls = useMemo( - () => [...new Set([...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...relays])], - [relays] - ) + const { relayUrls, key: relayKey } = useRssUrlThreadQueryRelays() const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) @@ -37,6 +32,11 @@ export default function RssUrlThreadEventsPreview({ canonicalUrl }: { canonicalU globalTimeout: 26_000, firstRelayResultGraceMs: false as const } + if (relayUrls.length === 0) { + return () => { + cancelled = true + } + } void Promise.all([ nonSocial.length > 0 ? queryService.fetchEvents(relayUrls, nonSocial, fetchOpts) : Promise.resolve([]), social.length > 0 ? queryService.fetchEvents(relayUrls, social, fetchOpts) : Promise.resolve([]) @@ -63,7 +63,7 @@ export default function RssUrlThreadEventsPreview({ canonicalUrl }: { canonicalU return () => { cancelled = true } - }, [canonicalUrl, relayHintsKey, relayUrls]) + }, [canonicalUrl, relayKey, relayUrls]) if (loading) { return ( diff --git a/src/components/RssUrlThreadStatsBar/index.tsx b/src/components/RssUrlThreadStatsBar/index.tsx index 5635f6aa..0a7333e3 100644 --- a/src/components/RssUrlThreadStatsBar/index.tsx +++ b/src/components/RssUrlThreadStatsBar/index.tsx @@ -2,7 +2,7 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useUserTrust } from '@/contexts/user-trust-context' import { cn } from '@/lib/utils' import noteStatsService from '@/services/note-stats.service' -import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' +import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays' import { useNostr } from '@/providers/NostrProvider' import { Bookmark, Highlighter, MessageCircle, ThumbsUp } from 'lucide-react' import type { Event } from 'nostr-tools' @@ -18,7 +18,7 @@ export default function RssUrlThreadStatsBar({ }) { const { t } = useTranslation() const { pubkey } = useNostr() - const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints() + const { relayUrls: statsRelays, key: statsRelaysKey } = useRssUrlThreadQueryRelays() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const noteStats = useNoteStatsById(event.id) const [loading, setLoading] = useState(false) diff --git a/src/hooks/useRssUrlThreadQueryRelays.ts b/src/hooks/useRssUrlThreadQueryRelays.ts new file mode 100644 index 00000000..193f78a2 --- /dev/null +++ b/src/hooks/useRssUrlThreadQueryRelays.ts @@ -0,0 +1,40 @@ +import { FAST_READ_RELAY_URLS } from '@/constants' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import { buildRssWebNostrQueryRelayUrls } from '@/lib/rss-web-feed' +import { useEffect, useMemo, useState } from 'react' +import { useNoteStatsRelayHints } from './useNoteStatsRelayHints' + +/** + * Relay set for RSS+Web article URL thread REQs: inbox/favorites/fast-read merge (same as URL discovery) + * plus {@link useNoteStatsRelayHints} (current relay context). + */ +export function useRssUrlThreadQueryRelays(): { relayUrls: string[]; key: string } { + const { pubkey } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { relays: hintRelays, key: hintKey } = useNoteStatsRelayHints() + const [baseUrls, setBaseUrls] = useState([]) + const [baseKey, setBaseKey] = useState('') + + useEffect(() => { + let cancelled = false + void buildRssWebNostrQueryRelayUrls({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays: blockedRelays ?? [] + }).then((urls) => { + if (cancelled) return + setBaseUrls(urls) + setBaseKey(urls.join('|')) + }) + return () => { + cancelled = true + } + }, [pubkey, favoriteRelays, blockedRelays]) + + return useMemo(() => { + const merged = [...new Set([...baseUrls, ...hintRelays])] + const relayUrls = merged.length > 0 ? merged : [...FAST_READ_RELAY_URLS] + return { relayUrls, key: `${baseKey}::${hintKey}::${relayUrls.length}` } + }, [baseUrls, baseKey, hintRelays, hintKey]) +} diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts index 73ed3dc1..9efca4df 100644 --- a/src/lib/rss-article.ts +++ b/src/lib/rss-article.ts @@ -120,10 +120,10 @@ export function getReactionPageUrlFromRTags(event: Pick) } /** - * 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). + * Canonical article URL plus common string variants for REQ filters (`i` / `I` / `r`). + * Relay matching is exact on tag values, so trailing slashes, query stripping, etc. are included. */ -export function computeRTagFilterValuesForArticleThread(canonicalUrl: string): string[] { +export function expandArticleUrlThreadQueryValues(canonicalUrl: string): string[] { const s = canonicalUrl.trim() if (!s.startsWith('http://') && !s.startsWith('https://')) return [] const out = new Set([s]) @@ -144,6 +144,27 @@ export function computeRTagFilterValuesForArticleThread(canonicalUrl: string): s return [...out] } +/** + * Values for a REQ `#r` filter on kind 9802 / kind 7 when the thread key is a canonical article URL. + * @deprecated Prefer {@link expandArticleUrlThreadQueryValues} — same values. + */ +export function computeRTagFilterValuesForArticleThread(canonicalUrl: string): string[] { + return expandArticleUrlThreadQueryValues(canonicalUrl) +} + +/** True if `urlFromEvent` refers to the same article as `canonicalThreadKey` (after normalization + variant match). */ +export function articleUrlMatchesThreadScope(urlFromEvent: string, canonicalThreadKey: string): boolean { + const key = canonicalizeRssArticleUrl(canonicalThreadKey) + const cand = canonicalizeRssArticleUrl(urlFromEvent) + if (key === cand) return true + const keyVariants = new Set(expandArticleUrlThreadQueryValues(key)) + if (keyVariants.has(cand)) return true + for (const v of expandArticleUrlThreadQueryValues(cand)) { + if (keyVariants.has(v)) return true + } + return false +} + /** True for http(s) URLs whose host is clawstr.com (incl. subdomains; supports protocol-relative `//…`). */ export function isClawstrDotComHttpUrl(url: string): boolean { const t = url.trim() diff --git a/src/lib/rss-web-feed.ts b/src/lib/rss-web-feed.ts index ed51a3c0..65b83698 100644 --- a/src/lib/rss-web-feed.ts +++ b/src/lib/rss-web-feed.ts @@ -3,8 +3,9 @@ import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { isReplyNoteEvent } from '@/lib/event' import { + articleUrlMatchesThreadScope, canonicalizeRssArticleUrl, - computeRTagFilterValuesForArticleThread, + expandArticleUrlThreadQueryValues, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl, getReactionPageUrlFromRTags, @@ -183,19 +184,20 @@ export function buildRssArticleUrlThreadInteractionFilterGroups( limit: number ): { nonSocial: Filter[]; social: Filter[] } { const canonical = canonicalizeRssArticleUrl(canonicalArticleUrl) - const rVals = computeRTagFilterValuesForArticleThread(canonical) + const tagVals = expandArticleUrlThreadQueryValues(canonical) + const iFilterVals = tagVals.length > 0 ? tagVals : [canonical] const social: Filter[] = [ - { '#i': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }, - { '#I': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit } + { '#i': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }, + { '#I': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit } ] const nonSocial: Filter[] = [ - { '#i': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit }, - { '#I': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit } + { '#i': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit }, + { '#I': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit } ] - if (rVals.length > 0) { + if (tagVals.length > 0) { nonSocial.push( - { '#r': rVals, kinds: [kinds.Highlights], limit }, - { '#r': rVals, kinds: [kinds.Reaction], limit } + { '#r': tagVals, kinds: [kinds.Highlights], limit }, + { '#r': tagVals, kinds: [kinds.Reaction], limit } ) } return { nonSocial, social } @@ -218,19 +220,19 @@ export function isRssArticleUrlThreadInteraction(evt: Event, canonicalArticleUrl const key = canonicalizeRssArticleUrl(canonicalArticleUrl) if (evt.kind === kinds.Highlights) { const hu = getHighlightSourceHttpUrl(evt) - return !!hu && canonicalizeRssArticleUrl(hu) === key + return !!hu && articleUrlMatchesThreadScope(hu, key) } if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { const u = getWebExternalReactionTargetUrl(evt) - return !!u && canonicalizeRssArticleUrl(u) === key + return !!u && articleUrlMatchesThreadScope(u, key) } if (evt.kind === kinds.Reaction) { const u = getReactionPageUrlFromRTags(evt) - return !!u && canonicalizeRssArticleUrl(u) === key + return !!u && articleUrlMatchesThreadScope(u, key) } if (!isReplyNoteEvent(evt)) return false const u = getArticleUrlFromCommentITags(evt) - return !!u && canonicalizeRssArticleUrl(u) === key + return !!u && articleUrlMatchesThreadScope(u, key) } /** diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 0d16a055..eb4a21ac 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -10,10 +10,11 @@ import { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { canonicalizeRssArticleUrl, - computeRTagFilterValuesForArticleThread, + expandArticleUrlThreadQueryValues, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl, getReactionPageUrlFromRTags, + getWebBookmarkArticleUrl, getWebExternalReactionTargetUrl, rssArticleStableEventId } from '@/lib/rss-article' @@ -288,6 +289,34 @@ class NoteStatsService { const reactionLimit = 300 const interactionLimit = 80 + /** Synthetic RSS/Web parents are not on relays; `#e` on the fake id returns nothing. Use only URL-scoped filters. */ + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + const url = getArticleUrlFromCommentITags(event) + if (!url) { + return { nonSocial: [], social: [] } + } + const canonical = canonicalizeRssArticleUrl(url) + const tagVals = expandArticleUrlThreadQueryValues(canonical) + const iVals = tagVals.length > 0 ? tagVals : [canonical] + const nonSocial: Filter[] = [ + { '#i': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, + { '#I': iVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, + { '#i': iVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 }, + { '#I': iVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit: 200 } + ] + if (tagVals.length > 0) { + nonSocial.push( + { '#r': tagVals, kinds: [kinds.Highlights], limit: interactionLimit }, + { '#r': tagVals, kinds: [kinds.Reaction], limit: reactionLimit } + ) + } + const social: Filter[] = [ + { '#i': iVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit }, + { '#I': iVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit } + ] + return { nonSocial, social } + } + const nonSocial: Filter[] = [ { '#e': [event.id], kinds: [kinds.Reaction], limit: reactionLimit }, { '#e': [event.id], kinds: [kinds.Zap], limit: 100 } @@ -312,29 +341,6 @@ class NoteStatsService { } ] - if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { - const url = getArticleUrlFromCommentITags(event) - if (url) { - const canonical = canonicalizeRssArticleUrl(url) - const rVals = computeRTagFilterValuesForArticleThread(canonical) - nonSocial.push( - { '#i': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, - { '#I': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, - { kinds: [kinds.BookmarkList], '#e': [event.id], limit: 200 } - ) - if (rVals.length > 0) { - nonSocial.push( - { '#r': rVals, kinds: [kinds.Highlights], limit: interactionLimit }, - { '#r': rVals, kinds: [kinds.Reaction], limit: reactionLimit } - ) - } - social.push( - { '#i': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit }, - { '#I': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit } - ) - } - } - if (replaceableCoordinate) { nonSocial.push( { '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit }, @@ -472,6 +478,8 @@ class NoteStatsService { } } else if (evt.kind === kinds.Highlights) { updatedEventId = this.addHighlightByEvent(evt, originalEventAuthor) + } else if (evt.kind === ExtendedKind.WEB_BOOKMARK) { + updatedEventId = this.addWebBookmarkByArticleUrlEvent(evt) } else if (evt.kind === kinds.BookmarkList) { this.addBookmarkListRefsByEvent(evt) } @@ -728,6 +736,20 @@ class NoteStatsService { return highlightedEventId } + /** Kind 39701: count one bookmark per pubkey for this article URL (synthetic thread id). */ + private addWebBookmarkByArticleUrlEvent(evt: Event): string | undefined { + const url = getWebBookmarkArticleUrl(evt) + if (!url) return + const targetId = rssArticleStableEventId(canonicalizeRssArticleUrl(url)) + const old = this.noteStatsMap.get(targetId) || {} + const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set() + if (bookmarkPubkeySet.has(evt.pubkey)) return targetId + bookmarkPubkeySet.add(evt.pubkey) + this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet }) + this.notifyNoteStats(targetId) + return targetId + } + /** Each bookmark list author counts once per target `e` id in that list. */ private addBookmarkListRefsByEvent(evt: Event) { for (const tag of evt.tags) {