819 lines
22 KiB
TypeScript
819 lines
22 KiB
TypeScript
import {
|
|
forwardRef,
|
|
isValidElement,
|
|
memo,
|
|
startTransition,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {
|
|
type FlatListProps,
|
|
type ListRenderItemInfo,
|
|
StyleSheet,
|
|
View,
|
|
type ViewProps,
|
|
} from 'react-native'
|
|
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
|
|
|
import {batchedUpdates} from '#/lib/batchedUpdates'
|
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
import {useScrollHandlers} from '#/lib/ScrollContext'
|
|
import {addStyle} from '#/lib/styles'
|
|
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
|
|
import {useTheme, web} from '#/alf'
|
|
import * as Layout from '#/components/Layout'
|
|
import {useAnalytics} from '#/analytics'
|
|
|
|
export type ListMethods = {
|
|
scrollToTop: () => void
|
|
// Signature kept compatible with FlatList's scrollToOffset (the native
|
|
// ListMethods type) so callers stay platform-agnostic.
|
|
scrollToOffset: (options: {animated?: boolean | null; offset: number}) => void
|
|
scrollToEnd: (options?: {animated?: boolean | null}) => void
|
|
// Signature kept compatible with FlatList's scrollToIndex (the native
|
|
// ListMethods type) so callers stay platform-agnostic. viewOffset is
|
|
// accepted for parity but not currently used by the web implementation.
|
|
scrollToIndex: (params: {
|
|
animated?: boolean | null
|
|
index: number
|
|
viewOffset?: number
|
|
viewPosition?: number
|
|
}) => void
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export type ListProps<ItemT = any> = Omit<
|
|
FlatListProps<ItemT>,
|
|
| 'onScroll' // Use ScrollContext instead.
|
|
| 'refreshControl' // Pass refreshing and/or onRefresh instead.
|
|
| 'contentOffset' // Pass headerOffset instead.
|
|
> & {
|
|
onScrolledDownChange?: (isScrolledDown: boolean) => void
|
|
headerOffset?: number
|
|
refreshing?: boolean
|
|
onRefresh?: () => void
|
|
onItemSeen?: (item: ItemT) => void
|
|
desktopFixedHeight?: number | boolean
|
|
// Web only prop to contain the scroll to the container rather than the window
|
|
disableFullWindowScroll?: boolean
|
|
/**
|
|
* @deprecated Should be using Layout components
|
|
*/
|
|
sideBorders?: boolean
|
|
}
|
|
export type ListRef = React.RefObject<ListMethods | null>
|
|
|
|
const ON_ITEM_SEEN_WAIT_DURATION = 0.5e3 // when we consider post to be "seen"
|
|
const ON_ITEM_SEEN_INTERSECTION_OPTS = {
|
|
rootMargin: '-200px 0px -200px 0px',
|
|
} // post must be 200px visible to be "seen"
|
|
|
|
const PAGE_STARTED_AT = Date.now()
|
|
const LARGE_LIST_MILESTONES = [100, 250, 500, 1000] as const
|
|
const LONG_TASK_REPORT_INTERVAL = 60e3
|
|
// This is diagnostic telemetry, so a session-level sample is sufficient and
|
|
// prevents popular feed surfaces from producing an event for every user.
|
|
const ENABLE_WEB_LIST_TELEMETRY = Math.random() < 0.1
|
|
|
|
function ListImpl<ItemT>(
|
|
{
|
|
ListHeaderComponent,
|
|
ListFooterComponent,
|
|
ListEmptyComponent,
|
|
disableFullWindowScroll: disableFullWindowScrollProp,
|
|
contentContainerStyle,
|
|
data,
|
|
desktopFixedHeight,
|
|
headerOffset,
|
|
keyExtractor,
|
|
refreshing: _unsupportedRefreshing,
|
|
onStartReached,
|
|
onStartReachedThreshold = 2,
|
|
onEndReached,
|
|
onEndReachedThreshold = 2,
|
|
onRefresh: _unsupportedOnRefresh,
|
|
onScrolledDownChange,
|
|
onContentSizeChange,
|
|
onItemSeen,
|
|
renderItem,
|
|
extraData,
|
|
style,
|
|
...props
|
|
}: ListProps<ItemT>,
|
|
ref: React.Ref<ListMethods>,
|
|
) {
|
|
const contextScrollHandlers = useScrollHandlers()
|
|
const {isWithinSplitView} = useIsWithinSplitView()
|
|
const t = useTheme()
|
|
|
|
// automatically disable full window scroll when within split view
|
|
const disableFullWindowScroll =
|
|
disableFullWindowScrollProp ?? isWithinSplitView
|
|
|
|
const isEmpty = !data || data.length === 0
|
|
|
|
let headerComponent: React.JSX.Element | null = null
|
|
if (ListHeaderComponent != null) {
|
|
if (isValidElement(ListHeaderComponent)) {
|
|
headerComponent = ListHeaderComponent
|
|
} else {
|
|
// @ts-ignore Nah it's fine.
|
|
headerComponent = <ListHeaderComponent />
|
|
}
|
|
}
|
|
|
|
let footerComponent: React.JSX.Element | null = null
|
|
if (ListFooterComponent != null) {
|
|
if (isValidElement(ListFooterComponent)) {
|
|
footerComponent = ListFooterComponent
|
|
} else {
|
|
// @ts-ignore Nah it's fine.
|
|
footerComponent = <ListFooterComponent />
|
|
}
|
|
}
|
|
|
|
let emptyComponent: React.JSX.Element | null = null
|
|
if (ListEmptyComponent != null) {
|
|
if (isValidElement(ListEmptyComponent)) {
|
|
emptyComponent = ListEmptyComponent
|
|
} else {
|
|
// @ts-ignore Nah it's fine.
|
|
emptyComponent = <ListEmptyComponent />
|
|
}
|
|
}
|
|
|
|
if (headerOffset != null) {
|
|
style = addStyle(style, {
|
|
paddingTop: headerOffset,
|
|
})
|
|
}
|
|
|
|
const getScrollableNode = useCallback(() => {
|
|
if (disableFullWindowScroll) {
|
|
const element = nativeRef.current
|
|
if (!element) return
|
|
|
|
return {
|
|
get scrollWidth() {
|
|
return element.scrollWidth
|
|
},
|
|
get scrollHeight() {
|
|
return element.scrollHeight
|
|
},
|
|
get clientWidth() {
|
|
return element.clientWidth
|
|
},
|
|
get clientHeight() {
|
|
return element.clientHeight
|
|
},
|
|
get scrollY() {
|
|
return element.scrollTop
|
|
},
|
|
get scrollX() {
|
|
return element.scrollLeft
|
|
},
|
|
scrollTo(options?: ScrollToOptions) {
|
|
element.scrollTo(options)
|
|
},
|
|
scrollBy(options: ScrollToOptions) {
|
|
element.scrollBy(options)
|
|
},
|
|
addEventListener(
|
|
event: string,
|
|
handler: EventListenerOrEventListenerObject,
|
|
) {
|
|
element.addEventListener(event, handler)
|
|
},
|
|
removeEventListener(
|
|
event: string,
|
|
handler: EventListenerOrEventListenerObject,
|
|
) {
|
|
element.removeEventListener(event, handler)
|
|
},
|
|
}
|
|
} else {
|
|
return {
|
|
get scrollWidth() {
|
|
return document.documentElement.scrollWidth
|
|
},
|
|
get scrollHeight() {
|
|
return document.documentElement.scrollHeight
|
|
},
|
|
get clientWidth() {
|
|
return window.innerWidth
|
|
},
|
|
get clientHeight() {
|
|
return window.innerHeight
|
|
},
|
|
get scrollY() {
|
|
return window.scrollY
|
|
},
|
|
get scrollX() {
|
|
return window.scrollX
|
|
},
|
|
scrollTo(options: ScrollToOptions) {
|
|
window.scrollTo(options)
|
|
},
|
|
scrollBy(options: ScrollToOptions) {
|
|
window.scrollBy(options)
|
|
},
|
|
addEventListener(
|
|
event: string,
|
|
handler: EventListenerOrEventListenerObject,
|
|
) {
|
|
window.addEventListener(event, handler)
|
|
},
|
|
removeEventListener(
|
|
event: string,
|
|
handler: EventListenerOrEventListenerObject,
|
|
) {
|
|
window.removeEventListener(event, handler)
|
|
},
|
|
}
|
|
}
|
|
}, [disableFullWindowScroll])
|
|
|
|
const nativeRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Registry of item index -> row DOM node. The list renders header/footer and
|
|
// visibility-detector siblings too, so we can't index into the container's
|
|
// children directly; each Row registers its own node here keyed by index.
|
|
const rowNodesRef = useRef<Map<number, HTMLElement>>(new Map())
|
|
const registerRowNode = useCallback(
|
|
(index: number, node: HTMLElement | null) => {
|
|
if (node) {
|
|
rowNodesRef.current.set(index, node)
|
|
} else {
|
|
rowNodesRef.current.delete(index)
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
scrollToTop() {
|
|
getScrollableNode()?.scrollTo({top: 0})
|
|
},
|
|
|
|
scrollToOffset({
|
|
animated,
|
|
offset,
|
|
}: {
|
|
animated?: boolean | null
|
|
offset: number
|
|
}) {
|
|
getScrollableNode()?.scrollTo({
|
|
left: 0,
|
|
top: offset,
|
|
behavior: animated ? 'smooth' : 'instant',
|
|
})
|
|
},
|
|
|
|
scrollToEnd({animated = true} = {}) {
|
|
const element = getScrollableNode()
|
|
element?.scrollTo({
|
|
left: 0,
|
|
top: element.scrollHeight,
|
|
behavior: animated ? 'smooth' : 'instant',
|
|
})
|
|
},
|
|
|
|
scrollToIndex({animated = true, index}) {
|
|
const node = rowNodesRef.current.get(index)
|
|
// scrollIntoView with block: 'center' roughly matches the caller's
|
|
// viewPosition of 0.3 - not exact, but close enough and it respects
|
|
// whichever element is the scroll container (window or nativeRef).
|
|
node?.scrollIntoView({
|
|
block: 'center',
|
|
behavior: animated ? 'smooth' : 'instant',
|
|
})
|
|
},
|
|
}),
|
|
[getScrollableNode],
|
|
)
|
|
|
|
// --- onContentSizeChange, maintainVisibleContentPosition ---
|
|
const containerRef = useRef(null)
|
|
useResizeObserver(containerRef, onContentSizeChange)
|
|
|
|
// --- onScroll ---
|
|
const [isInsideVisibleTree, setIsInsideVisibleTree] = useState(false)
|
|
useWebListTelemetry({
|
|
containerRef: nativeRef,
|
|
enabled: isInsideVisibleTree,
|
|
itemCount: data?.length ?? 0,
|
|
rowNodesRef,
|
|
})
|
|
const handleScroll = useNonReactiveCallback(() => {
|
|
if (!isInsideVisibleTree) return
|
|
|
|
const element = getScrollableNode()
|
|
contextScrollHandlers.onScroll?.(
|
|
{
|
|
contentOffset: {
|
|
x: Math.max(0, element?.scrollX ?? 0),
|
|
y: Math.max(0, element?.scrollY ?? 0),
|
|
},
|
|
layoutMeasurement: {
|
|
width: element?.clientWidth,
|
|
height: element?.clientHeight,
|
|
},
|
|
contentSize: {
|
|
width: element?.scrollWidth,
|
|
height: element?.scrollHeight,
|
|
},
|
|
} as Exclude<
|
|
ReanimatedScrollEvent,
|
|
| 'velocity'
|
|
| 'eventName'
|
|
| 'zoomScale'
|
|
| 'targetContentOffset'
|
|
| 'contentInset'
|
|
>,
|
|
{},
|
|
)
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!isInsideVisibleTree) {
|
|
// Prevents hidden tabs from firing scroll events.
|
|
// Only one list is expected to be firing these at a time.
|
|
return
|
|
}
|
|
|
|
const element = getScrollableNode()
|
|
|
|
element?.addEventListener('scroll', handleScroll)
|
|
return () => {
|
|
element?.removeEventListener('scroll', handleScroll)
|
|
}
|
|
}, [
|
|
isInsideVisibleTree,
|
|
handleScroll,
|
|
disableFullWindowScroll,
|
|
getScrollableNode,
|
|
])
|
|
|
|
// --- onScrolledDownChange ---
|
|
const isScrolledDown = useRef(false)
|
|
function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
|
|
const didScrollDown = !isAboveTheFold
|
|
if (isScrolledDown.current !== didScrollDown) {
|
|
isScrolledDown.current = didScrollDown
|
|
startTransition(() => {
|
|
onScrolledDownChange?.(didScrollDown)
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- onStartReached ---
|
|
const onHeadVisibilityChange = useNonReactiveCallback(
|
|
(isHeadVisible: boolean) => {
|
|
if (isHeadVisible) {
|
|
onStartReached?.({
|
|
distanceFromStart: onStartReachedThreshold || 0,
|
|
})
|
|
}
|
|
},
|
|
)
|
|
|
|
// --- onEndReached ---
|
|
const onTailVisibilityChange = useNonReactiveCallback(
|
|
(isTailVisible: boolean) => {
|
|
if (isTailVisible) {
|
|
onEndReached?.({
|
|
distanceFromEnd: onEndReachedThreshold || 0,
|
|
})
|
|
}
|
|
},
|
|
)
|
|
|
|
return (
|
|
<View
|
|
{...props}
|
|
style={[
|
|
isWithinSplitView &&
|
|
web({
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
|
}),
|
|
style,
|
|
disableFullWindowScroll && {
|
|
flex: 1,
|
|
overflowY: isWithinSplitView ? 'auto' : 'scroll',
|
|
},
|
|
]}
|
|
ref={nativeRef as unknown as React.Ref<React.ComponentRef<typeof View>>}>
|
|
<Visibility
|
|
onVisibleChange={setIsInsideVisibleTree}
|
|
style={
|
|
// This has position: fixed, so it should always report as visible
|
|
// unless we're within a display: none tree (like a hidden tab).
|
|
styles.parentTreeVisibilityDetector
|
|
}
|
|
/>
|
|
<Layout.Center>
|
|
<View
|
|
ref={containerRef}
|
|
style={[
|
|
contentContainerStyle,
|
|
desktopFixedHeight && !disableFullWindowScroll
|
|
? styles.minHeightViewport
|
|
: null,
|
|
]}>
|
|
<Visibility
|
|
root={disableFullWindowScroll ? nativeRef : null}
|
|
onVisibleChange={handleAboveTheFoldVisibleChange}
|
|
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
|
/>
|
|
{onStartReached && !isEmpty && (
|
|
<EdgeVisibility
|
|
root={disableFullWindowScroll ? nativeRef : null}
|
|
onVisibleChange={onHeadVisibilityChange}
|
|
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
|
containerRef={containerRef}
|
|
/>
|
|
)}
|
|
{headerComponent}
|
|
{isEmpty
|
|
? emptyComponent
|
|
: (data as Array<ItemT>)?.map((item, index) => {
|
|
const key = keyExtractor!(item, index)
|
|
return (
|
|
<Row<ItemT>
|
|
key={key}
|
|
item={item}
|
|
index={index}
|
|
renderItem={renderItem}
|
|
extraData={extraData}
|
|
onItemSeen={onItemSeen}
|
|
registerRowNode={registerRowNode}
|
|
/>
|
|
)
|
|
})}
|
|
{onEndReached && !isEmpty && (
|
|
<EdgeVisibility
|
|
root={disableFullWindowScroll ? nativeRef : null}
|
|
onVisibleChange={onTailVisibilityChange}
|
|
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
|
containerRef={containerRef}
|
|
/>
|
|
)}
|
|
{footerComponent}
|
|
</View>
|
|
</Layout.Center>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
type ChromiumPerformance = Performance & {
|
|
memory?: {
|
|
usedJSHeapSize: number
|
|
jsHeapSizeLimit: number
|
|
}
|
|
}
|
|
|
|
function getWebListDiagnostics(
|
|
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
rowNodesRef: React.RefObject<Map<number, HTMLElement>>,
|
|
) {
|
|
const memory = (performance as ChromiumPerformance).memory
|
|
return {
|
|
renderedRowCount: rowNodesRef.current.size,
|
|
contentHeight: containerRef.current?.scrollHeight ?? 0,
|
|
sessionAgeMs: Date.now() - PAGE_STARTED_AT,
|
|
...(memory && {
|
|
heapUsedBytes: memory.usedJSHeapSize,
|
|
heapLimitBytes: memory.jsHeapSizeLimit,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* APP-2859 diagnostic telemetry. Web List currently mounts every loaded row;
|
|
* these events let us correlate list growth and browser main-thread stalls by
|
|
* route without including any row content. Remove after the investigation.
|
|
*/
|
|
function useWebListTelemetry({
|
|
containerRef,
|
|
enabled,
|
|
itemCount,
|
|
rowNodesRef,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>
|
|
enabled: boolean
|
|
itemCount: number
|
|
rowNodesRef: React.RefObject<Map<number, HTMLElement>>
|
|
}) {
|
|
const ax = useAnalytics()
|
|
const reportedMilestones = useRef(new Set<number>())
|
|
const itemCountRef = useRef(itemCount)
|
|
itemCountRef.current = itemCount
|
|
const isLargeList = itemCount >= LARGE_LIST_MILESTONES[0]
|
|
|
|
useEffect(() => {
|
|
if (!ENABLE_WEB_LIST_TELEMETRY || !enabled) return
|
|
|
|
for (const milestone of LARGE_LIST_MILESTONES) {
|
|
if (itemCount < milestone || reportedMilestones.current.has(milestone)) {
|
|
continue
|
|
}
|
|
reportedMilestones.current.add(milestone)
|
|
ax.metric('web:list:size', {
|
|
itemCount,
|
|
milestone,
|
|
...getWebListDiagnostics(containerRef, rowNodesRef),
|
|
})
|
|
}
|
|
}, [ax, containerRef, enabled, itemCount, rowNodesRef])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!ENABLE_WEB_LIST_TELEMETRY ||
|
|
!enabled ||
|
|
!isLargeList ||
|
|
!('PerformanceObserver' in globalThis)
|
|
) {
|
|
return
|
|
}
|
|
|
|
let taskCount = 0
|
|
let totalDurationMs = 0
|
|
let maxDurationMs = 0
|
|
const observer = new PerformanceObserver(list => {
|
|
for (const entry of list.getEntries()) {
|
|
taskCount++
|
|
totalDurationMs += entry.duration
|
|
maxDurationMs = Math.max(maxDurationMs, entry.duration)
|
|
}
|
|
})
|
|
|
|
try {
|
|
observer.observe({type: 'longtask'})
|
|
} catch {
|
|
// Long Tasks API is not available in all browsers.
|
|
return
|
|
}
|
|
|
|
const report = () => {
|
|
if (taskCount === 0) return
|
|
ax.metric('web:list:longTasks', {
|
|
itemCount: itemCountRef.current,
|
|
taskCount,
|
|
totalDurationMs: Math.round(totalDurationMs),
|
|
maxDurationMs: Math.round(maxDurationMs),
|
|
intervalMs: LONG_TASK_REPORT_INTERVAL,
|
|
...getWebListDiagnostics(containerRef, rowNodesRef),
|
|
})
|
|
taskCount = 0
|
|
totalDurationMs = 0
|
|
maxDurationMs = 0
|
|
}
|
|
const interval = setInterval(report, LONG_TASK_REPORT_INTERVAL)
|
|
return () => {
|
|
clearInterval(interval)
|
|
observer.disconnect()
|
|
report()
|
|
}
|
|
}, [ax, containerRef, enabled, isLargeList, rowNodesRef])
|
|
}
|
|
|
|
function EdgeVisibility({
|
|
root,
|
|
topMargin,
|
|
bottomMargin,
|
|
containerRef,
|
|
onVisibleChange,
|
|
}: {
|
|
root?: React.RefObject<HTMLDivElement | null> | null
|
|
topMargin?: string
|
|
bottomMargin?: string
|
|
containerRef: React.RefObject<Element | null>
|
|
onVisibleChange: (isVisible: boolean) => void
|
|
}) {
|
|
const [containerHeight, setContainerHeight] = useState(0)
|
|
useResizeObserver(containerRef, (w, h) => {
|
|
setContainerHeight(h)
|
|
})
|
|
return (
|
|
<Visibility
|
|
key={containerHeight}
|
|
root={root}
|
|
topMargin={topMargin}
|
|
bottomMargin={bottomMargin}
|
|
onVisibleChange={onVisibleChange}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function useResizeObserver(
|
|
ref: React.RefObject<Element | null>,
|
|
onResize: undefined | ((w: number, h: number) => void),
|
|
) {
|
|
const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
|
|
const isActive = !!onResize
|
|
useEffect(() => {
|
|
if (!isActive) {
|
|
return
|
|
}
|
|
const resizeObserver = new ResizeObserver(entries => {
|
|
batchedUpdates(() => {
|
|
for (let entry of entries) {
|
|
const rect = entry.contentRect
|
|
handleResize(rect.width, rect.height)
|
|
}
|
|
})
|
|
})
|
|
const node = ref.current!
|
|
resizeObserver.observe(node)
|
|
return () => {
|
|
resizeObserver.unobserve(node)
|
|
}
|
|
}, [handleResize, isActive, ref])
|
|
}
|
|
|
|
let Row = function RowImpl<ItemT>({
|
|
item,
|
|
index,
|
|
renderItem,
|
|
extraData: _unused,
|
|
onItemSeen,
|
|
registerRowNode,
|
|
}: {
|
|
item: ItemT
|
|
index: number
|
|
renderItem:
|
|
null | undefined | ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
|
|
extraData: unknown
|
|
onItemSeen: ((item: ItemT) => void) | undefined
|
|
registerRowNode: (index: number, node: HTMLElement | null) => void
|
|
}): React.ReactNode {
|
|
const rowRef = useRef(null)
|
|
const intersectionTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
undefined,
|
|
)
|
|
|
|
const handleIntersection = useNonReactiveCallback(
|
|
(entries: IntersectionObserverEntry[]) => {
|
|
batchedUpdates(() => {
|
|
if (!onItemSeen) {
|
|
return
|
|
}
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
if (!intersectionTimeout.current) {
|
|
intersectionTimeout.current = setTimeout(() => {
|
|
intersectionTimeout.current = undefined
|
|
onItemSeen(item)
|
|
}, ON_ITEM_SEEN_WAIT_DURATION)
|
|
}
|
|
} else {
|
|
if (intersectionTimeout.current) {
|
|
clearTimeout(intersectionTimeout.current)
|
|
intersectionTimeout.current = undefined
|
|
}
|
|
}
|
|
})
|
|
})
|
|
},
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!onItemSeen) {
|
|
return
|
|
}
|
|
const observer = new IntersectionObserver(
|
|
handleIntersection,
|
|
ON_ITEM_SEEN_INTERSECTION_OPTS,
|
|
)
|
|
const row: Element | null = rowRef.current
|
|
if (row) {
|
|
observer.observe(row)
|
|
}
|
|
return () => {
|
|
if (row) {
|
|
observer.unobserve(row)
|
|
}
|
|
}
|
|
}, [handleIntersection, onItemSeen])
|
|
|
|
// Register this row's DOM node so the list can scroll to it by index.
|
|
useEffect(() => {
|
|
const node: HTMLElement | null = rowRef.current
|
|
registerRowNode(index, node)
|
|
return () => {
|
|
registerRowNode(index, null)
|
|
}
|
|
}, [index, registerRowNode])
|
|
|
|
if (!renderItem) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<View ref={rowRef}>
|
|
{renderItem({
|
|
item,
|
|
index,
|
|
separators: null as unknown as ListRenderItemInfo<ItemT>['separators'],
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
Row = memo(Row) as <ItemT>(props: {
|
|
item: ItemT
|
|
index: number
|
|
renderItem:
|
|
null | undefined | ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
|
|
extraData: unknown
|
|
onItemSeen: ((item: ItemT) => void) | undefined
|
|
registerRowNode: (index: number, node: HTMLElement | null) => void
|
|
}) => React.ReactNode
|
|
|
|
let Visibility = ({
|
|
root,
|
|
topMargin = '0px',
|
|
bottomMargin = '0px',
|
|
onVisibleChange,
|
|
style,
|
|
}: {
|
|
root?: React.RefObject<HTMLDivElement | null> | null
|
|
topMargin?: string
|
|
bottomMargin?: string
|
|
onVisibleChange: (isVisible: boolean) => void
|
|
style?: ViewProps['style']
|
|
}): React.ReactNode => {
|
|
const tailRef = useRef(null)
|
|
const isIntersecting = useRef(false)
|
|
|
|
const handleIntersection = useNonReactiveCallback(
|
|
(entries: IntersectionObserverEntry[]) => {
|
|
batchedUpdates(() => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting !== isIntersecting.current) {
|
|
isIntersecting.current = entry.isIntersecting
|
|
onVisibleChange(entry.isIntersecting)
|
|
}
|
|
})
|
|
})
|
|
},
|
|
)
|
|
|
|
useEffect(() => {
|
|
const observer = new IntersectionObserver(handleIntersection, {
|
|
root: root?.current ?? null,
|
|
rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
|
|
})
|
|
const tail: Element | null = tailRef.current
|
|
if (tail) {
|
|
observer.observe(tail)
|
|
}
|
|
return () => {
|
|
if (tail) {
|
|
observer.unobserve(tail)
|
|
}
|
|
}
|
|
}, [bottomMargin, handleIntersection, topMargin, root])
|
|
|
|
return (
|
|
<View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
|
|
)
|
|
}
|
|
Visibility = memo(Visibility)
|
|
|
|
export const List = memo(forwardRef(ListImpl)) as <ItemT>(
|
|
props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
|
|
) => React.ReactElement
|
|
|
|
// https://stackoverflow.com/questions/7944460/detect-safari-browser
|
|
|
|
const styles = StyleSheet.create({
|
|
minHeightViewport: {
|
|
// @ts-ignore web only
|
|
minHeight: '100vh',
|
|
},
|
|
parentTreeVisibilityDetector: {
|
|
// @ts-ignore web only
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
},
|
|
aboveTheFoldDetector: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
// Bottom is dynamic.
|
|
},
|
|
visibilityDetector: {
|
|
pointerEvents: 'none',
|
|
zIndex: -1,
|
|
},
|
|
})
|