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