about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/feeds/FeedPage.tsx46
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx26
-rw-r--r--src/view/com/lists/ListMembers.tsx20
-rw-r--r--src/view/com/lists/MyLists.tsx4
-rw-r--r--src/view/com/lists/ProfileLists.tsx26
-rw-r--r--src/view/com/notifications/Feed.tsx19
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx47
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx5
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx5
-rw-r--r--src/view/com/post-thread/PostThread.tsx7
-rw-r--r--src/view/com/posts/Feed.tsx20
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx5
-rw-r--r--src/view/com/profile/ProfileFollows.tsx5
-rw-r--r--src/view/com/util/List.tsx64
-rw-r--r--src/view/com/util/MainScrollProvider.tsx97
-rw-r--r--src/view/com/util/ViewSelector.tsx11
-rw-r--r--src/view/com/util/Views.d.ts2
-rw-r--r--src/view/com/util/Views.jsx2
-rw-r--r--src/view/com/util/Views.web.tsx2
19 files changed, 259 insertions, 154 deletions
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 31ebc75a1..9c92a0dd5 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -7,13 +7,15 @@ import {useNavigation} from '@react-navigation/native'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useQueryClient} from '@tanstack/react-query'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {MainScrollProvider} from '../util/MainScrollProvider'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useSetMinimalShellMode} from '#/state/shell'
 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {ComposeIcon2} from 'lib/icons'
 import {colors, s} from 'lib/styles'
-import {FlatList, View, useWindowDimensions} from 'react-native'
+import {View, useWindowDimensions} from 'react-native'
+import {ListMethods} from '../util/List'
 import {Feed} from '../posts/Feed'
 import {TextLink} from '../util/Link'
 import {FAB} from '../util/fab/FAB'
@@ -51,10 +53,11 @@ export function FeedPage({
   const {isDesktop} = useWebMediaQueries()
   const queryClient = useQueryClient()
   const {openComposer} = useComposerControls()
-  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
+  const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+  const setMinimalShellMode = useSetMinimalShellMode()
   const {screen, track} = useAnalytics()
   const headerOffset = useHeaderOffset()
-  const scrollElRef = React.useRef<FlatList>(null)
+  const scrollElRef = React.useRef<ListMethods>(null)
   const [hasNew, setHasNew] = React.useState(false)
 
   const scrollToTop = React.useCallback(() => {
@@ -62,8 +65,8 @@ export function FeedPage({
       animated: isNative,
       offset: -headerOffset,
     })
-    resetMainScroll()
-  }, [headerOffset, resetMainScroll])
+    setMinimalShellMode(false)
+  }, [headerOffset, setMinimalShellMode])
 
   const onSoftReset = React.useCallback(() => {
     const isScreenFocused =
@@ -164,21 +167,22 @@ export function FeedPage({
 
   return (
     <View testID={testID} style={s.h100pct}>
-      <Feed
-        testID={testID ? `${testID}-feed` : undefined}
-        enabled={isPageFocused}
-        feed={feed}
-        feedParams={feedParams}
-        pollInterval={POLL_FREQ}
-        scrollElRef={scrollElRef}
-        onScroll={onMainScroll}
-        onHasNew={setHasNew}
-        scrollEventThrottle={1}
-        renderEmptyState={renderEmptyState}
-        renderEndOfFeed={renderEndOfFeed}
-        ListHeaderComponent={ListHeaderComponent}
-        headerOffset={headerOffset}
-      />
+      <MainScrollProvider>
+        <Feed
+          testID={testID ? `${testID}-feed` : undefined}
+          enabled={isPageFocused}
+          feed={feed}
+          feedParams={feedParams}
+          pollInterval={POLL_FREQ}
+          scrollElRef={scrollElRef}
+          onScrolledDownChange={setIsScrolledDown}
+          onHasNew={setHasNew}
+          renderEmptyState={renderEmptyState}
+          renderEndOfFeed={renderEndOfFeed}
+          ListHeaderComponent={ListHeaderComponent}
+          headerOffset={headerOffset}
+        />
+      </MainScrollProvider>
       {(isScrolledDown || hasNew) && (
         <LoadLatestBtn
           onPress={onPressLoadLatest}
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 43b170ced..ff6505501 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -1,4 +1,4 @@
-import React, {MutableRefObject} from 'react'
+import React from 'react'
 import {
   Dimensions,
   RefreshControl,
@@ -8,18 +8,16 @@ import {
   ViewStyle,
 } from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
-import {FlatList} from '../util/Views'
+import {List, ListRef} from '../util/List'
 import {FeedSourceCardLoaded} from './FeedSourceCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
-import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
 import {Trans} from '@lingui/macro'
 import {cleanError} from '#/lib/strings/errors'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useTheme} from '#/lib/ThemeContext'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {hydrateFeedGenerator} from '#/state/queries/feed'
@@ -37,9 +35,7 @@ interface SectionRef {
 
 interface ProfileFeedgensProps {
   did: string
-  scrollElRef: MutableRefObject<FlatList<any> | null>
-  onScroll?: OnScrollHandler
-  scrollEventThrottle?: number
+  scrollElRef: ListRef
   headerOffset: number
   enabled?: boolean
   style?: StyleProp<ViewStyle>
@@ -50,16 +46,7 @@ export const ProfileFeedgens = React.forwardRef<
   SectionRef,
   ProfileFeedgensProps
 >(function ProfileFeedgensImpl(
-  {
-    did,
-    scrollElRef,
-    onScroll,
-    scrollEventThrottle,
-    headerOffset,
-    enabled,
-    style,
-    testID,
-  },
+  {did, scrollElRef, headerOffset, enabled, style, testID},
   ref,
 ) {
   const pal = usePalette('default')
@@ -185,10 +172,9 @@ export const ProfileFeedgens = React.forwardRef<
     [error, refetch, onPressRetryLoadMore, pal, preferences],
   )
 
-  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
   return (
     <View testID={testID} style={style}>
-      <FlatList
+      <List
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
         data={items}
@@ -207,8 +193,6 @@ export const ProfileFeedgens = React.forwardRef<
           minHeight: Dimensions.get('window').height * 1.5,
         }}
         style={{paddingTop: headerOffset}}
-        onScroll={onScroll != null ? scrollHandler : undefined}
-        scrollEventThrottle={scrollEventThrottle}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         removeClippedSubviews={true}
         contentOffset={{x: 0, y: headerOffset * -1}}
diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx
index 160b4b3e5..a31ca4793 100644
--- a/src/view/com/lists/ListMembers.tsx
+++ b/src/view/com/lists/ListMembers.tsx
@@ -1,4 +1,4 @@
-import React, {MutableRefObject} from 'react'
+import React from 'react'
 import {
   ActivityIndicator,
   Dimensions,
@@ -8,7 +8,7 @@ import {
   ViewStyle,
 } from 'react-native'
 import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
-import {FlatList} from '../util/Views'
+import {List, ListRef} from '../util/List'
 import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@@ -18,10 +18,8 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useListMembersQuery} from '#/state/queries/list-members'
-import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useSession} from '#/state/session'
 import {cleanError} from '#/lib/strings/errors'
 
@@ -34,24 +32,22 @@ export function ListMembers({
   list,
   style,
   scrollElRef,
-  onScroll,
+  onScrolledDownChange,
   onPressTryAgain,
   renderHeader,
   renderEmptyState,
   testID,
-  scrollEventThrottle,
   headerOffset = 0,
   desktopFixedHeightOffset,
 }: {
   list: string
   style?: StyleProp<ViewStyle>
-  scrollElRef?: MutableRefObject<FlatList<any> | null>
-  onScroll: OnScrollHandler
+  scrollElRef?: ListRef
+  onScrolledDownChange: (isScrolledDown: boolean) => void
   onPressTryAgain?: () => void
   renderHeader: () => JSX.Element
   renderEmptyState: () => JSX.Element
   testID?: string
-  scrollEventThrottle?: number
   headerOffset?: number
   desktopFixedHeightOffset?: number
 }) {
@@ -209,10 +205,9 @@ export function ListMembers({
     [isFetching],
   )
 
-  const scrollHandler = useAnimatedScrollHandler(onScroll)
   return (
     <View testID={testID} style={style}>
-      <FlatList
+      <List
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
         data={items}
@@ -233,10 +228,9 @@ export function ListMembers({
           minHeight: Dimensions.get('window').height * 1.5,
         }}
         style={{paddingTop: headerOffset}}
-        onScroll={scrollHandler}
+        onScrolledDownChange={onScrolledDownChange}
         onEndReached={onEndReached}
         onEndReachedThreshold={0.6}
-        scrollEventThrottle={scrollEventThrottle}
         removeClippedSubviews={true}
         contentOffset={{x: 0, y: headerOffset * -1}}
         // @ts-ignore our .web version only -prf
diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx
index b46d34ba5..586ad234e 100644
--- a/src/view/com/lists/MyLists.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -15,7 +15,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
-import {FlatList} from '../util/Views'
+import {List} from '../util/List'
 import {s} from 'lib/styles'
 import {logger} from '#/logger'
 import {Trans} from '@lingui/macro'
@@ -119,7 +119,7 @@ export function MyLists({
     [error, onRefresh, renderItem, pal],
   )
 
-  const FlatListCom = inline ? RNFlatList : FlatList
+  const FlatListCom = inline ? RNFlatList : List
   return (
     <View testID={testID} style={style}>
       {items.length > 0 && (
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index 1bd9d188f..e3d9bd0b4 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -1,4 +1,4 @@
-import React, {MutableRefObject} from 'react'
+import React from 'react'
 import {
   Dimensions,
   RefreshControl,
@@ -8,7 +8,7 @@ import {
   ViewStyle,
 } from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
-import {FlatList} from '../util/Views'
+import {List, ListRef} from '../util/List'
 import {ListCard} from './ListCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@@ -16,11 +16,9 @@ import {Text} from '../util/text/Text'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
-import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
 import {Trans} from '@lingui/macro'
 import {cleanError} from '#/lib/strings/errors'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useTheme} from '#/lib/ThemeContext'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {isNative} from '#/platform/detection'
@@ -36,9 +34,7 @@ interface SectionRef {
 
 interface ProfileListsProps {
   did: string
-  scrollElRef: MutableRefObject<FlatList<any> | null>
-  onScroll?: OnScrollHandler
-  scrollEventThrottle?: number
+  scrollElRef: ListRef
   headerOffset: number
   enabled?: boolean
   style?: StyleProp<ViewStyle>
@@ -47,16 +43,7 @@ interface ProfileListsProps {
 
 export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
   function ProfileListsImpl(
-    {
-      did,
-      scrollElRef,
-      onScroll,
-      scrollEventThrottle,
-      headerOffset,
-      enabled,
-      style,
-      testID,
-    },
+    {did, scrollElRef, headerOffset, enabled, style, testID},
     ref,
   ) {
     const pal = usePalette('default')
@@ -187,10 +174,9 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
       [error, refetch, onPressRetryLoadMore, pal],
     )
 
-    const scrollHandler = useAnimatedScrollHandler(onScroll || {})
     return (
       <View testID={testID} style={style}>
-        <FlatList
+        <List
           testID={testID ? `${testID}-flatlist` : undefined}
           ref={scrollElRef}
           data={items}
@@ -209,8 +195,6 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
             minHeight: Dimensions.get('window').height * 1.5,
           }}
           style={{paddingTop: headerOffset}}
-          onScroll={onScroll != null ? scrollHandler : undefined}
-          scrollEventThrottle={scrollEventThrottle}
           indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
           removeClippedSubviews={true}
           contentOffset={{x: 0, y: headerOffset * -1}}
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 260c9bbd5..52d534c4f 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -1,13 +1,11 @@
-import React, {MutableRefObject} from 'react'
-import {CenteredView, FlatList} from '../util/Views'
+import React from 'react'
+import {CenteredView} from '../util/Views'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
 import {FeedItem} from './FeedItem'
 import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {EmptyState} from '../util/EmptyState'
-import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
@@ -15,6 +13,7 @@ import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
 import {logger} from '#/logger'
 import {cleanError} from '#/lib/strings/errors'
 import {useModerationOpts} from '#/state/queries/preferences'
+import {List, ListRef} from '../util/List'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@@ -23,12 +22,12 @@ const LOADING_ITEM = {_reactKey: '__loading__'}
 export function Feed({
   scrollElRef,
   onPressTryAgain,
-  onScroll,
+  onScrolledDownChange,
   ListHeaderComponent,
 }: {
-  scrollElRef?: MutableRefObject<FlatList<any> | null>
+  scrollElRef?: ListRef
   onPressTryAgain?: () => void
-  onScroll?: OnScrollHandler
+  onScrolledDownChange: (isScrolledDown: boolean) => void
   ListHeaderComponent?: () => JSX.Element
 }) {
   const pal = usePalette('default')
@@ -135,7 +134,6 @@ export function Feed({
     [isFetchingNextPage],
   )
 
-  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
   return (
     <View style={s.hContentRegion}>
       {error && (
@@ -146,7 +144,7 @@ export function Feed({
           />
         </CenteredView>
       )}
-      <FlatList
+      <List
         testID="notifsFeed"
         ref={scrollElRef}
         data={items}
@@ -164,8 +162,7 @@ export function Feed({
         }
         onEndReached={onEndReached}
         onEndReachedThreshold={0.6}
-        onScroll={scrollHandler}
-        scrollEventThrottle={1}
+        onScrolledDownChange={onScrolledDownChange}
         contentContainerStyle={s.contentContainer}
         // @ts-ignore our .web version only -prf
         desktopFixedHeight
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index cda2d1306..158940d67 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -1,7 +1,6 @@
 import * as React from 'react'
 import {
   LayoutChangeEvent,
-  FlatList,
   ScrollView,
   StyleSheet,
   View,
@@ -20,17 +19,14 @@ import Animated, {
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {TabBar} from './TabBar'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-
-const SCROLLED_DOWN_LIMIT = 200
+import {ListMethods} from '../util/List'
+import {ScrollProvider} from '#/lib/ScrollContext'
 
 export interface PagerWithHeaderChildParams {
   headerHeight: number
   isFocused: boolean
-  onScroll: OnScrollHandler
-  isScrolledDown: boolean
-  scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
+  scrollElRef: React.MutableRefObject<ListMethods | ScrollView | null>
 }
 
 export interface PagerWithHeaderProps {
@@ -62,7 +58,6 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
     const [currentPage, setCurrentPage] = React.useState(0)
     const [tabBarHeight, setTabBarHeight] = React.useState(0)
     const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
-    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
     const scrollY = useSharedValue(0)
     const headerHeight = headerOnlyHeight + tabBarHeight
 
@@ -155,15 +150,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
       if (!throttleTimeout.current) {
         throttleTimeout.current = setTimeout(() => {
           throttleTimeout.current = null
-
           runOnUI(adjustScrollForOtherPages)()
-
-          const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT
-          if (isScrolledDown !== nextIsScrolledDown) {
-            React.startTransition(() => {
-              setIsScrolledDown(nextIsScrolledDown)
-            })
-          }
         }, 80 /* Sync often enough you're unlikely to catch it unsynced */)
       }
     })
@@ -211,7 +198,6 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
                   index={i}
                   isReady={isReady}
                   isFocused={i === currentPage}
-                  isScrolledDown={isScrolledDown}
                   onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
                   registerRef={registerRef}
                   renderTab={child}
@@ -293,7 +279,6 @@ function PagerItem({
   index,
   isReady,
   isFocused,
-  isScrolledDown,
   onScrollWorklet,
   renderTab,
   registerRef,
@@ -302,7 +287,6 @@ function PagerItem({
   index: number
   isFocused: boolean
   isReady: boolean
-  isScrolledDown: boolean
   registerRef: (scrollRef: AnimatedRef<any> | null, atIndex: number) => void
   onScrollWorklet: (e: NativeScrollEvent) => void
   renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
@@ -316,24 +300,21 @@ function PagerItem({
     }
   }, [scrollElRef, registerRef, index])
 
-  const scrollHandler = React.useMemo(
-    () => ({onScroll: onScrollWorklet}),
-    [onScrollWorklet],
-  )
-
   if (!isReady || renderTab == null) {
     return null
   }
 
-  return renderTab({
-    headerHeight,
-    isFocused,
-    isScrolledDown,
-    onScroll: scrollHandler,
-    scrollElRef: scrollElRef as React.MutableRefObject<
-      FlatList<any> | ScrollView | null
-    >,
-  })
+  return (
+    <ScrollProvider onScroll={onScrollWorklet}>
+      {renderTab({
+        headerHeight,
+        isFocused,
+        scrollElRef: scrollElRef as React.MutableRefObject<
+          ListMethods | ScrollView | null
+        >,
+      })}
+    </ScrollProvider>
+  )
 }
 
 const styles = StyleSheet.create({
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 60afe1f9c..245ba59e8 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,7 +1,8 @@
 import React, {useCallback, useMemo, useState} from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
 import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
-import {CenteredView, FlatList} from '../util/Views'
+import {CenteredView} from '../util/Views'
+import {List} from '../util/List'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -84,7 +85,7 @@ export function PostLikedBy({uri}: {uri: string}) {
   // loaded
   // =
   return (
-    <FlatList
+    <List
       data={likes}
       keyExtractor={item => item.actor.did}
       refreshControl={
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 1162fec40..5cc006388 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,7 +1,8 @@
 import React, {useMemo, useCallback, useState} from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
-import {CenteredView, FlatList} from '../util/Views'
+import {CenteredView} from '../util/Views'
+import {List} from '../util/List'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -85,7 +86,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
   // loaded
   // =
   return (
-    <FlatList
+    <List
       data={repostedBy}
       keyExtractor={item => item.did}
       refreshControl={
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 051bc7849..f27da331f 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -8,7 +8,8 @@ import {
   View,
 } from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
-import {CenteredView, FlatList} from '../util/Views'
+import {CenteredView} from '../util/Views'
+import {List, ListMethods} from '../util/List'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -140,7 +141,7 @@ function PostThreadLoaded({
   const {_} = useLingui()
   const pal = usePalette('default')
   const {isTablet, isDesktop} = useWebMediaQueries()
-  const ref = useRef<FlatList>(null)
+  const ref = useRef<ListMethods>(null)
   const highlightedPostRef = useRef<View | null>(null)
   const needsScrollAdjustment = useRef<boolean>(
     !isNative || // web always uses scroll adjustment
@@ -335,7 +336,7 @@ function PostThreadLoaded({
   )
 
   return (
-    <FlatList
+    <List
       ref={ref}
       data={posts}
       initialNumToRender={!isNative ? posts.length : undefined}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 24a7f5b42..9194bb163 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -1,4 +1,4 @@
-import React, {memo, MutableRefObject} from 'react'
+import React, {memo} from 'react'
 import {
   ActivityIndicator,
   AppState,
@@ -10,15 +10,13 @@ import {
   ViewStyle,
 } from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
-import {FlatList} from '../util/Views'
+import {List, ListRef} from '../util/List'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {FeedErrorMessage} from './FeedErrorMessage'
 import {FeedSlice} from './FeedSlice'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
-import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {useTheme} from 'lib/ThemeContext'
 import {logger} from '#/logger'
 import {
@@ -45,9 +43,8 @@ let Feed = ({
   enabled,
   pollInterval,
   scrollElRef,
-  onScroll,
+  onScrolledDownChange,
   onHasNew,
-  scrollEventThrottle,
   renderEmptyState,
   renderEndOfFeed,
   testID,
@@ -62,10 +59,9 @@ let Feed = ({
   style?: StyleProp<ViewStyle>
   enabled?: boolean
   pollInterval?: number
-  scrollElRef?: MutableRefObject<FlatList<any> | null>
+  scrollElRef?: ListRef
   onHasNew?: (v: boolean) => void
-  onScroll?: OnScrollHandler
-  scrollEventThrottle?: number
+  onScrolledDownChange?: (isScrolledDown: boolean) => void
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
   testID?: string
@@ -270,10 +266,9 @@ let Feed = ({
     )
   }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
 
-  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
   return (
     <View testID={testID} style={style}>
-      <FlatList
+      <List
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
         data={feedItems}
@@ -294,8 +289,7 @@ let Feed = ({
           minHeight: Dimensions.get('window').height * 1.5,
         }}
         style={{paddingTop: headerOffset}}
-        onScroll={onScroll != null ? scrollHandler : undefined}
-        scrollEventThrottle={scrollEventThrottle}
+        onScrolledDownChange={onScrolledDownChange}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         onEndReached={onEndReached}
         onEndReachedThreshold={2} // number of posts left to trigger load more
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index d94f5103e..077dabe53 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -1,7 +1,8 @@
 import React from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
-import {CenteredView, FlatList} from '../util/Views'
+import {CenteredView} from '../util/Views'
+import {List} from '../util/List'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -86,7 +87,7 @@ export function ProfileFollowers({name}: {name: string}) {
   // loaded
   // =
   return (
-    <FlatList
+    <List
       data={followers}
       keyExtractor={item => item.did}
       refreshControl={
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index 890c13eb2..5265ee07e 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -1,7 +1,8 @@
 import React from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
-import {CenteredView, FlatList} from '../util/Views'
+import {CenteredView} from '../util/Views'
+import {List} from '../util/List'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -86,7 +87,7 @@ export function ProfileFollows({name}: {name: string}) {
   // loaded
   // =
   return (
-    <FlatList
+    <List
       data={follows}
       keyExtractor={item => item.did}
       refreshControl={
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
new file mode 100644
index 000000000..5947fe87a
--- /dev/null
+++ b/src/view/com/util/List.tsx
@@ -0,0 +1,64 @@
+import React, {memo, startTransition} from 'react'
+import {FlatListProps} from 'react-native'
+import {FlatList_INTERNAL} from './Views'
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {runOnJS, useSharedValue} from 'react-native-reanimated'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+
+export type ListMethods = FlatList_INTERNAL
+export type ListProps<ItemT> = Omit<
+  FlatListProps<ItemT>,
+  'onScroll' // Use ScrollContext instead.
+> & {
+  onScrolledDownChange?: (isScrolledDown: boolean) => void
+}
+export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
+
+const SCROLLED_DOWN_LIMIT = 200
+
+function ListImpl<ItemT>(
+  {onScrolledDownChange, ...props}: ListProps<ItemT>,
+  ref: React.Ref<ListMethods>,
+) {
+  const isScrolledDown = useSharedValue(false)
+  const contextScrollHandlers = useScrollHandlers()
+
+  function handleScrolledDownChange(didScrollDown: boolean) {
+    startTransition(() => {
+      onScrolledDownChange?.(didScrollDown)
+    })
+  }
+
+  const scrollHandler = useAnimatedScrollHandler({
+    onBeginDrag(e, ctx) {
+      contextScrollHandlers.onBeginDrag?.(e, ctx)
+    },
+    onEndDrag(e, ctx) {
+      contextScrollHandlers.onEndDrag?.(e, ctx)
+    },
+    onScroll(e, ctx) {
+      contextScrollHandlers.onScroll?.(e, ctx)
+
+      const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT
+      if (isScrolledDown.value !== didScrollDown) {
+        isScrolledDown.value = didScrollDown
+        if (onScrolledDownChange != null) {
+          runOnJS(handleScrolledDownChange)(didScrollDown)
+        }
+      }
+    },
+  })
+
+  return (
+    <FlatList_INTERNAL
+      {...props}
+      onScroll={scrollHandler}
+      scrollEventThrottle={1}
+      ref={ref}
+    />
+  )
+}
+
+export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
+  props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
+) => React.ReactElement
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
new file mode 100644
index 000000000..31a4ef0c8
--- /dev/null
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -0,0 +1,97 @@
+import React, {useCallback} from 'react'
+import {ScrollProvider} from '#/lib/ScrollContext'
+import {NativeScrollEvent} from 'react-native'
+import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {isWeb} from 'platform/detection'
+import {useSharedValue, interpolate} from 'react-native-reanimated'
+
+function clamp(num: number, min: number, max: number) {
+  'worklet'
+  return Math.min(Math.max(num, min), max)
+}
+
+export function MainScrollProvider({children}: {children: React.ReactNode}) {
+  const {headerHeight} = useShellLayout()
+  const mode = useMinimalShellMode()
+  const setMode = useSetMinimalShellMode()
+  const startDragOffset = useSharedValue<number | null>(null)
+  const startMode = useSharedValue<number | null>(null)
+
+  const onBeginDrag = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      startDragOffset.value = e.contentOffset.y
+      startMode.value = mode.value
+    },
+    [mode, startDragOffset, startMode],
+  )
+
+  const onEndDrag = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      startDragOffset.value = null
+      startMode.value = null
+      if (e.contentOffset.y < headerHeight.value / 2) {
+        // If we're close to the top, show the shell.
+        setMode(false)
+      } else {
+        // Snap to whichever state is the closest.
+        setMode(Math.round(mode.value) === 1)
+      }
+    },
+    [startDragOffset, startMode, setMode, mode, headerHeight],
+  )
+
+  const onScroll = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      if (startDragOffset.value === null || startMode.value === null) {
+        if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
+          // If we're close enough to the top, always show the shell.
+          // Even if we're not dragging.
+          setMode(false)
+          return
+        }
+        if (isWeb) {
+          // On the web, there is no concept of "starting" the drag.
+          // When we get the first scroll event, we consider that the start.
+          startDragOffset.value = e.contentOffset.y
+          startMode.value = mode.value
+        }
+        return
+      }
+
+      // The "mode" value is always between 0 and 1.
+      // Figure out how much to move it based on the current dragged distance.
+      const dy = e.contentOffset.y - startDragOffset.value
+      const dProgress = interpolate(
+        dy,
+        [-headerHeight.value, headerHeight.value],
+        [-1, 1],
+      )
+      const newValue = clamp(startMode.value + dProgress, 0, 1)
+      if (newValue !== mode.value) {
+        // Manually adjust the value. This won't be (and shouldn't be) animated.
+        mode.value = newValue
+      }
+      if (isWeb) {
+        // On the web, there is no concept of "starting" the drag,
+        // so we don't have any specific anchor point to calculate the distance.
+        // Instead, update it continuosly along the way and diff with the last event.
+        startDragOffset.value = e.contentOffset.y
+        startMode.value = mode.value
+      }
+    },
+    [headerHeight, mode, setMode, startDragOffset, startMode],
+  )
+
+  return (
+    <ScrollProvider
+      onBeginDrag={onBeginDrag}
+      onEndDrag={onEndDrag}
+      onScroll={onScroll}>
+      {children}
+    </ScrollProvider>
+  )
+}
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index 935d93033..ee993c564 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -1,13 +1,14 @@
 import React, {useEffect, useState} from 'react'
 import {
+  NativeSyntheticEvent,
+  NativeScrollEvent,
   Pressable,
   RefreshControl,
   StyleSheet,
   View,
   ScrollView,
 } from 'react-native'
-import {FlatList} from './Views'
-import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {FlatList_INTERNAL} from './Views'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {Text} from './text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -38,7 +39,7 @@ export const ViewSelector = React.forwardRef<
       | null
       | undefined
     onSelectView?: (viewIndex: number) => void
-    onScroll?: OnScrollCb
+    onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
     onRefresh?: () => void
     onEndReached?: (info: {distanceFromEnd: number}) => void
   }
@@ -59,7 +60,7 @@ export const ViewSelector = React.forwardRef<
 ) {
   const pal = usePalette('default')
   const [selectedIndex, setSelectedIndex] = useState<number>(0)
-  const flatListRef = React.useRef<FlatList>(null)
+  const flatListRef = React.useRef<FlatList_INTERNAL>(null)
 
   // events
   // =
@@ -110,7 +111,7 @@ export const ViewSelector = React.forwardRef<
     [items],
   )
   return (
-    <FlatList
+    <FlatList_INTERNAL
       ref={flatListRef}
       data={data}
       keyExtractor={keyExtractor}
diff --git a/src/view/com/util/Views.d.ts b/src/view/com/util/Views.d.ts
index 91df1d6bc..6a90cc229 100644
--- a/src/view/com/util/Views.d.ts
+++ b/src/view/com/util/Views.d.ts
@@ -1,6 +1,6 @@
 import React from 'react'
 import {ViewProps} from 'react-native'
-export {FlatList, ScrollView} from 'react-native'
+export {FlatList as FlatList_INTERNAL, ScrollView} from 'react-native'
 export function CenteredView({
   style,
   sideBorders,
diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx
index 8a93ce511..7d6120583 100644
--- a/src/view/com/util/Views.jsx
+++ b/src/view/com/util/Views.jsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {View} from 'react-native'
 import Animated from 'react-native-reanimated'
 
-export const FlatList = Animated.FlatList
+export const FlatList_INTERNAL = Animated.FlatList
 export const ScrollView = Animated.ScrollView
 export function CenteredView(props) {
   return <View {...props} />
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 5a4f266fd..db3b9de0d 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -49,7 +49,7 @@ export function CenteredView({
   return <View style={style} {...props} />
 }
 
-export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
+export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
   {
     contentContainerStyle,
     style,