about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/ScrollContext.tsx35
-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.tsx (renamed from src/lib/hooks/useOnMainScroll.ts)54
-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
-rw-r--r--src/view/screens/Feeds.tsx4
-rw-r--r--src/view/screens/Notifications.tsx25
-rw-r--r--src/view/screens/Profile.tsx82
-rw-r--r--src/view/screens/ProfileFeed.tsx46
-rw-r--r--src/view/screens/ProfileList.tsx63
-rw-r--r--src/view/screens/Search/Search.tsx9
26 files changed, 280 insertions, 354 deletions
diff --git a/src/lib/ScrollContext.tsx b/src/lib/ScrollContext.tsx
new file mode 100644
index 000000000..00b197bed
--- /dev/null
+++ b/src/lib/ScrollContext.tsx
@@ -0,0 +1,35 @@
+import React, {createContext, useContext, useMemo} from 'react'
+import {ScrollHandlers} from 'react-native-reanimated'
+
+const ScrollContext = createContext<ScrollHandlers<any>>({
+  onBeginDrag: undefined,
+  onEndDrag: undefined,
+  onScroll: undefined,
+})
+
+export function useScrollHandlers(): ScrollHandlers<any> {
+  return useContext(ScrollContext)
+}
+
+type ProviderProps = {children: React.ReactNode} & ScrollHandlers<any>
+
+// Note: this completely *overrides* the parent handlers.
+// It's up to you to compose them with the parent ones via useScrollHandlers() if needed.
+export function ScrollProvider({
+  children,
+  onBeginDrag,
+  onEndDrag,
+  onScroll,
+}: ProviderProps) {
+  const handlers = useMemo(
+    () => ({
+      onBeginDrag,
+      onEndDrag,
+      onScroll,
+    }),
+    [onBeginDrag, onEndDrag, onScroll],
+  )
+  return (
+    <ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
+  )
+}
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/lib/hooks/useOnMainScroll.ts b/src/view/com/util/MainScrollProvider.tsx
index 2e7a79913..31a4ef0c8 100644
--- a/src/lib/hooks/useOnMainScroll.ts
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -1,30 +1,18 @@
-import {useState, useCallback, useMemo} from 'react'
-import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
+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 {s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
-import {
-  useSharedValue,
-  interpolate,
-  runOnJS,
-  ScrollHandlers,
-} from 'react-native-reanimated'
+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 type OnScrollCb = (
-  event: NativeSyntheticEvent<NativeScrollEvent>,
-) => void
-export type OnScrollHandler = ScrollHandlers<any>
-export type ResetCb = () => void
-
-export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
+export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const {headerHeight} = useShellLayout()
-  const [isScrolledDown, setIsScrolledDown] = useState(false)
   const mode = useMinimalShellMode()
   const setMode = useSetMinimalShellMode()
   const startDragOffset = useSharedValue<number | null>(null)
@@ -58,13 +46,6 @@ export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
   const onScroll = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
-      // Keep track of whether we want to show "scroll to top".
-      if (!isScrolledDown && e.contentOffset.y > s.window.height) {
-        runOnJS(setIsScrolledDown)(true)
-      } else if (isScrolledDown && e.contentOffset.y < s.window.height) {
-        runOnJS(setIsScrolledDown)(false)
-      }
-
       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.
@@ -102,24 +83,15 @@ export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
         startMode.value = mode.value
       }
     },
-    [headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode],
+    [headerHeight, mode, setMode, startDragOffset, startMode],
   )
 
-  const scrollHandler: ScrollHandlers<any> = useMemo(
-    () => ({
-      onBeginDrag,
-      onEndDrag,
-      onScroll,
-    }),
-    [onBeginDrag, onEndDrag, onScroll],
+  return (
+    <ScrollProvider
+      onBeginDrag={onBeginDrag}
+      onEndDrag={onEndDrag}
+      onScroll={onScroll}>
+      {children}
+    </ScrollProvider>
   )
-
-  return [
-    scrollHandler,
-    isScrolledDown,
-    useCallback(() => {
-      setIsScrolledDown(false)
-      setMode(false)
-    }, [setMode]),
-  ]
 }
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,
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index f319fbc39..d2d0aafb8 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -19,7 +19,7 @@ import {
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
-import {FlatList} from 'view/com/util/Views'
+import {List} from 'view/com/util/List'
 import {useFocusEffect} from '@react-navigation/native'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {Trans, msg} from '@lingui/macro'
@@ -481,7 +481,7 @@ export function FeedsScreen(_props: Props) {
 
       {preferences ? <View /> : <ActivityIndicator />}
 
-      <FlatList
+      <List
         style={[!isTabletOrDesktop && s.flex1, styles.list]}
         data={items}
         keyExtractor={item => item.key}
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index fceaa60c2..e28a67e37 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {FlatList, View} from 'react-native'
+import {View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 import {
@@ -9,8 +9,9 @@ import {
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/notifications/Feed'
 import {TextLink} from 'view/com/util/Link'
+import {ListMethods} from 'view/com/util/List'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {MainScrollProvider} from '../com/util/MainScrollProvider'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {s, colors} from 'lib/styles'
@@ -35,8 +36,8 @@ type Props = NativeStackScreenProps<
 export function NotificationsScreen({}: Props) {
   const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
-  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
-  const scrollElRef = React.useRef<FlatList>(null)
+  const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+  const scrollElRef = React.useRef<ListMethods>(null)
   const checkLatestRef = React.useRef<() => void | null>()
   const {screen} = useAnalytics()
   const pal = usePalette('default')
@@ -50,8 +51,8 @@ export function NotificationsScreen({}: Props) {
   // =
   const scrollToTop = React.useCallback(() => {
     scrollElRef.current?.scrollToOffset({animated: isNative, offset: 0})
-    resetMainScroll()
-  }, [scrollElRef, resetMainScroll])
+    setMinimalShellMode(false)
+  }, [scrollElRef, setMinimalShellMode])
 
   const onPressLoadLatest = React.useCallback(() => {
     scrollToTop()
@@ -130,11 +131,13 @@ export function NotificationsScreen({}: Props) {
   return (
     <View testID="notificationsScreen" style={s.hContentRegion}>
       <ViewHeader title={_(msg`Notifications`)} canGoBack={false} />
-      <Feed
-        onScroll={onMainScroll}
-        scrollElRef={scrollElRef}
-        ListHeaderComponent={ListHeaderComponent}
-      />
+      <MainScrollProvider>
+        <Feed
+          onScrolledDownChange={setIsScrolledDown}
+          scrollElRef={scrollElRef}
+          ListHeaderComponent={ListHeaderComponent}
+        />
+      </MainScrollProvider>
       {(isScrolledDown || hasNew) && (
         <LoadLatestBtn
           onPress={onPressLoadLatest}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index b7dac8c6d..3f2dd773e 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -5,7 +5,8 @@ import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {CenteredView, FlatList} from '../com/util/Views'
+import {CenteredView} from '../com/util/Views'
+import {ListRef} from '../com/util/List'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {Feed} from 'view/com/posts/Feed'
 import {ProfileLists} from '../com/lists/ProfileLists'
@@ -20,7 +21,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {ComposeIcon2} from 'lib/icons'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {combinedDisplayName} from 'lib/strings/display-names'
-import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {useProfileQuery} from '#/state/queries/profile'
@@ -277,103 +277,67 @@ function ProfileScreenLoaded({
         onPageSelected={onPageSelected}
         onCurrentPageSelected={onCurrentPageSelected}
         renderHeader={renderHeader}>
-        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
+        {({headerHeight, isFocused, scrollElRef}) => (
           <FeedSection
             ref={postsSectionRef}
             feed={`author|${profile.did}|posts_and_author_threads`}
-            onScroll={onScroll}
             headerHeight={headerHeight}
             isFocused={isFocused}
-            isScrolledDown={isScrolledDown}
-            scrollElRef={
-              scrollElRef as React.MutableRefObject<FlatList<any> | null>
-            }
+            scrollElRef={scrollElRef as ListRef}
             ignoreFilterFor={profile.did}
           />
         )}
         {showRepliesTab
-          ? ({
-              onScroll,
-              headerHeight,
-              isFocused,
-              isScrolledDown,
-              scrollElRef,
-            }) => (
+          ? ({headerHeight, isFocused, scrollElRef}) => (
               <FeedSection
                 ref={repliesSectionRef}
                 feed={`author|${profile.did}|posts_with_replies`}
-                onScroll={onScroll}
                 headerHeight={headerHeight}
                 isFocused={isFocused}
-                isScrolledDown={isScrolledDown}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
+                scrollElRef={scrollElRef as ListRef}
                 ignoreFilterFor={profile.did}
               />
             )
           : null}
-        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
+        {({headerHeight, isFocused, scrollElRef}) => (
           <FeedSection
             ref={mediaSectionRef}
             feed={`author|${profile.did}|posts_with_media`}
-            onScroll={onScroll}
             headerHeight={headerHeight}
             isFocused={isFocused}
-            isScrolledDown={isScrolledDown}
-            scrollElRef={
-              scrollElRef as React.MutableRefObject<FlatList<any> | null>
-            }
+            scrollElRef={scrollElRef as ListRef}
             ignoreFilterFor={profile.did}
           />
         )}
         {showLikesTab
-          ? ({
-              onScroll,
-              headerHeight,
-              isFocused,
-              isScrolledDown,
-              scrollElRef,
-            }) => (
+          ? ({headerHeight, isFocused, scrollElRef}) => (
               <FeedSection
                 ref={likesSectionRef}
                 feed={`likes|${profile.did}`}
-                onScroll={onScroll}
                 headerHeight={headerHeight}
                 isFocused={isFocused}
-                isScrolledDown={isScrolledDown}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
+                scrollElRef={scrollElRef as ListRef}
                 ignoreFilterFor={profile.did}
               />
             )
           : null}
         {showFeedsTab
-          ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
+          ? ({headerHeight, isFocused, scrollElRef}) => (
               <ProfileFeedgens
                 ref={feedsSectionRef}
                 did={profile.did}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
-                onScroll={onScroll}
-                scrollEventThrottle={1}
+                scrollElRef={scrollElRef as ListRef}
                 headerOffset={headerHeight}
                 enabled={isFocused}
               />
             )
           : null}
         {showListsTab
-          ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
+          ? ({headerHeight, isFocused, scrollElRef}) => (
               <ProfileLists
                 ref={listsSectionRef}
                 did={profile.did}
-                scrollElRef={
-                  scrollElRef as React.MutableRefObject<FlatList<any> | null>
-                }
-                onScroll={onScroll}
-                scrollEventThrottle={1}
+                scrollElRef={scrollElRef as ListRef}
                 headerOffset={headerHeight}
                 enabled={isFocused}
               />
@@ -396,28 +360,19 @@ function ProfileScreenLoaded({
 
 interface FeedSectionProps {
   feed: FeedDescriptor
-  onScroll: OnScrollHandler
   headerHeight: number
   isFocused: boolean
-  isScrolledDown: boolean
-  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+  scrollElRef: ListRef
   ignoreFilterFor?: string
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
   function FeedSectionImpl(
-    {
-      feed,
-      onScroll,
-      headerHeight,
-      isFocused,
-      isScrolledDown,
-      scrollElRef,
-      ignoreFilterFor,
-    },
+    {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
     ref,
   ) {
     const queryClient = useQueryClient()
     const [hasNew, setHasNew] = React.useState(false)
+    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
 
     const onScrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({
@@ -443,8 +398,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
           feed={feed}
           scrollElRef={scrollElRef}
           onHasNew={setHasNew}
-          onScroll={onScroll}
-          scrollEventThrottle={1}
+          onScrolledDownChange={setIsScrolledDown}
           renderEmptyState={renderPostsEmpty}
           headerOffset={headerHeight}
           renderEndOfFeed={ProfileEndOfFeed}
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 620b1814a..ea92ebab1 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -1,11 +1,5 @@
 import React, {useMemo, useCallback} from 'react'
-import {
-  Dimensions,
-  StyleSheet,
-  View,
-  ActivityIndicator,
-  FlatList,
-} from 'react-native'
+import {Dimensions, StyleSheet, View, ActivityIndicator} from 'react-native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
@@ -20,6 +14,7 @@ import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
 import {Feed} from 'view/com/posts/Feed'
 import {TextLink} from 'view/com/util/Link'
+import {ListRef} from 'view/com/util/List'
 import {Button} from 'view/com/util/forms/Button'
 import {Text} from 'view/com/util/text/Text'
 import {RichText} from 'view/com/util/text/RichText'
@@ -29,12 +24,13 @@ import {EmptyState} from 'view/com/util/EmptyState'
 import * as Toast from 'view/com/util/Toast'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {shareUrl} from 'lib/sharing'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {Haptics} from 'lib/haptics'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {makeCustomFeedLink} from 'lib/routes/links'
 import {pluralize} from 'lib/strings/helpers'
 import {CenteredView, ScrollView} from 'view/com/util/Views'
@@ -46,7 +42,6 @@ import {logger} from '#/logger'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {
   useFeedSourceInfoQuery,
   FeedSourceFeedInfo,
@@ -403,17 +398,13 @@ export function ProfileFeedScreenInner({
         isHeaderReady={true}
         renderHeader={renderHeader}
         onCurrentPageSelected={onCurrentPageSelected}>
-        {({onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}) =>
+        {({headerHeight, scrollElRef, isFocused}) =>
           isPublicResponse?.isPublic ? (
             <FeedSection
               ref={feedSectionRef}
               feed={`feedgen|${feedInfo.uri}`}
-              onScroll={onScroll}
               headerHeight={headerHeight}
-              isScrolledDown={isScrolledDown}
-              scrollElRef={
-                scrollElRef as React.MutableRefObject<FlatList<any> | null>
-              }
+              scrollElRef={scrollElRef as ListRef}
               isFocused={isFocused}
             />
           ) : (
@@ -422,13 +413,12 @@ export function ProfileFeedScreenInner({
             </CenteredView>
           )
         }
-        {({onScroll, headerHeight, scrollElRef}) => (
+        {({headerHeight, scrollElRef}) => (
           <AboutSection
             feedOwnerDid={feedInfo.creatorDid}
             feedRkey={feedInfo.route.params.rkey}
             feedInfo={feedInfo}
             headerHeight={headerHeight}
-            onScroll={onScroll}
             scrollElRef={
               scrollElRef as React.MutableRefObject<ScrollView | null>
             }
@@ -497,18 +487,14 @@ function NonPublicFeedMessage({rawError}: {rawError?: Error}) {
 
 interface FeedSectionProps {
   feed: FeedDescriptor
-  onScroll: OnScrollHandler
   headerHeight: number
-  isScrolledDown: boolean
-  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+  scrollElRef: ListRef
   isFocused: boolean
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
-  function FeedSectionImpl(
-    {feed, onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused},
-    ref,
-  ) {
+  function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) {
     const [hasNew, setHasNew] = React.useState(false)
+    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
     const queryClient = useQueryClient()
 
     const onScrollToTop = useCallback(() => {
@@ -536,8 +522,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
           pollInterval={30e3}
           scrollElRef={scrollElRef}
           onHasNew={setHasNew}
-          onScroll={onScroll}
-          scrollEventThrottle={5}
+          onScrolledDownChange={setIsScrolledDown}
           renderEmptyState={renderPostsEmpty}
           headerOffset={headerHeight}
         />
@@ -558,7 +543,6 @@ function AboutSection({
   feedRkey,
   feedInfo,
   headerHeight,
-  onScroll,
   scrollElRef,
   isOwner,
 }: {
@@ -566,13 +550,13 @@ function AboutSection({
   feedRkey: string
   feedInfo: FeedSourceFeedInfo
   headerHeight: number
-  onScroll: OnScrollHandler
   scrollElRef: React.MutableRefObject<ScrollView | null>
   isOwner: boolean
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const scrollHandler = useAnimatedScrollHandler(onScroll)
+  const scrollHandlers = useScrollHandlers()
+  const onScroll = useAnimatedScrollHandler(scrollHandlers)
   const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
   const {hasSession} = useSession()
   const {track} = useAnalytics()
@@ -608,12 +592,12 @@ function AboutSection({
   return (
     <ScrollView
       ref={scrollElRef}
+      onScroll={onScroll}
       scrollEventThrottle={1}
       contentContainerStyle={{
         paddingTop: headerHeight,
         minHeight: Dimensions.get('window').height * 1.5,
-      }}
-      onScroll={scrollHandler}>
+      }}>
       <View
         style={[
           {
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 3dcc7121f..7f922e5b4 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -1,11 +1,5 @@
 import React, {useCallback, useMemo} from 'react'
-import {
-  ActivityIndicator,
-  FlatList,
-  Pressable,
-  StyleSheet,
-  View,
-} from 'react-native'
+import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {useNavigation} from '@react-navigation/native'
@@ -22,6 +16,7 @@ import {EmptyState} from 'view/com/util/EmptyState'
 import {RichText} from 'view/com/util/text/RichText'
 import {Button} from 'view/com/util/forms/Button'
 import {TextLink} from 'view/com/util/Link'
+import {ListRef} from 'view/com/util/List'
 import * as Toast from 'view/com/util/Toast'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {FAB} from 'view/com/util/fab/FAB'
@@ -31,7 +26,6 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {NavigationProp} from 'lib/routes/types'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {shareUrl} from 'lib/sharing'
@@ -165,36 +159,22 @@ function ProfileListScreenLoaded({
           isHeaderReady={true}
           renderHeader={renderHeader}
           onCurrentPageSelected={onCurrentPageSelected}>
-          {({
-            onScroll,
-            headerHeight,
-            isScrolledDown,
-            scrollElRef,
-            isFocused,
-          }) => (
+          {({headerHeight, scrollElRef, isFocused}) => (
             <FeedSection
               ref={feedSectionRef}
               feed={`list|${uri}`}
-              scrollElRef={
-                scrollElRef as React.MutableRefObject<FlatList<any> | null>
-              }
-              onScroll={onScroll}
+              scrollElRef={scrollElRef as ListRef}
               headerHeight={headerHeight}
-              isScrolledDown={isScrolledDown}
               isFocused={isFocused}
             />
           )}
-          {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          {({headerHeight, scrollElRef}) => (
             <AboutSection
               ref={aboutSectionRef}
-              scrollElRef={
-                scrollElRef as React.MutableRefObject<FlatList<any> | null>
-              }
+              scrollElRef={scrollElRef as ListRef}
               list={list}
               onPressAddUser={onPressAddUser}
-              onScroll={onScroll}
               headerHeight={headerHeight}
-              isScrolledDown={isScrolledDown}
             />
           )}
         </PagerWithHeader>
@@ -221,16 +201,12 @@ function ProfileListScreenLoaded({
         items={SECTION_TITLES_MOD}
         isHeaderReady={true}
         renderHeader={renderHeader}>
-        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+        {({headerHeight, scrollElRef}) => (
           <AboutSection
             list={list}
-            scrollElRef={
-              scrollElRef as React.MutableRefObject<FlatList<any> | null>
-            }
+            scrollElRef={scrollElRef as ListRef}
             onPressAddUser={onPressAddUser}
-            onScroll={onScroll}
             headerHeight={headerHeight}
-            isScrolledDown={isScrolledDown}
           />
         )}
       </PagerWithHeader>
@@ -615,19 +591,15 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
 
 interface FeedSectionProps {
   feed: FeedDescriptor
-  onScroll: OnScrollHandler
   headerHeight: number
-  isScrolledDown: boolean
-  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+  scrollElRef: ListRef
   isFocused: boolean
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
-  function FeedSectionImpl(
-    {feed, scrollElRef, onScroll, headerHeight, isScrolledDown, isFocused},
-    ref,
-  ) {
+  function FeedSectionImpl({feed, scrollElRef, headerHeight, isFocused}, ref) {
     const queryClient = useQueryClient()
     const [hasNew, setHasNew] = React.useState(false)
+    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
 
     const onScrollToTop = useCallback(() => {
       scrollElRef.current?.scrollToOffset({
@@ -654,8 +626,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
           pollInterval={30e3}
           scrollElRef={scrollElRef}
           onHasNew={setHasNew}
-          onScroll={onScroll}
-          scrollEventThrottle={1}
+          onScrolledDownChange={setIsScrolledDown}
           renderEmptyState={renderPostsEmpty}
           headerOffset={headerHeight}
         />
@@ -674,20 +645,19 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
 interface AboutSectionProps {
   list: AppBskyGraphDefs.ListView
   onPressAddUser: () => void
-  onScroll: OnScrollHandler
   headerHeight: number
-  isScrolledDown: boolean
-  scrollElRef: React.MutableRefObject<FlatList<any> | null>
+  scrollElRef: ListRef
 }
 const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
   function AboutSectionImpl(
-    {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef},
+    {list, onPressAddUser, headerHeight, scrollElRef},
     ref,
   ) {
     const pal = usePalette('default')
     const {_} = useLingui()
     const {isMobile} = useWebMediaQueries()
     const {currentAccount} = useSession()
+    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
     const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
     const isOwner = list.creator.did === currentAccount?.did
 
@@ -817,8 +787,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
           renderHeader={renderHeader}
           renderEmptyState={renderEmptyState}
           headerOffset={headerHeight}
-          onScroll={onScroll}
-          scrollEventThrottle={1}
+          onScrolledDownChange={setIsScrolledDown}
         />
         {isScrolledDown && (
           <LoadLatestBtn
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index b4db270b3..7d7b4098f 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -8,7 +8,8 @@ import {
   Pressable,
   Platform,
 } from 'react-native'
-import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views'
+import {ScrollView, CenteredView} from '#/view/com/util/Views'
+import {List} from '#/view/com/util/List'
 import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -155,7 +156,7 @@ function SearchScreenSuggestedFollows() {
   }, [currentAccount, setSuggestions, getSuggestedFollowsByActor])
 
   return suggestions.length ? (
-    <FlatList
+    <List
       data={suggestions}
       renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />}
       keyExtractor={item => item.did}
@@ -243,7 +244,7 @@ function SearchScreenPostResults({query}: {query: string}) {
       {isFetched ? (
         <>
           {posts.length ? (
-            <FlatList
+            <List
               data={items}
               renderItem={({item}) => {
                 if (item.type === 'post') {
@@ -284,7 +285,7 @@ function SearchScreenUserResults({query}: {query: string}) {
   return isFetched && results ? (
     <>
       {results.length ? (
-        <FlatList
+        <List
           data={results}
           renderItem={({item}) => (
             <ProfileCardWithFollowBtn profile={item} noBg />