diff --git a/src/components/FavoriteRelaysFeedPicker/index.tsx b/src/components/FavoriteRelaysFeedPicker/index.tsx deleted file mode 100644 index ded2b6fa..00000000 --- a/src/components/FavoriteRelaysFeedPicker/index.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { getHttpRelayListFromEvent } from '@/lib/event-metadata' -import { toRelaySettings } from '@/lib/link' -import { normalizeAnyRelayUrl, normalizeUrl, simplifyUrl } from '@/lib/url' -import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' -import { cn } from '@/lib/utils' -import { useContainerWidth } from '@/hooks/useContainerWidth' -import { useSecondaryPage } from '@/PageManager' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useFeed } from '@/providers/FeedProvider' -import { useNostr } from '@/providers/NostrProvider' -import { SquarePen } from 'lucide-react' -import { useMemo, useRef } from 'react' -import { useTranslation } from 'react-i18next' - -/** Chips → dropdown below this container width (px). Matches Tailwind `sm` breakpoint. */ -const NARROW_THRESHOLD = 640 - -const ALL_FAVORITES_VALUE = '__all_favorites__' - -function relaySetToSelectValue(id: string) { - return `rs:${encodeURIComponent(id)}` -} - -function selectValueToRelaySetId(v: string) { - if (!v.startsWith('rs:')) return null - return decodeURIComponent(v.slice(3)) -} - -/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, single relays, HTTP index relays. */ -export default function FavoriteRelaysFeedPicker() { - const { t } = useTranslation() - const containerRef = useRef(null) - const containerWidth = useContainerWidth(containerRef) - // True when the component's own container is narrow — covers both mobile viewports - // and the left pane in double-pane desktop mode. - const isNarrow = containerWidth !== undefined ? containerWidth < NARROW_THRESHOLD : false - const { push } = useSecondaryPage() - const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() - const { feedInfo, switchFeed } = useFeed() - const { httpRelayListEvent } = useNostr() - - const openFavoriteRelaySettings = () => { - push(toRelaySettings('favorite-relays')) - } - - const settingsLabel = t('Relay settings') - - const urls = useMemo( - () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays), - [favoriteRelays, blockedRelays] - ) - - /** HTTP index relay URLs from kind 10243, deduped, excluding any already in favorites. */ - const httpRelayUrls = useMemo(() => { - if (!httpRelayListEvent) return [] - const list = getHttpRelayListFromEvent(httpRelayListEvent) - const favKeys = new Set(urls.map((u) => normalizeAnyRelayUrl(u) || u)) - const seen = new Set() - const out: string[] = [] - for (const u of [...list.httpRead, ...list.httpWrite]) { - const k = normalizeAnyRelayUrl(u) || u - if (!k || seen.has(k) || favKeys.has(k)) continue - seen.add(k) - out.push(k) - } - return out - }, [httpRelayListEvent, urls]) - - const wispTrendingRelayUrl = useMemo(() => buildWispTrendingNotesRelayUrl(), []) - const wispTrendingRelayKey = useMemo( - () => normalizeUrl(wispTrendingRelayUrl) || wispTrendingRelayUrl, - [wispTrendingRelayUrl] - ) - const trendingUrlInFavoriteList = useMemo( - () => urls.some((u) => (normalizeUrl(u) || u) === wispTrendingRelayKey), - [urls, wispTrendingRelayKey] - ) - - // Use normalizeAnyRelayUrl so HTTP relay IDs are matched correctly (normalizeUrl converts http→ws). - const currentRelayKey = - feedInfo.feedType === 'relay' && feedInfo.id - ? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id - : null - - const allActive = feedInfo.feedType === 'all-favorites' - - const trendingRelayActive = - feedInfo.feedType === 'relay' && currentRelayKey === wispTrendingRelayKey - - const relaySetIdActive = feedInfo.feedType === 'relays' && feedInfo.id ? feedInfo.id : null - - const orphanRelaySetId = - relaySetIdActive && !relaySets.some((s) => s.id === relaySetIdActive) ? relaySetIdActive : null - - const selectValue = allActive - ? ALL_FAVORITES_VALUE - : relaySetIdActive - ? relaySetToSelectValue(relaySetIdActive) - : currentRelayKey - ? currentRelayKey - : ALL_FAVORITES_VALUE - - /** Values that exist in the mobile Select (for controlled `value` validation). */ - const selectItems = useMemo(() => { - const items: { value: string }[] = [{ value: ALL_FAVORITES_VALUE }] - if (!trendingUrlInFavoriteList) { - items.push({ value: wispTrendingRelayKey }) - } - for (const set of relaySets) { - items.push({ value: relaySetToSelectValue(set.id) }) - } - if (orphanRelaySetId) { - items.push({ value: relaySetToSelectValue(orphanRelaySetId) }) - } - for (const url of urls) { - items.push({ value: normalizeAnyRelayUrl(url) || url }) - } - for (const url of httpRelayUrls) { - items.push({ value: normalizeAnyRelayUrl(url) || url }) - } - if ( - !allActive && - feedInfo.feedType === 'relay' && - feedInfo.id && - !items.some((i) => i.value === currentRelayKey) - ) { - items.push({ value: normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id }) - } - return items - }, [ - urls, - httpRelayUrls, - allActive, - feedInfo.feedType, - feedInfo.id, - currentRelayKey, - relaySets, - orphanRelaySetId, - trendingUrlInFavoriteList, - wispTrendingRelayKey - ]) - - const resolvedSelectValue = selectItems.some((i) => i.value === selectValue) - ? selectValue - : ALL_FAVORITES_VALUE - - const resolveRelayUrl = (value: string) => { - if (value === ALL_FAVORITES_VALUE) return null - const fromFav = urls.find((u) => (normalizeAnyRelayUrl(u) || u) === value) - if (fromFav) return fromFav - const fromHttp = httpRelayUrls.find((u) => (normalizeAnyRelayUrl(u) || u) === value) - if (fromHttp) return fromHttp - return value - } - - const onPickValue = (v: string) => { - if (v === ALL_FAVORITES_VALUE) { - void switchFeed('all-favorites') - return - } - if (v === wispTrendingRelayKey) { - void switchFeed('relay', { relay: wispTrendingRelayUrl }) - return - } - const setId = selectValueToRelaySetId(v) - if (setId) { - void switchFeed('relays', { activeRelaySetId: setId }) - return - } - const relay = resolveRelayUrl(v) - if (relay) void switchFeed('relay', { relay }) - } - - if (urls.length === 0 && httpRelayUrls.length === 0 && relaySets.length === 0) return null - - const editSettingsButton = ( - - ) - - if (isNarrow) { - return ( -
-
- -
- {editSettingsButton} -
- ) - } - - return ( -
-
- - {!trendingUrlInFavoriteList ? ( - - ) : null} - {(relaySets.length > 0 || orphanRelaySetId) && ( -
- )} - {relaySets.map((set) => { - const active = feedInfo.feedType === 'relays' && feedInfo.id === set.id - return ( - - ) - })} - {orphanRelaySetId ? ( - - ) : null} - {urls.length > 0 && (relaySets.length > 0 || orphanRelaySetId) && ( -
- )} - {urls.map((url) => { - const key = normalizeAnyRelayUrl(url) || url - const active = feedInfo.feedType === 'relay' && currentRelayKey === key - return ( - - ) - })} - {httpRelayUrls.length > 0 && ( -
- )} - {httpRelayUrls.map((url) => { - const key = normalizeAnyRelayUrl(url) || url - const active = feedInfo.feedType === 'relay' && currentRelayKey === key - return ( - - ) - })} -
- {editSettingsButton} -
- ) -} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index f9b3b640..62986bbe 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -134,7 +134,7 @@ const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960 /** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */ const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180 /** When the scroll container is within this many px of the top, auto-merge pending live notes (see {@link NewNotesButton}). */ -const AUTO_MERGE_NEW_EVENTS_TOP_PX = 120 +const AUTO_MERGE_NEW_EVENTS_TOP_PX = 280 function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null { if (!node) return null @@ -1019,6 +1019,11 @@ const NoteList = forwardRef( /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ const timelineEffectLastRefreshCountRef = useRef(refreshCount) const followingFeedDeltaCloserRef = useRef<(() => void) | null>(null) + /** + * After `setEvents([])` / session restore / disk prime, React may not have flushed before the relay emits. + * Without this, `mergeEventBatchesById(prev, …)` can merge the new relay into the previous feed's rows. + */ + const timelineMergeBootstrapRef = useRef(null) useLayoutEffect(() => { publicReadFallbackAttemptedRef.current = false @@ -1132,6 +1137,8 @@ const NoteList = forwardRef( showAllKindsRef.current = showAllKinds const withKindFilterRef = useRef(withKindFilter) withKindFilterRef.current = withKindFilter + const hostPrimaryPageNameRef = useRef(hostPrimaryPageName) + hostPrimaryPageNameRef.current = hostPrimaryPageName const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => { if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs @@ -1873,6 +1880,7 @@ const NoteList = forwardRef( async function init() { if (timelineEffectStale()) return undefined + timelineMergeBootstrapRef.current = null feedPaintSessionPendingRef.current = false feedPaintRelayPendingRef.current = false feedPaintRelayMetaRef.current = null @@ -1954,10 +1962,65 @@ const NoteList = forwardRef( ? ALGO_LIMIT : LIMIT + const isSpellPageLocalWarmup = + hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0 + + /** + * IndexedDB + session peek (inside {@link ClientService.getTimelineDiskSnapshotEvents}) without blocking + * relay REQ/subscribe. Merges the same way as live {@link onEvents} so rows appear as soon as disk resolves. + */ + const startNonBlockingTimelineDiskPrime = () => { + if (oneShotFetch || mappedSubRequests.length === 0) return + if (isSpellPageLocalWarmup) return + const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + void client + .getTimelineDiskSnapshotEvents(diskReq) + .then((diskRaw) => { + if (!effectActive || timelineEffectStale()) return + const diskNarrowed = narrowLiveBatch(diskRaw) + if (diskNarrowed.length === 0) return + + setEvents((prev) => { + const boot = timelineMergeBootstrapRef.current + const base = boot !== null ? boot : prev + const next = progressiveWarmupQueryRef.current?.trim() + ? mergeProgressiveSearchEvents( + base, + diskNarrowed, + oneShotAfterMergeComparatorRef.current + ) + : collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(base, diskNarrowed, eventCapEarly, areAlgoRelays) + ) + if (next.length > 0) { + timelineMergeBootstrapRef.current = next.slice() + } + lastEventsForTimelinePrefetchRef.current = next + return next + }) + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + if (!feedPaintLiveRelayDoneRef.current) { + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'disk_snapshot_async', + mergedCount: diskNarrowed.length + } + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } + }) + .catch(() => { + /* best-effort */ + }) + } + if (!keepExistingTimelineEvents) { if (restoredFromSession && sessionSnap) { feedPaintSessionPendingRef.current = true const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap) + timelineMergeBootstrapRef.current = restored.slice() setEvents(restored) lastEventsForTimelinePrefetchRef.current = restored setNewEvents([]) @@ -1966,8 +2029,6 @@ const NoteList = forwardRef( } else { let primedFromDisk = false let spellLocalMergeBase: Event[] = [] - const isSpellPageLocalWarmup = - hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0 if (isSpellPageLocalWarmup) { const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) @@ -1997,6 +2058,7 @@ const NoteList = forwardRef( ) if (mergedS.length > 0) { spellLocalMergeBase = mergedS + timelineMergeBootstrapRef.current = mergedS.slice() setEvents(mergedS) lastEventsForTimelinePrefetchRef.current = mergedS setNewEvents([]) @@ -2012,20 +2074,21 @@ const NoteList = forwardRef( } } - try { - const [diskRaw, fromPub, fromArch] = await Promise.all([ - client.getTimelineDiskSnapshotEvents( - mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> - ), - indexedDb.getCachedPublicationEventsByKinds(localLayerCap * 2, kindsForScan), - indexedDb.scanEventArchiveByKinds({ - kinds: kindsForScan, - since: sinceTightest, - maxRowsScanned: 10_000, - maxMatches: localLayerCap * 2 - }) - ]) - if (!timelineEffectStale()) { + void (async () => { + try { + const [diskRaw, fromPub, fromArch] = await Promise.all([ + client.getTimelineDiskSnapshotEvents( + mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + ), + indexedDb.getCachedPublicationEventsByKinds(localLayerCap * 2, kindsForScan), + indexedDb.scanEventArchiveByKinds({ + kinds: kindsForScan, + since: sinceTightest, + maxRowsScanned: 10_000, + maxMatches: localLayerCap * 2 + }) + ]) + if (!effectActive || timelineEffectStale()) return const seen = new Set() const combinedRaw: Event[] = [] for (const ev of diskRaw) { @@ -2046,62 +2109,33 @@ const NoteList = forwardRef( combinedRaw.push(ev) } combinedRaw.sort((a, b) => b.created_at - a.created_at) - if (combinedRaw.length > 0) { - const diskNarrowed = narrowLiveBatch(combinedRaw) - if (diskNarrowed.length > 0) { - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) - ) - if (merged.length > 0) { - setEvents(merged) - lastEventsForTimelinePrefetchRef.current = merged - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: - spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot', - mergedCount: merged.length - } - primedFromDisk = true - } - } - } - } - } catch { - /* spell local + disk snapshot is best-effort */ - } - } else if (!oneShotFetch && mappedSubRequests.length > 0) { - try { - const diskRaw = await client.getTimelineDiskSnapshotEvents( - mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> - ) - if (!timelineEffectStale() && diskRaw.length > 0) { - const diskNarrowed = narrowLiveBatch(diskRaw) - if (diskNarrowed.length > 0) { - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById([], diskNarrowed, eventCapEarly, areAlgoRelays) - ) - setEvents(merged) - lastEventsForTimelinePrefetchRef.current = merged - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: 'disk_snapshot', - mergedCount: merged.length - } - primedFromDisk = true + if (combinedRaw.length === 0) return + const diskNarrowed = narrowLiveBatch(combinedRaw) + if (diskNarrowed.length === 0) return + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length === 0) return + timelineMergeBootstrapRef.current = merged.slice() + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: + spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot', + mergedCount: merged.length } + } catch { + /* spell local + disk snapshot is best-effort */ } - } catch { - /* disk snapshot is best-effort */ - } + })() } if (!primedFromDisk) { if (!keepRowsVisible) setLoading(true) + timelineMergeBootstrapRef.current = [] setEvents([]) setNewEvents([]) setShowCount(revealBatchSize ?? SHOW_COUNT) @@ -2110,6 +2144,11 @@ const NoteList = forwardRef( } else if (!keepRowsVisible) { setLoading(true) } + + if (!oneShotFetch && mappedSubRequests.length > 0) { + startNonBlockingTimelineDiskPrime() + } + setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh @@ -2138,33 +2177,35 @@ const NoteList = forwardRef( return undefined } if (!warmQOneShot && mappedSubRequests.length > 0) { - try { - const diskRaw = await client.getTimelineDiskSnapshotEvents( - mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> - ) - if (!timelineEffectStale() && diskRaw.length > 0) { - const capDisk = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP + const capDisk = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP + const diskReqOneShot = mappedSubRequests as Array<{ + urls: string[] + filter: TSubRequestFilter + }> + void client + .getTimelineDiskSnapshotEvents(diskReqOneShot) + .then((diskRaw) => { + if (!effectActive || timelineEffectStale()) return + if (diskRaw.length === 0) return const narrowed = narrowLiveBatch(diskRaw) - if (narrowed.length > 0) { - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById([], narrowed, capDisk, areAlgoRelays) - ) - if (merged.length > 0) { - setEvents(merged) - lastEventsForTimelinePrefetchRef.current = merged - setLoading(false) - feedRelayReturnedAnyEventRef.current = true - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: 'disk_snapshot_one_shot', - mergedCount: merged.length - } - } + if (narrowed.length === 0) return + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowed, capDisk, areAlgoRelays) + ) + if (merged.length === 0) return + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setLoading(false) + feedRelayReturnedAnyEventRef.current = true + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'disk_snapshot_one_shot', + mergedCount: merged.length } - } - } catch { - /* best-effort */ - } + }) + .catch(() => { + /* best-effort */ + }) } const firstRelayGraceResolved = oneShotFirstRelayGraceMs === undefined @@ -2432,15 +2473,20 @@ const NoteList = forwardRef( if (batch.length > 0) { if (narrowed.length > 0) { setEvents((prev) => { + const boot = timelineMergeBootstrapRef.current + const base = boot !== null ? boot : prev const next = progressiveWarmupQueryRef.current?.trim() ? mergeProgressiveSearchEvents( - prev, + base, narrowed, oneShotAfterMergeComparatorRef.current ) : collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays) + mergeEventBatchesById(base, narrowed, eventCap, areAlgoRelays) ) + if (boot !== null && narrowed.length > 0) { + timelineMergeBootstrapRef.current = null + } lastEventsForTimelinePrefetchRef.current = next return next }) @@ -2538,6 +2584,10 @@ const NoteList = forwardRef( ) { setFeedReasonLabelsTick((n) => n + 1) } + + if (eosed && timelineMergeBootstrapRef.current !== null) { + timelineMergeBootstrapRef.current = null + } }, onNew: (event: Event) => { if (!effectActive) return @@ -2580,15 +2630,52 @@ const NoteList = forwardRef( if (shouldHideEventRef.current(event)) return if (pubkey && event.pubkey === pubkey) { setEvents((oldEvents) => { - if (oldEvents.some((e) => e.id === event.id)) return oldEvents + const boot = timelineMergeBootstrapRef.current + const base = boot !== null ? boot : oldEvents + if (base.some((e) => e.id === event.id)) { + return boot !== null ? base : oldEvents + } if ( isNip18RepostKind(event.kind) && - feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents) + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base) ) { noteStatsService.updateNoteStatsByEvents([event], undefined) - return oldEvents + return boot !== null ? base : oldEvents + } + if (boot !== null) { + timelineMergeBootstrapRef.current = null + } + return [event, ...base] + }) + } else if (hostPrimaryPageNameRef.current === 'feed') { + // Primary home relay feeds: merge live EVENTs into the timeline immediately. The generic path + // buffered everyone else's notes in `newEvents` until scroll-to-top — that felt like no streaming. + setEvents((oldEvents) => { + const boot = timelineMergeBootstrapRef.current + const base = boot !== null ? boot : oldEvents + if (base.some((e) => e.id === event.id)) { + return boot !== null ? base : oldEvents + } + if ( + isNip18RepostKind(event.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base) + ) { + noteStatsService.updateNoteStatsByEvents([event], undefined) + return boot !== null ? base : oldEvents } - return [event, ...oldEvents] + if (boot !== null) { + timelineMergeBootstrapRef.current = null + } + const cap = allowKindlessRelayExploreRef.current + ? RELAY_EXPLORE_LIMIT + : areAlgoRelays + ? ALGO_LIMIT + : LIMIT + const next = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(base, [event], cap, areAlgoRelays) + ) + lastEventsForTimelinePrefetchRef.current = next + return next }) } else { setNewEvents((oldEvents) => { @@ -2659,6 +2746,7 @@ const NoteList = forwardRef( const snapshotKeyForCleanup = sessionSnapshotIdentityKey return () => { effectActive = false + timelineMergeBootstrapRef.current = null setProgressiveLayersSearching(false) followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current = null @@ -2883,6 +2971,22 @@ const NoteList = forwardRef( } return [event, ...oldEvents] }) + } else if (hostPrimaryPageNameRef.current === 'feed') { + setEvents((oldEvents) => { + if (oldEvents.some((e) => e.id === event.id)) return oldEvents + if ( + isNip18RepostKind(event.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents) + ) { + noteStatsService.updateNoteStatsByEvents([event], undefined) + return oldEvents + } + const next = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(oldEvents, [event], eventCapDelta, areAlgoRelays) + ) + lastEventsForTimelinePrefetchRef.current = next + return next + }) } else { setNewEvents((oldEvents) => { const pool = [...eventsRef.current, ...oldEvents] @@ -3199,6 +3303,13 @@ const NoteList = forwardRef( blankFeedHiddenAtRef.current = Date.now() return } + if ( + !oneShotFetchRef.current && + feedFullSearchEventsRef.current === null && + newEventsRef.current.length > 0 + ) { + flushPendingNewEventsIntoTimelineRef.current() + } const hidAt = blankFeedHiddenAtRef.current blankFeedHiddenAtRef.current = null const hiddenMs = hidAt != null ? Date.now() - hidAt : 0 @@ -4045,7 +4156,7 @@ const NoteList = forwardRef( ) return ( -
+
{supportTouch ? ( diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index ef087ef0..673850e6 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -496,11 +496,16 @@ export function useFetchProfile(id?: string, skipCache = false) { } } - // CRITICAL: Guard against infinite loops - limit effect runs per pubkey (reduced from 10 to 3) - // Only increment if we're actually going to process (not early exiting) + // CRITICAL: Guard against infinite loops — limit effect runs per pubkey. Feed batch often leaves + // {@link batchPlaceholder} rows that need several retries across noteFeed.version bumps; use a higher cap. if (extractedPubkey) { const runCount = effectRunCountRef.current.get(extractedPubkey) || 0 - if (runCount >= 3) { + const pkLower = extractedPubkey.toLowerCase() + const feedBatchPlaceholder = + noteFeed?.profiles.get(pkLower)?.batchPlaceholder === true || + noteFeed?.profiles.get(extractedPubkey)?.batchPlaceholder === true + const maxRunsBeforeCircuitBreak = feedBatchPlaceholder ? 12 : 3 + if (runCount >= maxRunsBeforeCircuitBreak) { logger.warn('[useFetchProfile] Too many effect runs for this pubkey, preventing infinite loop', { extractedPubkey, runCount diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts index b550f709..b3abdcd9 100644 --- a/src/lib/translate-client.ts +++ b/src/lib/translate-client.ts @@ -154,7 +154,14 @@ export async function fetchTranslateLanguages(): Promise 10_000) { lastLanguagesFailureLogAt = t - logger.warn('[Translate] /languages failed', { status: res.status }) + if (import.meta.env.DEV && (res.status === 503 || res.status === 502)) { + logger.debug( + '[Translate] /languages skipped — dev translate proxy has no backend (:5000). See PROXY_SETUP.md.', + { status: res.status } + ) + } else { + logger.warn('[Translate] /languages failed', { status: res.status }) + } } languagesCache = { list: [], at: t, fromFailure: true } recordAdvertisedTranslateCodesFromServer([]) diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index a2e32c1e..e528179f 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -1,18 +1,16 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' -import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { checkAlgoRelay } from '@/lib/relay' import { isWispTrendingNotesRelayUrl, WISP_TRENDING_FEED_KINDS } from '@/lib/wisp-trending-relay' -import { normalizeUrl } from '@/lib/url' +import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' 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, useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' +import React, { forwardRef, useEffect, useMemo, useState } from 'react' const RelaysFeed = forwardRef< TNoteListRef, @@ -23,12 +21,9 @@ 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) - /** After kindless single-relay REQ EOSEs with no events, re-subscribe with the normal kind list. */ - const [singleRelayKindFallback, setSingleRelayKindFallback] = useState(false) const relayUrlsKey = useMemo( () => @@ -92,11 +87,18 @@ const RelaysFeed = forwardRef< if (feedInfo.feedType === 'all-favorites') return 'all-favorites' if (feedInfo.feedType === 'relays') return `relays:${feedInfo.id ?? ''}` if (feedInfo.feedType === 'relay') { - const id = feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : '' + /** Same canonical URL identity as {@link NoteList} `subRequestsKey` (not `normalizeUrl` alone — HTTP index relays differ). */ + const urlsKey = [...relayUrls] + .map((u) => normalizeAnyRelayUrl(u) || u) + .filter(Boolean) + .sort() + .join('|') + if (urlsKey) return `relay:${urlsKey}` + const id = feedInfo.id ? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id : '' return `relay:${id}` } return undefined - }, [feedInfo.feedType, feedInfo.id]) + }, [feedInfo.feedType, feedInfo.id, relayUrls]) const wispTrendingSingleRelay = feedInfo.feedType === 'relay' && @@ -104,30 +106,6 @@ const RelaysFeed = forwardRef< !!relayUrls[0] && isWispTrendingNotesRelayUrl(relayUrls[0]) - /** 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 && - !wispTrendingSingleRelay - - const feedTopNotice = singleRelayKindFallback ? ( -

{t('singleRelayKindFallbackNotice')}

- ) : null - // Hooks must run every render — never place useMemo after conditional returns. const subRequests = useMemo(() => { if (!canRenderFeed) return [] @@ -139,9 +117,6 @@ const RelaysFeed = forwardRef< } ] } - if (singleRelayKindlessExplore) { - return [{ urls: relayUrls, filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } }] - } return [ { urls: relayUrls, @@ -150,14 +125,7 @@ const RelaysFeed = forwardRef< } } ] - }, [ - canRenderFeed, - relayUrls, - defaultKinds, - kindsOverride, - singleRelayKindlessExplore, - wispTrendingSingleRelay - ]) + }, [canRenderFeed, relayUrls, defaultKinds, wispTrendingSingleRelay]) if (!canRenderFeed) { return null @@ -176,17 +144,14 @@ const RelaysFeed = forwardRef< onSubHeaderRefresh={onSubHeaderRefresh} preserveTimelineOnSubRequestsChange feedTimelineScopeKey={feedTimelineScopeKey} - useFilterAsIs={singleRelayKindlessExplore} - allowKindlessRelayExplore={singleRelayKindlessExplore} - clientSideKindFilter={singleRelayKindlessExplore} showFeedClientFilter hostPrimaryPageName="feed" - onSingleRelayKindlessEmpty={ - feedInfo.feedType === 'relay' && relayUrls.length === 1 && !kindsOverride?.length - ? onSingleRelayKindlessEmpty - : undefined - } - feedTopNotice={feedTopNotice} + /** + * {@link timelinePublicReadFallback} uses {@link FAST_READ_RELAY_URLS} with the shard filter’s kinds only — + * there is no “this relay URL” scope. For a **single chip**, that made every relay show the same global batch. + * Keep fallback for multi-relay surfaces where a broad read matches user intent; single-chip feeds rely on + * that relay + disk/session hydrate only. + */ timelinePublicReadFallback={ feedInfo.feedType === 'all-favorites' || (feedInfo.feedType === 'relays' && relayUrls.length > 1) diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index d06b3ba2..75ba1c23 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -14,13 +14,11 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip' -import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker' import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import Logo from '@/assets/Logo' @@ -37,9 +35,9 @@ const NoteListPage = forwardRef((_, ref) => { const [homeSubHeader, setHomeSubHeader] = useState(null) const usesSubHeader = + feedInfo.feedType === 'all-favorites' || feedInfo.feedType === 'relay' || - feedInfo.feedType === 'relays' || - feedInfo.feedType === 'all-favorites' + feedInfo.feedType === 'relays' const runFeedRefresh = useCallback(() => { feedRef.current?.refresh() @@ -105,19 +103,7 @@ const NoteListPage = forwardRef((_, ref) => { ) } - const showFavoriteRelaysPicker = - isReady && - (feedInfo.feedType === 'all-favorites' || - feedInfo.feedType === 'relay' || - feedInfo.feedType === 'relays') - - const feedPageTitle = useMemo( - () => - feedInfo.feedType === 'relays' - ? t('relayType_relay_set') - : t('Favorite Relays'), - [feedInfo.feedType, t] - ) + const feedPageTitle = t('Favorite Relays') const subHeader = ( <> @@ -125,16 +111,12 @@ const NoteListPage = forwardRef((_, ref) => {

{feedPageTitle}

- {showFavoriteRelaysPicker ? : null} {homeSubHeader} ) /** Desktop: nav/logo/account live in titlebar only on small screens; refresh moves to subheader when present. Omit empty h-12 strip. */ - const showNoteListTitlebar = - isSmallScreen || - !usesSubHeader || - (feedInfo.feedType === 'relay' && !!feedInfo.id) + const showNoteListTitlebar = isSmallScreen || !usesSubHeader return ( ({ - feedType: 'relay', - id: DEFAULT_FAVORITE_RELAYS[0] + feedType: 'all-favorites' }) const feedInfoRef = useRef(feedInfo) /** Same logical list as {@link mergeRelayUrlLayers} result — reuse array ref so NoteList does not re-subscribe. */ @@ -204,60 +202,29 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { logger.debug('FeedProvider: favoriteRelays is empty, using defaults') } - const favoritesFeedRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - let feedInfo: TFeedInfo = { - feedType: 'relay', - id: favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0] - } - - // Ensure we always have a valid relay ID - if (!feedInfo.id) { - feedInfo.id = DEFAULT_FAVORITE_RELAYS[0] - } - logger.debug('Initial feedInfo setup:', { favoritesFeedRelays, favoriteRelays, blockedRelays, feedInfo }) - + let stored: TFeedInfo | null = null if (pubkey) { - const storedFeedInfo = storage.getFeedInfo(pubkey) - logger.debug('Stored feed info:', storedFeedInfo) - if (storedFeedInfo) { - feedInfo = storedFeedInfo - } + const fromStorage = storage.getFeedInfo(pubkey) + logger.debug('Stored feed info:', fromStorage) + if (fromStorage) stored = fromStorage } - // Pre-rewrite main feeds (`following`, `bookmarks`) are no longer supported; migrate persisted state. - const storedFeedType = (feedInfo as { feedType?: string }).feedType - const deprecatedMainFeed = storedFeedType === 'following' || storedFeedType === 'bookmarks' - if (deprecatedMainFeed) { - const previousMainFeed = storedFeedType + const storedFeedType = (stored as { feedType?: string } | null)?.feedType + const migrateHomeToCombo = + storedFeedType === 'following' || + storedFeedType === 'bookmarks' || + storedFeedType === 'relay' || + storedFeedType === 'relays' + + if (migrateHomeToCombo && pubkey) { const migrated: TFeedInfo = { feedType: 'all-favorites' } - feedInfo = migrated - if (pubkey) { - storage.setFeedInfo(migrated, pubkey) - } - logger.info('[FeedProvider] Migrated deprecated feed type to all-favorites', { - previous: previousMainFeed + storage.setFeedInfo(migrated, pubkey) + logger.info('[FeedProvider] Home feed uses combo (all-favorites); migrated stored selection', { + previous: storedFeedType }) - return await switchFeedRef.current('all-favorites') } - if (feedInfo.feedType === 'relays') { - return await switchFeedRef.current('relays', { activeRelaySetId: feedInfo.id }) - } - - if (feedInfo.feedType === 'relay') { - // Check if the stored relay is blocked, if so use first visible relay instead - if (feedInfo.id && blockedRelays.includes(feedInfo.id)) { - logger.component('FeedProvider', 'Stored relay is blocked, using first visible relay instead') - feedInfo.id = favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0] - } - logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id }) - return await switchFeedRef.current('relay', { relay: feedInfo.id }) - } - - if (feedInfo.feedType === 'all-favorites') { - logger.debug('Initializing all-favorites feed') - return await switchFeedRef.current('all-favorites') - } + return await switchFeedRef.current('all-favorites') } void init() diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 85903805..a52b4f7e 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -1132,6 +1132,61 @@ export class ReplaceableEventService { /* ignore */ } } + + /** + * Batched kind-0 REQ / DataLoader can miss rows that already exist in session or IndexedDB (ordering, + * timing, or chunk boundaries). Hydrate gaps from local caches first; only then hit the network. + */ + const gapIndices: number[] = [] + for (let i = 0; i < deduped.length; i++) { + if (!events[i]) gapIndices.push(i) + } + const LOCAL_GAP_CHUNK = 16 + for (let off = 0; off < gapIndices.length; off += LOCAL_GAP_CHUNK) { + const slice = gapIndices.slice(off, off + LOCAL_GAP_CHUNK) + await Promise.allSettled( + slice.map(async (idx) => { + const pubkey = deduped[idx]! + const pkLower = pubkey.toLowerCase() + let ev: NEvent | undefined = client.eventService.getSessionMetadataForPubkey(pkLower) + if (ev && shouldDropEventOnIngest(ev)) ev = undefined + if (!ev) { + try { + const row = await indexedDb.getReplaceableEvent(pkLower, kinds.Metadata) + if (row && !shouldDropEventOnIngest(row)) ev = row as NEvent + } catch { + /* ignore */ + } + } + if (ev) events[idx] = ev + }) + ) + } + + const MAX_METADATA_GAP_FILL_NETWORK = 48 + const GAP_FILL_NETWORK_PARALLEL = 4 + const stillGap: number[] = [] + for (let i = 0; i < deduped.length; i++) { + if (!events[i]) stillGap.push(i) + } + const cappedNetwork = stillGap.slice(0, MAX_METADATA_GAP_FILL_NETWORK) + for (let off = 0; off < cappedNetwork.length; off += GAP_FILL_NETWORK_PARALLEL) { + const slice = cappedNetwork.slice(off, off + GAP_FILL_NETWORK_PARALLEL) + await Promise.allSettled( + slice.map(async (idx) => { + const pubkey = deduped[idx]! + try { + const ev = await this.fetchProfileEvent(pubkey, false) + if (ev && !shouldDropEventOnIngest(ev)) { + events[idx] = ev + } + } catch { + /* ignore */ + } + }) + ) + } + const profiles: TProfile[] = [] for (let i = 0; i < deduped.length; i++) { const ev = events[i] diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 255e2e80..77a12f96 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2481,6 +2481,14 @@ class ClientService extends EventTarget { urls: relays } timeline = this.timelines[key] + } else { + // New subscription wave for this leaf key: the prior closer does not delete `timelines[key]`. + // Reusing stale `refs` made `handleTimelineEose` merge against an old head timestamp — e.g. when the + // relay sent a full `limit` batch whose newest row was still older than that head, `newRefs` became + // empty and `tl.refs` was replaced with [] or failed to adopt fresh rows (feed looked permanently stale). + timeline.filter = filter + timeline.urls = relays + timeline.refs = [] } // eslint-disable-next-line @typescript-eslint/no-this-alias diff --git a/vite.config.ts b/vite.config.ts index fc2a0ab5..c5ec4c6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -46,33 +46,72 @@ function fullReloadOnProvidersAndPages(): Plugin { } /** - * Default proxy logs one multiline error + stack per failed request when the index relay is down. - * Throttle to one hint: match `/api/events` paths (dev-index-relay), not other proxies like `/sites`. + * `http-proxy` logs `Error: connect ECONNREFUSED …` via `console.error`, bypassing Vite's `logger.error`. */ -function quietDevIndexRelayProxyErrors(devIndexRelayTarget: string): Plugin { - let lastSuppressedLog = 0 +function isOptionalDevProxyConnRefusedNoise(args: unknown[]): boolean { + const blob = args + .map((a) => { + if (typeof a === 'string') return a + if (a instanceof Error) return `${a.message}\n${a.stack ?? ''}` + return '' + }) + .join('\n') + if (!blob.includes('ECONNREFUSED')) return false + return ( + blob.includes('127.0.0.1:5000') || + blob.includes('127.0.0.1:8090') || + /\b:5000\b/.test(blob) || + /\b:8090\b/.test(blob) + ) +} + +/** + * When optional localhost backends are down, `http-proxy` otherwise logs a multiline stack per request. + * Throttle to one hint per category (cooldown), matching paths only — real misconfigurations still log. + */ +function quietOptionalDevProxyErrors(devIndexRelayTarget: string): Plugin { + let lastIndexRelaySuppressed = 0 + let lastTranslateSitesSuppressed = 0 const COOLDOWN_MS = 60_000 return { - name: 'quiet-dev-index-relay-proxy-errors', + name: 'quiet-optional-dev-proxy-errors', apply: 'serve', + configureServer(server) { + const prevConsoleError = console.error.bind(console) + console.error = (...args: unknown[]) => { + if (isOptionalDevProxyConnRefusedNoise(args)) return + prevConsoleError(...args) + } + server.httpServer?.on('close', () => { + console.error = prevConsoleError + }) + }, configResolved(config) { const prevError = config.logger.error.bind(config.logger) config.logger.error = (msg, options) => { const text = typeof msg === 'string' ? msg : '' - if ( - text.includes('http proxy error') && - text.includes('ECONNREFUSED') && - text.includes('/api/events') - ) { - const now = Date.now() - if (now - lastSuppressedLog >= COOLDOWN_MS) { - lastSuppressedLog = now - config.logger.warn( - `[vite] Dev index relay not reachable (${devIndexRelayTarget}). Start it or set VITE_DEV_INDEX_RELAY_TARGET. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.` - ) + if (text.includes('http proxy error') && text.includes('ECONNREFUSED')) { + if (text.includes('/api/events') || text.includes('/dev-index-relay')) { + const now = Date.now() + if (now - lastIndexRelaySuppressed >= COOLDOWN_MS) { + lastIndexRelaySuppressed = now + config.logger.warn( + `[vite] Dev index relay not reachable (${devIndexRelayTarget}). Start it or set VITE_DEV_INDEX_RELAY_TARGET. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.` + ) + } + return + } + if (text.includes('/api/translate') || text.includes('/sites')) { + const now = Date.now() + if (now - lastTranslateSitesSuppressed >= COOLDOWN_MS) { + lastTranslateSitesSuppressed = now + config.logger.warn( + `[vite] Optional dev proxies unreachable (LibreTranslate /api/translate → :5000, OG /sites → :8090). Start them or ignore — see PROXY_SETUP.md. Suppressing duplicate proxy errors for ${COOLDOWN_MS / 1000}s.` + ) + } + return } - return } prevError(msg, options) } @@ -148,7 +187,28 @@ export default defineConfig(({ mode }) => { '/api/translate': { target: 'http://127.0.0.1:5000', changeOrigin: true, - rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/' + rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/', + /** Match `/sites`: when LibreTranslate is not running, return JSON instead of a broken proxy response. */ + configure(proxy) { + proxy.on('error', (_err, _req, res) => { + const r = res as { + headersSent?: boolean + writeHead?: (c: number, h: Record) => void + end?: (b: string) => void + } + if (r.headersSent) return + if (typeof r?.writeHead === 'function' && typeof r?.end === 'function') { + r.writeHead(503, { 'Content-Type': 'application/json' }) + r.end( + JSON.stringify({ + ok: false, + error: 'translate_proxy_unreachable', + hint: 'Start LibreTranslate (or compatible API) on :5000 — see PROXY_SETUP.md' + }) + ) + } + }) + } }, '/sites': { target: 'http://127.0.0.1:8090', @@ -352,7 +412,7 @@ export default defineConfig(({ mode }) => { plugins: [ react(), fullReloadOnProvidersAndPages(), - quietDevIndexRelayProxyErrors(devIndexRelayTarget), + quietOptionalDevProxyErrors(devIndexRelayTarget), VitePWA({ registerType: 'autoUpdate', // Use public/manifest.webmanifest and index.html only; avoid duplicate manifest link in build