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