about summary refs log tree commit diff
path: root/src/lib/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/hooks')
-rw-r--r--src/lib/hooks/useAccountSwitcher.ts74
-rw-r--r--src/lib/hooks/useAnimatedScrollHandler_FIXED.ts15
-rw-r--r--src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts44
-rw-r--r--src/lib/hooks/useCustomFeed.ts18
-rw-r--r--src/lib/hooks/useDesktopRightNavItems.ts51
-rw-r--r--src/lib/hooks/useFollowProfile.ts55
-rw-r--r--src/lib/hooks/useHomeTabs.ts29
-rw-r--r--src/lib/hooks/useMinimalShellMode.tsx53
-rw-r--r--src/lib/hooks/useNonReactiveCallback.ts23
-rw-r--r--src/lib/hooks/useOTAUpdate.ts14
-rw-r--r--src/lib/hooks/useOnMainScroll.ts156
-rw-r--r--src/lib/hooks/useSetTitle.ts12
-rw-r--r--src/lib/hooks/useToggleMutationQueue.ts98
-rw-r--r--src/lib/hooks/useWebMediaQueries.tsx4
14 files changed, 360 insertions, 286 deletions
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 1ddb181a8..8a1dea5fe 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -1,43 +1,55 @@
-import {useCallback, useState} from 'react'
-import {useStores} from 'state/index'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {StackActions, useNavigation} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
-import {AccountData} from 'state/models/session'
-import {reset as resetNavigation} from '../../Navigation'
-import * as Toast from 'view/com/util/Toast'
-import {useSetDrawerOpen} from '#/state/shell/drawer-open'
+import {useCallback} from 'react'
+import {useNavigation} from '@react-navigation/native'
 
-export function useAccountSwitcher(): [
-  boolean,
-  (v: boolean) => void,
-  (acct: AccountData) => Promise<void>,
-] {
+import {isWeb} from '#/platform/detection'
+import {NavigationProp} from '#/lib/routes/types'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {useSessionApi, SessionAccount} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {useCloseAllActiveElements} from '#/state/util'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+
+export function useAccountSwitcher() {
   const {track} = useAnalytics()
-  const store = useStores()
-  const setDrawerOpen = useSetDrawerOpen()
-  const [isSwitching, setIsSwitching] = useState(false)
+  const {selectAccount, clearCurrentAccount} = useSessionApi()
+  const closeAllActiveElements = useCloseAllActiveElements()
   const navigation = useNavigation<NavigationProp>()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
 
   const onPressSwitchAccount = useCallback(
-    async (acct: AccountData) => {
+    async (account: SessionAccount) => {
       track('Settings:SwitchAccountButtonClicked')
-      setIsSwitching(true)
-      const success = await store.session.resumeSession(acct)
-      setDrawerOpen(false)
-      store.shell.closeAllActiveElements()
-      if (success) {
-        resetNavigation()
-        Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
-      } else {
+
+      try {
+        if (account.accessJwt) {
+          closeAllActiveElements()
+          navigation.navigate(isWeb ? 'Home' : 'HomeTab')
+          await selectAccount(account)
+          setTimeout(() => {
+            Toast.show(`Signed in as @${account.handle}`)
+          }, 100)
+        } else {
+          closeAllActiveElements()
+          setShowLoggedOut(true)
+          Toast.show(
+            `Please sign in as @${account.handle}`,
+            'circle-exclamation',
+          )
+        }
+      } catch (e) {
         Toast.show('Sorry! We need you to enter your password.')
-        navigation.navigate('HomeTab')
-        navigation.dispatch(StackActions.popToTop())
-        store.session.clear()
+        clearCurrentAccount() // back user out to login
       }
     },
-    [track, setIsSwitching, navigation, store, setDrawerOpen],
+    [
+      track,
+      clearCurrentAccount,
+      selectAccount,
+      closeAllActiveElements,
+      navigation,
+      setShowLoggedOut,
+    ],
   )
 
-  return [isSwitching, setIsSwitching, onPressSwitchAccount]
+  return {onPressSwitchAccount}
 }
diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts
new file mode 100644
index 000000000..56a1e8b11
--- /dev/null
+++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts
@@ -0,0 +1,15 @@
+// Be warned. This Hook is very buggy unless used in a very constrained way.
+// To use it safely:
+//
+// - DO NOT pass its return value as a prop to any user-defined component.
+// - DO NOT pass its return value to more than a single component.
+//
+// In other words, the only safe way to use it is next to the leaf Reanimated View.
+//
+// Relevant bug reports:
+// - https://github.com/software-mansion/react-native-reanimated/issues/5345
+// - https://github.com/software-mansion/react-native-reanimated/issues/5360
+// - https://github.com/software-mansion/react-native-reanimated/issues/5364
+//
+// It's great when it works though.
+export {useAnimatedScrollHandler} from 'react-native-reanimated'
diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts
new file mode 100644
index 000000000..98e05a8ce
--- /dev/null
+++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts
@@ -0,0 +1,44 @@
+import {useRef, useEffect} from 'react'
+import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
+
+export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
+  config,
+  deps,
+) => {
+  const ref = useRef(config)
+  useEffect(() => {
+    ref.current = config
+  })
+  return useAnimatedScrollHandler_BUGGY(
+    {
+      onBeginDrag(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
+          ref.current.onBeginDrag(e, ctx)
+        }
+      },
+      onEndDrag(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
+          ref.current.onEndDrag(e, ctx)
+        }
+      },
+      onMomentumBegin(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
+          ref.current.onMomentumBegin(e, ctx)
+        }
+      },
+      onMomentumEnd(e, ctx) {
+        if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
+          ref.current.onMomentumEnd(e, ctx)
+        }
+      },
+      onScroll(e, ctx) {
+        if (typeof ref.current === 'function') {
+          ref.current(e, ctx)
+        } else if (ref.current.onScroll) {
+          ref.current.onScroll(e, ctx)
+        }
+      },
+    },
+    deps,
+  )
+}
diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts
deleted file mode 100644
index 04201b9a1..000000000
--- a/src/lib/hooks/useCustomFeed.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-export function useCustomFeed(uri: string): FeedSourceModel | undefined {
-  const store = useStores()
-  const [item, setItem] = useState<FeedSourceModel | undefined>()
-  useEffect(() => {
-    async function buildFeedItem() {
-      const model = new FeedSourceModel(store, uri)
-      await model.setup()
-      setItem(model)
-    }
-    buildFeedItem()
-  }, [store, uri])
-
-  return item
-}
diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts
deleted file mode 100644
index f27efd28f..000000000
--- a/src/lib/hooks/useDesktopRightNavItems.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import isEqual from 'lodash.isequal'
-import {AtUri} from '@atproto/api'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-interface RightNavItem {
-  uri: string
-  href: string
-  hostname: string
-  collection: string
-  rkey: string
-  displayName: string
-}
-
-export function useDesktopRightNavItems(uris: string[]): RightNavItem[] {
-  const store = useStores()
-  const [items, setItems] = useState<RightNavItem[]>([])
-  const [lastUris, setLastUris] = useState<string[]>([])
-
-  useEffect(() => {
-    if (isEqual(uris, lastUris)) {
-      // no changes
-      return
-    }
-
-    async function fetchFeedInfo() {
-      const models = uris
-        .slice(0, 25)
-        .map(uri => new FeedSourceModel(store, uri))
-      await Promise.all(models.map(m => m.setup()))
-      setItems(
-        models.map(model => {
-          const {hostname, collection, rkey} = new AtUri(model.uri)
-          return {
-            uri: model.uri,
-            href: model.href,
-            hostname,
-            collection,
-            rkey,
-            displayName: model.displayName,
-          }
-        }),
-      )
-      setLastUris(uris)
-    }
-    fetchFeedInfo()
-  }, [store, uris, lastUris, setLastUris, setItems])
-
-  return items
-}
diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts
deleted file mode 100644
index 98dd63f5f..000000000
--- a/src/lib/hooks/useFollowProfile.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {AppBskyActorDefs} from '@atproto/api'
-import {useStores} from 'state/index'
-import {FollowState} from 'state/models/cache/my-follows'
-import {logger} from '#/logger'
-
-export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
-  const store = useStores()
-  const state = store.me.follows.getFollowState(profile.did)
-
-  return {
-    state,
-    following: state === FollowState.Following,
-    toggle: React.useCallback(async () => {
-      if (state === FollowState.Following) {
-        try {
-          await store.agent.deleteFollow(
-            store.me.follows.getFollowUri(profile.did),
-          )
-          store.me.follows.removeFollow(profile.did)
-          return {
-            state: FollowState.NotFollowing,
-            following: false,
-          }
-        } catch (e: any) {
-          logger.error('Failed to delete follow', {error: e})
-          throw e
-        }
-      } else if (state === FollowState.NotFollowing) {
-        try {
-          const res = await store.agent.follow(profile.did)
-          store.me.follows.addFollow(profile.did, {
-            followRecordUri: res.uri,
-            did: profile.did,
-            handle: profile.handle,
-            displayName: profile.displayName,
-            avatar: profile.avatar,
-          })
-          return {
-            state: FollowState.Following,
-            following: true,
-          }
-        } catch (e: any) {
-          logger.error('Failed to create follow', {error: e})
-          throw e
-        }
-      }
-
-      return {
-        state: FollowState.Unknown,
-        following: false,
-      }
-    }, [store, profile, state]),
-  }
-}
diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts
deleted file mode 100644
index 69183e627..000000000
--- a/src/lib/hooks/useHomeTabs.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {useEffect, useState} from 'react'
-import {useStores} from 'state/index'
-import isEqual from 'lodash.isequal'
-import {FeedSourceModel} from 'state/models/content/feed-source'
-
-export function useHomeTabs(uris: string[]): string[] {
-  const store = useStores()
-  const [tabs, setTabs] = useState<string[]>(['Following'])
-  const [lastUris, setLastUris] = useState<string[]>([])
-
-  useEffect(() => {
-    if (isEqual(uris, lastUris)) {
-      // no changes
-      return
-    }
-
-    async function fetchFeedInfo() {
-      const models = uris
-        .slice(0, 25)
-        .map(uri => new FeedSourceModel(store, uri))
-      await Promise.all(models.map(m => m.setup()))
-      setTabs(['Following'].concat(models.map(f => f.displayName)))
-      setLastUris(uris)
-    }
-    fetchFeedInfo()
-  }, [store, uris, lastUris, setLastUris, setTabs])
-
-  return tabs
-}
diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx
index ada934a26..e81fc434f 100644
--- a/src/lib/hooks/useMinimalShellMode.tsx
+++ b/src/lib/hooks/useMinimalShellMode.tsx
@@ -1,60 +1,43 @@
-import React from 'react'
-import {autorun} from 'mobx'
-import {
-  Easing,
-  interpolate,
-  useAnimatedStyle,
-  useSharedValue,
-  withTiming,
-} from 'react-native-reanimated'
-
+import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
 import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
+import {useShellLayout} from '#/state/shell/shell-layout'
 
 export function useMinimalShellMode() {
-  const minimalShellMode = useMinimalShellModeState()
-  const minimalShellInterp = useSharedValue(0)
+  const mode = useMinimalShellModeState()
+  const {footerHeight, headerHeight} = useShellLayout()
+
   const footerMinimalShellTransform = useAnimatedStyle(() => {
     return {
-      opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
+      pointerEvents: mode.value === 0 ? 'auto' : 'none',
+      opacity: Math.pow(1 - mode.value, 2),
       transform: [
-        {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
+        {
+          translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
+        },
       ],
     }
   })
   const headerMinimalShellTransform = useAnimatedStyle(() => {
     return {
-      opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
+      pointerEvents: mode.value === 0 ? 'auto' : 'none',
+      opacity: Math.pow(1 - mode.value, 2),
       transform: [
-        {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
+        {
+          translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
+        },
       ],
     }
   })
   const fabMinimalShellTransform = useAnimatedStyle(() => {
     return {
       transform: [
-        {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
+        {
+          translateY: interpolate(mode.value, [0, 1], [-44, 0]),
+        },
       ],
     }
   })
-
-  React.useEffect(() => {
-    return autorun(() => {
-      if (minimalShellMode) {
-        minimalShellInterp.value = withTiming(1, {
-          duration: 125,
-          easing: Easing.bezier(0.25, 0.1, 0.25, 1),
-        })
-      } else {
-        minimalShellInterp.value = withTiming(0, {
-          duration: 125,
-          easing: Easing.bezier(0.25, 0.1, 0.25, 1),
-        })
-      }
-    })
-  }, [minimalShellInterp, minimalShellMode])
-
   return {
-    minimalShellMode,
     footerMinimalShellTransform,
     headerMinimalShellTransform,
     fabMinimalShellTransform,
diff --git a/src/lib/hooks/useNonReactiveCallback.ts b/src/lib/hooks/useNonReactiveCallback.ts
new file mode 100644
index 000000000..4b3d6abb9
--- /dev/null
+++ b/src/lib/hooks/useNonReactiveCallback.ts
@@ -0,0 +1,23 @@
+import {useCallback, useInsertionEffect, useRef} from 'react'
+
+// This should be used sparingly. It erases reactivity, i.e. when the inputs
+// change, the function itself will remain the same. This means that if you
+// use this at a higher level of your tree, and then some state you read in it
+// changes, there is no mechanism for anything below in the tree to "react"
+// to this change (e.g. by knowing to call your function again).
+//
+// Also, you should avoid calling the returned function during rendering
+// since the values captured by it are going to lag behind.
+export function useNonReactiveCallback<T extends Function>(fn: T): T {
+  const ref = useRef(fn)
+  useInsertionEffect(() => {
+    ref.current = fn
+  }, [fn])
+  return useCallback(
+    (...args: any) => {
+      const latestFn = ref.current
+      return latestFn(...args)
+    },
+    [ref],
+  ) as unknown as T
+}
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 0ce97a4c8..55147329b 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -1,26 +1,26 @@
 import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
-import {useStores} from 'state/index'
 import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const store = useStores()
+  const {openModal} = useModalControls()
 
   // HELPER FUNCTIONS
   const showUpdatePopup = useCallback(() => {
-    store.shell.openModal({
+    openModal({
       name: 'confirm',
-      title: 'Update Available',
-      message:
-        'A new version of the app is available. Please update to continue using the app.',
+      title: t`Update Available`,
+      message: t`A new version of the app is available. Please update to continue using the app.`,
       onPressConfirm: async () => {
         Updates.reloadAsync().catch(err => {
           throw err
         })
       },
     })
-  }, [store.shell])
+  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts
index 2eab4b250..2e7a79913 100644
--- a/src/lib/hooks/useOnMainScroll.ts
+++ b/src/lib/hooks/useOnMainScroll.ts
@@ -1,69 +1,125 @@
-import {useState, useCallback, useRef} from 'react'
+import {useState, useCallback, useMemo} from 'react'
 import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
-import {s} from 'lib/styles'
-import {useWebMediaQueries} from './useWebMediaQueries'
 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'
 
-const Y_LIMIT = 10
-
-const useDeviceLimits = () => {
-  const {isDesktop} = useWebMediaQueries()
-  return {
-    dyLimitUp: isDesktop ? 30 : 10,
-    dyLimitDown: isDesktop ? 150 : 10,
-  }
+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(): [OnScrollCb, boolean, ResetCb] {
-  let lastY = useRef(0)
-  let [isScrolledDown, setIsScrolledDown] = useState(false)
-  const {dyLimitUp, dyLimitDown} = useDeviceLimits()
-  const minimalShellMode = useMinimalShellMode()
-  const setMinimalShellMode = useSetMinimalShellMode()
+export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
+  const {headerHeight} = useShellLayout()
+  const [isScrolledDown, setIsScrolledDown] = useState(false)
+  const mode = useMinimalShellMode()
+  const setMode = useSetMinimalShellMode()
+  const startDragOffset = useSharedValue<number | null>(null)
+  const startMode = useSharedValue<number | null>(null)
 
-  return [
-    useCallback(
-      (event: NativeSyntheticEvent<NativeScrollEvent>) => {
-        const y = event.nativeEvent.contentOffset.y
-        const dy = y - (lastY.current || 0)
-        lastY.current = y
+  const onBeginDrag = useCallback(
+    (e: NativeScrollEvent) => {
+      'worklet'
+      startDragOffset.value = e.contentOffset.y
+      startMode.value = mode.value
+    },
+    [mode, startDragOffset, startMode],
+  )
 
-        if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
-          setMinimalShellMode(true)
-        } else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) {
-          setMinimalShellMode(false)
-        }
+  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'
+      // 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 (
-          !isScrolledDown &&
-          event.nativeEvent.contentOffset.y > s.window.height
-        ) {
-          setIsScrolledDown(true)
-        } else if (
-          isScrolledDown &&
-          event.nativeEvent.contentOffset.y < s.window.height
-        ) {
-          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.
+          // Even if we're not dragging.
+          setMode(false)
+          return
         }
-      },
-      [
-        dyLimitDown,
-        dyLimitUp,
-        isScrolledDown,
-        minimalShellMode,
-        setMinimalShellMode,
-      ],
-    ),
+        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, isScrolledDown, startDragOffset, startMode],
+  )
+
+  const scrollHandler: ScrollHandlers<any> = useMemo(
+    () => ({
+      onBeginDrag,
+      onEndDrag,
+      onScroll,
+    }),
+    [onBeginDrag, onEndDrag, onScroll],
+  )
+
+  return [
+    scrollHandler,
     isScrolledDown,
     useCallback(() => {
       setIsScrolledDown(false)
-      setMinimalShellMode(false)
-      lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
-    }, [setIsScrolledDown, setMinimalShellMode]),
+      setMode(false)
+    }, [setMode]),
   ]
 }
diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts
index c5c7a5ca1..129023f71 100644
--- a/src/lib/hooks/useSetTitle.ts
+++ b/src/lib/hooks/useSetTitle.ts
@@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
 
 import {NavigationProp} from 'lib/routes/types'
 import {bskyTitle} from 'lib/strings/headings'
-import {useStores} from 'state/index'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
 
-/**
- * Requires consuming component to be wrapped in `observer`:
- * https://stackoverflow.com/a/71488009
- */
 export function useSetTitle(title?: string) {
   const navigation = useNavigation<NavigationProp>()
-  const {unreadCountLabel} = useStores().me.notifications
+  const numUnread = useUnreadNotifications()
   useEffect(() => {
     if (title) {
-      navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
+      navigation.setOptions({title: bskyTitle(title, numUnread)})
     }
-  }, [title, navigation, unreadCountLabel])
+  }, [title, navigation, numUnread])
 }
diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts
new file mode 100644
index 000000000..28ae86142
--- /dev/null
+++ b/src/lib/hooks/useToggleMutationQueue.ts
@@ -0,0 +1,98 @@
+import {useState, useRef, useEffect, useCallback} from 'react'
+
+type Task<TServerState> = {
+  isOn: boolean
+  resolve: (serverState: TServerState) => void
+  reject: (e: unknown) => void
+}
+
+type TaskQueue<TServerState> = {
+  activeTask: Task<TServerState> | null
+  queuedTask: Task<TServerState> | null
+}
+
+function AbortError() {
+  const e = new Error()
+  e.name = 'AbortError'
+  return e
+}
+
+export function useToggleMutationQueue<TServerState>({
+  initialState,
+  runMutation,
+  onSuccess,
+}: {
+  initialState: TServerState
+  runMutation: (
+    prevState: TServerState,
+    nextIsOn: boolean,
+  ) => Promise<TServerState>
+  onSuccess: (finalState: TServerState) => void
+}) {
+  // We use the queue as a mutable object.
+  // This is safe becuase it is not used for rendering.
+  const [queue] = useState<TaskQueue<TServerState>>({
+    activeTask: null,
+    queuedTask: null,
+  })
+
+  async function processQueue() {
+    if (queue.activeTask) {
+      // There is another active processQueue call iterating over tasks.
+      // It will handle any newly added tasks, so we should exit early.
+      return
+    }
+    // To avoid relying on the rendered state, capture it once at the start.
+    // From that point on, and until the queue is drained, we'll use the real server state.
+    let confirmedState: TServerState = initialState
+    try {
+      while (queue.queuedTask) {
+        const prevTask = queue.activeTask
+        const nextTask = queue.queuedTask
+        queue.activeTask = nextTask
+        queue.queuedTask = null
+        if (prevTask?.isOn === nextTask.isOn) {
+          // Skip multiple requests to update to the same value in a row.
+          prevTask.reject(new (AbortError as any)())
+          continue
+        }
+        try {
+          // The state received from the server feeds into the next task.
+          // This lets us queue deletions of not-yet-created resources.
+          confirmedState = await runMutation(confirmedState, nextTask.isOn)
+          nextTask.resolve(confirmedState)
+        } catch (e) {
+          nextTask.reject(e)
+        }
+      }
+    } finally {
+      onSuccess(confirmedState)
+      queue.activeTask = null
+      queue.queuedTask = null
+    }
+  }
+
+  function queueToggle(isOn: boolean): Promise<TServerState> {
+    return new Promise((resolve, reject) => {
+      // This is a toggle, so the next queued value can safely replace the queued one.
+      if (queue.queuedTask) {
+        queue.queuedTask.reject(new (AbortError as any)())
+      }
+      queue.queuedTask = {isOn, resolve, reject}
+      processQueue()
+    })
+  }
+
+  const queueToggleRef = useRef(queueToggle)
+  useEffect(() => {
+    queueToggleRef.current = queueToggle
+  })
+  const queueToggleStable = useCallback(
+    (isOn: boolean): Promise<TServerState> => {
+      const queueToggleLatest = queueToggleRef.current
+      return queueToggleLatest(isOn)
+    },
+    [],
+  )
+  return queueToggleStable
+}
diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx
index 3f43a0aaf..71a96a89b 100644
--- a/src/lib/hooks/useWebMediaQueries.tsx
+++ b/src/lib/hooks/useWebMediaQueries.tsx
@@ -3,8 +3,8 @@ import {isNative} from 'platform/detection'
 
 export function useWebMediaQueries() {
   const isDesktop = useMediaQuery({minWidth: 1300})
-  const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300})
-  const isMobile = useMediaQuery({maxWidth: 800})
+  const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
+  const isMobile = useMediaQuery({maxWidth: 800 - 1})
   const isTabletOrMobile = isMobile || isTablet
   const isTabletOrDesktop = isDesktop || isTablet
   if (isNative) {