Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
07f57013e6
  1. 21
      src/components/NoteList/VirtualizedFeedRows.tsx
  2. 94
      src/components/NoteList/index.tsx
  3. 4
      src/constants.ts
  4. 25
      src/contexts/primary-page-scroll-area-context.tsx
  5. 7
      src/layouts/PrimaryPageLayout/index.tsx

21
src/components/NoteList/VirtualizedFeedRows.tsx

@ -6,7 +6,8 @@ import { memo } from 'react'
const ESTIMATE_NOTE_ROW_PX = 280 const ESTIMATE_NOTE_ROW_PX = 280
const ESTIMATE_GRID_ROW_PX = 120 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 = { export type VirtualizedFeedRowsProps = {
events: Event[] events: Event[]
@ -33,12 +34,13 @@ const WindowRows = memo(function WindowRows({
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
overscan: VIRTUAL_OVERSCAN, overscan: VIRTUAL_OVERSCAN,
scrollMargin: scrollMarginTop, scrollMargin: scrollMarginTop,
getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) getItemKey: (index) =>
gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}`
}) })
return ( return (
<div <div
className="relative w-full overflow-hidden" className="relative isolate min-h-0 w-full overflow-x-hidden"
style={{ height: virtualizer.getTotalSize() }} style={{ height: virtualizer.getTotalSize() }}
> >
{virtualizer.getVirtualItems().map((vi) => ( {virtualizer.getVirtualItems().map((vi) => (
@ -46,8 +48,8 @@ const WindowRows = memo(function WindowRows({
key={vi.key} key={vi.key}
data-index={vi.index} data-index={vi.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full" className="absolute left-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }} style={{ top: vi.start }}
> >
{gridLayout ? ( {gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4"> <div className="grid grid-cols-3 gap-0.5 pr-4">
@ -84,12 +86,13 @@ const ElementRows = memo(function ElementRows({
getScrollElement: () => scrollElement, getScrollElement: () => scrollElement,
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
overscan: VIRTUAL_OVERSCAN, overscan: VIRTUAL_OVERSCAN,
getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) getItemKey: (index) =>
gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}`
}) })
return ( return (
<div <div
className="relative w-full overflow-hidden" className="relative isolate min-h-0 w-full overflow-x-hidden"
style={{ height: virtualizer.getTotalSize() }} style={{ height: virtualizer.getTotalSize() }}
> >
{virtualizer.getVirtualItems().map((vi) => ( {virtualizer.getVirtualItems().map((vi) => (
@ -97,8 +100,8 @@ const ElementRows = memo(function ElementRows({
key={vi.key} key={vi.key}
data-index={vi.index} data-index={vi.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full" className="absolute left-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }} style={{ top: vi.start }}
> >
{gridLayout ? ( {gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4"> <div className="grid grid-cols-3 gap-0.5 pr-4">

94
src/components/NoteList/index.tsx

@ -65,6 +65,7 @@ import { createPortal } from 'react-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { usePrimaryPageOptional } from '@/contexts/primary-page-context' import { usePrimaryPageOptional } from '@/contexts/primary-page-context'
import { usePrimaryPageScrollAreaRefOptional } from '@/contexts/primary-page-scroll-area-context'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -156,6 +157,23 @@ function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | n
return null 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 { function distanceFromScrollBottom(scrollRoot: HTMLElement | Window): number {
if (scrollRoot === window) { if (scrollRoot === window) {
const doc = document.documentElement const doc = document.documentElement
@ -709,6 +727,7 @@ const NoteList = forwardRef(
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('') const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day') const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day')
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const primaryScrollAreaRef = usePrimaryPageScrollAreaRefOptional()
const timelineEventsForFilter = feedFullSearchEvents ?? events const timelineEventsForFilter = feedFullSearchEvents ?? events
@ -720,8 +739,14 @@ const NoteList = forwardRef(
displayTimelineSourceRef.current = timelineEventsForFilter displayTimelineSourceRef.current = timelineEventsForFilter
}, [timelineEventsForFilter]) }, [timelineEventsForFilter])
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null) const feedRootRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null) const topRef = useRef<HTMLDivElement | null>(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('') const spellFeedFirstPaintLoggedKeyRef = useRef('')
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries 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). * were still settling (absolute rows could paint past the list bounds).
*/ */
useLayoutEffect(() => { 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 const root = feedRootRef.current
if (!root) { if (root && typeof ResizeObserver !== 'undefined') {
setFeedVirtualScrollParent(null) ro = new ResizeObserver(() => {
setFeedVirtualScrollMarginTop(0) if (!alive) return
return applyFeedScrollPort()
})
ro.observe(root)
} }
setFeedVirtualScrollParent(getNearestScrollableAncestor(root))
setFeedVirtualScrollMarginTop(root.offsetTop) return () => {
}, [timelineSubscriptionKey, refreshCount]) alive = false
cancelAnimationFrame(outerRaf)
cancelAnimationFrame(innerRaf)
window.clearTimeout(deferTimer)
ro?.disconnect()
}
}, [timelineSubscriptionKey, refreshCount, primaryScrollAreaRef])
const clientFilteredNewEvents = useMemo( const clientFilteredNewEvents = useMemo(
() => () =>
@ -3049,8 +3112,9 @@ const NoteList = forwardRef(
} }
const wireScrollPrefetch = () => { const wireScrollPrefetch = () => {
const anchor = feedRootRef.current const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current)
const parent = getNearestScrollableAncestor(anchor) const layoutEl = primaryScrollAreaRef?.current ?? null
const parent = resolvePrimaryFeedScrollPort(layoutEl, anchor)
const next: HTMLElement | Window = parent ?? window const next: HTMLElement | Window = parent ?? window
if (scrollPrefetchTarget && scrollPrefetchTarget !== next) { if (scrollPrefetchTarget && scrollPrefetchTarget !== next) {
scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch) scrollPrefetchTarget.removeEventListener('scroll', onScrollPrefetch)
@ -3099,7 +3163,7 @@ const NoteList = forwardRef(
loadMoreTimeoutRef.current = null loadMoreTimeoutRef.current = null
} }
} }
}, [timelineSubscriptionKey]) }, [timelineSubscriptionKey, primaryScrollAreaRef])
// CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content) // CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content)
// This ensures embedded events are ready before user scrolls to them // This ensures embedded events are ready before user scrolls to them
@ -3623,7 +3687,9 @@ const NoteList = forwardRef(
</div> </div>
) : null} ) : null}
{showFeedClientFilter ? feedClientFilterBar : null} {showFeedClientFilter ? feedClientFilterBar : null}
{list} <div ref={feedListScrollAnchorRef} className="min-w-0 w-full">
{list}
</div>
</div> </div>
</PullToRefresh> </PullToRefresh>
) : ( ) : (
@ -3637,7 +3703,9 @@ const NoteList = forwardRef(
</div> </div>
) : null} ) : null}
{showFeedClientFilter ? feedClientFilterBar : null} {showFeedClientFilter ? feedClientFilterBar : null}
{list} <div ref={feedListScrollAnchorRef} className="min-w-0 w-full">
{list}
</div>
</div> </div>
)} )}
</NoteFeedProfileContext.Provider> </NoteFeedProfileContext.Provider>

4
src/constants.ts

@ -432,7 +432,9 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
'wss://relay.wikifreedia.xyz', 'wss://relay.wikifreedia.xyz',
'wss://nostr.einundzwanzig.space', '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 = [ export const PROFILE_RELAY_URLS = [

25
src/contexts/primary-page-scroll-area-context.tsx

@ -0,0 +1,25 @@
import { createContext, useContext, type ReactNode, type RefObject } from 'react'
const PrimaryPageScrollAreaRefContext = createContext<RefObject<HTMLDivElement | null> | null>(null)
/**
* The desktop primary columns 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<HTMLDivElement | null>
children: ReactNode
}) {
return (
<PrimaryPageScrollAreaRefContext.Provider value={scrollAreaRef}>
{children}
</PrimaryPageScrollAreaRefContext.Provider>
)
}
export function usePrimaryPageScrollAreaRefOptional(): RefObject<HTMLDivElement | null> | null {
return useContext(PrimaryPageScrollAreaRefContext)
}

7
src/layouts/PrimaryPageLayout/index.tsx

@ -3,6 +3,7 @@ import ScrollToTopButton from '@/components/ScrollToTopButton'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { PrimaryPageScrollAreaRefProvider } from '@/contexts/primary-page-scroll-area-context'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' 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' : 'absolute bottom-0 left-0 right-0 top-0 min-w-0 overflow-y-auto overflow-x-auto'
} }
> >
{children} <PrimaryPageScrollAreaRefProvider scrollAreaRef={scrollAreaRef}>
<div className="h-4" /> {children}
<div className="h-4" />
</PrimaryPageScrollAreaRefProvider>
</div> </div>
</div> </div>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />} {displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}

Loading…
Cancel
Save