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 601 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 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})