Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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})