From 07f57013e662d1eb2a4499a49939e46b188a1084 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 5 May 2026 18:00:00 +0200 Subject: [PATCH] bug-fixes --- .../NoteList/VirtualizedFeedRows.tsx | 21 +++-- src/components/NoteList/index.tsx | 94 ++++++++++++++++--- src/constants.ts | 4 +- .../primary-page-scroll-area-context.tsx | 25 +++++ src/layouts/PrimaryPageLayout/index.tsx | 7 +- 5 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 src/contexts/primary-page-scroll-area-context.tsx diff --git a/src/components/NoteList/VirtualizedFeedRows.tsx b/src/components/NoteList/VirtualizedFeedRows.tsx index 51c0c7c9..d8236744 100644 --- a/src/components/NoteList/VirtualizedFeedRows.tsx +++ b/src/components/NoteList/VirtualizedFeedRows.tsx @@ -6,7 +6,8 @@ import { memo } from 'react' const ESTIMATE_NOTE_ROW_PX = 280 const ESTIMATE_GRID_ROW_PX = 120 -const VIRTUAL_OVERSCAN = 10 +/** 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[] @@ -33,12 +34,13 @@ const WindowRows = memo(function WindowRows({ estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), overscan: VIRTUAL_OVERSCAN, scrollMargin: scrollMarginTop, - getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) + getItemKey: (index) => + gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}` }) return (
{virtualizer.getVirtualItems().map((vi) => ( @@ -46,8 +48,8 @@ const WindowRows = memo(function WindowRows({ key={vi.key} data-index={vi.index} ref={virtualizer.measureElement} - className="absolute left-0 top-0 w-full" - style={{ transform: `translateY(${vi.start}px)` }} + className="absolute left-0 w-full" + style={{ top: vi.start }} > {gridLayout ? (
@@ -84,12 +86,13 @@ const ElementRows = memo(function ElementRows({ getScrollElement: () => scrollElement, estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), overscan: VIRTUAL_OVERSCAN, - getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) + getItemKey: (index) => + gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}` }) return (
{virtualizer.getVirtualItems().map((vi) => ( @@ -97,8 +100,8 @@ const ElementRows = memo(function ElementRows({ key={vi.key} data-index={vi.index} ref={virtualizer.measureElement} - className="absolute left-0 top-0 w-full" - style={{ transform: `translateY(${vi.start}px)` }} + className="absolute left-0 w-full" + style={{ top: vi.start }} > {gridLayout ? (
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 4c1bd2c3..fc699032 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -65,6 +65,7 @@ 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' @@ -156,6 +157,23 @@ 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 @@ -709,6 +727,7 @@ const NoteList = forwardRef( const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('') const [feedClientTimeUnit, setFeedClientTimeUnit] = useState('day') const supportTouch = useMemo(() => isTouchDevice(), []) + const primaryScrollAreaRef = usePrimaryPageScrollAreaRefOptional() const timelineEventsForFilter = feedFullSearchEvents ?? events @@ -720,8 +739,14 @@ const NoteList = forwardRef( displayTimelineSourceRef.current = timelineEventsForFilter }, [timelineEventsForFilter]) const bottomRef = useRef(null) - /** List root for resolving the feed’s scroll container (desktop primary layout scrolls a div, not `window`). */ + /** 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 @@ -1353,15 +1378,53 @@ const NoteList = forwardRef( * were still settling (absolute rows could paint past the list bounds). */ useLayoutEffect(() => { + let alive = true + const applyFeedScrollPort = () => { + if (!alive) return + const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current) + if (!anchor) { + setFeedVirtualScrollParent(null) + setFeedVirtualScrollMarginTop(0) + return + } + const layoutEl = primaryScrollAreaRef?.current ?? null + setFeedVirtualScrollParent(resolvePrimaryFeedScrollPort(layoutEl, anchor)) + setFeedVirtualScrollMarginTop(anchor.offsetTop) + } + + 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) + + let ro: ResizeObserver | null = null const root = feedRootRef.current - if (!root) { - setFeedVirtualScrollParent(null) - setFeedVirtualScrollMarginTop(0) - return + if (root && typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(() => { + if (!alive) return + applyFeedScrollPort() + }) + ro.observe(root) } - setFeedVirtualScrollParent(getNearestScrollableAncestor(root)) - setFeedVirtualScrollMarginTop(root.offsetTop) - }, [timelineSubscriptionKey, refreshCount]) + + return () => { + alive = false + cancelAnimationFrame(outerRaf) + cancelAnimationFrame(innerRaf) + window.clearTimeout(deferTimer) + ro?.disconnect() + } + }, [timelineSubscriptionKey, refreshCount, primaryScrollAreaRef]) const clientFilteredNewEvents = useMemo( () => @@ -3049,8 +3112,9 @@ const NoteList = forwardRef( } const wireScrollPrefetch = () => { - const anchor = feedRootRef.current - const parent = getNearestScrollableAncestor(anchor) + const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current) + const layoutEl = primaryScrollAreaRef?.current ?? null + const parent = resolvePrimaryFeedScrollPort(layoutEl, anchor) const next: HTMLElement | Window = parent ?? window if (scrollPrefetchTarget && scrollPrefetchTarget !== next) { scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch) @@ -3099,7 +3163,7 @@ const NoteList = forwardRef( loadMoreTimeoutRef.current = null } } - }, [timelineSubscriptionKey]) + }, [timelineSubscriptionKey, primaryScrollAreaRef]) // CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content) // This ensures embedded events are ready before user scrolls to them @@ -3623,7 +3687,9 @@ const NoteList = forwardRef(
) : null} {showFeedClientFilter ? feedClientFilterBar : null} - {list} +
+ {list} +
) : ( @@ -3637,7 +3703,9 @@ const NoteList = forwardRef(
) : null} {showFeedClientFilter ? feedClientFilterBar : null} - {list} +
+ {list} +
)} diff --git a/src/constants.ts b/src/constants.ts index d470b813..0e0338ed 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -432,7 +432,9 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://relay.noswhere.com', 'wss://relay.wikifreedia.xyz', 'wss://nostr.einundzwanzig.space', - 'wss://nostr-pub.wellorder.net' + 'wss://nostr-pub.wellorder.net', + 'wss://pyramid.fiatjaf.com/', + 'wss://nostrelites.org' ] export const PROFILE_RELAY_URLS = [ diff --git a/src/contexts/primary-page-scroll-area-context.tsx b/src/contexts/primary-page-scroll-area-context.tsx new file mode 100644 index 00000000..1ed906eb --- /dev/null +++ b/src/contexts/primary-page-scroll-area-context.tsx @@ -0,0 +1,25 @@ +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 cd84af23..1b2714e2 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -3,6 +3,7 @@ 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' @@ -157,8 +158,10 @@ const PrimaryPageLayout = forwardRef( : 'absolute bottom-0 left-0 right-0 top-0 min-w-0 overflow-y-auto overflow-x-auto' } > - {children} -
+ + {children} +
+
{displayScrollToTopButton && }