about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/List.tsx6
-rw-r--r--src/view/com/util/List.web.tsx341
-rw-r--r--src/view/com/util/MainScrollProvider.tsx37
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx16
-rw-r--r--src/view/com/util/Toast.web.tsx3
-rw-r--r--src/view/com/util/fab/FABInner.tsx4
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: {