forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {
2 isValidElement,
3 type JSX,
4 memo,
5 startTransition,
6 useRef,
7} from 'react'
8import {
9 type FlatListProps,
10 StyleSheet,
11 View,
12 type ViewProps,
13} from 'react-native'
14import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
15
16import {batchedUpdates} from '#/lib/batchedUpdates'
17import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
18import {useScrollHandlers} from '#/lib/ScrollContext'
19import {addStyle} from '#/lib/styles'
20import * as Layout from '#/components/Layout'
21
22export type ListMethods = any // TODO: Better types.
23export type ListProps<ItemT> = Omit<
24 FlatListProps<ItemT>,
25 | 'onScroll' // Use ScrollContext instead.
26 | 'refreshControl' // Pass refreshing and/or onRefresh instead.
27 | 'contentOffset' // Pass headerOffset instead.
28> & {
29 onScrolledDownChange?: (isScrolledDown: boolean) => void
30 headerOffset?: number
31 refreshing?: boolean
32 onRefresh?: () => void
33 onItemSeen?: (item: ItemT) => void
34 desktopFixedHeight?: number | boolean
35 // Web only prop to contain the scroll to the container rather than the window
36 disableFullWindowScroll?: boolean
37 /**
38 * @deprecated Should be using Layout components
39 */
40 sideBorders?: boolean
41}
42export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
43
44const ON_ITEM_SEEN_WAIT_DURATION = 0.5e3 // when we consider post to be "seen"
45const ON_ITEM_SEEN_INTERSECTION_OPTS = {
46 rootMargin: '-200px 0px -200px 0px',
47} // post must be 200px visible to be "seen"
48
49function ListImpl<ItemT>(
50 {
51 ListHeaderComponent,
52 ListFooterComponent,
53 ListEmptyComponent,
54 disableFullWindowScroll,
55 contentContainerStyle,
56 data,
57 desktopFixedHeight,
58 headerOffset,
59 keyExtractor,
60 refreshing: _unsupportedRefreshing,
61 onStartReached,
62 onStartReachedThreshold = 2,
63 onEndReached,
64 onEndReachedThreshold = 2,
65 onRefresh: _unsupportedOnRefresh,
66 onScrolledDownChange,
67 onContentSizeChange,
68 onItemSeen,
69 renderItem,
70 extraData,
71 style,
72 ...props
73 }: ListProps<ItemT>,
74 ref: React.Ref<ListMethods>,
75) {
76 const contextScrollHandlers = useScrollHandlers()
77
78 const isEmpty = !data || data.length === 0
79
80 let headerComponent: JSX.Element | null = null
81 if (ListHeaderComponent != null) {
82 if (isValidElement(ListHeaderComponent)) {
83 headerComponent = ListHeaderComponent
84 } else {
85 // @ts-ignore Nah it's fine.
86 headerComponent = <ListHeaderComponent />
87 }
88 }
89
90 let footerComponent: JSX.Element | null = null
91 if (ListFooterComponent != null) {
92 if (isValidElement(ListFooterComponent)) {
93 footerComponent = ListFooterComponent
94 } else {
95 // @ts-ignore Nah it's fine.
96 footerComponent = <ListFooterComponent />
97 }
98 }
99
100 let emptyComponent: JSX.Element | null = null
101 if (ListEmptyComponent != null) {
102 if (isValidElement(ListEmptyComponent)) {
103 emptyComponent = ListEmptyComponent
104 } else {
105 // @ts-ignore Nah it's fine.
106 emptyComponent = <ListEmptyComponent />
107 }
108 }
109
110 if (headerOffset != null) {
111 style = addStyle(style, {
112 paddingTop: headerOffset,
113 })
114 }
115
116 const getScrollableNode = React.useCallback(() => {
117 if (disableFullWindowScroll) {
118 const element = nativeRef.current as HTMLDivElement | null
119 if (!element) return
120
121 return {
122 get scrollWidth() {
123 return element.scrollWidth
124 },
125 get scrollHeight() {
126 return element.scrollHeight
127 },
128 get clientWidth() {
129 return element.clientWidth
130 },
131 get clientHeight() {
132 return element.clientHeight
133 },
134 get scrollY() {
135 return element.scrollTop
136 },
137 get scrollX() {
138 return element.scrollLeft
139 },
140 scrollTo(options?: ScrollToOptions) {
141 element.scrollTo(options)
142 },
143 scrollBy(options: ScrollToOptions) {
144 element.scrollBy(options)
145 },
146 addEventListener(event: string, handler: any) {
147 element.addEventListener(event, handler)
148 },
149 removeEventListener(event: string, handler: any) {
150 element.removeEventListener(event, handler)
151 },
152 }
153 } else {
154 return {
155 get scrollWidth() {
156 return document.documentElement.scrollWidth
157 },
158 get scrollHeight() {
159 return document.documentElement.scrollHeight
160 },
161 get clientWidth() {
162 return window.innerWidth
163 },
164 get clientHeight() {
165 return window.innerHeight
166 },
167 get scrollY() {
168 return window.scrollY
169 },
170 get scrollX() {
171 return window.scrollX
172 },
173 scrollTo(options: ScrollToOptions) {
174 window.scrollTo(options)
175 },
176 scrollBy(options: ScrollToOptions) {
177 window.scrollBy(options)
178 },
179 addEventListener(event: string, handler: any) {
180 window.addEventListener(event, handler)
181 },
182 removeEventListener(event: string, handler: any) {
183 window.removeEventListener(event, handler)
184 },
185 }
186 }
187 }, [disableFullWindowScroll])
188
189 const nativeRef = React.useRef<HTMLDivElement>(null)
190 React.useImperativeHandle(
191 ref,
192 () =>
193 ({
194 scrollToTop() {
195 getScrollableNode()?.scrollTo({top: 0})
196 },
197
198 scrollToOffset({
199 animated,
200 offset,
201 }: {
202 animated: boolean
203 offset: number
204 }) {
205 getScrollableNode()?.scrollTo({
206 left: 0,
207 top: offset,
208 behavior: animated ? 'smooth' : 'instant',
209 })
210 },
211
212 scrollToEnd({animated = true}: {animated?: boolean}) {
213 const element = getScrollableNode()
214 element?.scrollTo({
215 left: 0,
216 top: element.scrollHeight,
217 behavior: animated ? 'smooth' : 'instant',
218 })
219 },
220 }) as any, // TODO: Better types.
221 [getScrollableNode],
222 )
223
224 // --- onContentSizeChange, maintainVisibleContentPosition ---
225 const containerRef = useRef(null)
226 useResizeObserver(containerRef, onContentSizeChange)
227
228 // --- onScroll ---
229 const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
230 const handleScroll = useNonReactiveCallback(() => {
231 if (!isInsideVisibleTree) return
232
233 const element = getScrollableNode()
234 contextScrollHandlers.onScroll?.(
235 {
236 contentOffset: {
237 x: Math.max(0, element?.scrollX ?? 0),
238 y: Math.max(0, element?.scrollY ?? 0),
239 },
240 layoutMeasurement: {
241 width: element?.clientWidth,
242 height: element?.clientHeight,
243 },
244 contentSize: {
245 width: element?.scrollWidth,
246 height: element?.scrollHeight,
247 },
248 } as Exclude<
249 ReanimatedScrollEvent,
250 | 'velocity'
251 | 'eventName'
252 | 'zoomScale'
253 | 'targetContentOffset'
254 | 'contentInset'
255 >,
256 null as any,
257 )
258 })
259
260 React.useEffect(() => {
261 if (!isInsideVisibleTree) {
262 // Prevents hidden tabs from firing scroll events.
263 // Only one list is expected to be firing these at a time.
264 return
265 }
266
267 const element = getScrollableNode()
268
269 element?.addEventListener('scroll', handleScroll)
270 return () => {
271 element?.removeEventListener('scroll', handleScroll)
272 }
273 }, [
274 isInsideVisibleTree,
275 handleScroll,
276 disableFullWindowScroll,
277 getScrollableNode,
278 ])
279
280 // --- onScrolledDownChange ---
281 const isScrolledDown = useRef(false)
282 function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
283 const didScrollDown = !isAboveTheFold
284 if (isScrolledDown.current !== didScrollDown) {
285 isScrolledDown.current = didScrollDown
286 startTransition(() => {
287 onScrolledDownChange?.(didScrollDown)
288 })
289 }
290 }
291
292 // --- onStartReached ---
293 const onHeadVisibilityChange = useNonReactiveCallback(
294 (isHeadVisible: boolean) => {
295 if (isHeadVisible) {
296 onStartReached?.({
297 distanceFromStart: onStartReachedThreshold || 0,
298 })
299 }
300 },
301 )
302
303 // --- onEndReached ---
304 const onTailVisibilityChange = useNonReactiveCallback(
305 (isTailVisible: boolean) => {
306 if (isTailVisible) {
307 onEndReached?.({
308 distanceFromEnd: onEndReachedThreshold || 0,
309 })
310 }
311 },
312 )
313
314 return (
315 <View
316 {...props}
317 style={[
318 style,
319 disableFullWindowScroll && {
320 flex: 1,
321 // @ts-expect-error web only
322 'overflow-y': 'scroll',
323 },
324 ]}
325 ref={nativeRef as any}>
326 <Visibility
327 onVisibleChange={setIsInsideVisibleTree}
328 style={
329 // This has position: fixed, so it should always report as visible
330 // unless we're within a display: none tree (like a hidden tab).
331 styles.parentTreeVisibilityDetector
332 }
333 />
334 <Layout.Center>
335 <View
336 ref={containerRef}
337 style={[
338 contentContainerStyle,
339 desktopFixedHeight ? styles.minHeightViewport : null,
340 ]}>
341 <Visibility
342 root={disableFullWindowScroll ? nativeRef : null}
343 onVisibleChange={handleAboveTheFoldVisibleChange}
344 style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
345 />
346 {onStartReached && !isEmpty && (
347 <EdgeVisibility
348 root={disableFullWindowScroll ? nativeRef : null}
349 onVisibleChange={onHeadVisibilityChange}
350 topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
351 containerRef={containerRef}
352 />
353 )}
354 {headerComponent}
355 {isEmpty
356 ? emptyComponent
357 : (data as Array<ItemT>)?.map((item, index) => {
358 const key = keyExtractor!(item, index)
359 return (
360 <Row<ItemT>
361 key={key}
362 item={item}
363 index={index}
364 renderItem={renderItem}
365 extraData={extraData}
366 onItemSeen={onItemSeen}
367 />
368 )
369 })}
370 {onEndReached && !isEmpty && (
371 <EdgeVisibility
372 root={disableFullWindowScroll ? nativeRef : null}
373 onVisibleChange={onTailVisibilityChange}
374 bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
375 containerRef={containerRef}
376 />
377 )}
378 {footerComponent}
379 </View>
380 </Layout.Center>
381 </View>
382 )
383}
384
385function EdgeVisibility({
386 root,
387 topMargin,
388 bottomMargin,
389 containerRef,
390 onVisibleChange,
391}: {
392 root?: React.RefObject<HTMLDivElement | null> | null
393 topMargin?: string
394 bottomMargin?: string
395 containerRef: React.RefObject<Element | null>
396 onVisibleChange: (isVisible: boolean) => void
397}) {
398 const [containerHeight, setContainerHeight] = React.useState(0)
399 useResizeObserver(containerRef, (w, h) => {
400 setContainerHeight(h)
401 })
402 return (
403 <Visibility
404 key={containerHeight}
405 root={root}
406 topMargin={topMargin}
407 bottomMargin={bottomMargin}
408 onVisibleChange={onVisibleChange}
409 />
410 )
411}
412
413function useResizeObserver(
414 ref: React.RefObject<Element | null>,
415 onResize: undefined | ((w: number, h: number) => void),
416) {
417 const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
418 const isActive = !!onResize
419 React.useEffect(() => {
420 if (!isActive) {
421 return
422 }
423 const resizeObserver = new ResizeObserver(entries => {
424 batchedUpdates(() => {
425 for (let entry of entries) {
426 const rect = entry.contentRect
427 handleResize(rect.width, rect.height)
428 }
429 })
430 })
431 const node = ref.current!
432 resizeObserver.observe(node)
433 return () => {
434 resizeObserver.unobserve(node)
435 }
436 }, [handleResize, isActive, ref])
437}
438
439let Row = function RowImpl<ItemT>({
440 item,
441 index,
442 renderItem,
443 extraData: _unused,
444 onItemSeen,
445}: {
446 item: ItemT
447 index: number
448 renderItem:
449 | null
450 | undefined
451 | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
452 extraData: any
453 onItemSeen: ((item: any) => void) | undefined
454}): React.ReactNode {
455 const rowRef = React.useRef(null)
456 const intersectionTimeout = React.useRef<
457 ReturnType<typeof setTimeout> | undefined
458 >(undefined)
459
460 const handleIntersection = useNonReactiveCallback(
461 (entries: IntersectionObserverEntry[]) => {
462 batchedUpdates(() => {
463 if (!onItemSeen) {
464 return
465 }
466 entries.forEach(entry => {
467 if (entry.isIntersecting) {
468 if (!intersectionTimeout.current) {
469 intersectionTimeout.current = setTimeout(() => {
470 intersectionTimeout.current = undefined
471 onItemSeen!(item)
472 }, ON_ITEM_SEEN_WAIT_DURATION)
473 }
474 } else {
475 if (intersectionTimeout.current) {
476 clearTimeout(intersectionTimeout.current as NodeJS.Timeout)
477 intersectionTimeout.current = undefined
478 }
479 }
480 })
481 })
482 },
483 )
484
485 React.useEffect(() => {
486 if (!onItemSeen) {
487 return
488 }
489 const observer = new IntersectionObserver(
490 handleIntersection,
491 ON_ITEM_SEEN_INTERSECTION_OPTS,
492 )
493 const row: Element | null = rowRef.current!
494 observer.observe(row)
495 return () => {
496 observer.unobserve(row)
497 }
498 }, [handleIntersection, onItemSeen])
499
500 if (!renderItem) {
501 return null
502 }
503
504 return (
505 <View ref={rowRef}>
506 {renderItem({item, index, separators: null as any})}
507 </View>
508 )
509}
510Row = React.memo(Row)
511
512let Visibility = ({
513 root,
514 topMargin = '0px',
515 bottomMargin = '0px',
516 onVisibleChange,
517 style,
518}: {
519 root?: React.RefObject<HTMLDivElement | null> | null
520 topMargin?: string
521 bottomMargin?: string
522 onVisibleChange: (isVisible: boolean) => void
523 style?: ViewProps['style']
524}): React.ReactNode => {
525 const tailRef = React.useRef(null)
526 const isIntersecting = React.useRef(false)
527
528 const handleIntersection = useNonReactiveCallback(
529 (entries: IntersectionObserverEntry[]) => {
530 batchedUpdates(() => {
531 entries.forEach(entry => {
532 if (entry.isIntersecting !== isIntersecting.current) {
533 isIntersecting.current = entry.isIntersecting
534 onVisibleChange(entry.isIntersecting)
535 }
536 })
537 })
538 },
539 )
540
541 React.useEffect(() => {
542 const observer = new IntersectionObserver(handleIntersection, {
543 root: root?.current ?? null,
544 rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
545 })
546 const tail: Element | null = tailRef.current!
547 observer.observe(tail)
548 return () => {
549 observer.unobserve(tail)
550 }
551 }, [bottomMargin, handleIntersection, topMargin, root])
552
553 return (
554 <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
555 )
556}
557Visibility = React.memo(Visibility)
558
559export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
560 props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
561) => React.ReactElement<any>
562
563// https://stackoverflow.com/questions/7944460/detect-safari-browser
564
565const styles = StyleSheet.create({
566 minHeightViewport: {
567 // @ts-ignore web only
568 minHeight: '100vh',
569 },
570 parentTreeVisibilityDetector: {
571 // @ts-ignore web only
572 position: 'fixed',
573 top: 0,
574 left: 0,
575 right: 0,
576 bottom: 0,
577 },
578 aboveTheFoldDetector: {
579 position: 'absolute',
580 top: 0,
581 left: 0,
582 right: 0,
583 // Bottom is dynamic.
584 },
585 visibilityDetector: {
586 pointerEvents: 'none',
587 zIndex: -1,
588 },
589})