From 0ed194873c42b5d900c73b4ebdf2b3f5f3f14599 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 22 May 2026 16:06:21 +0200 Subject: [PATCH] bug-fixes --- src/PageManager.tsx | 7 +- .../ConnectedRelaysSidebarStrip.tsx | 3 +- src/components/NoteList/index.tsx | 52 +++++++++-- src/components/Relay/index.tsx | 6 +- src/hooks/useRelayConnectionRows.ts | 90 ++++++++++++++----- src/services/client.service.ts | 5 ++ 6 files changed, 127 insertions(+), 36 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index af3719ee..ded7bc0a 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -2277,9 +2277,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { noteStatsService.setBackgroundStatsPaused(primaryFrozen) if (primaryFrozen) { extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS) - client.interruptBackgroundQueries() + // Double-pane: keep the left feed's in-flight REQ alive; interrupt only when primary is hidden. + if (isSmallScreen || panelMode === 'single') { + client.interruptBackgroundQueries() + } } - }, [primaryFrozen]) + }, [primaryFrozen, isSmallScreen, panelMode]) const primaryPageContextValue = useMemo( (): PrimaryPageContextValue => ({ diff --git a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx index cfe970b8..58112760 100644 --- a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx +++ b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx @@ -32,7 +32,8 @@ function rowTitle(url: string, connected: boolean, t: (k: string) => string) { } /** - * Desktop sidebar: relay avatars for favorites + defaults + inbox; muted when the pool socket is down. + * Desktop sidebar: relay avatars for favorites, inbox, cache, HTTP index, and defaults; + * muted when the WebSocket is down (HTTP index relays count as active when configured). */ export function ConnectedRelaysSidebarStrip({ className }: { className?: string }) { const { t } = useTranslation() diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 2c2ecbf2..30d1d52e 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -944,6 +944,9 @@ const NoteList = forwardRef( }>(() => ({ profiles: new Map(), pending: new Set(), version: 0 })) const feedProfileLoadedRef = useRef>(new Set()) const feedProfileBatchGenRef = useRef(0) + /** Dedupes layout-time pending sync so a new `events` array reference alone cannot loop setState. */ + const lastProfilePrefetchPubkeysKeyRef = useRef('') + const clientFilteredVisibleCountRef = useRef(0) const noteFeedProfileContextValue = useMemo( () => ({ @@ -1074,13 +1077,16 @@ const NoteList = forwardRef( useLayoutEffect(() => { publicReadFallbackAttemptedRef.current = false - setFeedTimelineEmptyUiReady(false) - setFeedSubscribeRelayOutcomes([]) - }, [timelineSubscriptionKey, subRequestsKey, refreshCount]) + if (!pauseTimelineForPrimaryFreeze) { + setFeedTimelineEmptyUiReady(false) + setFeedSubscribeRelayOutcomes([]) + } + }, [timelineSubscriptionKey, subRequestsKey, refreshCount, pauseTimelineForPrimaryFreeze]) useEffect(() => { feedProfileBatchGenRef.current += 1 feedProfileLoadedRef.current.clear() + lastProfilePrefetchPubkeysKeyRef.current = '' setFeedProfileBatch({ profiles: new Map(), pending: new Set(), version: 0 }) }, [timelineSubscriptionKey, refreshCount]) @@ -1093,22 +1099,30 @@ const NoteList = forwardRef( for (const e of newEvents) { collectProfilePrefetchPubkeysFromEvent(e, candidates) } + const pubkeysKey = [...candidates].sort().join('\n') + if (pubkeysKey === lastProfilePrefetchPubkeysKeyRef.current) return + lastProfilePrefetchPubkeysKeyRef.current = pubkeysKey setFeedProfileBatch((prev) => { const pending = new Set(prev.pending) let changed = false for (const pk of candidates) { - if (!prev.profiles.has(pk) && !pending.has(pk)) { - pending.add(pk) - changed = true + if ( + prev.profiles.has(pk) || + pending.has(pk) || + feedProfileLoadedRef.current.has(pk) + ) { + continue } + pending.add(pk) + changed = true } if (!changed) return prev // Do not bump `version` here — only the debounced batch + profile merges should notify // `useFetchProfile` (via profiles map / pending membership), not every pending-key sync. return { ...prev, pending } }) - }, [timelineEventsForFilter, newEvents]) + }) const subRequestsRef = useRef(subRequests) subRequestsRef.current = subRequests @@ -1508,6 +1522,10 @@ const NoteList = forwardRef( [showFeedClientFilter, applyClientFeedFilter, filteredEvents] ) + useEffect(() => { + clientFilteredVisibleCountRef.current = clientFilteredEvents.length + }, [clientFilteredEvents.length]) + const visibleNoteIdsForStatsPrefetchKey = useMemo( () => clientFilteredEvents @@ -1929,6 +1947,10 @@ const NoteList = forwardRef( timelineEstablishedCloserRef.current = null if (pauseTimelineForPrimaryFreeze) { + setLoading(false) + if (eventsRef.current.length > 0) { + setFeedTimelineEmptyUiReady(true) + } return () => {} } @@ -3896,11 +3918,15 @@ const NoteList = forwardRef( const remaining = currentEvents.length - currentShowCount const step = revealBatchSize ?? REVEAL_BATCH_STEP const increment = Math.min(step, remaining) - setShowCount((prev) => prev + increment) + const exhausted = bufferExhaustedForVisibleQuotaRef.current + const noVisibleRowsYet = clientFilteredVisibleCountRef.current === 0 + // Revealing more raw buffer rows cannot surface visible cards (aggressive filters / seen-on gate). + if (!(exhausted && noVisibleRowsYet)) { + setShowCount((prev) => prev + increment) + } // `showCount` is a *visible-row quota*, not an offset into the raw merged timeline. Skipping relay // fetch when `events.length - showCount` is large breaks sparse feeds (e.g. only zap receipts): the // buffer can hold many raw events while every visible row is already shown — we must still REQ. - const exhausted = bufferExhaustedForVisibleQuotaRef.current if ( !exhausted && currentEvents.length >= 50 && @@ -4188,6 +4214,14 @@ const NoteList = forwardRef( const ev = eventsRef.current const sc = showCountRef.current if (sc < ev.length || hasMoreRef.current) { + if ( + sc < ev.length && + !hasMoreRef.current && + bufferExhaustedForVisibleQuotaRef.current && + clientFilteredVisibleCountRef.current === 0 + ) { + return + } loadMore() } }, options) diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index dd853c42..92aa5509 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -15,6 +15,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r import { useTranslation } from 'react-i18next' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url' +import { stableFeedKindKey } from '@/features/feed/descriptor' import NotFound from '../NotFound' const Relay = forwardRef< @@ -81,9 +82,10 @@ const Relay = forwardRef< }, [normalizedUrl, noteListRef]) /** Default browse: explicit kinds (many strfry / small relays never return a useful kindless global REQ). */ + const relayBrowseKindsKey = useMemo(() => stableFeedKindKey(showKinds), [showKinds]) const relayBrowseKinds = useMemo( () => (showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]), - [showKinds] + [relayBrowseKindsKey, showKinds] ) const relayFeedSubRequests = useMemo(() => { @@ -103,7 +105,7 @@ const Relay = forwardRef< filter: { kinds: [...relayBrowseKinds], limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } } ] - }, [normalizedUrl, debouncedInput, relayBrowseKinds]) + }, [normalizedUrl, debouncedInput, relayBrowseKindsKey]) const allowKindlessRelayExplore = debouncedInput.trim().length > 0 diff --git a/src/hooks/useRelayConnectionRows.ts b/src/hooks/useRelayConnectionRows.ts index 76c313f4..1e61482a 100644 --- a/src/hooks/useRelayConnectionRows.ts +++ b/src/hooks/useRelayConnectionRows.ts @@ -1,5 +1,14 @@ import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' -import { normalizeAnyRelayUrl } from '@/lib/url' +import { + getHttpRelayListFromEvent, + getRelayListReadFromEventNoFastFallback +} from '@/lib/event-metadata' +import { + canonicalRelaySessionKey, + normalizeAnyRelayUrl, + normalizeHttpRelayUrl, + urlMatchesConfiguredHttpIndexRelay +} from '@/lib/url' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' @@ -7,8 +16,14 @@ import { useEffect, useMemo, useState } from 'react' const POLL_MS = 1500 -function canon(url: string): string { - return (normalizeAnyRelayUrl(url) || url).trim().toLowerCase() +function normalizeRelayRowUrl(raw: string): string { + const t = raw.trim() + if (/^https?:\/\//i.test(t)) return normalizeHttpRelayUrl(t) || t + return normalizeAnyRelayUrl(t) || t +} + +function rowCanon(url: string): string { + return (canonicalRelaySessionKey(url) || normalizeRelayRowUrl(url)).trim().toLowerCase() } function mergeUniquePreserveOrder(...lists: (readonly string[] | undefined)[]): string[] { @@ -17,8 +32,8 @@ function mergeUniquePreserveOrder(...lists: (readonly string[] | undefined)[]): for (const list of lists) { if (!list?.length) continue for (const raw of list) { - const n = normalizeAnyRelayUrl(raw) || raw - const k = canon(n) + const n = normalizeRelayRowUrl(raw) + const k = rowCanon(n) if (!k || seen.has(k)) continue seen.add(k) out.push(n) @@ -29,58 +44,89 @@ function mergeUniquePreserveOrder(...lists: (readonly string[] | undefined)[]): export type TRelayConnectionRow = { url: string - /** WebSocket in the pool is open. */ + /** WebSocket open in the pool, or HTTP index relay in use for the viewer. */ connected: boolean } /** - * Relays to show in “active relays” UI: favorites + NIP-65 read/write + defaults + fast-read, - * then any pool-connected URL not already listed. {@link row.connected} reflects the live WebSocket. + * Relays for “active relays” UI: favorites + NIP-65 read/write + kind 10432 cache + kind 10243 HTTP index + * + defaults + fast-read, then any pool-connected URL not already listed. */ export function useRelayConnectionRows(): { rows: TRelayConnectionRow[] - /** Relays that currently have an open WebSocket connection. */ + /** Relays counted as active (open WebSocket or configured HTTP index). */ connectedCount: number } { - const { relayList } = useNostr() - const { favoriteRelays } = useFavoriteRelays() + const { relayList, cacheRelayListEvent, httpRelayListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [connectedCanon, setConnectedCanon] = useState>(() => - new Set(client.getConnectedRelayUrls().map(canon)) + new Set(client.getConnectedRelayUrls().map(rowCanon)) + ) + const [httpIndexBases, setHttpIndexBases] = useState(() => + client.getViewerHttpIndexRelayBases() ) useEffect(() => { - const tick = () => setConnectedCanon(new Set(client.getConnectedRelayUrls().map(canon))) + const tick = () => { + setConnectedCanon(new Set(client.getConnectedRelayUrls().map(rowCanon))) + setHttpIndexBases(client.getViewerHttpIndexRelayBases()) + } tick() const id = window.setInterval(tick, POLL_MS) return () => clearInterval(id) }, []) + const cacheRelayUrls = useMemo(() => { + if (!cacheRelayListEvent) return [] + return getRelayListReadFromEventNoFastFallback(cacheRelayListEvent, blockedRelays) + }, [cacheRelayListEvent, blockedRelays]) + + const httpIndexRelayUrls = useMemo(() => { + const out: string[] = [...(relayList?.httpRead ?? []), ...(relayList?.httpWrite ?? [])] + if (httpRelayListEvent) { + const http = getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays) + out.push(...http.httpRead, ...http.httpWrite) + } + return out + }, [relayList?.httpRead, relayList?.httpWrite, httpRelayListEvent, blockedRelays]) + return useMemo(() => { const inbox = [...(relayList?.read ?? []), ...(relayList?.write ?? [])] const base = mergeUniquePreserveOrder( favoriteRelays, inbox, + cacheRelayUrls, + httpIndexRelayUrls, DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS ) - const baseCanon = new Set(base.map(canon)) + const baseCanon = new Set(base.map(rowCanon)) - const rowFor = (url: string, socketConnected: boolean): TRelayConnectionRow => ({ + const isConnected = (url: string) => + urlMatchesConfiguredHttpIndexRelay(url, httpIndexBases) || connectedCanon.has(rowCanon(url)) + + const rowFor = (url: string): TRelayConnectionRow => ({ url, - connected: socketConnected + connected: isConnected(url) }) - const rows: TRelayConnectionRow[] = base.map((url) => - rowFor(url, connectedCanon.has(canon(url))) - ) + const rows: TRelayConnectionRow[] = base.map((url) => rowFor(url)) for (const url of client.getConnectedRelayUrls()) { - const k = canon(url) + const k = rowCanon(url) if (baseCanon.has(k)) continue - rows.push(rowFor(url, true)) + rows.push(rowFor(url)) } const connectedCount = rows.filter((r) => r.connected).length return { rows, connectedCount } - }, [favoriteRelays, relayList?.read, relayList?.write, connectedCanon]) + }, [ + favoriteRelays, + relayList?.read, + relayList?.write, + cacheRelayUrls, + httpIndexRelayUrls, + connectedCanon, + httpIndexBases + ]) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 253a16a6..955cbcaf 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3294,6 +3294,11 @@ class ClientService extends EventTarget { return [...new Set(out)].sort((a, b) => a.localeCompare(b)) } + /** Kind 10243 HTTP index bases for the logged-in viewer (read + write). */ + getViewerHttpIndexRelayBases(): readonly string[] { + return this.viewerHttpIndexRelayBases + } + trackEventSeenOn(eventId: string, relay: AbstractRelay) { const key = canonicalSeenOnEventId(eventId) let set = this.pool.seenOn.get(key)