@@ -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(