From 0e987713c5061a88f0a6d8eb50494df811bafda8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 30 Mar 2026 14:13:52 +0200 Subject: [PATCH] fix relay feeds --- src/components/NormalFeed/index.tsx | 14 ++- src/components/NoteList/index.tsx | 96 ++++++++++++++----- src/components/ui/sonner.tsx | 5 +- src/constants.ts | 23 ++++- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/pages/primary/NoteListPage/RelaysFeed.tsx | 39 +++++++- src/providers/ThemeProvider.tsx | 3 + src/services/client-query.service.ts | 13 ++- src/services/client.service.ts | 25 +++-- 10 files changed, 177 insertions(+), 45 deletions(-) diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index c84ba8e4..0290c832 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -7,7 +7,7 @@ import storage from '@/services/local-storage.service' import type { TPrimaryPageName } from '@/PageManager' import { TFeedSubRequest, TNoteListMode } from '@/types' import { cn } from '@/lib/utils' -import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import KindFilter from '../KindFilter' const NormalFeed = forwardRef void + /** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */ + feedTopNotice?: ReactNode }>(function NormalFeed( { subRequests, @@ -53,7 +57,9 @@ const NormalFeed = forwardRef diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 70f8191e..c995d961 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -47,7 +47,8 @@ import { useLayoutEffect, useMemo, useRef, - useState + useState, + type ReactNode } from 'react' import { CircleAlert } from 'lucide-react' import { useLongPressAction } from '@/hooks/use-long-press-action' @@ -254,8 +255,9 @@ const NoteList = forwardRef( */ timelineLoadingSafetyTimeoutMs, /** - * With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none, and narrow - * incoming events to {@link showKinds} before merging (so caps are not filled by unrelated kinds). + * With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none. Kindless relay feeds + * merge the full batch; the kind picker still applies in the list via {@link applyKindPickerInUi}. Other + * `useFilterAsIs` paths may still narrow merged batches to {@link showKinds}. */ clientSideKindFilter = false, /** @@ -293,7 +295,9 @@ const NoteList = forwardRef( * When {@link NormalFeed} renders Notes/Replies + kind row, it passes the slot element so the 🔍 control * sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList. */ - feedClientFilterTabRowHost + feedClientFilterTabRowHost, + onSingleRelayKindlessEmpty, + feedTopNotice }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -335,6 +339,10 @@ const NoteList = forwardRef( showFeedClientFilter?: boolean hostPrimaryPageName?: TPrimaryPageName feedClientFilterTabRowHost?: HTMLElement | null + /** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */ + onSingleRelayKindlessEmpty?: () => void + /** Optional banner above the feed (e.g. kindless→kinds fallback). */ + feedTopNotice?: ReactNode }, ref ) => { @@ -400,6 +408,10 @@ const NoteList = forwardRef( const feedPaintLiveRelayDoneRef = useRef(false) /** True if any timeline `onEvents` batch had `batch.length > 0`, or one-shot fetches returned any raw events (before UI filters). */ const feedRelayReturnedAnyEventRef = useRef(false) + /** One-shot per timeline init: avoid double-calling parent fallback (Strict Mode / duplicate EOSE). */ + const singleRelayKindlessFallbackAttemptedRef = useRef(false) + const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty) + onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty /** Dedupe {@link toast.error} when relays return nothing for a feed load. */ const emptyRelayNoHitsToastKeyRef = useRef('') /** Per-relay outcomes for the current subscribe wave (merged shards); drives empty-feed toast detail. */ @@ -605,8 +617,9 @@ const NoteList = forwardRef( clientSideKindFilterRef.current = clientSideKindFilter /** - * When to apply kind picker + kind-1/1111/GitRelease visibility to rows. Kindless home relay chips use a - * kindless REQ and narrow here via {@link clientSideKindFilter}; standalone relay explore keeps firehose. + * When to apply kind picker + kind-1/1111/GitRelease visibility to visible rows. Kindless relay REQs merge + * the full relay batch; this still filters what the list shows (unlike standalone relay explore, which sets + * {@link allowKindlessRelayExplore} without {@link clientSideKindFilter} and shows the firehose). */ const applyKindPickerInUi = useMemo( () => @@ -1184,6 +1197,7 @@ const NoteList = forwardRef( feedPaintRelayMetaRef.current = null feedPaintLiveRelayDoneRef.current = false feedRelayReturnedAnyEventRef.current = false + singleRelayKindlessFallbackAttemptedRef.current = false // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. const keepRowsVisible = @@ -1289,18 +1303,16 @@ const NoteList = forwardRef( return undefined } + /** + * Kindless relay REQ (`allowKindlessRelayExplore`): never drop events here — relays return many kinds; + * merging only rows in {@link showKinds} left almost nothing in the timeline (e.g. christpill 200 events → 1 + * visible) while relay explore showed the full firehose. {@link applyKindPickerInUi} / {@link filteredEvents} + * still apply the kind picker for what the user sees. + */ const narrowLiveBatch = (evs: Event[]) => { if (seeAllFeedEventsRef.current) return evs - if ( - allowKindlessRelayExploreRef.current && - !(useFilterAsIsRef.current && clientSideKindFilterRef.current) - ) { - return evs - } - if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) { - if (!allowKindlessRelayExploreRef.current) return evs - return evs - } + if (allowKindlessRelayExploreRef.current) return evs + if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs return evs.filter((e) => showKinds.includes(e.kind)) } @@ -1536,15 +1548,38 @@ const NoteList = forwardRef( setHasMore(true) } } + + // Single-relay home chip: kindless REQ returned nothing — parent re-subscribes with explicit kinds. + if ( + eosed && + effectActive && + onSingleRelayKindlessEmptyRef.current && + !singleRelayKindlessFallbackAttemptedRef.current && + !feedRelayReturnedAnyEventRef.current + ) { + const reqs = subRequestsRef.current + const f0 = reqs[0] + if ( + reqs.length === 1 && + f0 && + f0.urls.length === 1 && + allowKindlessRelayExploreRef.current && + useFilterAsIsRef.current && + clientSideKindFilterRef.current + ) { + const f = f0.filter as Filter + const noKinds = !f.kinds || f.kinds.length === 0 + if (noKinds) { + singleRelayKindlessFallbackAttemptedRef.current = true + onSingleRelayKindlessEmptyRef.current() + } + } + } }, onNew: (event: Event) => { if (!effectActive) return feedRelayReturnedAnyEventRef.current = true - if ( - !seeAllFeedEventsRef.current && - (!allowKindlessRelayExploreRef.current || - (useFilterAsIsRef.current && clientSideKindFilterRef.current)) - ) { + if (!seeAllFeedEventsRef.current && !allowKindlessRelayExploreRef.current) { if (!useFilterAsIsRef.current && !showKinds.includes(event.kind)) return if ( clientSideKindFilterRef.current && @@ -1657,7 +1692,8 @@ const NoteList = forwardRef( oneShotEoseTimeoutMs, oneShotFirstRelayGraceMs, clientSideKindFilter, - allowKindlessRelayExplore + allowKindlessRelayExplore, + onSingleRelayKindlessEmpty ]) const oneShotDebugPrevLoadingRef = useRef(false) @@ -2461,12 +2497,28 @@ const NoteList = forwardRef( pullingContent="" >
+ {feedTopNotice ? ( +
+ {feedTopNotice} +
+ ) : null} {showFeedClientFilter ? feedClientFilterBar : null} {list}
) : (
+ {feedTopNotice ? ( +
+ {feedTopNotice} +
+ ) : null} {showFeedClientFilter ? feedClientFilterBar : null} {list}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 45d4025b..e2a0bb1d 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,10 +1,11 @@ -import { useTheme } from '@/providers/ThemeProvider' +import { useThemeOptional } from '@/providers/ThemeProvider' import { Toaster as Sonner } from 'sonner' type ToasterProps = React.ComponentProps const Toaster = ({ ...props }: ToasterProps) => { - const { themeSetting } = useTheme() + const themeCtx = useThemeOptional() + const themeSetting = themeCtx?.themeSetting ?? 'system' return ( SOCIAL_KIND_BLOCKED_KIND_SET.has(kind)) } +/** + * After dropping {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} from a relay stack: if every URL was removed but the caller + * passed exactly one relay (e.g. a favorite-relay chip), keep it. Blended stacks still omit these relays; a + * user-targeted single-relay feed should actually contact that relay (e.g. thecitadel for kinds the relay does carry). + */ +export function relaysAfterSocialKindBlockedStrip( + originalDedupedUrls: string[], + afterStrip: string[] +): string[] { + if (afterStrip.length > 0) return afterStrip + if (originalDedupedUrls.length === 1) return [...originalDedupedUrls] + return afterStrip +} + /** Event kinds that show “Read this note aloud” in note options (Web Speech API). */ export const READ_ALOUD_KINDS: readonly number[] = [ kinds.ShortTextNote, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index a4486593..b6fba3b5 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -649,6 +649,8 @@ export default { None: 'Keine', 'Cache & offline storage': 'Cache & Offline-Speicher', feedStarting: 'Starting feeds and relays… This can take a few seconds after login.', + singleRelayKindFallbackNotice: + 'Dieses Relay hat auf eine offene Anfrage (ohne kinds im Filter) keine Events geliefert. Der Feed unten nutzt stattdessen deinen gewohnten Kind-Filter.', refreshCacheButtonExplainer: 'Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.', 'eventArchive.sectionTitle': 'Notes & feed archive', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3e83c5a1..9058b828 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -638,6 +638,8 @@ export default { None: 'None', 'Cache & offline storage': 'Cache & offline storage', feedStarting: 'Starting feeds and relays… This can take a few seconds after login.', + singleRelayKindFallbackNotice: + 'This relay returned no events for an open-ended request (no kinds in the filter). The feed below uses your usual kind filter instead.', refreshCacheButtonExplainer: 'Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.', 'eventArchive.sectionTitle': 'Notes & feed archive', diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 214740ff..83f1d129 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -7,7 +7,8 @@ import { useFeed } from '@/providers/FeedProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import relayInfoService from '@/services/relay-info.service' import { kinds } from 'nostr-tools' -import React, { forwardRef, useEffect, useMemo, useState } from 'react' +import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' const RelaysFeed = forwardRef< TNoteListRef, @@ -18,10 +19,13 @@ const RelaysFeed = forwardRef< kindsOverride?: number[] } >(function RelaysFeed({ setSubHeader, onSubHeaderRefresh, kindsOverride }, ref) { + const { t } = useTranslation() const { feedInfo, relayUrls } = useFeed() const { showKinds } = useKindFilterOrDefaults() const [areAlgoRelays, setAreAlgoRelays] = useState(false) const [relayAlgoReady, setRelayAlgoReady] = useState(false) + /** After kindless single-relay REQ EOSEs with no events, re-subscribe with the normal kind list. */ + const [singleRelayKindFallback, setSingleRelayKindFallback] = useState(false) const relayUrlsKey = useMemo( () => @@ -76,10 +80,6 @@ const RelaysFeed = forwardRef< ? showKinds : [kinds.ShortTextNote] - /** One relay + user kind filter: avoid huge `kinds` REQ (many relays error with "too many kinds"). */ - const singleRelayKindlessExplore = - feedInfo.feedType === 'relay' && relayUrls.length === 1 && !kindsOverride?.length - const canRenderFeed = (feedInfo.feedType === 'relay' || feedInfo.feedType === 'relays' || @@ -97,6 +97,29 @@ const RelaysFeed = forwardRef< return undefined }, [feedInfo.feedType, feedInfo.id]) + /** New relay chip / set: try kindless first again. */ + useEffect(() => { + setSingleRelayKindFallback(false) + }, [feedTimelineScopeKey]) + + const onSingleRelayKindlessEmpty = useCallback(() => { + setSingleRelayKindFallback(true) + }, []) + + /** + * One relay + user kind filter: kindless `{ limit }` REQ first (many relays error on huge `kinds` arrays). + * If that EOSEs with no events, `onSingleRelayKindlessEmpty` switches to explicit `kinds`. + */ + const singleRelayKindlessExplore = + feedInfo.feedType === 'relay' && + relayUrls.length === 1 && + !kindsOverride?.length && + !singleRelayKindFallback + + const feedTopNotice = singleRelayKindFallback ? ( +

{t('singleRelayKindFallbackNotice')}

+ ) : null + // Hooks must run every render — never place useMemo after conditional returns. const subRequests = useMemo(() => { if (!canRenderFeed) return [] @@ -136,6 +159,12 @@ const RelaysFeed = forwardRef< clientSideKindFilter={singleRelayKindlessExplore} showFeedClientFilter hostPrimaryPageName="feed" + onSingleRelayKindlessEmpty={ + feedInfo.feedType === 'relay' && relayUrls.length === 1 && !kindsOverride?.length + ? onSingleRelayKindlessEmpty + : undefined + } + feedTopNotice={feedTopNotice} /> ) }) diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx index 2039268b..9abdb0d3 100644 --- a/src/providers/ThemeProvider.tsx +++ b/src/providers/ThemeProvider.tsx @@ -89,3 +89,6 @@ export const useTheme = () => { return context } + +/** For leaf UI (e.g. Toaster) during Vite HMR when the tree can briefly mount outside ThemeProvider. */ +export const useThemeOptional = (): ThemeProviderState | undefined => useContext(ThemeProviderContext) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index d63c0edd..a9c976d7 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -2,6 +2,7 @@ import { FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT, FIRST_RELAY_RESULT_GRACE_MS, relayFilterIncludesSocialKindBlockedKind, + relaysAfterSocialKindBlockedStrip, SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_SUBS_PER_RELAY, @@ -447,7 +448,8 @@ export class QueryService { callbacks: SubscribeCallbacks, relayOpMeta?: { source: string; logLevel?: 'info' | 'debug' } ): { close: () => void } { - let relays = Array.from(new Set(urls)) + const originalDedupedRelays = Array.from(new Set(urls)) + let relays = originalDedupedRelays const filters = Array.isArray(filter) ? filter : [filter] const stripSocialBlockedRelays = @@ -455,7 +457,8 @@ export class QueryService { filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) if (stripSocialBlockedRelays) { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped) } if (this.shouldSkipRelayForSession) { relays = relays.filter((url) => { @@ -686,7 +689,8 @@ export class QueryService { onevent?: (evt: NEvent) => void } & QueryOptions ): Promise { - let relays = Array.from(new Set(urls)) + const originalDedupedRelays = Array.from(new Set(urls)) + let relays = originalDedupedRelays if (relays.length === 0) { const { FAST_READ_RELAY_URLS } = await import('@/constants') relays = [...FAST_READ_RELAY_URLS] @@ -697,7 +701,8 @@ export class QueryService { filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) if (stripSocialBlockedRelays) { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped) } const { onevent, ...queryOpts } = options ?? {} return this.query(relays, filter, onevent, queryOpts) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index f29c8498..cb12f5ea 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -5,6 +5,7 @@ import { FIRST_RELAY_RESULT_GRACE_MS, isSocialKindBlockedKind, relayFilterIncludesSocialKindBlockedKind, + relaysAfterSocialKindBlockedStrip, SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, RELAY_POOL_CONNECTION_TIMEOUT_MS, @@ -14,6 +15,7 @@ import { NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, READ_ONLY_RELAY_URLS, + NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' @@ -128,7 +130,9 @@ function summarizeFiltersForRelayLog(filters: Filter[]): Record } const READ_ONLY_RELAY_CONNECT_BOOST_URLS = new Set( - READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u) + [...READ_ONLY_RELAY_URLS, ...NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS].map( + (u) => normalizeUrl(u) || u + ) ) /** Hostname (+ path when not "/") for readable publish / retry console lines. */ @@ -324,9 +328,10 @@ class ClientService extends EventTarget { this.signerType = signerType this.queryService.setSigner(signer, signerType) /** - * NIP-42: answer `AUTH` on the wire only for read-only aggregators (`READ_ONLY_RELAY_URLS`, e.g. aggr). - * They often require AUTH before REQ; `master`-style auth only on `CLOSED` is too late. Other relays stay - * on reactive `relay.auth()` after `auth-required` to avoid double-sign races with the wider pool. + * NIP-42: proactive `AUTH` for relays that need it before the first REQ (read-only aggregators + + * {@link NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS}). Without this, a REQ can EOSE empty while the extension + * is still signing; the batch then finishes and never refetches. Other relays stay on reactive + * `relay.auth()` after `auth-required` to avoid double-sign races with the wider pool. */ if (signer && signerType !== 'npub') { this.pool.automaticallyAuth = (relayURL: string) => { @@ -1812,7 +1817,8 @@ class ClientService extends EventTarget { }, relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { - let relays = Array.from(new Set(urls)) + const originalDedupedRelays = Array.from(new Set(urls)) + let relays = originalDedupedRelays const filters = Array.isArray(filter) ? filter : [filter] const stripSocialBlockedRelays = @@ -1820,7 +1826,8 @@ class ClientService extends EventTarget { filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) if (stripSocialBlockedRelays) { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped) } relays = this.relayUrlsAfterStrikesOrRecover(relays) @@ -2481,7 +2488,8 @@ class ClientService extends EventTarget { immediateReturn?: boolean } = {} ) { - let relays = Array.from(new Set(urls)) + const originalDedupedRelays = Array.from(new Set(urls)) + let relays = originalDedupedRelays if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS] const filters = Array.isArray(filter) ? filter : [filter] const stripSocialBlockedRelays = @@ -2489,7 +2497,8 @@ class ClientService extends EventTarget { filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) if (stripSocialBlockedRelays) { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) + relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped) } relays = this.relayUrlsAfterStrikesOrRecover(relays) const events = await this.queryService.query(relays, filter, onevent, {