Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 606 lines 16 kB view raw
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})