diff --git a/package-lock.json b/package-lock.json index 6a4f0abc..ea58ac91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.7.1", + "version": "23.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.7.1", + "version": "23.7.3", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", @@ -47,7 +47,6 @@ "@radix-ui/react-tabs": "^1.1.2", "@scure/base": "^2.0.0", "@tailwindcss/typography": "^0.5.16", - "@tanstack/react-virtual": "^3.13.24", "@tiptap/core": "^2.12.0", "@tiptap/extension-document": "^2.12.0", "@tiptap/extension-emoji": "^2.26.1", @@ -1161,9 +1160,9 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", + "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", "dev": true, "license": "MIT", "dependencies": { @@ -5708,33 +5707,6 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, - "node_modules/@tanstack/react-virtual": { - "version": "3.13.24", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", - "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.14.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", - "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index a683d2de..6bd51333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.7.1", + "version": "23.7.3", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", @@ -77,7 +77,6 @@ "@radix-ui/react-tabs": "^1.1.2", "@scure/base": "^2.0.0", "@tailwindcss/typography": "^0.5.16", - "@tanstack/react-virtual": "^3.13.24", "@tiptap/core": "^2.12.0", "@tiptap/extension-document": "^2.12.0", "@tiptap/extension-emoji": "^2.26.1", diff --git a/src/components/NewNotesButton/index.tsx b/src/components/NewNotesButton/index.tsx index ea5cd84b..39bcab43 100644 --- a/src/components/NewNotesButton/index.tsx +++ b/src/components/NewNotesButton/index.tsx @@ -32,7 +32,7 @@ export default function NewNotesButton({ {newEvents.length > 0 && (
boolean /** Override default cap for merged one-shot batches (wide d-tag / search merges). */ oneShotMergedCap?: number + /** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */ + timelinePublicReadFallback?: boolean }>(function NormalFeed( { subRequests, @@ -106,7 +108,8 @@ const NormalFeed = forwardRef
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 9e22a60e..86ee2838 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -478,7 +478,7 @@ export default function Note({ userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} maxFileSizeKb={showFull ? 2048 : 500} - deferRemoteAvatar={!showFull} + deferRemoteAvatar={false} />
diff --git a/src/components/NoteList/VirtualizedFeedRows.tsx b/src/components/NoteList/VirtualizedFeedRows.tsx deleted file mode 100644 index fb7715ea..00000000 --- a/src/components/NoteList/VirtualizedFeedRows.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import NoteCard from '@/components/NoteCard' -import MediaGridItem from '@/components/MediaGridItem' -import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual' -import type { Event } from 'nostr-tools' -import { memo } from 'react' - -const ESTIMATE_NOTE_ROW_PX = 280 -const ESTIMATE_GRID_ROW_PX = 120 -/** Smaller overscan reduces stacked off-screen rows when scroll sync is briefly wrong (Firefox paint glitches). */ -const VIRTUAL_OVERSCAN = 4 - -export type VirtualizedFeedRowsProps = { - events: Event[] - gridLayout: boolean - filterMutedNotes: boolean - eventReasonLabelMap: Map - /** When true, list scrolls with `window`; otherwise `scrollElement` must be set. */ - useWindowScroll: boolean - scrollElement: HTMLElement | null - /** Document offset of the list root (window virtualizer scroll margin). */ - scrollMarginTop: number -} - -const WindowRows = memo(function WindowRows({ - events, - gridLayout, - filterMutedNotes, - eventReasonLabelMap, - scrollMarginTop -}: Omit) { - const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length - const virtualizer = useWindowVirtualizer({ - count: rowCount, - estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), - overscan: VIRTUAL_OVERSCAN, - scrollMargin: scrollMarginTop, - // Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state). - getItemKey: (index) => - gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`) - }) - - return ( -
- {virtualizer.getVirtualItems().map((vi) => ( -
- {gridLayout ? ( -
- {events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => ( - - ))} -
- ) : ( - - )} -
- ))} -
- ) -}) - -const ElementRows = memo(function ElementRows({ - events, - gridLayout, - filterMutedNotes, - eventReasonLabelMap, - scrollElement -}: Omit & { - scrollElement: HTMLElement -}) { - const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length - const virtualizer = useVirtualizer({ - count: rowCount, - getScrollElement: () => scrollElement, - estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), - overscan: VIRTUAL_OVERSCAN, - // Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state). - getItemKey: (index) => - gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`) - }) - - return ( -
- {virtualizer.getVirtualItems().map((vi) => ( -
- {gridLayout ? ( -
- {events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => ( - - ))} -
- ) : ( - - )} -
- ))} -
- ) -}) - -/** Window- or element-scrolling virtual list for feed rows (and 3-column media grid by row). */ -export default memo(function VirtualizedFeedRows({ - events, - gridLayout, - filterMutedNotes, - eventReasonLabelMap, - useWindowScroll, - scrollElement, - scrollMarginTop -}: VirtualizedFeedRowsProps) { - if (events.length === 0) { - return null - } - - if (useWindowScroll) { - return ( - - ) - } - - if (!scrollElement) { - return null - } - - return ( - - ) -}) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index acbf32dd..f9b3b640 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,5 +1,11 @@ import NewNotesButton from '@/components/NewNotesButton' -import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' +import { + ExtendedKind, + FAST_READ_RELAY_URLS, + FIRST_RELAY_RESULT_GRACE_MS, + SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, + SINGLE_RELAY_KINDLESS_REQ_LIMIT +} from '@/constants' import { collectEmbeddedEventPrefetchTargets, getNip18RepostTargetId, @@ -66,7 +72,6 @@ import { createPortal } from 'react-dom' import { toast } from 'sonner' import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey' import { usePrimaryPageOptional } from '@/contexts/primary-page-context' -import { usePrimaryPageScrollAreaRefOptional } from '@/contexts/primary-page-scroll-area-context' import type { TPrimaryPageName } from '@/PageManager' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -83,8 +88,8 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import { NoteCardLoadingSkeleton } from '../NoteCard' -import VirtualizedFeedRows from './VirtualizedFeedRows' +import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' +import MediaGridItem from '../MediaGridItem' const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips) const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds @@ -128,6 +133,8 @@ const LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT = 2.35 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 function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null { if (!node) return null @@ -140,23 +147,6 @@ function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | n return null } -/** Scrollport used by {@link VirtualizedFeedRows} — must sit on the same DOM chain as the list rows. */ -function resolveFeedVirtualScrollAnchor(root: HTMLElement | null, listAnchor: HTMLElement | null): HTMLElement | null { - return listAnchor ?? root -} - -/** Prefer the layout’s primary scroll div when the feed is inside it; otherwise walk ancestors. */ -function resolvePrimaryFeedScrollPort( - layoutScrollEl: HTMLElement | null, - anchor: HTMLElement | null -): HTMLElement | null { - if (!anchor) return null - if (layoutScrollEl && layoutScrollEl.contains(anchor)) { - return layoutScrollEl - } - return getNearestScrollableAncestor(anchor) -} - function distanceFromScrollBottom(scrollRoot: HTMLElement | Window): number { if (scrollRoot === window) { const doc = document.documentElement @@ -705,7 +695,12 @@ const NoteList = forwardRef( feedClientFilterTabRowHost, onSingleRelayKindlessEmpty, feedTopNotice, - gridLayout = false + gridLayout = false, + /** + * When true (multi-relay home feeds): if every relay in the subscribe wave fails before EOSE, run one + * {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only. + */ + timelinePublicReadFallback = false }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -760,6 +755,7 @@ const NoteList = forwardRef( feedTopNotice?: ReactNode /** When true, render events as an Instagram-style 3-column square media grid. */ gridLayout?: boolean + timelinePublicReadFallback?: boolean }, ref ) => { @@ -778,6 +774,7 @@ const NoteList = forwardRef( const feedFullSearchEventsRef = useRef(null) const displayTimelineSourceRef = useRef([]) const [newEvents, setNewEvents] = useState([]) + const newEventsRef = useRef([]) const [hasMore, setHasMore] = useState(true) const [loading, setLoading] = useState(true) /** Session/IDB/relay layers still running for {@link progressiveWarmupQuery} feeds (drives “Looking for more…”). */ @@ -793,7 +790,6 @@ const NoteList = forwardRef( const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('') const [feedClientTimeUnit, setFeedClientTimeUnit] = useState('day') const supportTouch = useMemo(() => isTouchDevice(), []) - const primaryScrollAreaRef = usePrimaryPageScrollAreaRefOptional() const timelineEventsForFilter = feedFullSearchEvents ?? events @@ -807,12 +803,6 @@ const NoteList = forwardRef( const bottomRef = useRef(null) /** List root for intersection / load-more wiring (outer NoteList shell). */ const feedRootRef = useRef(null) - /** - * Wrapper around the virtualized list block — closer to rows than {@link feedRootRef}, so - * {@link getNearestScrollableAncestor} picks the same scrollport the user actually scrolls (e.g. - * `react-simple-pull-to-refresh`’s inner panel on touch, or the primary page div on desktop). - */ - const feedListScrollAnchorRef = useRef(null) const topRef = useRef(null) const spellFeedFirstPaintLoggedKeyRef = useRef('') const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries @@ -853,6 +843,8 @@ const NoteList = forwardRef( const emptyRelayNoHitsToastKeyRef = useRef('') /** Per-relay outcomes for the current subscribe wave (merged shards); drives empty-feed toast detail. */ const [feedSubscribeRelayOutcomes, setFeedSubscribeRelayOutcomes] = useState([]) + /** One-shot per timeline init: after an all-failed relay wave, try {@link FAST_READ_RELAY_URLS}. */ + const publicReadFallbackAttemptedRef = useRef(false) /** * Bumped when {@link feedPaintLiveRelayDoneRef} becomes true so the empty-feed toast effect re-runs. * (Loading clears when subscribe wires; merged EOSE arrives later.) @@ -1029,6 +1021,7 @@ const NoteList = forwardRef( const followingFeedDeltaCloserRef = useRef<(() => void) | null>(null) useLayoutEffect(() => { + publicReadFallbackAttemptedRef.current = false setFeedTimelineEmptyUiReady(false) setFeedSubscribeRelayOutcomes([]) }, [timelineSubscriptionKey, refreshCount]) @@ -1140,6 +1133,24 @@ const NoteList = forwardRef( const withKindFilterRef = useRef(withKindFilter) withKindFilterRef.current = withKindFilter + const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => { + if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs + if (withKindFilterRef.current && !showAllKindsRef.current) { + return evs.filter((e) => + eventPassesNoteListKindPicker( + e, + effectiveShowKindsRef.current, + showKind1OPsRef.current, + showKind1RepliesRef.current, + showKind1111Ref.current + ) + ) + } + if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs + if (!withKindFilterRef.current) return evs + return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind)) + } + /** * When to apply kind picker + kind-1 OP|reply / 1111 / GitRelease splits to visible rows. * Home feeds default to {@link withKindFilter}. Relay explorer sets {@link showAllKinds} explicitly (kindless @@ -1436,88 +1447,6 @@ const NoteList = forwardRef( } }, [visibleNoteIdsForStatsPrefetchKey]) - const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState(null) - const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0) - /** Last applied scroll port — skip redundant setState when RO fires on every row/media resize (fixes feed “shake”). */ - const lastFeedScrollPortRef = useRef<{ parent: HTMLElement | null; marginTop: number } | null>(null) - /** - * Resolve the scroll container once per feed / refresh — not on every {@link clientFilteredEvents} length tick. - * Re-running this on each timeline merge re-set scroll state and interacted badly with the virtualizer while rows - * were still settling (absolute rows could paint past the list bounds). - */ - useLayoutEffect(() => { - let alive = true - let resizeCoalesceRaf = 0 - - const applyFeedScrollPort = () => { - if (!alive) return - const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current) - if (!anchor) { - const last = lastFeedScrollPortRef.current - if (!last || last.parent !== null || last.marginTop !== 0) { - lastFeedScrollPortRef.current = { parent: null, marginTop: 0 } - setFeedVirtualScrollParent(null) - setFeedVirtualScrollMarginTop(0) - } - return - } - const layoutEl = primaryScrollAreaRef?.current ?? null - const nextParent = resolvePrimaryFeedScrollPort(layoutEl, anchor) - const nextMargin = Math.round(anchor.offsetTop) - const last = lastFeedScrollPortRef.current - if (last && last.parent === nextParent && last.marginTop === nextMargin) { - return - } - lastFeedScrollPortRef.current = { parent: nextParent, marginTop: nextMargin } - setFeedVirtualScrollParent(nextParent) - setFeedVirtualScrollMarginTop(nextMargin) - } - - lastFeedScrollPortRef.current = null - applyFeedScrollPort() - let innerRaf = 0 - const outerRaf = requestAnimationFrame(() => { - if (!alive) return - applyFeedScrollPort() - innerRaf = requestAnimationFrame(() => { - if (!alive) return - applyFeedScrollPort() - }) - }) - const deferTimer = window.setTimeout(() => { - if (!alive) return - applyFeedScrollPort() - }, 0) - - const scheduleApplyFromResize = () => { - if (!alive) return - if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf) - resizeCoalesceRaf = requestAnimationFrame(() => { - resizeCoalesceRaf = 0 - if (!alive) return - applyFeedScrollPort() - }) - } - - let ro: ResizeObserver | null = null - const root = feedRootRef.current - if (root && typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(() => { - scheduleApplyFromResize() - }) - ro.observe(root) - } - - return () => { - alive = false - if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf) - cancelAnimationFrame(outerRaf) - cancelAnimationFrame(innerRaf) - window.clearTimeout(deferTimer) - ro?.disconnect() - } - }, [timelineSubscriptionKey, refreshCount, primaryScrollAreaRef]) - const clientFilteredNewEvents = useMemo( () => showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents, @@ -1657,10 +1586,52 @@ const NoteList = forwardRef( }, 500) }, [scrollToTop]) + const flushPendingNewEventsIntoTimeline = useCallback(() => { + const pending = newEventsRef.current + if (pending.length === 0) return + setEvents((oldEvents) => { + const pool: Event[] = [...oldEvents] + const statsOnly: Event[] = [] + const kept: Event[] = [] + for (const ev of pending) { + if ( + isNip18RepostKind(ev.kind) && + feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool) + ) { + statsOnly.push(ev) + continue + } + kept.push(ev) + pool.push(ev) + } + if (statsOnly.length > 0) { + noteStatsService.updateNoteStatsByEvents(statsOnly, undefined) + } + return [...kept, ...oldEvents] + }) + setNewEvents([]) + }, []) + + const flushPendingNewEventsIntoTimelineRef = useRef(flushPendingNewEventsIntoTimeline) + flushPendingNewEventsIntoTimelineRef.current = flushPendingNewEventsIntoTimeline + + useEffect(() => { + if (oneShotFetchRef.current) return + if (newEvents.length === 0) return + const anchor = feedRootRef.current + const parent = getNearestScrollableAncestor(anchor) + const root: HTMLElement | Window = parent ?? window + const top = root === window ? window.scrollY : (root as HTMLElement).scrollTop + if (top > AUTO_MERGE_NEW_EVENTS_TOP_PX) return + flushPendingNewEventsIntoTimeline() + }, [newEvents.length, flushPendingNewEventsIntoTimeline]) + // Re-subscribe whenever connectivity flips so we immediately switch between // local-only (offline) and normal (online) relay sets without waiting for // the next user-triggered refresh. const isOfflineRef = useRef(isOffline) + const oneShotFetchRef = useRef(oneShotFetch) + oneShotFetchRef.current = oneShotFetch useEffect(() => { const prev = isOfflineRef.current isOfflineRef.current = isOffline @@ -3010,6 +2981,10 @@ const NoteList = forwardRef( eventsRef.current = events }, [events]) + useEffect(() => { + newEventsRef.current = newEvents + }, [newEvents]) + const loadingSafetyMs = timelineLoadingSafetyTimeoutMs ?? 15_000 useEffect(() => { @@ -3051,6 +3026,7 @@ const NoteList = forwardRef( const blankFeedHiddenAtRef = useRef(null) /** Avoid subscribe storms when the tab stays empty (dead relays): visibility resume used to call `refresh()` every few seconds. */ const blankFeedVisibilityResumeRetryAtRef = useRef(0) + const lastNewNotesAutoFlushMsRef = useRef(0) useEffect(() => { showCountRef.current = showCount @@ -3128,6 +3104,86 @@ const NoteList = forwardRef( oneShotFetch, t ]) + + useEffect(() => { + if (!timelinePublicReadFallback) return + if (oneShotFetch || areAlgoRelays) return + if (!navigator.onLine) return + const warm = progressiveWarmupQuery?.trim() + if (warm) return + if (feedFullSearchEvents !== null) return + if (feedSubscribeRelayOutcomes.length === 0) return + if (publicReadFallbackAttemptedRef.current) return + + const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes) + if (uiStatuses.some((s) => s.success)) return + + publicReadFallbackAttemptedRef.current = true + + const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) + if (!mapped.length) return + + const filter: Filter = { ...(mapped[0]!.filter as Filter) } + if (!filter.kinds?.length) { + filter.kinds = effectiveShowKinds.length > 0 ? [...effectiveShowKinds] : [kinds.ShortTextNote] + } + filter.limit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) + + const eventCap = allowKindlessRelayExplore + ? RELAY_EXPLORE_LIMIT + : areAlgoRelays + ? ALGO_LIMIT + : LIMIT + + void (async () => { + try { + const raw = await client.fetchEvents(FAST_READ_RELAY_URLS, filter, { + cache: true, + globalTimeout: 22_000, + eoseTimeout: 3500, + firstRelayResultGraceMs: false + }) + if (raw.length === 0) return + + const narrowed = narrowLiveBatchUsingRefs(raw) + if (narrowed.length === 0) return + + logger.info('[NoteList] Public read fallback merged after all relays failed', { + timelineSubscriptionKey, + fetched: raw.length, + mergedVisible: narrowed.length + }) + + setEvents((prev) => { + const next = progressiveWarmupQueryRef.current?.trim() + ? mergeProgressiveSearchEvents( + prev, + narrowed, + oneShotAfterMergeComparatorRef.current + ) + : collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays) + ) + lastEventsForTimelinePrefetchRef.current = next + return next + }) + feedRelayReturnedAnyEventRef.current = true + } catch (e) { + logger.warn('[NoteList] timeline public read fallback failed', { error: e }) + } + })() + }, [ + timelinePublicReadFallback, + oneShotFetch, + areAlgoRelays, + progressiveWarmupQuery, + feedFullSearchEvents, + feedSubscribeRelayOutcomes, + mapLiveSubRequestsForTimeline, + effectiveShowKinds, + allowKindlessRelayExplore, + timelineSubscriptionKey + ]) useEffect(() => { hasMoreRef.current = hasMore @@ -3400,6 +3456,20 @@ const NoteList = forwardRef( let lastScrollTopForPrefetchDir = 0 let lastScrollPrefetchInvokeMs = 0 + const onScrollFlushNewNotesAtTop = () => { + if (oneShotFetchRef.current) return + if (feedFullSearchEventsRef.current !== null) return + const t = scrollPrefetchTarget + if (!t) return + const top = t === window ? window.scrollY : (t as HTMLElement).scrollTop + if (top > AUTO_MERGE_NEW_EVENTS_TOP_PX) return + if (newEventsRef.current.length === 0) return + const now = Date.now() + if (now - lastNewNotesAutoFlushMsRef.current < 350) return + lastNewNotesAutoFlushMsRef.current = now + flushPendingNewEventsIntoTimelineRef.current() + } + const onScrollPrefetch = () => { if (scrollPrefetchRafId) return scrollPrefetchRafId = requestAnimationFrame(() => { @@ -3434,17 +3504,18 @@ const NoteList = forwardRef( } const wireScrollPrefetch = () => { - const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current) - const layoutEl = primaryScrollAreaRef?.current ?? null - const parent = resolvePrimaryFeedScrollPort(layoutEl, anchor) + const anchor = feedRootRef.current + const parent = getNearestScrollableAncestor(anchor) const next: HTMLElement | Window = parent ?? window if (scrollPrefetchTarget && scrollPrefetchTarget !== next) { scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch) + scrollPrefetchTarget.removeEventListener('scroll', onScrollFlushNewNotesAtTop) } scrollPrefetchTarget = next lastScrollTopForPrefetchDir = next === window ? window.scrollY : (next as HTMLElement).scrollTop next.addEventListener('scroll', onScrollPrefetch, { passive: true }) + next.addEventListener('scroll', onScrollFlushNewNotesAtTop, { passive: true }) } const wireScrollPrefetchSoonId = window.setTimeout(() => { @@ -3474,6 +3545,7 @@ const NoteList = forwardRef( window.clearTimeout(wireScrollPrefetchSoonId) if (scrollPrefetchTarget) { scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch) + scrollPrefetchTarget.removeEventListener('scroll', onScrollFlushNewNotesAtTop) scrollPrefetchTarget = null } if (observerInstance && currentBottomRef) { @@ -3485,7 +3557,7 @@ const NoteList = forwardRef( loadMoreTimeoutRef.current = null } } - }, [timelineSubscriptionKey, primaryScrollAreaRef]) + }, [timelineSubscriptionKey]) // CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content) // This ensures embedded events are ready before user scrolls to them @@ -3613,27 +3685,7 @@ const NoteList = forwardRef( }, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents]) const showNewEvents = () => { - setEvents((oldEvents) => { - const pool: Event[] = [...oldEvents] - const statsOnly: Event[] = [] - const kept: Event[] = [] - for (const ev of newEvents) { - if ( - isNip18RepostKind(ev.kind) && - feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool) - ) { - statsOnly.push(ev) - continue - } - kept.push(ev) - pool.push(ev) - } - if (statsOnly.length > 0) { - noteStatsService.updateNoteStatsByEvents(statsOnly, undefined) - } - return [...kept, ...oldEvents] - }) - setNewEvents([]) + flushPendingNewEventsIntoTimeline() setTimeout(() => { scrollToTop('smooth') }, 0) @@ -3901,18 +3953,23 @@ const NoteList = forwardRef( {t('Feed full search empty')}
) : null} - {clientFilteredEvents.length > 0 ? ( - - ) : null} + {gridLayout ? ( +
+ {clientFilteredEvents.map((event) => ( + + ))} +
+ ) : ( + clientFilteredEvents.map((event) => ( + + )) + )} {listSourceEvents.length === 0 && !feedFullSearchActive && (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? ( @@ -4009,9 +4066,7 @@ const NoteList = forwardRef(
) : null} {showFeedClientFilter ? feedClientFilterBar : null} -
- {list} -
+ {list}
) : ( @@ -4025,9 +4080,7 @@ const NoteList = forwardRef( ) : null} {showFeedClientFilter ? feedClientFilterBar : null} -
- {list} -
+ {list} )} diff --git a/src/contexts/primary-page-scroll-area-context.tsx b/src/contexts/primary-page-scroll-area-context.tsx deleted file mode 100644 index 1ed906eb..00000000 --- a/src/contexts/primary-page-scroll-area-context.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createContext, useContext, type ReactNode, type RefObject } from 'react' - -const PrimaryPageScrollAreaRefContext = createContext | null>(null) - -/** - * The desktop primary column’s main `overflow-y: auto` node (see {@link PrimaryPageLayout}). - * Feeds use this so {@link VirtualizedFeedRows} observes the same scrollport the user actually scrolls. - */ -export function PrimaryPageScrollAreaRefProvider({ - scrollAreaRef, - children -}: { - scrollAreaRef: RefObject - children: ReactNode -}) { - return ( - - {children} - - ) -} - -export function usePrimaryPageScrollAreaRefOptional(): RefObject | null { - return useContext(PrimaryPageScrollAreaRefContext) -} diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index 1b2714e2..cd84af23 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -3,7 +3,6 @@ import ScrollToTopButton from '@/components/ScrollToTopButton' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { Titlebar } from '@/components/Titlebar' import { usePrimaryPage } from '@/contexts/primary-page-context' -import { PrimaryPageScrollAreaRefProvider } from '@/contexts/primary-page-scroll-area-context' import type { TPrimaryPageName } from '@/PageManager' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -158,10 +157,8 @@ const PrimaryPageLayout = forwardRef( : 'absolute bottom-0 left-0 right-0 top-0 min-w-0 overflow-y-auto overflow-x-auto' } > - - {children} -
- + {children} +
{displayScrollToTopButton && } diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 9ac59195..e13fae85 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -65,10 +65,10 @@ export interface RelayListBuilderOptions { /** Whether to include user's favorite relays (kind 10012) */ includeFavoriteRelays?: boolean /** - * When true with fast-read / searchable includes: insert `FAST_READ_RELAY_URLS` and - * `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before** author + user - * NIP-65 lists. Used for single-event / embed fetches so public mirrors (e.g. nos.lol) are not - * queued behind dozens of personal relays under the global connection cap. + * When true with fast-read / searchable / profile-fetch includes: insert `PROFILE_FETCH_RELAY_URLS`, + * `FAST_READ_RELAY_URLS`, and `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before** + * author + user NIP-65 lists. Used for batched metadata and embed fetches so public mirrors are not queued + * behind broken personal relays under the global connection cap. */ preferPublicReadRelaysEarly?: boolean } @@ -122,8 +122,12 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio // 3. Relays where containing event was found (for embedded events) containingEventRelays.forEach(addRelay) - // 3b. Public read / index relays before author + user NIP-65 expansion (embed + fetchEvent). + // 3b. Public profile / read relays before user favorites & NIP-65 (batched kind-0 — avoids burning + // connection slots on broken personal relays before PROFILE_FETCH + FAST_READ answer). if (preferPublicReadRelaysEarly) { + if (includeProfileFetchRelays) { + PROFILE_FETCH_RELAY_URLS.forEach(addRelay) + } if (includeFastReadRelays) { FAST_READ_RELAY_URLS.forEach(addRelay) } diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 68150508..a2e32c1e 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -187,6 +187,10 @@ const RelaysFeed = forwardRef< : undefined } feedTopNotice={feedTopNotice} + timelinePublicReadFallback={ + feedInfo.feedType === 'all-favorites' || + (feedInfo.feedType === 'relays' && relayUrls.length > 1) + } /> ) }) diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 64eacf64..69241c2e 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -537,7 +537,8 @@ export class ReplaceableEventService { includeLocalRelays: true, /** Many users publish kind 0 to NIP-65 write relays; batch path skipped these before. */ includeFastWriteRelays: true, - includeSearchableRelays: false + includeSearchableRelays: false, + preferPublicReadRelaysEarly: true }) } catch { relayUrls = Array.from(new Set([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS])) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 68762a04..255e2e80 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -284,16 +284,6 @@ class ClientService extends EventTarget { }) - /** - * Session-only: connection/publish failures per normalized relay URL. After - * {@link ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD} strikes we skip that relay for reads and publishes until reload. - */ - private publishStrikeCount = new Map() - /** Many shards / parallel REQs used to hit the strike threshold instantly on one dead relay; only one increment per window. */ - private sessionRelayFailureLastIncrementAt = new Map() - public static readonly SESSION_RELAY_FAILURE_STRIKE_THRESHOLD = 4 - private static readonly SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS = 12_000 - /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ private sessionRelayPublishStats = new Map() @@ -334,17 +324,8 @@ class ClientService extends EventTarget { // Initialize sub-services this.queryService = new QueryService(this.pool, { - shouldSkipRelayForSession: (url) => { - const key = canonicalRelayStrikeKey(url) - if (!key) return false - return ( - (this.publishStrikeCount.get(key) ?? 0) >= - ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - ) - }, - onRelayConnectionFailure: (url) => this.recordSessionRelayFailure(url), onRelayNoticeStrike: (normalizedUrl, noticeMessage) => - this.recordRelayNoticeFetchFailure(normalizedUrl, noticeMessage) + this.logRelayNoticeFetchFailure(normalizedUrl, noticeMessage) }) this.eventService = new EventService(this.queryService) this.replaceableEventService = new ReplaceableEventService( @@ -1127,158 +1108,42 @@ class ClientService extends EventTarget { return relays } - /** One failed publish or subscribe connection per normalized URL (accumulates until {@link SESSION_RELAY_FAILURE_STRIKE_THRESHOLD}). */ - /** NOTICE "failed to fetch events" (relay DB/backend) — same session strike as a failed connection. */ - private notifySessionRelayStrikesChanged(affectedUrl?: string): void { - if (typeof window === 'undefined') return - window.dispatchEvent( - new CustomEvent(JUMBLE_SESSION_RELAY_STRIKES_CHANGED, { - detail: { url: affectedUrl } - }) - ) - } - - /** Strikes accumulated this session for this relay (connection / NOTICE failures). */ - getSessionRelayStrikeCountForUrl(url: string): number { + /** NOTICE "failed to fetch events" — logged only (no session relay blocking). */ + private logRelayNoticeFetchFailure(url: string, noticeMessage: string) { const n = canonicalRelayStrikeKey(url) - if (!n) return 0 - return this.publishStrikeCount.get(n) ?? 0 - } - - getSessionRelayFailureStrikeThreshold(): number { - return ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - } - - /** True when this relay is skipped for reads/publishes until strikes are cleared. */ - isSessionRelayStrikedForReads(url: string): boolean { - return this.getSessionRelayStrikeCountForUrl(url) >= this.getSessionRelayFailureStrikeThreshold() - } - - private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) { - const n = canonicalRelayStrikeKey(url) - if (!n) return - const prev = this.publishStrikeCount.get(n) ?? 0 - if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { - return - } - logger.info('[Relay] NOTICE failed-fetch → session strike', { - url: n, + logger.debug('[Relay] NOTICE failed-fetch', { + url: n ?? url, noticeSnippet: noticeMessage.slice(0, 220) }) - this.recordSessionRelayFailure(url) } - private recordSessionRelayFailure(url: string) { - const n = canonicalRelayStrikeKey(url) - if (!n) return - if (isLocalNetworkUrl(n)) { - return - } - const prev = this.publishStrikeCount.get(n) ?? 0 - if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { - return - } - const now = Date.now() - const lastInc = this.sessionRelayFailureLastIncrementAt.get(n) ?? 0 - if (now - lastInc < ClientService.SESSION_RELAY_FAILURE_INCREMENT_DEBOUNCE_MS) { - return - } - this.sessionRelayFailureLastIncrementAt.set(n, now) - const count = prev + 1 - this.publishStrikeCount.set(n, count) - if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { - logger.info('[Relay] Session strike threshold — relay skipped for reads/publishes until reload', { - url: n, - strikes: count - }) - } - this.notifySessionRelayStrikesChanged(n) + /** Legacy API: session strikes removed; always zero. */ + getSessionRelayStrikeCountForUrl(_url: string): number { + return 0 } - private filterSessionStrikedRelays(urls: string[]): string[] { - return urls.filter((u) => { - const n = canonicalRelayStrikeKey(u) - if (!n) return true - return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - }) + getSessionRelayFailureStrikeThreshold(): number { + return 4 } - /** - * If every URL was session-striked, clear strikes once so reads/publishes can retry (mobile WebSocket churn). - */ - clearSessionRelayStrikes(): void { - if (this.publishStrikeCount.size === 0 && this.sessionRelayFailureLastIncrementAt.size === 0) return - logger.info('[Relay] Session relay strikes cleared', { relayCount: this.publishStrikeCount.size }) - this.publishStrikeCount.clear() - this.sessionRelayFailureLastIncrementAt.clear() - this.notifySessionRelayStrikesChanged() + /** Legacy API: session strikes removed; relays are never skipped for reads for flaky connections. */ + isSessionRelayStrikedForReads(_url: string): boolean { + return false } - /** - * Clear session failure strikes for one normalized relay URL so reads and publishes use it again - * until new failures accrue (same counter as {@link clearSessionRelayStrikes}). - */ - clearSessionRelayStrikeForUrl(url: string): boolean { - const n = canonicalRelayStrikeKey(url) - if (!n) return false - const had = this.publishStrikeCount.delete(n) - this.sessionRelayFailureLastIncrementAt.delete(n) - if (had) { - logger.info('[Relay] Session strikes cleared for relay (manual)', { url: n }) - this.notifySessionRelayStrikesChanged(n) - } - return had + /** No-op: use relay block list in settings instead of automatic session strikes. */ + clearSessionRelayStrikes(): void {} + + clearSessionRelayStrikeForUrl(_url: string): boolean { + return false } - /** - * Clear session strikes for several URLs at once (e.g. publish relay picker). One UI notification. - */ - clearSessionRelayStrikesForUrls(urls: string[]): number { - let cleared = 0 - for (const url of urls) { - const n = canonicalRelayStrikeKey(url) - if (!n) continue - if (this.publishStrikeCount.delete(n)) { - cleared += 1 - this.sessionRelayFailureLastIncrementAt.delete(n) - } - } - if (cleared > 0) { - logger.info('[Relay] Session strikes cleared for relays (added to publish selection)', { - cleared, - urlCount: urls.length - }) - this.notifySessionRelayStrikesChanged() - } - return cleared + clearSessionRelayStrikesForUrls(_urls: string[]): number { + return 0 } - /** - * Apply strike filter; if that removes all candidates while some were provided, clear strikes **for those URLs - * only** and retry once. (A global clear here caused storms: e.g. NIP-65 outbox retry with 2 relays wiped strikes - * for every relay in the tab session.) - */ private relayUrlsAfterStrikesOrRecover(urls: string[]): string[] { - const unique = Array.from(new Set(urls)) - const filtered = this.filterSessionStrikedRelays(unique) - if (filtered.length === 0 && unique.length > 0) { - let cleared = 0 - for (const u of unique) { - const n = canonicalRelayStrikeKey(u) - if (n && this.publishStrikeCount.delete(n)) { - cleared += 1 - this.sessionRelayFailureLastIncrementAt.delete(n) - } - } - if (cleared === 0) return filtered - logger.info('[Relay] Batch was all session-striked — cleared strikes for this batch only', { - batchUrlCount: unique.length, - strikeEntriesCleared: cleared - }) - this.notifySessionRelayStrikesChanged() - return this.filterSessionStrikedRelays(unique) - } - return filtered + return Array.from(new Set(urls)) } /** Record a successful publish and its latency for session-based preference when selecting random relays. */ @@ -1305,7 +1170,6 @@ class ClientService extends EventTarget { if (stats.successCount < 1) continue const n = canonicalRelayStrikeKey(url) if (!n || readOnlySet.has(n)) continue - if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) continue out.push(n) } out.sort((a, b) => { @@ -1318,8 +1182,7 @@ class ClientService extends EventTarget { } /** - * Session-only debug info for the Session Relays settings tab: working/striked preset relays and scored random relays. - * Strikes accrue from failed publishes and failed subscribe/query connections (same counter). + * Session-only debug for Settings: scored publish relays (no automatic session strikes). */ getSessionRelayDebug(): { strikedUrls: string[] @@ -1338,27 +1201,18 @@ class ClientService extends EventTarget { if (n) presetSet.add(canonicalRelayStrikeKey(n)) } const preset = Array.from(presetSet) - const strikedUrls = Array.from(this.publishStrikeCount.entries()) - .filter(([, count]) => count >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) - .map(([url]) => url) - const presetStriked = preset.filter( - (url) => (this.publishStrikeCount.get(url) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - ) - const presetWorking = preset.filter( - (url) => (this.publishStrikeCount.get(url) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - ) const scoredRelays = Array.from(this.sessionRelayPublishStats.entries()).map(([url, s]) => ({ url, successCount: s.successCount, avgLatencyMs: Math.round(s.sumLatencyMs / s.successCount) })) scoredRelays.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs) - return { strikedUrls, scoredRelays, presetWorking, presetStriked } + return { strikedUrls: [], scoredRelays, presetWorking: preset, presetStriked: [] } } /** * From a list of candidate relay URLs (e.g. public lively), return up to `count` relays, - * preferring those that have succeeded and been fast this session. Excludes 3-strike and read-only relays. + * preferring those that have succeeded and been fast this session. Excludes read-only relays. */ getPreferredRelaysForRandom(candidateUrls: string[], count: number): string[] { const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u)) @@ -1366,14 +1220,9 @@ class ClientService extends EventTarget { .map((u) => normalizeAnyRelayUrl(u) || u) .filter((n) => n && !readOnlySet.has(n)) const unique = Array.from(new Set(normalizedCandidates)) - const notStruckOut = unique.filter((u) => { - const n = canonicalRelayStrikeKey(u) - if (!n) return false - return (this.publishStrikeCount.get(n) ?? 0) < ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD - }) const preferred: string[] = [] const rest: string[] = [] - for (const url of notStruckOut) { + for (const url of unique) { const sk = canonicalRelayStrikeKey(url) const stats = sk ? this.sessionRelayPublishStats.get(sk) : undefined if (stats && stats.successCount >= 1) preferred.push(url) @@ -1450,7 +1299,7 @@ class ClientService extends EventTarget { finalContactedRelayCount: uniqueRelayUrls.length, finalRelays: uniqueRelayUrls, explain: - 'Your NIP-65 write relays are prepended, then the list is de-duplicated, filtered (read-only / social-kind blocks / session strike skips), and capped at maxPublishRelays in outbox→inbox→favorite→fast-write priority. Unchecked relays in the picker are never contacted; checked relays beyond the cap or filtered out are also skipped.' + 'Your NIP-65 write relays are prepended, then the list is de-duplicated, filtered (read-only / social-kind blocks), and capped at maxPublishRelays in outbox→inbox→favorite→fast-write priority. Unchecked relays in the picker are never contacted; checked relays beyond the cap or filtered out are also skipped.' }) } @@ -1576,7 +1425,6 @@ class ClientService extends EventTarget { if (!alreadyFinished) { logger.warn('[PublishEvent] Marking relay as timed out', { url }) relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' }) - client.recordSessionRelayFailure(url) finishedCount++ } }) @@ -1676,7 +1524,7 @@ class ClientService extends EventTarget { logger.debug(`[PublishEvent] Relay connected`, { url }) const relayKeyPub = normalizeUrl(url) || url patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) => - that.recordRelayNoticeFetchFailure(u, m) + that.logRelayNoticeFetchFailure(u, m) ) applyRelayNip42AckTimeout(relay as unknown as AbstractRelay) @@ -1722,13 +1570,11 @@ class ClientService extends EventTarget { logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) errors.push({ url, error: authError }) relayStatuses.push({ url, success: false, error: authError.message }) - that.recordSessionRelayFailure(url) }) } else { logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) errors.push({ url, error }) relayStatuses.push({ url, success: false, error: error.message }) - that.recordSessionRelayFailure(url) } }) @@ -1786,7 +1632,6 @@ class ClientService extends EventTarget { success: false, error: error instanceof Error ? error.message : 'Connection failed' }) - that.recordSessionRelayFailure(url) } finally { clearTimeout(relayTimeout) const currentFinished = ++finishedCount @@ -2417,10 +2262,9 @@ class ClientService extends EventTarget { try { relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => - that.recordRelayNoticeFetchFailure(u, m) + that.logRelayNoticeFetchFailure(u, m) ) } catch (err) { - that.recordSessionRelayFailure(url) that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) return @@ -2472,11 +2316,10 @@ class ClientService extends EventTarget { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) patchRelayNoticeForFetchFailures(liveRelay, relayKey, (u, m) => - that.recordRelayNoticeFetchFailure(u, m) + that.logRelayNoticeFetchFailure(u, m) ) } catch (err) { nip42ResubscribePending.delete(i) - that.recordSessionRelayFailure(url) that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) return @@ -3145,20 +2988,14 @@ class ClientService extends EventTarget { return { events: [], connectionError: e instanceof Error ? e.message : String(e) } } } - const usableAfterStrikes = this.relayUrlsAfterStrikesOrRecover([normalized]) - if (usableAfterStrikes.length === 0) { - return { events: [], connectionError: 'Relay skipped this session (repeated failures)' } - } - const relayForConn = usableAfterStrikes[0]! try { - await this.pool.ensureRelay(relayForConn, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) + await this.pool.ensureRelay(normalized, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) } catch (e) { - this.recordSessionRelayFailure(relayForConn) const msg = e instanceof Error ? e.message : String(e) return { events: [], connectionError: msg } } try { - const events = await this.queryService.query([relayForConn], filter, undefined, { + const events = await this.queryService.query([normalized], filter, undefined, { globalTimeout: options?.globalTimeout ?? 25_000 }) return { events, connectionError: undefined } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index cf51c4c2..821f1692 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -94,11 +94,11 @@ class NoteStatsService { private processBatchRunning = false /** While greater than zero, {@link processBatch} defers so user publishes are not starved for WebSocket pool / bandwidth. */ private publishPriorityDepth = 0 - private readonly BATCH_DELAY = 200 - /** Small slices so a slow batch does not block newer cards (e.g. spell feed swaps placeholder rows → discussions). */ - private readonly MAX_BATCH_SIZE = 8 - /** Avoid 20+ simultaneous stats REQs (relay strikes / hangs); each slice runs in waves. */ - private readonly STATS_SLICE_CONCURRENCY = 4 + private readonly BATCH_DELAY = 120 + /** Larger slices: feed cards each trigger a stats fetch; tiny slices left the tail of the feed starved. */ + private readonly MAX_BATCH_SIZE = 20 + /** Parallel stats REQs per slice (bounded by relay pool pressure). */ + private readonly STATS_SLICE_CONCURRENCY = 6 /** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */ private pendingSyntheticRootById = new Map() /** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */ @@ -161,7 +161,8 @@ class NoteStatsService { if (this.processBatchRunning) { return } - const backlogLarge = this.pendingEvents.size >= this.MAX_BATCH_SIZE + const backlogLarge = + this.pendingForeground.size + this.pendingEvents.size >= this.MAX_BATCH_SIZE if (backlogLarge || foreground) { if (this.batchTimeout) { clearTimeout(this.batchTimeout) @@ -262,7 +263,8 @@ class NoteStatsService { } private async processBatch() { - if (this.publishPriorityDepth > 0) { + /** Defer only background fetches while the user is publishing; open note / `foreground` must not starve. */ + if (this.publishPriorityDepth > 0 && this.pendingForeground.size === 0) { if (this.batchTimeout) { clearTimeout(this.batchTimeout) } @@ -514,8 +516,8 @@ class NoteStatsService { event: Event, replaceableCoordinate?: string ): { nonSocial: Filter[]; social: Filter[] } { - const reactionLimit = 300 - const interactionLimit = 80 + const reactionLimit = 500 + const interactionLimit = 120 const nip18RepostKinds = [kinds.Repost, ExtendedKind.GENERIC_REPOST] /** Synthetic RSS/Web parents are not on relays; `#e` on the fake id returns nothing. Use only URL-scoped filters. */ @@ -857,26 +859,32 @@ class NoteStatsService { return emoji } - private addLikeByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { - let targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags) - if (!targetEventId && evt.kind === kinds.Reaction) { + private reactionTargetHexForLike(evt: Event, forcedTargetEventId?: string): string | undefined { + const forced = forcedTargetEventId?.trim() + if (forced) return forced + const parentHex = getParentEventHexId(evt) + if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) return parentHex + const firstE = getFirstHexEventIdFromETags(evt.tags) + if (firstE) return firstE + if (evt.kind === kinds.Reaction) { const pageUrl = getReactionPageUrlFromRTags(evt) if (pageUrl) { - targetEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl)) + return rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl)) } } - if (!targetEventId) return - targetEventId = this.statsKey(targetEventId) + return undefined + } + + private addLikeByEvent(evt: Event, _originalEventAuthor?: string, forcedTargetEventId?: string) { + const targetEventIdRaw = this.reactionTargetHexForLike(evt, forcedTargetEventId) + if (!targetEventIdRaw) return + const targetEventId = this.statsKey(targetEventIdRaw) const old = this.noteStatsMap.get(targetEventId) || {} const likeIdSet = old.likeIdSet || new Set() const likes = old.likes || [] if (likeIdSet.has(evt.id)) return - if (originalEventAuthor && originalEventAuthor === evt.pubkey) { - return - } - const emoji = this.reactionEmojiFromEvent(evt) likeIdSet.add(evt.id) @@ -888,7 +896,7 @@ class NoteStatsService { /** NIP-25 kind 17 reactions to http(s) URLs; stats key matches synthetic RSS thread root id. */ private addLikeByExternalWebReactionEvent( evt: Event, - originalEventAuthor?: string, + _originalEventAuthor?: string, forcedTargetEventId?: string ) { const url = getWebExternalReactionTargetUrl(evt) @@ -903,10 +911,6 @@ class NoteStatsService { const likes = old.likes || [] if (likeIdSet.has(evt.id)) return - if (originalEventAuthor && originalEventAuthor === evt.pubkey) { - return - } - const emoji = this.reactionEmojiFromEvent(evt) likeIdSet.add(evt.id)