Browse Source

bug-fixes

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

21
src/components/NoteList/VirtualizedFeedRows.tsx

@ -6,7 +6,8 @@ import { memo } from 'react' @@ -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({ @@ -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 (
<div
className="relative w-full overflow-hidden"
className="relative isolate min-h-0 w-full overflow-x-hidden"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vi) => (
@ -46,8 +48,8 @@ const WindowRows = memo(function WindowRows({ @@ -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 ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
@ -84,12 +86,13 @@ const ElementRows = memo(function ElementRows({ @@ -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 (
<div
className="relative w-full overflow-hidden"
className="relative isolate min-h-0 w-full overflow-x-hidden"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vi) => (
@ -97,8 +100,8 @@ const ElementRows = memo(function ElementRows({ @@ -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 ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">

86
src/components/NoteList/index.tsx

@ -65,6 +65,7 @@ import { createPortal } from 'react-dom' @@ -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 @@ -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( @@ -709,6 +727,7 @@ const NoteList = forwardRef(
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day')
const supportTouch = useMemo(() => isTouchDevice(), [])
const primaryScrollAreaRef = usePrimaryPageScrollAreaRefOptional()
const timelineEventsForFilter = feedFullSearchEvents ?? events
@ -720,8 +739,14 @@ const NoteList = forwardRef( @@ -720,8 +739,14 @@ const NoteList = forwardRef(
displayTimelineSourceRef.current = timelineEventsForFilter
}, [timelineEventsForFilter])
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)
/**
* 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 spellFeedFirstPaintLoggedKeyRef = useRef('')
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent infinite retries
@ -1353,15 +1378,53 @@ const NoteList = forwardRef( @@ -1353,15 +1378,53 @@ const NoteList = forwardRef(
* were still settling (absolute rows could paint past the list bounds).
*/
useLayoutEffect(() => {
const root = feedRootRef.current
if (!root) {
let alive = true
const applyFeedScrollPort = () => {
if (!alive) return
const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current)
if (!anchor) {
setFeedVirtualScrollParent(null)
setFeedVirtualScrollMarginTop(0)
return
}
setFeedVirtualScrollParent(getNearestScrollableAncestor(root))
setFeedVirtualScrollMarginTop(root.offsetTop)
}, [timelineSubscriptionKey, refreshCount])
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 && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => {
if (!alive) return
applyFeedScrollPort()
})
ro.observe(root)
}
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( @@ -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( @@ -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,8 +3687,10 @@ const NoteList = forwardRef( @@ -3623,8 +3687,10 @@ const NoteList = forwardRef(
</div>
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
<div ref={feedListScrollAnchorRef} className="min-w-0 w-full">
{list}
</div>
</div>
</PullToRefresh>
) : (
<div>
@ -3637,8 +3703,10 @@ const NoteList = forwardRef( @@ -3637,8 +3703,10 @@ const NoteList = forwardRef(
</div>
) : null}
{showFeedClientFilter ? feedClientFilterBar : null}
<div ref={feedListScrollAnchorRef} className="min-w-0 w-full">
{list}
</div>
</div>
)}
</NoteFeedProfileContext.Provider>
<div className="h-40" />

4
src/constants.ts

@ -432,7 +432,9 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -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 = [

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

@ -0,0 +1,25 @@ @@ -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)
}

3
src/layouts/PrimaryPageLayout/index.tsx

@ -3,6 +3,7 @@ import ScrollToTopButton from '@/components/ScrollToTopButton' @@ -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( @@ -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'
}
>
<PrimaryPageScrollAreaRefProvider scrollAreaRef={scrollAreaRef}>
{children}
<div className="h-4" />
</PrimaryPageScrollAreaRefProvider>
</div>
</div>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}

Loading…
Cancel
Save