diff options
Diffstat (limited to 'src/view/com/util')
-rw-r--r-- | src/view/com/util/List.tsx | 6 | ||||
-rw-r--r-- | src/view/com/util/List.web.tsx | 341 | ||||
-rw-r--r-- | src/view/com/util/MainScrollProvider.tsx | 37 | ||||
-rw-r--r-- | src/view/com/util/SimpleViewHeader.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/Toast.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/fab/FABInner.tsx | 4 |
6 files changed, 398 insertions, 9 deletions
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 9abd7d35a..d30a9d805 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,4 +1,4 @@ -import React, {memo, startTransition} from 'react' +import React, {memo} from 'react' import {FlatListProps, RefreshControl} from 'react-native' import {FlatList_INTERNAL} from './Views' import {addStyle} from 'lib/styles' @@ -39,9 +39,7 @@ function ListImpl<ItemT>( const pal = usePalette('default') function handleScrolledDownChange(didScrollDown: boolean) { - startTransition(() => { - onScrolledDownChange?.(didScrollDown) - }) + onScrolledDownChange?.(didScrollDown) } const scrollHandler = useAnimatedScrollHandler({ diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx new file mode 100644 index 000000000..3e81a8c37 --- /dev/null +++ b/src/view/com/util/List.web.tsx @@ -0,0 +1,341 @@ +import React, {isValidElement, memo, useRef, startTransition} from 'react' +import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' +import {addStyle} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useScrollHandlers} from '#/lib/ScrollContext' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {batchedUpdates} from '#/lib/batchedUpdates' + +export type ListMethods = any // TODO: Better types. +export type ListProps<ItemT> = Omit< + FlatListProps<ItemT>, + | 'onScroll' // Use ScrollContext instead. + | 'refreshControl' // Pass refreshing and/or onRefresh instead. + | 'contentOffset' // Pass headerOffset instead. +> & { + onScrolledDownChange?: (isScrolledDown: boolean) => void + headerOffset?: number + refreshing?: boolean + onRefresh?: () => void + desktopFixedHeight: any // TODO: Better types. +} +export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. + +function ListImpl<ItemT>( + { + ListHeaderComponent, + ListFooterComponent, + contentContainerStyle, + data, + desktopFixedHeight, + headerOffset, + keyExtractor, + refreshing: _unsupportedRefreshing, + onEndReached, + onEndReachedThreshold = 0, + onRefresh: _unsupportedOnRefresh, + onScrolledDownChange, + onContentSizeChange, + renderItem, + extraData, + style, + ...props + }: ListProps<ItemT>, + ref: React.Ref<ListMethods>, +) { + const contextScrollHandlers = useScrollHandlers() + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + if (!isMobile) { + contentContainerStyle = addStyle( + contentContainerStyle, + styles.containerScroll, + ) + } + + let header: JSX.Element | null = null + if (ListHeaderComponent != null) { + if (isValidElement(ListHeaderComponent)) { + header = ListHeaderComponent + } else { + // @ts-ignore Nah it's fine. + header = <ListHeaderComponent /> + } + } + + let footer: JSX.Element | null = null + if (ListFooterComponent != null) { + if (isValidElement(ListFooterComponent)) { + footer = ListFooterComponent + } else { + // @ts-ignore Nah it's fine. + footer = <ListFooterComponent /> + } + } + + if (headerOffset != null) { + style = addStyle(style, { + paddingTop: headerOffset, + }) + } + + const nativeRef = React.useRef(null) + React.useImperativeHandle( + ref, + () => + ({ + scrollToTop() { + window.scrollTo({top: 0}) + }, + scrollToOffset({ + animated, + offset, + }: { + animated: boolean + offset: number + }) { + window.scrollTo({ + left: 0, + top: offset, + behavior: animated ? 'smooth' : 'instant', + }) + }, + } as any), // TODO: Better types. + [], + ) + + // --- onContentSizeChange --- + const containerRef = useRef(null) + useResizeObserver(containerRef, onContentSizeChange) + + // --- onScroll --- + const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) + const handleWindowScroll = useNonReactiveCallback(() => { + if (isInsideVisibleTree) { + contextScrollHandlers.onScroll?.( + { + contentOffset: { + x: Math.max(0, window.scrollX), + y: Math.max(0, window.scrollY), + }, + } as any, // TODO: Better types. + null as any, + ) + } + }) + React.useEffect(() => { + if (!isInsideVisibleTree) { + // Prevents hidden tabs from firing scroll events. + // Only one list is expected to be firing these at a time. + return + } + window.addEventListener('scroll', handleWindowScroll) + return () => { + window.removeEventListener('scroll', handleWindowScroll) + } + }, [isInsideVisibleTree, handleWindowScroll]) + + // --- onScrolledDownChange --- + const isScrolledDown = useRef(false) + function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) { + const didScrollDown = !isAboveTheFold + if (isScrolledDown.current !== didScrollDown) { + isScrolledDown.current = didScrollDown + startTransition(() => { + onScrolledDownChange?.(didScrollDown) + }) + } + } + + // --- onEndReached --- + const onTailVisibilityChange = useNonReactiveCallback( + (isTailVisible: boolean) => { + if (isTailVisible) { + onEndReached?.({ + distanceFromEnd: onEndReachedThreshold || 0, + }) + } + }, + ) + + return ( + <View {...props} style={style} ref={nativeRef}> + <Visibility + onVisibleChange={setIsInsideVisibleTree} + style={ + // This has position: fixed, so it should always report as visible + // unless we're within a display: none tree (like a hidden tab). + styles.parentTreeVisibilityDetector + } + /> + <View + ref={containerRef} + style={[ + styles.contentContainer, + contentContainerStyle, + desktopFixedHeight ? styles.minHeightViewport : null, + pal.border, + ]}> + <Visibility + onVisibleChange={handleAboveTheFoldVisibleChange} + style={[styles.aboveTheFoldDetector, {height: headerOffset}]} + /> + {header} + {(data as Array<ItemT>).map((item, index) => ( + <Row<ItemT> + key={keyExtractor!(item, index)} + item={item} + index={index} + renderItem={renderItem} + extraData={extraData} + /> + ))} + {onEndReached && ( + <Visibility + topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} + onVisibleChange={onTailVisibilityChange} + /> + )} + {footer} + </View> + </View> + ) +} + +function useResizeObserver( + ref: React.RefObject<Element>, + onResize: undefined | ((w: number, h: number) => void), +) { + const handleResize = useNonReactiveCallback(onResize ?? (() => {})) + const isActive = !!onResize + React.useEffect(() => { + if (!isActive) { + return + } + const resizeObserver = new ResizeObserver(entries => { + batchedUpdates(() => { + for (let entry of entries) { + const rect = entry.contentRect + handleResize(rect.width, rect.height) + } + }) + }) + const node = ref.current! + resizeObserver.observe(node) + return () => { + resizeObserver.unobserve(node) + } + }, [handleResize, isActive, ref]) +} + +let Row = function RowImpl<ItemT>({ + item, + index, + renderItem, + extraData: _unused, +}: { + item: ItemT + index: number + renderItem: + | null + | undefined + | ((data: {index: number; item: any; separators: any}) => React.ReactNode) + extraData: any +}): React.ReactNode { + if (!renderItem) { + return null + } + return ( + <View style={styles.row}> + {renderItem({item, index, separators: null as any})} + </View> + ) +} +Row = React.memo(Row) + +let Visibility = ({ + topMargin = '0px', + onVisibleChange, + style, +}: { + topMargin?: string + onVisibleChange: (isVisible: boolean) => void + style?: ViewProps['style'] +}): React.ReactNode => { + const tailRef = React.useRef(null) + const isIntersecting = React.useRef(false) + + const handleIntersection = useNonReactiveCallback( + (entries: IntersectionObserverEntry[]) => { + batchedUpdates(() => { + entries.forEach(entry => { + if (entry.isIntersecting !== isIntersecting.current) { + isIntersecting.current = entry.isIntersecting + onVisibleChange(entry.isIntersecting) + } + }) + }) + }, + ) + + React.useEffect(() => { + const observer = new IntersectionObserver(handleIntersection, { + rootMargin: `${topMargin} 0px 0px 0px`, + }) + const tail: Element | null = tailRef.current! + observer.observe(tail) + return () => { + observer.unobserve(tail) + } + }, [handleIntersection, topMargin]) + + return ( + <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> + ) +} +Visibility = React.memo(Visibility) + +export const List = memo(React.forwardRef(ListImpl)) as <ItemT>( + props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>}, +) => React.ReactElement + +const styles = StyleSheet.create({ + contentContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + containerScroll: { + width: '100%', + maxWidth: 600, + marginLeft: 'auto', + marginRight: 'auto', + }, + row: { + // @ts-ignore web only + contentVisibility: 'auto', + }, + minHeightViewport: { + // @ts-ignore web only + minHeight: '100vh', + }, + parentTreeVisibilityDetector: { + // @ts-ignore web only + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + aboveTheFoldDetector: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + // Bottom is dynamic. + }, + visibilityDetector: { + pointerEvents: 'none', + zIndex: -1, + }, +}) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 3ac28d31f..2c90e33ff 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -1,9 +1,10 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useEffect} from 'react' +import EventEmitter from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' import {NativeScrollEvent} from 'react-native' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' -import {isNative} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {useSharedValue, interpolate} from 'react-native-reanimated' const WEB_HIDE_SHELL_THRESHOLD = 200 @@ -20,6 +21,15 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const startDragOffset = useSharedValue<number | null>(null) const startMode = useSharedValue<number | null>(null) + useEffect(() => { + if (isWeb) { + return listenToForcedWindowScroll(() => { + startDragOffset.value = null + startMode.value = null + }) + } + }) + const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' @@ -100,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { </ScrollProvider> ) } + +const emitter = new EventEmitter() + +if (isWeb) { + const originalScroll = window.scroll + window.scroll = function () { + emitter.emit('forced-scroll') + return originalScroll.apply(this, arguments as any) + } + + const originalScrollTo = window.scrollTo + window.scrollTo = function () { + emitter.emit('forced-scroll') + return originalScrollTo.apply(this, arguments as any) + } +} + +function listenToForcedWindowScroll(listener: () => void) { + emitter.addListener('forced-scroll', listener) + return () => { + emitter.removeListener('forced-scroll', listener) + } +} diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx index e86e37565..814b2fb15 100644 --- a/src/view/com/util/SimpleViewHeader.tsx +++ b/src/view/com/util/SimpleViewHeader.tsx @@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' import {useSetDrawerOpen} from '#/state/shell' +import {isWeb} from '#/platform/detection' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -47,7 +48,14 @@ export function SimpleViewHeader({ const Container = isMobile ? View : CenteredView return ( - <Container style={[styles.header, isMobile && styles.headerMobile, style]}> + <Container + style={[ + styles.header, + isMobile && styles.headerMobile, + isWeb && styles.headerWeb, + pal.view, + style, + ]}> {showBackButton ? ( <TouchableOpacity testID="viewHeaderDrawerBtn" @@ -89,6 +97,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 10, }, + headerWeb: { + // @ts-ignore web-only + position: 'sticky', + top: 0, + zIndex: 1, + }, backBtn: { width: 30, height: 30, diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index beb67c30c..d5a843541 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { const styles = StyleSheet.create({ container: { - position: 'absolute', + // @ts-ignore web only + position: 'fixed', left: 20, bottom: 20, // @ts-ignore web only diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 9787d92fb..27a16117b 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lib/numbers' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {isWeb} from '#/platform/detection' import Animated from 'react-native-reanimated' export interface FABProps @@ -64,7 +65,8 @@ const styles = StyleSheet.create({ borderRadius: 35, }, outer: { - position: 'absolute', + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', zIndex: 1, }, inner: { |