Bluesky app fork with some witchin' additions 馃挮
at readme-update 589 lines 16 kB view raw
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})