diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 299111f1..bec5590b 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,5 +1,5 @@ import NewNotesButton from '@/components/NewNotesButton' -import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' +import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { collectEmbeddedEventPrefetchTargets, getReplaceableCoordinateFromEvent, @@ -593,6 +593,8 @@ const NoteList = forwardRef( const singleRelayKindlessFallbackAttemptedRef = useRef(false) const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty) onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty + /** Timeout handle for kindless EOSE fallback; cleared when EOSE arrives or effect tears down. */ + const kindlessEoseTimeoutRef = useRef | null>(null) /** 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. */ @@ -1787,6 +1789,40 @@ const NoteList = forwardRef( return undefined } + // Kindless single-relay mode: fall back to explicit kinds if EOSE is too slow. + // Relays that can't efficiently handle a filter with no kinds clause may hang for tens + // of seconds; the timeout fires the same fallback as the empty-EOSE path so the user + // sees content without waiting indefinitely. + if ( + allowKindlessRelayExploreRef.current && + useFilterAsIsRef.current && + mappedSubRequests.length === 1 && + mappedSubRequests[0] && + mappedSubRequests[0].urls.length === 1 && + !singleRelayKindlessFallbackAttemptedRef.current && + onSingleRelayKindlessEmptyRef.current + ) { + if (kindlessEoseTimeoutRef.current) clearTimeout(kindlessEoseTimeoutRef.current) + kindlessEoseTimeoutRef.current = setTimeout(() => { + kindlessEoseTimeoutRef.current = null + if (!effectActive) return + if (singleRelayKindlessFallbackAttemptedRef.current) return + const reqs = subRequestsRef.current + const f0 = reqs[0] + if ( + reqs.length === 1 && + f0 && + f0.urls.length === 1 && + allowKindlessRelayExploreRef.current && + useFilterAsIsRef.current && + (!f0.filter.kinds || (f0.filter.kinds as unknown[]).length === 0) + ) { + singleRelayKindlessFallbackAttemptedRef.current = true + onSingleRelayKindlessEmptyRef.current?.() + } + }, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS) + } + timelineSubscribePromise = client.subscribeTimeline( mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>, { @@ -1795,6 +1831,11 @@ const NoteList = forwardRef( if (batch.length > 0) { feedRelayReturnedAnyEventRef.current = true } + // EOSE arrived — cancel the kindless timeout so the fallback doesn't fire afterwards. + if (eosed && kindlessEoseTimeoutRef.current) { + clearTimeout(kindlessEoseTimeoutRef.current) + kindlessEoseTimeoutRef.current = null + } const narrowed = narrowLiveBatch(batch) const paintDoneBefore = feedPaintLiveRelayDoneRef.current if (!feedPaintLiveRelayDoneRef.current) { @@ -2027,6 +2068,10 @@ const NoteList = forwardRef( followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current = null setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) + if (kindlessEoseTimeoutRef.current) { + clearTimeout(kindlessEoseTimeoutRef.current) + kindlessEoseTimeoutRef.current = null + } if (timelinePrefetchDebounceRef.current) { clearTimeout(timelinePrefetchDebounceRef.current) timelinePrefetchDebounceRef.current = null diff --git a/src/constants.ts b/src/constants.ts index 2ffb7d10..aec42d69 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -153,6 +153,13 @@ export const FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT = 200 */ export const SINGLE_RELAY_KINDLESS_REQ_LIMIT = 500 +/** + * If a kindless single-relay REQ hasn't EOSEd within this many milliseconds, fall back to an + * explicit-kinds filter (same path as when the kindless query returns no events). Prevents + * relays that are very slow on open-ended filters from stalling the home feed indefinitely. + */ +export const SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS = 6000 + /** * Minimum time between full account network hydrates (NostrProvider: relay + replaceable fetch from relays). * IndexedDB cache still applies on every load; this only skips redundant network merges after a recent run.