about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx2
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx1
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx2
-rw-r--r--src/view/com/composer/Composer.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx23
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx2
-rw-r--r--src/view/com/feeds/FeedPage.tsx210
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx467
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx302
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx6
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts47
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts150
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts41
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts32
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts25
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts431
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts24
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx190
-rw-r--r--src/view/com/lightbox/ImageViewing/transforms.ts98
-rw-r--r--src/view/com/lightbox/ImageViewing/utils.ts139
-rw-r--r--src/view/com/lightbox/Lightbox.tsx170
-rw-r--r--src/view/com/modals/ChangeEmail.tsx244
-rw-r--r--src/view/com/modals/CreateOrEditMuteList.tsx4
-rw-r--r--src/view/com/modals/EditProfile.tsx4
-rw-r--r--src/view/com/modals/Modal.tsx20
-rw-r--r--src/view/com/modals/ProfilePreview.tsx11
-rw-r--r--src/view/com/modals/SwitchAccount.tsx113
-rw-r--r--src/view/com/modals/VerifyEmail.tsx281
-rw-r--r--src/view/com/modals/Waitlist.tsx2
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx6
-rw-r--r--src/view/com/notifications/InvitedUsers.tsx2
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx28
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx42
-rw-r--r--src/view/com/pager/TabBar.tsx1
-rw-r--r--src/view/com/post-thread/PostThread.tsx4
-rw-r--r--src/view/com/posts/Feed.tsx19
-rw-r--r--src/view/com/posts/FollowingEmptyState.tsx98
-rw-r--r--src/view/com/posts/FollowingEndOfFeed.tsx100
-rw-r--r--src/view/com/profile/FollowButton.tsx9
-rw-r--r--src/view/com/profile/ProfileCard.tsx2
-rw-r--r--src/view/com/profile/ProfileHeader.tsx19
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx22
-rw-r--r--src/view/com/search/HeaderWithInput.tsx2
-rw-r--r--src/view/com/util/EmptyState.tsx3
-rw-r--r--src/view/com/util/ErrorBoundary.tsx2
-rw-r--r--src/view/com/util/Link.tsx9
-rw-r--r--src/view/com/util/UserAvatar.tsx108
-rw-r--r--src/view/com/util/ViewHeader.tsx31
-rw-r--r--src/view/com/util/ViewSelector.tsx15
-rw-r--r--src/view/com/util/fab/FABInner.tsx48
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx12
-rw-r--r--src/view/com/util/images/Gallery.tsx17
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx118
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx34
-rw-r--r--src/view/screens/Home.tsx213
-rw-r--r--src/view/screens/Notifications.tsx1
-rw-r--r--src/view/screens/SearchMobile.tsx12
-rw-r--r--src/view/screens/Support.tsx10
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx9
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx2
-rw-r--r--src/view/shell/desktop/Search.tsx11
-rw-r--r--src/view/shell/index.tsx7
63 files changed, 1853 insertions, 2210 deletions
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index 24fc9eef1..aaba19c80 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -65,7 +65,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
           tdStyles.title2,
           isTabletOrMobile && tdStyles.title2Small,
         ]}>
-        Recomended
+        Recommended
       </Text>
       <Text
         style={[
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index d130dc138..6796c64db 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
       }
     } else {
       try {
-        await item.save()
         await item.pin()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 51e3bc382..2b26918d0 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -89,7 +89,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
         </View>
 
         <FollowButton
-          did={profile.did}
+          profile={profile}
           labelStyle={styles.followButton}
           onToggleFollow={async isFollow => {
             if (isFollow) {
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f7b657272..e44a0ce01 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -131,6 +131,9 @@ export const ComposePost = observer(function ComposePost({
   }, [store, onClose, graphemeLength, gallery])
   // android back button
   useEffect(() => {
+    if (!isAndroid) {
+      return
+    }
     const backHandler = BackHandler.addEventListener(
       'hardwareBackPress',
       () => {
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index c5d094ea5..2810129f6 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -187,16 +187,19 @@ export const TextInput = forwardRef(function TextInputImpl(
   const textDecorated = useMemo(() => {
     let i = 0
 
-    return Array.from(richtext.segments()).map(segment => (
-      <Text
-        key={i++}
-        style={[
-          !segment.facet ? pal.text : pal.link,
-          styles.textInputFormatting,
-        ]}>
-        {segment.text}
-      </Text>
-    ))
+    return Array.from(richtext.segments()).map(segment => {
+      const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
+      return (
+        <Text
+          key={i++}
+          style={[
+            segment.facet && !isTag ? pal.link : pal.text,
+            styles.textInputFormatting,
+          ]}>
+          {segment.text}
+        </Text>
+      )
+    })
   }, [richtext, pal.link, pal.text])
 
   return (
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 31e372567..35482bc70 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -119,7 +119,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       onUpdate({editor: editorProp}) {
         const json = editorProp.getJSON()
 
-        const newRt = new RichText({text: editorJsonToText(json).trim()})
+        const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
 
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
new file mode 100644
index 000000000..725106d59
--- /dev/null
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -0,0 +1,210 @@
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useIsFocused} from '@react-navigation/native'
+import {useAnalytics} from '@segment/analytics-react-native'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {ComposeIcon2} from 'lib/icons'
+import {colors, s} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import React from 'react'
+import {FlatList, View} from 'react-native'
+import {useStores} from 'state/index'
+import {PostsFeedModel} from 'state/models/feeds/posts'
+import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
+import {Feed} from '../posts/Feed'
+import {TextLink} from '../util/Link'
+import {FAB} from '../util/fab/FAB'
+import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
+import useAppState from 'react-native-appstate-hook'
+
+export const FeedPage = observer(function FeedPageImpl({
+  testID,
+  isPageFocused,
+  feed,
+  renderEmptyState,
+  renderEndOfFeed,
+}: {
+  testID?: string
+  feed: PostsFeedModel
+  isPageFocused: boolean
+  renderEmptyState: () => JSX.Element
+  renderEndOfFeed?: () => JSX.Element
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isDesktop} = useWebMediaQueries()
+  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
+  const {screen, track} = useAnalytics()
+  const headerOffset = useHeaderOffset()
+  const scrollElRef = React.useRef<FlatList>(null)
+  const {appState} = useAppState({
+    onForeground: () => doPoll(true),
+  })
+  const isScreenFocused = useIsFocused()
+  const hasNew = feed.hasNewLatest && !feed.isRefreshing
+
+  React.useEffect(() => {
+    // called on first load
+    if (!feed.hasLoaded && isPageFocused) {
+      feed.setup()
+    }
+  }, [isPageFocused, feed])
+
+  const doPoll = React.useCallback(
+    (knownActive = false) => {
+      if (
+        (!knownActive && appState !== 'active') ||
+        !isScreenFocused ||
+        !isPageFocused
+      ) {
+        return
+      }
+      if (feed.isLoading) {
+        return
+      }
+      store.log.debug('HomeScreen: Polling for new posts')
+      feed.checkForLatest()
+    },
+    [appState, isScreenFocused, isPageFocused, store, feed],
+  )
+
+  const scrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({offset: -headerOffset})
+    resetMainScroll()
+  }, [headerOffset, resetMainScroll])
+
+  const onSoftReset = React.useCallback(() => {
+    if (isPageFocused) {
+      scrollToTop()
+      feed.refresh()
+    }
+  }, [isPageFocused, scrollToTop, feed])
+
+  // fires when page within screen is activated/deactivated
+  // - check for latest
+  React.useEffect(() => {
+    if (!isPageFocused || !isScreenFocused) {
+      return
+    }
+
+    const softResetSub = store.onScreenSoftReset(onSoftReset)
+    const feedCleanup = feed.registerListeners()
+    const pollInterval = setInterval(doPoll, POLL_FREQ)
+
+    screen('Feed')
+    store.log.debug('HomeScreen: Updating feed')
+    feed.checkForLatest()
+
+    return () => {
+      clearInterval(pollInterval)
+      softResetSub.remove()
+      feedCleanup()
+    }
+  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
+
+  const onPressCompose = React.useCallback(() => {
+    track('HomeScreen:PressCompose')
+    store.shell.openComposer({})
+  }, [store, track])
+
+  const onPressTryAgain = React.useCallback(() => {
+    feed.refresh()
+  }, [feed])
+
+  const onPressLoadLatest = React.useCallback(() => {
+    scrollToTop()
+    feed.refresh()
+  }, [feed, scrollToTop])
+
+  const ListHeaderComponent = React.useCallback(() => {
+    if (isDesktop) {
+      return (
+        <View
+          style={[
+            pal.view,
+            {
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              paddingHorizontal: 18,
+              paddingVertical: 12,
+            },
+          ]}>
+          <TextLink
+            type="title-lg"
+            href="/"
+            style={[pal.text, {fontWeight: 'bold'}]}
+            text={
+              <>
+                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+                {hasNew && (
+                  <View
+                    style={{
+                      top: -8,
+                      backgroundColor: colors.blue3,
+                      width: 8,
+                      height: 8,
+                      borderRadius: 4,
+                    }}
+                  />
+                )}
+              </>
+            }
+            onPress={() => store.emitScreenSoftReset()}
+          />
+          <TextLink
+            type="title-lg"
+            href="/settings/home-feed"
+            style={{fontWeight: 'bold'}}
+            accessibilityLabel="Feed Preferences"
+            accessibilityHint=""
+            text={
+              <FontAwesomeIcon
+                icon="sliders"
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            }
+          />
+        </View>
+      )
+    }
+    return <></>
+  }, [isDesktop, pal, store, hasNew])
+
+  return (
+    <View testID={testID} style={s.h100pct}>
+      <Feed
+        testID={testID ? `${testID}-feed` : undefined}
+        key="default"
+        feed={feed}
+        scrollElRef={scrollElRef}
+        onPressTryAgain={onPressTryAgain}
+        onScroll={onMainScroll}
+        scrollEventThrottle={100}
+        renderEmptyState={renderEmptyState}
+        renderEndOfFeed={renderEndOfFeed}
+        ListHeaderComponent={ListHeaderComponent}
+        headerOffset={headerOffset}
+      />
+      {(isScrolledDown || hasNew) && (
+        <LoadLatestBtn
+          onPress={onPressLoadLatest}
+          label="Load new posts"
+          showIndicator={hasNew}
+        />
+      )}
+      <FAB
+        testID="composeFAB"
+        onPress={onPressCompose}
+        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+        accessibilityRole="button"
+        accessibilityLabel="New post"
+        accessibilityHint=""
+      />
+    </View>
+  )
+})
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index f5e858209..7c7ad0616 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,157 +1,386 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
+import React, {useState} from 'react'
 
-import React, {useCallback, useRef, useState} from 'react'
-
-import {
-  Animated,
-  ScrollView,
-  Dimensions,
-  StyleSheet,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  NativeMethodsMixin,
-} from 'react-native'
+import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
-
+import Animated, {
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedStyle,
+  useAnimatedReaction,
+  useSharedValue,
+  withDecay,
+  withSpring,
+} from 'react-native-reanimated'
+import {GestureDetector, Gesture} from 'react-native-gesture-handler'
 import useImageDimensions from '../../hooks/useImageDimensions'
-import usePanResponder from '../../hooks/usePanResponder'
-
-import {getImageStyles, getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
-import {ImageLoading} from './ImageLoading'
+import {
+  createTransform,
+  readTransform,
+  applyRounding,
+  prependPan,
+  prependPinch,
+  prependTransform,
+  TransformMatrix,
+} from '../../transforms'
+import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 
-const SWIPE_CLOSE_OFFSET = 75
-const SWIPE_CLOSE_VELOCITY = 1.75
 const SCREEN = Dimensions.get('window')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
+const MIN_DOUBLE_TAP_SCALE = 2
+const MAX_ORIGINAL_IMAGE_ZOOM = 2
+
+const AnimatedImage = Animated.createAnimatedComponent(Image)
+const initialTransform = createTransform()
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (isZoomed: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
-
-const AnimatedImage = Animated.createAnimatedComponent(Image)
-
 const ImageItem = ({
   imageSrc,
+  onTap,
   onZoom,
   onRequestClose,
-  onLongPress,
-  delayLongPress,
-  swipeToCloseEnabled = true,
-  doubleTapToZoomEnabled = true,
+  isScrollViewBeingDragged,
 }: Props) => {
-  const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isLoaded, setIsLoaded] = useState(false)
   const imageDimensions = useImageDimensions(imageSrc)
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const scrollValueY = new Animated.Value(0)
-  const [isLoaded, setLoadEnd] = useState(false)
-
-  const onLoaded = useCallback(() => setLoadEnd(true), [])
-  const onZoomPerformed = useCallback(
-    (isZoomed: boolean) => {
-      onZoom(isZoomed)
-      if (imageContainer?.current) {
-        imageContainer.current.setNativeProps({
-          scrollEnabled: !isZoomed,
-        })
+  const committedTransform = useSharedValue(initialTransform)
+  const panTranslation = useSharedValue({x: 0, y: 0})
+  const pinchOrigin = useSharedValue({x: 0, y: 0})
+  const pinchScale = useSharedValue(1)
+  const pinchTranslation = useSharedValue({x: 0, y: 0})
+  const dismissSwipeTranslateY = useSharedValue(0)
+  const containerRef = useAnimatedRef()
+
+  // Keep track of when we're entering or leaving scaled rendering.
+  // Note: DO NOT move any logic reading animated values outside this function.
+  useAnimatedReaction(
+    () => {
+      if (pinchScale.value !== 1) {
+        // We're currently pinching.
+        return true
+      }
+      const [, , committedScale] = readTransform(committedTransform.value)
+      if (committedScale !== 1) {
+        // We started from a pinched in state.
+        return true
+      }
+      // We're at rest.
+      return false
+    },
+    (nextIsScaled, prevIsScaled) => {
+      if (nextIsScaled !== prevIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onZoom],
   )
 
-  const onLongPressHandler = useCallback(() => {
-    onLongPress(imageSrc)
-  }, [imageSrc, onLongPress])
-
-  const [panHandlers, scaleValue, translateValue] = usePanResponder({
-    initialScale: scale || 1,
-    initialTranslate: translate || {x: 0, y: 0},
-    onZoom: onZoomPerformed,
-    doubleTapToZoomEnabled,
-    onLongPress: onLongPressHandler,
-    delayLongPress,
-  })
+  function handleZoom(nextIsScaled: boolean) {
+    setIsScaled(nextIsScaled)
+    onZoom(nextIsScaled)
+  }
 
-  const imagesStyles = getImageStyles(
-    imageDimensions,
-    translateValue,
-    scaleValue,
-  )
-  const imageOpacity = scrollValueY.interpolate({
-    inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-    outputRange: [0.7, 1, 0.7],
+  const animatedStyle = useAnimatedStyle(() => {
+    // Apply the active adjustments on top of the committed transform before the gestures.
+    // This is matrix multiplication, so operations are applied in the reverse order.
+    let t = createTransform()
+    prependPan(t, panTranslation.value)
+    prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
+    prependTransform(t, committedTransform.value)
+    const [translateX, translateY, scale] = readTransform(t)
+
+    const dismissDistance = dismissSwipeTranslateY.value
+    const dismissProgress = Math.min(
+      Math.abs(dismissDistance) / (SCREEN.height / 2),
+      1,
+    )
+    return {
+      opacity: 1 - dismissProgress,
+      transform: [
+        {translateX},
+        {translateY: translateY + dismissDistance},
+        {scale},
+      ],
+    }
   })
-  const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
-
-  const onScrollEndDrag = ({
-    nativeEvent,
-  }: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const velocityY = nativeEvent?.velocity?.y ?? 0
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
-
-    if (
-      (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
-        offsetY > SWIPE_CLOSE_OFFSET) ||
-      offsetY > SCREEN_HEIGHT / 2
-    ) {
-      onRequestClose()
+
+  // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
+  // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
+  function getExtraTranslationToStayInBounds(
+    candidateTransform: TransformMatrix,
+  ) {
+    'worklet'
+    if (!imageDimensions) {
+      return [0, 0]
     }
+    const [nextTranslateX, nextTranslateY, nextScale] =
+      readTransform(candidateTransform)
+    const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
+    const clampedTranslateX = clampTranslation(
+      nextTranslateX,
+      scaledDimensions.width,
+      SCREEN.width,
+    )
+    const clampedTranslateY = clampTranslation(
+      nextTranslateY,
+      scaledDimensions.height,
+      SCREEN.height,
+    )
+    const dx = clampedTranslateX - nextTranslateX
+    const dy = clampedTranslateY - nextTranslateY
+    return [dx, dy]
   }
 
-  const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
+  const pinch = Gesture.Pinch()
+    .onStart(e => {
+      pinchOrigin.value = {
+        x: e.focalX - SCREEN.width / 2,
+        y: e.focalY - SCREEN.height / 2,
+      }
+    })
+    .onChange(e => {
+      if (!imageDimensions) {
+        return
+      }
+      // Don't let the picture zoom in so close that it gets blurry.
+      // Also, like in stock Android apps, don't let the user zoom out further than 1:1.
+      const [, , committedScale] = readTransform(committedTransform.value)
+      const maxCommittedScale =
+        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+      const minPinchScale = 1 / committedScale
+      const maxPinchScale = maxCommittedScale / committedScale
+      const nextPinchScale = Math.min(
+        Math.max(minPinchScale, e.scale),
+        maxPinchScale,
+      )
+      pinchScale.value = nextPinchScale
 
-    scrollValueY.setValue(offsetY)
-  }
+      // Zooming out close to the corner could push us out of bounds, which we don't want on Android.
+      // Calculate where we'll end up so we know how much to translate back to stay in bounds.
+      const t = createTransform()
+      prependPan(t, panTranslation.value)
+      prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
+      prependTransform(t, committedTransform.value)
+      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      if (dx !== 0 || dy !== 0) {
+        pinchTranslation.value = {
+          x: pinchTranslation.value.x + dx,
+          y: pinchTranslation.value.y + dy,
+        }
+      }
+    })
+    .onEnd(() => {
+      // Commit just the pinch.
+      let t = createTransform()
+      prependPinch(
+        t,
+        pinchScale.value,
+        pinchOrigin.value,
+        pinchTranslation.value,
+      )
+      prependTransform(t, committedTransform.value)
+      applyRounding(t)
+      committedTransform.value = t
+
+      // Reset just the pinch.
+      pinchScale.value = 1
+      pinchOrigin.value = {x: 0, y: 0}
+      pinchTranslation.value = {x: 0, y: 0}
+    })
 
+  const pan = Gesture.Pan()
+    .averageTouches(true)
+    // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
+    .minPointers(isScaled ? 1 : 2)
+    .onChange(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const nextPanTranslation = {x: e.translationX, y: e.translationY}
+      let t = createTransform()
+      prependPan(t, nextPanTranslation)
+      prependPinch(
+        t,
+        pinchScale.value,
+        pinchOrigin.value,
+        pinchTranslation.value,
+      )
+      prependTransform(t, committedTransform.value)
+
+      // Prevent panning from going out of bounds.
+      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      nextPanTranslation.x += dx
+      nextPanTranslation.y += dy
+      panTranslation.value = nextPanTranslation
+    })
+    .onEnd(() => {
+      // Commit just the pan.
+      let t = createTransform()
+      prependPan(t, panTranslation.value)
+      prependTransform(t, committedTransform.value)
+      applyRounding(t)
+      committedTransform.value = t
+
+      // Reset just the pan.
+      panTranslation.value = {x: 0, y: 0}
+    })
+
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const [, , committedScale] = readTransform(committedTransform.value)
+      if (committedScale !== 1) {
+        // Go back to 1:1 using the identity vector.
+        let t = createTransform()
+        committedTransform.value = withClampedSpring(t)
+        return
+      }
+
+      // Try to zoom in so that we get rid of the black bars (whatever the orientation was).
+      const imageAspect = imageDimensions.width / imageDimensions.height
+      const screenAspect = SCREEN.width / SCREEN.height
+      const candidateScale = Math.max(
+        imageAspect / screenAspect,
+        screenAspect / imageAspect,
+        MIN_DOUBLE_TAP_SCALE,
+      )
+      // But don't zoom in so close that the picture gets blurry.
+      const maxScale =
+        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+      const scale = Math.min(candidateScale, maxScale)
+
+      // Calculate where we would be if the user pinched into the double tapped point.
+      // We won't use this transform directly because it may go out of bounds.
+      const candidateTransform = createTransform()
+      const origin = {
+        x: e.absoluteX - SCREEN.width / 2,
+        y: e.absoluteY - SCREEN.height / 2,
+      }
+      prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
+
+      // Now we know how much we went out of bounds, so we can shoot correctly.
+      const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
+      const finalTransform = createTransform()
+      prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
+      committedTransform.value = withClampedSpring(finalTransform)
+    })
+
+  const dismissSwipePan = Gesture.Pan()
+    .enabled(!isScaled)
+    .activeOffsetY([-10, 10])
+    .failOffsetX([-10, 10])
+    .maxPointers(1)
+    .onUpdate(e => {
+      dismissSwipeTranslateY.value = e.translationY
+    })
+    .onEnd(e => {
+      if (Math.abs(e.velocityY) > 1000) {
+        dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
+        runOnJS(onRequestClose)()
+      } else {
+        dismissSwipeTranslateY.value = withSpring(0, {
+          stiffness: 700,
+          damping: 50,
+        })
+      }
+    })
+
+  const composedGesture = isScrollViewBeingDragged
+    ? // If the parent is not at rest, provide a no-op gesture.
+      Gesture.Manual()
+    : Gesture.Exclusive(
+        dismissSwipePan,
+        Gesture.Simultaneous(pinch, pan),
+        doubleTap,
+        singleTap,
+      )
+
+  const isLoading = !isLoaded || !imageDimensions
   return (
-    <ScrollView
-      ref={imageContainer}
-      style={styles.listItem}
-      pagingEnabled
-      nestedScrollEnabled
-      showsHorizontalScrollIndicator={false}
-      showsVerticalScrollIndicator={false}
-      contentContainerStyle={styles.imageScrollContainer}
-      scrollEnabled={swipeToCloseEnabled}
-      {...(swipeToCloseEnabled && {
-        onScroll,
-        onScrollEndDrag,
-      })}>
-      <AnimatedImage
-        {...panHandlers}
-        source={imageSrc}
-        style={imageStylesWithOpacity}
-        onLoad={onLoaded}
-        accessibilityLabel={imageSrc.alt}
-        accessibilityHint=""
-      />
-      {(!isLoaded || !imageDimensions) && <ImageLoading />}
-    </ScrollView>
+    <Animated.View ref={containerRef} style={styles.container}>
+      {isLoading && (
+        <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
+      )}
+      <GestureDetector gesture={composedGesture}>
+        <AnimatedImage
+          contentFit="contain"
+          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
+          source={{uri: imageSrc.uri}}
+          style={[styles.image, animatedStyle]}
+          accessibilityLabel={imageSrc.alt}
+          accessibilityHint=""
+          onLoad={() => setIsLoaded(true)}
+        />
+      </GestureDetector>
+    </Animated.View>
   )
 }
 
 const styles = StyleSheet.create({
-  listItem: {
-    width: SCREEN_WIDTH,
-    height: SCREEN_HEIGHT,
+  container: {
+    width: SCREEN.width,
+    height: SCREEN.height,
+    overflow: 'hidden',
+  },
+  image: {
+    flex: 1,
   },
-  imageScrollContainer: {
-    height: SCREEN_HEIGHT * 2,
+  loading: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    top: 0,
+    bottom: 0,
   },
 })
 
+function getScaledDimensions(
+  imageDimensions: ImageDimensions,
+  scale: number,
+): ImageDimensions {
+  'worklet'
+  const imageAspect = imageDimensions.width / imageDimensions.height
+  const screenAspect = SCREEN.width / SCREEN.height
+  const isLandscape = imageAspect > screenAspect
+  if (isLandscape) {
+    return {
+      width: scale * SCREEN.width,
+      height: (scale * SCREEN.width) / imageAspect,
+    }
+  } else {
+    return {
+      width: scale * SCREEN.height * imageAspect,
+      height: scale * SCREEN.height,
+    }
+  }
+}
+
+function clampTranslation(
+  value: number,
+  scaledSize: number,
+  screenSize: number,
+): number {
+  'worklet'
+  // Figure out how much the user should be allowed to pan, and constrain the translation.
+  const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
+  const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
+  return clampedValue
+}
+
+function withClampedSpring(value: any) {
+  'worklet'
+  return withSpring(value, {overshootClamping: true})
+}
+
 export default React.memo(ImageItem)
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index 03bf45af1..f73f355ac 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -6,159 +6,251 @@
  *
  */
 
-import React, {useCallback, useRef, useState} from 'react'
-
-import {
-  Animated,
-  Dimensions,
-  ScrollView,
-  StyleSheet,
-  View,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import React, {useState} from 'react'
+
+import {Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
+import Animated, {
+  interpolate,
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedScrollHandler,
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 
-import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
 import useImageDimensions from '../../hooks/useImageDimensions'
 
-import {getImageStyles, getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
+import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 import {ImageLoading} from './ImageLoading'
 
 const SWIPE_CLOSE_OFFSET = 75
 const SWIPE_CLOSE_VELOCITY = 1
 const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
-const MAX_SCALE = 2
+const MAX_ORIGINAL_IMAGE_ZOOM = 2
+const MIN_DOUBLE_TAP_SCALE = 2
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
 
 const AnimatedImage = Animated.createAnimatedComponent(Image)
 
-const ImageItem = ({
-  imageSrc,
-  onZoom,
-  onRequestClose,
-  onLongPress,
-  delayLongPress,
-  swipeToCloseEnabled = true,
-  doubleTapToZoomEnabled = true,
-}: Props) => {
-  const scrollViewRef = useRef<ScrollView>(null)
+const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
+  const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
+  const translationY = useSharedValue(0)
   const [loaded, setLoaded] = useState(false)
   const [scaled, setScaled] = useState(false)
   const imageDimensions = useImageDimensions(imageSrc)
-  const handleDoubleTap = useDoubleTapToZoom(
-    scrollViewRef,
-    scaled,
-    SCREEN,
-    imageDimensions,
-  )
-
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const scrollValueY = new Animated.Value(0)
-  const scaleValue = new Animated.Value(scale || 1)
-  const translateValue = new Animated.ValueXY(translate)
-  const maxScrollViewZoom = MAX_SCALE / (scale || 1)
+  const maxZoomScale = imageDimensions
+    ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+    : 1
 
-  const imageOpacity = scrollValueY.interpolate({
-    inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-    outputRange: [0.5, 1, 0.5],
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      opacity: interpolate(
+        translationY.value,
+        [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
+        [0.5, 1, 0.5],
+      ),
+    }
   })
-  const imagesStyles = getImageStyles(
-    imageDimensions,
-    translateValue,
-    scaleValue,
-  )
-  const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
-
-  const onScrollEndDrag = useCallback(
-    ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-      const velocityY = nativeEvent?.velocity?.y ?? 0
-      const currentScaled = nativeEvent?.zoomScale > 1
-
-      onZoom(currentScaled)
-      setScaled(currentScaled)
-
-      if (
-        !currentScaled &&
-        swipeToCloseEnabled &&
-        Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
-      ) {
-        onRequestClose()
+
+  const scrollHandler = useAnimatedScrollHandler({
+    onScroll(e) {
+      const nextIsScaled = e.zoomScale > 1
+      translationY.value = nextIsScaled ? 0 : e.contentOffset.y
+      if (scaled !== nextIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onRequestClose, onZoom, swipeToCloseEnabled],
-  )
+    onEndDrag(e) {
+      const velocityY = e.velocity?.y ?? 0
+      const nextIsScaled = e.zoomScale > 1
+      if (scaled !== nextIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
+      }
+      if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
+        runOnJS(onRequestClose)()
+      }
+    },
+  })
+
+  function handleZoom(nextIsScaled: boolean) {
+    onZoom(nextIsScaled)
+    setScaled(nextIsScaled)
+  }
 
-  const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
+  function handleDoubleTap(absoluteX: number, absoluteY: number) {
+    const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
+    let nextZoomRect = {
+      x: 0,
+      y: 0,
+      width: SCREEN.width,
+      height: SCREEN.height,
+    }
 
-    if (nativeEvent?.zoomScale > 1) {
-      return
+    const willZoom = !scaled
+    if (willZoom) {
+      nextZoomRect = getZoomRectAfterDoubleTap(
+        imageDimensions,
+        absoluteX,
+        absoluteY,
+      )
     }
 
-    scrollValueY.setValue(offsetY)
+    // @ts-ignore
+    scrollResponderRef?.scrollResponderZoomTo({
+      ...nextZoomRect, // This rect is in screen coordinates
+      animated: true,
+    })
   }
 
-  const onLongPressHandler = useCallback(() => {
-    onLongPress(imageSrc)
-  }, [imageSrc, onLongPress])
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      const {absoluteX, absoluteY} = e
+      runOnJS(handleDoubleTap)(absoluteX, absoluteY)
+    })
+
+  const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
 
   return (
-    <View>
-      <ScrollView
+    <GestureDetector gesture={composedGesture}>
+      <Animated.ScrollView
+        // @ts-ignore Something's up with the types here
         ref={scrollViewRef}
         style={styles.listItem}
         pinchGestureEnabled
         showsHorizontalScrollIndicator={false}
         showsVerticalScrollIndicator={false}
-        maximumZoomScale={maxScrollViewZoom}
+        maximumZoomScale={maxZoomScale}
         contentContainerStyle={styles.imageScrollContainer}
-        scrollEnabled={swipeToCloseEnabled}
-        onScrollEndDrag={onScrollEndDrag}
-        scrollEventThrottle={1}
-        {...(swipeToCloseEnabled && {
-          onScroll,
-        })}>
+        onScroll={scrollHandler}>
         {(!loaded || !imageDimensions) && <ImageLoading />}
-        <TouchableWithoutFeedback
-          onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
-          onLongPress={onLongPressHandler}
-          delayLongPress={delayLongPress}
-          accessibilityRole="image"
+        <AnimatedImage
+          contentFit="contain"
+          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
+          source={{uri: imageSrc.uri}}
+          style={[styles.image, animatedStyle]}
           accessibilityLabel={imageSrc.alt}
-          accessibilityHint="">
-          <AnimatedImage
-            source={imageSrc}
-            style={imageStylesWithOpacity}
-            onLoad={() => setLoaded(true)}
-          />
-        </TouchableWithoutFeedback>
-      </ScrollView>
-    </View>
+          accessibilityHint=""
+          onLoad={() => setLoaded(true)}
+        />
+      </Animated.ScrollView>
+    </GestureDetector>
   )
 }
 
 const styles = StyleSheet.create({
+  imageScrollContainer: {
+    height: SCREEN.height,
+  },
   listItem: {
-    width: SCREEN_WIDTH,
-    height: SCREEN_HEIGHT,
+    width: SCREEN.width,
+    height: SCREEN.height,
   },
-  imageScrollContainer: {
-    height: SCREEN_HEIGHT,
+  image: {
+    width: SCREEN.width,
+    height: SCREEN.height,
   },
 })
 
+const getZoomRectAfterDoubleTap = (
+  imageDimensions: ImageDimensions | null,
+  touchX: number,
+  touchY: number,
+): {
+  x: number
+  y: number
+  width: number
+  height: number
+} => {
+  if (!imageDimensions) {
+    return {
+      x: 0,
+      y: 0,
+      width: SCREEN.width,
+      height: SCREEN.height,
+    }
+  }
+
+  // First, let's figure out how much we want to zoom in.
+  // We want to try to zoom in at least close enough to get rid of black bars.
+  const imageAspect = imageDimensions.width / imageDimensions.height
+  const screenAspect = SCREEN.width / SCREEN.height
+  const zoom = Math.max(
+    imageAspect / screenAspect,
+    screenAspect / imageAspect,
+    MIN_DOUBLE_TAP_SCALE,
+  )
+  // Unlike in the Android version, we don't constrain the *max* zoom level here.
+  // Instead, this is done in the ScrollView props so that it constraints pinch too.
+
+  // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
+  // We already know the zoom level, so this gives us the rectangle size.
+  let rectWidth = SCREEN.width / zoom
+  let rectHeight = SCREEN.height / zoom
+
+  // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
+  // We don't want to introduce new black bars or make existing black bars unbalanced.
+  let minX = 0
+  let minY = 0
+  let maxX = SCREEN.width - rectWidth
+  let maxY = SCREEN.height - rectHeight
+  if (imageAspect >= screenAspect) {
+    // The image has horizontal black bars. Exclude them from the safe area.
+    const renderedHeight = SCREEN.width / imageAspect
+    const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2
+    minY += horizontalBarHeight
+    maxY -= horizontalBarHeight
+  } else {
+    // The image has vertical black bars. Exclude them from the safe area.
+    const renderedWidth = SCREEN.height * imageAspect
+    const verticalBarWidth = (SCREEN.width - renderedWidth) / 2
+    minX += verticalBarWidth
+    maxX -= verticalBarWidth
+  }
+
+  // Finally, we can position the rect according to its size and the safe area.
+  let rectX
+  if (maxX >= minX) {
+    // Content fills the screen horizontally so we have horizontal wiggle room.
+    // Try to keep the tapped point under the finger after zoom.
+    rectX = touchX - touchX / zoom
+    rectX = Math.min(rectX, maxX)
+    rectX = Math.max(rectX, minX)
+  } else {
+    // Keep the rect centered on the screen so that black bars are balanced.
+    rectX = SCREEN.width / 2 - rectWidth / 2
+  }
+  let rectY
+  if (maxY >= minY) {
+    // Content fills the screen vertically so we have vertical wiggle room.
+    // Try to keep the tapped point under the finger after zoom.
+    rectY = touchY - touchY / zoom
+    rectY = Math.min(rectY, maxY)
+    rectY = Math.max(rectY, minY)
+  } else {
+    // Keep the rect centered on the screen so that black bars are balanced.
+    rectY = SCREEN.height / 2 - rectHeight / 2
+  }
+
+  return {
+    x: rectX,
+    y: rectY,
+    height: rectHeight,
+    width: rectWidth,
+  }
+}
+
 export default React.memo(ImageItem)
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index fd377dde2..16688b820 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -7,11 +7,9 @@ import {ImageSource} from '../../@types'
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
deleted file mode 100644
index c21cd7f2c..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {Animated} from 'react-native'
-
-const INITIAL_POSITION = {x: 0, y: 0}
-const ANIMATION_CONFIG = {
-  duration: 200,
-  useNativeDriver: true,
-}
-
-const useAnimatedComponents = () => {
-  const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
-  const footerTranslate = new Animated.ValueXY(INITIAL_POSITION)
-
-  const toggleVisible = (isVisible: boolean) => {
-    if (isVisible) {
-      Animated.parallel([
-        Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
-        Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
-      ]).start()
-    } else {
-      Animated.parallel([
-        Animated.timing(headerTranslate.y, {
-          ...ANIMATION_CONFIG,
-          toValue: -300,
-        }),
-        Animated.timing(footerTranslate.y, {
-          ...ANIMATION_CONFIG,
-          toValue: 300,
-        }),
-      ]).start()
-    }
-  }
-
-  const headerTransform = headerTranslate.getTranslateTransform()
-  const footerTransform = footerTranslate.getTranslateTransform()
-
-  return [headerTransform, footerTransform, toggleVisible] as const
-}
-
-export default useAnimatedComponents
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
deleted file mode 100644
index ea81d9f1c..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import React, {useCallback} from 'react'
-import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
-
-import {Dimensions} from '../@types'
-
-const DOUBLE_TAP_DELAY = 300
-const MIN_ZOOM = 2
-
-let lastTapTS: number | null = null
-
-/**
- * This is iOS only.
- * Same functionality for Android implemented inside usePanResponder hook.
- */
-function useDoubleTapToZoom(
-  scrollViewRef: React.RefObject<ScrollView>,
-  scaled: boolean,
-  screen: Dimensions,
-  imageDimensions: Dimensions | null,
-) {
-  const handleDoubleTap = useCallback(
-    (event: NativeSyntheticEvent<NativeTouchEvent>) => {
-      const nowTS = new Date().getTime()
-      const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
-
-      const getZoomRectAfterDoubleTap = (
-        touchX: number,
-        touchY: number,
-      ): {
-        x: number
-        y: number
-        width: number
-        height: number
-      } => {
-        if (!imageDimensions) {
-          return {
-            x: 0,
-            y: 0,
-            width: screen.width,
-            height: screen.height,
-          }
-        }
-
-        // First, let's figure out how much we want to zoom in.
-        // We want to try to zoom in at least close enough to get rid of black bars.
-        const imageAspect = imageDimensions.width / imageDimensions.height
-        const screenAspect = screen.width / screen.height
-        const zoom = Math.max(
-          imageAspect / screenAspect,
-          screenAspect / imageAspect,
-          MIN_ZOOM,
-        )
-        // Unlike in the Android version, we don't constrain the *max* zoom level here.
-        // Instead, this is done in the ScrollView props so that it constraints pinch too.
-
-        // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
-        // We already know the zoom level, so this gives us the rectangle size.
-        let rectWidth = screen.width / zoom
-        let rectHeight = screen.height / zoom
-
-        // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
-        // We don't want to introduce new black bars or make existing black bars unbalanced.
-        let minX = 0
-        let minY = 0
-        let maxX = screen.width - rectWidth
-        let maxY = screen.height - rectHeight
-        if (imageAspect >= screenAspect) {
-          // The image has horizontal black bars. Exclude them from the safe area.
-          const renderedHeight = screen.width / imageAspect
-          const horizontalBarHeight = (screen.height - renderedHeight) / 2
-          minY += horizontalBarHeight
-          maxY -= horizontalBarHeight
-        } else {
-          // The image has vertical black bars. Exclude them from the safe area.
-          const renderedWidth = screen.height * imageAspect
-          const verticalBarWidth = (screen.width - renderedWidth) / 2
-          minX += verticalBarWidth
-          maxX -= verticalBarWidth
-        }
-
-        // Finally, we can position the rect according to its size and the safe area.
-        let rectX
-        if (maxX >= minX) {
-          // Content fills the screen horizontally so we have horizontal wiggle room.
-          // Try to keep the tapped point under the finger after zoom.
-          rectX = touchX - touchX / zoom
-          rectX = Math.min(rectX, maxX)
-          rectX = Math.max(rectX, minX)
-        } else {
-          // Keep the rect centered on the screen so that black bars are balanced.
-          rectX = screen.width / 2 - rectWidth / 2
-        }
-        let rectY
-        if (maxY >= minY) {
-          // Content fills the screen vertically so we have vertical wiggle room.
-          // Try to keep the tapped point under the finger after zoom.
-          rectY = touchY - touchY / zoom
-          rectY = Math.min(rectY, maxY)
-          rectY = Math.max(rectY, minY)
-        } else {
-          // Keep the rect centered on the screen so that black bars are balanced.
-          rectY = screen.height / 2 - rectHeight / 2
-        }
-
-        return {
-          x: rectX,
-          y: rectY,
-          height: rectHeight,
-          width: rectWidth,
-        }
-      }
-
-      if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
-        let nextZoomRect = {
-          x: 0,
-          y: 0,
-          width: screen.width,
-          height: screen.height,
-        }
-
-        const willZoom = !scaled
-        if (willZoom) {
-          const {pageX, pageY} = event.nativeEvent
-          nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
-        }
-
-        // @ts-ignore
-        scrollResponderRef?.scrollResponderZoomTo({
-          ...nextZoomRect, // This rect is in screen coordinates
-          animated: true,
-        })
-      } else {
-        lastTapTS = nowTS
-      }
-    },
-    [imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
-  )
-
-  return handleDoubleTap
-}
-
-export default useDoubleTapToZoom
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
index a5b0b6bd4..cb46fd0d9 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
@@ -8,11 +8,29 @@
 
 import {useEffect, useState} from 'react'
 import {Image, ImageURISource} from 'react-native'
-
-import {createCache} from '../utils'
 import {Dimensions, ImageSource} from '../@types'
 
 const CACHE_SIZE = 50
+
+type CacheStorageItem = {key: string; value: any}
+
+const createCache = (cacheSize: number) => ({
+  _storage: [] as CacheStorageItem[],
+  get(key: string): any {
+    const {value} =
+      this._storage.find(({key: storageKey}) => storageKey === key) || {}
+
+    return value
+  },
+  set(key: string, value: any) {
+    if (this._storage.length >= cacheSize) {
+      this._storage.shift()
+    }
+
+    this._storage.push({key, value})
+  },
+})
+
 const imageDimensionsCache = createCache(CACHE_SIZE)
 
 const useImageDimensions = (image: ImageSource): Dimensions | null => {
@@ -21,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => {
   // eslint-disable-next-line @typescript-eslint/no-shadow
   const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
     return new Promise(resolve => {
-      if (typeof image === 'number') {
-        const cacheKey = `${image}`
-        let imageDimensions = imageDimensionsCache.get(cacheKey)
-
-        if (!imageDimensions) {
-          const {width, height} = Image.resolveAssetSource(image)
-          imageDimensions = {width, height}
-          imageDimensionsCache.set(cacheKey, imageDimensions)
-        }
-
-        resolve(imageDimensions)
-
-        return
-      }
-
-      // @ts-ignore
       if (image.uri) {
         const source = image as ImageURISource
-
         const cacheKey = source.uri as string
-
         const imageDimensions = imageDimensionsCache.get(cacheKey)
-
         if (imageDimensions) {
           resolve(imageDimensions)
         } else {
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
deleted file mode 100644
index 16430f3aa..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useState} from 'react'
-import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
-
-import {Dimensions} from '../@types'
-
-const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
-  const [currentImageIndex, setImageIndex] = useState(imageIndex)
-  const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const {
-      nativeEvent: {
-        contentOffset: {x: scrollX},
-      },
-    } = event
-
-    if (screen.width) {
-      const nextIndex = Math.round(scrollX / screen.width)
-      setImageIndex(nextIndex < 0 ? 0 : nextIndex)
-    }
-  }
-
-  return [currentImageIndex, onScroll] as const
-}
-
-export default useImageIndexChange
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
deleted file mode 100644
index 3969945bb..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useEffect} from 'react'
-import {Image} from 'react-native'
-import {ImageSource} from '../@types'
-
-const useImagePrefetch = (images: ImageSource[]) => {
-  useEffect(() => {
-    images.forEach(image => {
-      //@ts-ignore
-      if (image.uri) {
-        //@ts-ignore
-        return Image.prefetch(image.uri)
-      }
-    })
-  }, [images])
-}
-
-export default useImagePrefetch
diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
deleted file mode 100644
index 7908504ea..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
+++ /dev/null
@@ -1,431 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useEffect} from 'react'
-import {
-  Animated,
-  Dimensions,
-  GestureResponderEvent,
-  GestureResponderHandlers,
-  NativeTouchEvent,
-  PanResponder,
-  PanResponderGestureState,
-} from 'react-native'
-
-import {Position} from '../@types'
-import {
-  getDistanceBetweenTouches,
-  getImageTranslate,
-  getImageDimensionsByTranslate,
-} from '../utils'
-
-const SCREEN = Dimensions.get('window')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
-const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
-const ANDROID_BAR_HEIGHT = 24
-
-const MIN_ZOOM = 2
-const MAX_SCALE = 2
-const DOUBLE_TAP_DELAY = 300
-const OUT_BOUND_MULTIPLIER = 0.75
-
-type Props = {
-  initialScale: number
-  initialTranslate: Position
-  onZoom: (isZoomed: boolean) => void
-  doubleTapToZoomEnabled: boolean
-  onLongPress: () => void
-  delayLongPress: number
-}
-
-const usePanResponder = ({
-  initialScale,
-  initialTranslate,
-  onZoom,
-  doubleTapToZoomEnabled,
-  onLongPress,
-  delayLongPress,
-}: Props): Readonly<
-  [GestureResponderHandlers, Animated.Value, Animated.ValueXY]
-> => {
-  let numberInitialTouches = 1
-  let initialTouches: NativeTouchEvent[] = []
-  let currentScale = initialScale
-  let currentTranslate = initialTranslate
-  let tmpScale = 0
-  let tmpTranslate: Position | null = null
-  let isDoubleTapPerformed = false
-  let lastTapTS: number | null = null
-  let longPressHandlerRef: NodeJS.Timeout | null = null
-
-  const meaningfulShift = MIN_DIMENSION * 0.01
-  const scaleValue = new Animated.Value(initialScale)
-  const translateValue = new Animated.ValueXY(initialTranslate)
-
-  const imageDimensions = getImageDimensionsByTranslate(
-    initialTranslate,
-    SCREEN,
-  )
-
-  const getBounds = (scale: number) => {
-    const scaledImageDimensions = {
-      width: imageDimensions.width * scale,
-      height: imageDimensions.height * scale,
-    }
-    const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
-
-    const left = initialTranslate.x - translateDelta.x
-    const right = left - (scaledImageDimensions.width - SCREEN.width)
-    const top = initialTranslate.y - translateDelta.y
-    const bottom = top - (scaledImageDimensions.height - SCREEN.height)
-
-    return [top, left, bottom, right]
-  }
-
-  const getTransformAfterDoubleTap = (
-    touchX: number,
-    touchY: number,
-  ): [number, Position] => {
-    let nextScale = initialScale
-    let nextTranslateX = initialTranslate.x
-    let nextTranslateY = initialTranslate.y
-
-    // First, let's figure out how much we want to zoom in.
-    // We want to try to zoom in at least close enough to get rid of black bars.
-    const imageAspect = imageDimensions.width / imageDimensions.height
-    const screenAspect = SCREEN.width / SCREEN.height
-    let zoom = Math.max(
-      imageAspect / screenAspect,
-      screenAspect / imageAspect,
-      MIN_ZOOM,
-    )
-    // Don't zoom so hard that the original image's pixels become blurry.
-    zoom = Math.min(zoom, MAX_SCALE / initialScale)
-    nextScale = initialScale * zoom
-
-    // Next, let's see if we need to adjust the scaled image translation.
-    // Ideally, we want the tapped point to stay under the finger after the scaling.
-    const dx = SCREEN.width / 2 - touchX
-    const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
-    // Before we try to adjust the translation, check how much wiggle room we have.
-    // We don't want to introduce new black bars or make existing black bars unbalanced.
-    const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
-    if (leftBound > rightBound) {
-      // Content fills the screen horizontally so we have horizontal wiggle room.
-      // Try to keep the tapped point under the finger after zoom.
-      nextTranslateX += dx * zoom - dx
-      nextTranslateX = Math.min(nextTranslateX, leftBound)
-      nextTranslateX = Math.max(nextTranslateX, rightBound)
-    }
-    if (topBound > bottomBound) {
-      // Content fills the screen vertically so we have vertical wiggle room.
-      // Try to keep the tapped point under the finger after zoom.
-      nextTranslateY += dy * zoom - dy
-      nextTranslateY = Math.min(nextTranslateY, topBound)
-      nextTranslateY = Math.max(nextTranslateY, bottomBound)
-    }
-
-    return [
-      nextScale,
-      {
-        x: nextTranslateX,
-        y: nextTranslateY,
-      },
-    ]
-  }
-
-  const fitsScreenByWidth = () =>
-    imageDimensions.width * currentScale < SCREEN_WIDTH
-  const fitsScreenByHeight = () =>
-    imageDimensions.height * currentScale < SCREEN_HEIGHT
-
-  useEffect(() => {
-    scaleValue.addListener(({value}) => {
-      if (typeof onZoom === 'function') {
-        onZoom(value !== initialScale)
-      }
-    })
-
-    return () => scaleValue.removeAllListeners()
-  })
-
-  const cancelLongPressHandle = () => {
-    longPressHandlerRef && clearTimeout(longPressHandlerRef)
-  }
-
-  const panResponder = PanResponder.create({
-    onStartShouldSetPanResponder: () => true,
-    onStartShouldSetPanResponderCapture: () => true,
-    onMoveShouldSetPanResponder: () => true,
-    onMoveShouldSetPanResponderCapture: () => true,
-    onPanResponderGrant: (
-      _: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      numberInitialTouches = gestureState.numberActiveTouches
-
-      if (gestureState.numberActiveTouches > 1) {
-        return
-      }
-
-      longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
-    },
-    onPanResponderStart: (
-      event: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      initialTouches = event.nativeEvent.touches
-      numberInitialTouches = gestureState.numberActiveTouches
-
-      if (gestureState.numberActiveTouches > 1) {
-        return
-      }
-
-      const tapTS = Date.now()
-      // Handle double tap event by calculating diff between first and second taps timestamps
-
-      isDoubleTapPerformed = Boolean(
-        lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
-      )
-
-      if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
-        let nextScale = initialScale
-        let nextTranslate = initialTranslate
-
-        const willZoom = currentScale === initialScale
-        if (willZoom) {
-          const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
-          ;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
-            touchX,
-            touchY,
-          )
-        }
-        onZoom(willZoom)
-
-        Animated.parallel(
-          [
-            Animated.timing(translateValue.x, {
-              toValue: nextTranslate.x,
-              duration: 300,
-              useNativeDriver: true,
-            }),
-            Animated.timing(translateValue.y, {
-              toValue: nextTranslate.y,
-              duration: 300,
-              useNativeDriver: true,
-            }),
-            Animated.timing(scaleValue, {
-              toValue: nextScale,
-              duration: 300,
-              useNativeDriver: true,
-            }),
-          ],
-          {stopTogether: false},
-        ).start(() => {
-          currentScale = nextScale
-          currentTranslate = nextTranslate
-        })
-
-        lastTapTS = null
-      } else {
-        lastTapTS = Date.now()
-      }
-    },
-    onPanResponderMove: (
-      event: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      const {dx, dy} = gestureState
-
-      if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
-        cancelLongPressHandle()
-      }
-
-      // Don't need to handle move because double tap in progress (was handled in onStart)
-      if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
-        cancelLongPressHandle()
-        return
-      }
-
-      if (
-        numberInitialTouches === 1 &&
-        gestureState.numberActiveTouches === 2
-      ) {
-        numberInitialTouches = 2
-        initialTouches = event.nativeEvent.touches
-      }
-
-      const isTapGesture =
-        numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
-      const isPinchGesture =
-        numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
-
-      if (isPinchGesture) {
-        cancelLongPressHandle()
-
-        const initialDistance = getDistanceBetweenTouches(initialTouches)
-        const currentDistance = getDistanceBetweenTouches(
-          event.nativeEvent.touches,
-        )
-
-        let nextScale = (currentDistance / initialDistance) * currentScale
-
-        /**
-         * In case image is scaling smaller than initial size ->
-         * slow down this transition by applying OUT_BOUND_MULTIPLIER
-         */
-        if (nextScale < initialScale) {
-          nextScale =
-            nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
-        }
-
-        /**
-         * In case image is scaling down -> move it in direction of initial position
-         */
-        if (currentScale > initialScale && currentScale > nextScale) {
-          const k = (currentScale - initialScale) / (currentScale - nextScale)
-
-          const nextTranslateX =
-            nextScale < initialScale
-              ? initialTranslate.x
-              : currentTranslate.x -
-                (currentTranslate.x - initialTranslate.x) / k
-
-          const nextTranslateY =
-            nextScale < initialScale
-              ? initialTranslate.y
-              : currentTranslate.y -
-                (currentTranslate.y - initialTranslate.y) / k
-
-          translateValue.x.setValue(nextTranslateX)
-          translateValue.y.setValue(nextTranslateY)
-
-          tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
-        }
-
-        scaleValue.setValue(nextScale)
-        tmpScale = nextScale
-      }
-
-      if (isTapGesture && currentScale > initialScale) {
-        const {x, y} = currentTranslate
-        // eslint-disable-next-line @typescript-eslint/no-shadow
-        const {dx, dy} = gestureState
-        const [topBound, leftBound, bottomBound, rightBound] =
-          getBounds(currentScale)
-
-        let nextTranslateX = x + dx
-        let nextTranslateY = y + dy
-
-        if (nextTranslateX > leftBound) {
-          nextTranslateX =
-            nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (nextTranslateX < rightBound) {
-          nextTranslateX =
-            nextTranslateX -
-            (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (nextTranslateY > topBound) {
-          nextTranslateY =
-            nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (nextTranslateY < bottomBound) {
-          nextTranslateY =
-            nextTranslateY -
-            (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (fitsScreenByWidth()) {
-          nextTranslateX = x
-        }
-
-        if (fitsScreenByHeight()) {
-          nextTranslateY = y
-        }
-
-        translateValue.x.setValue(nextTranslateX)
-        translateValue.y.setValue(nextTranslateY)
-
-        tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
-      }
-    },
-    onPanResponderRelease: () => {
-      cancelLongPressHandle()
-
-      if (isDoubleTapPerformed) {
-        isDoubleTapPerformed = false
-      }
-
-      if (tmpScale > 0) {
-        if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
-          tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
-          Animated.timing(scaleValue, {
-            toValue: tmpScale,
-            duration: 100,
-            useNativeDriver: true,
-          }).start()
-        }
-
-        currentScale = tmpScale
-        tmpScale = 0
-      }
-
-      if (tmpTranslate) {
-        const {x, y} = tmpTranslate
-        const [topBound, leftBound, bottomBound, rightBound] =
-          getBounds(currentScale)
-
-        let nextTranslateX = x
-        let nextTranslateY = y
-
-        if (!fitsScreenByWidth()) {
-          if (nextTranslateX > leftBound) {
-            nextTranslateX = leftBound
-          } else if (nextTranslateX < rightBound) {
-            nextTranslateX = rightBound
-          }
-        }
-
-        if (!fitsScreenByHeight()) {
-          if (nextTranslateY > topBound) {
-            nextTranslateY = topBound
-          } else if (nextTranslateY < bottomBound) {
-            nextTranslateY = bottomBound
-          }
-        }
-
-        Animated.parallel([
-          Animated.timing(translateValue.x, {
-            toValue: nextTranslateX,
-            duration: 100,
-            useNativeDriver: true,
-          }),
-          Animated.timing(translateValue.y, {
-            toValue: nextTranslateY,
-            duration: 100,
-            useNativeDriver: true,
-          }),
-        ]).start()
-
-        currentTranslate = {x: nextTranslateX, y: nextTranslateY}
-        tmpTranslate = null
-      }
-    },
-    onPanResponderTerminationRequest: () => false,
-    onShouldBlockNativeResponder: () => false,
-  })
-
-  return [panResponder.panHandlers, scaleValue, translateValue]
-}
-
-export default usePanResponder
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
deleted file mode 100644
index 4cd03fe71..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useState} from 'react'
-
-const useRequestClose = (onRequestClose: () => void) => {
-  const [opacity, setOpacity] = useState(1)
-
-  return [
-    opacity,
-    () => {
-      setOpacity(0)
-      onRequestClose()
-      setTimeout(() => setOpacity(1), 0)
-    },
-  ] as const
-}
-
-export default useRequestClose
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 1a64fb3af..b6835793d 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,91 +8,72 @@
 // Original code copied and simplified from the link below as the codebase is currently not maintained:
 // https://github.com/jobtoday/react-native-image-viewing
 
-import React, {
-  ComponentType,
-  useCallback,
-  useRef,
-  useEffect,
-  useMemo,
-} from 'react'
-import {
-  Animated,
-  Dimensions,
-  StyleSheet,
-  View,
-  VirtualizedList,
-  ModalProps,
-  Platform,
-} from 'react-native'
-import {ModalsContainer} from '../../modals/Modal'
+import React, {ComponentType, useCallback, useMemo, useState} from 'react'
+import {StyleSheet, View, Platform} from 'react-native'
 
 import ImageItem from './components/ImageItem/ImageItem'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 
-import useAnimatedComponents from './hooks/useAnimatedComponents'
-import useImageIndexChange from './hooks/useImageIndexChange'
-import useRequestClose from './hooks/useRequestClose'
 import {ImageSource} from './@types'
+import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
 import {Edge, SafeAreaView} from 'react-native-safe-area-context'
+import PagerView from 'react-native-pager-view'
 
 type Props = {
   images: ImageSource[]
-  keyExtractor?: (imageSrc: ImageSource, index: number) => string
-  imageIndex: number
+  initialImageIndex: number
   visible: boolean
   onRequestClose: () => void
-  onLongPress?: (image: ImageSource) => void
-  onImageIndexChange?: (imageIndex: number) => void
-  presentationStyle?: ModalProps['presentationStyle']
-  animationType?: ModalProps['animationType']
   backgroundColor?: string
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
-  delayLongPress?: number
   HeaderComponent?: ComponentType<{imageIndex: number}>
   FooterComponent?: ComponentType<{imageIndex: number}>
 }
 
 const DEFAULT_BG_COLOR = '#000'
-const DEFAULT_DELAY_LONG_PRESS = 800
-const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
 
 function ImageViewing({
   images,
-  keyExtractor,
-  imageIndex,
+  initialImageIndex,
   visible,
   onRequestClose,
-  onLongPress = () => {},
-  onImageIndexChange,
   backgroundColor = DEFAULT_BG_COLOR,
-  swipeToCloseEnabled,
-  doubleTapToZoomEnabled,
-  delayLongPress = DEFAULT_DELAY_LONG_PRESS,
   HeaderComponent,
   FooterComponent,
 }: Props) {
-  const imageList = useRef<VirtualizedList<ImageSource>>(null)
-  const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
-  const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
-  const [headerTransform, footerTransform, toggleBarsVisible] =
-    useAnimatedComponents()
-
-  useEffect(() => {
-    if (onImageIndexChange) {
-      onImageIndexChange(currentImageIndex)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isDragging, setIsDragging] = useState(false)
+  const [imageIndex, setImageIndex] = useState(initialImageIndex)
+  const [showControls, setShowControls] = useState(true)
+
+  const animatedHeaderStyle = useAnimatedStyle(() => ({
+    pointerEvents: showControls ? 'auto' : 'none',
+    opacity: withClampedSpring(showControls ? 1 : 0),
+    transform: [
+      {
+        translateY: withClampedSpring(showControls ? 0 : -30),
+      },
+    ],
+  }))
+  const animatedFooterStyle = useAnimatedStyle(() => ({
+    pointerEvents: showControls ? 'auto' : 'none',
+    opacity: withClampedSpring(showControls ? 1 : 0),
+    transform: [
+      {
+        translateY: withClampedSpring(showControls ? 0 : 30),
+      },
+    ],
+  }))
+
+  const onTap = useCallback(() => {
+    setShowControls(show => !show)
+  }, [])
+
+  const onZoom = useCallback((nextIsScaled: boolean) => {
+    setIsScaled(nextIsScaled)
+    if (nextIsScaled) {
+      setShowControls(false)
     }
-  }, [currentImageIndex, onImageIndexChange])
-
-  const onZoom = useCallback(
-    (isScaled: boolean) => {
-      // @ts-ignore
-      imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
-      toggleBarsVisible(!isScaled)
-    },
-    [toggleBarsVisible],
-  )
+  }, [])
 
   const edges = useMemo(() => {
     if (Platform.OS === 'android') {
@@ -101,12 +82,6 @@ function ImageViewing({
     return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
   }, [])
 
-  const onLayout = useCallback(() => {
-    if (imageIndex) {
-      imageList.current?.scrollToIndex({index: imageIndex, animated: false})
-    }
-  }, [imageList, imageIndex])
-
   if (!visible) {
     return null
   }
@@ -114,60 +89,47 @@ function ImageViewing({
   return (
     <SafeAreaView
       style={styles.screen}
-      onLayout={onLayout}
       edges={edges}
       aria-modal
       accessibilityViewIsModal>
-      <ModalsContainer />
-      <View style={[styles.container, {opacity, backgroundColor}]}>
-        <Animated.View style={[styles.header, {transform: headerTransform}]}>
+      <View style={[styles.container, {backgroundColor}]}>
+        <Animated.View style={[styles.header, animatedHeaderStyle]}>
           {typeof HeaderComponent !== 'undefined' ? (
             React.createElement(HeaderComponent, {
-              imageIndex: currentImageIndex,
+              imageIndex,
             })
           ) : (
-            <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
+            <ImageDefaultHeader onRequestClose={onRequestClose} />
           )}
         </Animated.View>
-        <VirtualizedList
-          ref={imageList}
-          data={images}
-          horizontal
-          pagingEnabled
-          showsHorizontalScrollIndicator={false}
-          showsVerticalScrollIndicator={false}
-          getItem={(_, index) => images[index]}
-          getItemCount={() => images.length}
-          getItemLayout={(_, index) => ({
-            length: SCREEN_WIDTH,
-            offset: SCREEN_WIDTH * index,
-            index,
-          })}
-          renderItem={({item: imageSrc}) => (
-            <ImageItem
-              onZoom={onZoom}
-              imageSrc={imageSrc}
-              onRequestClose={onRequestCloseEnhanced}
-              onLongPress={onLongPress}
-              delayLongPress={delayLongPress}
-              swipeToCloseEnabled={swipeToCloseEnabled}
-              doubleTapToZoomEnabled={doubleTapToZoomEnabled}
-            />
-          )}
-          onMomentumScrollEnd={onScroll}
-          //@ts-ignore
-          keyExtractor={(imageSrc, index) =>
-            keyExtractor
-              ? keyExtractor(imageSrc, index)
-              : typeof imageSrc === 'number'
-              ? `${imageSrc}`
-              : imageSrc.uri
-          }
-        />
+        <PagerView
+          scrollEnabled={!isScaled}
+          initialPage={initialImageIndex}
+          onPageSelected={e => {
+            setImageIndex(e.nativeEvent.position)
+            setIsScaled(false)
+          }}
+          onPageScrollStateChanged={e => {
+            setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
+          }}
+          overdrag={true}
+          style={styles.pager}>
+          {images.map(imageSrc => (
+            <View key={imageSrc.uri}>
+              <ImageItem
+                onTap={onTap}
+                onZoom={onZoom}
+                imageSrc={imageSrc}
+                onRequestClose={onRequestClose}
+                isScrollViewBeingDragged={isDragging}
+              />
+            </View>
+          ))}
+        </PagerView>
         {typeof FooterComponent !== 'undefined' && (
-          <Animated.View style={[styles.footer, {transform: footerTransform}]}>
+          <Animated.View style={[styles.footer, animatedFooterStyle]}>
             {React.createElement(FooterComponent, {
-              imageIndex: currentImageIndex,
+              imageIndex,
             })}
           </Animated.View>
         )}
@@ -179,11 +141,18 @@ function ImageViewing({
 const styles = StyleSheet.create({
   screen: {
     position: 'absolute',
+    top: 0,
+    left: 0,
+    bottom: 0,
+    right: 0,
   },
   container: {
     flex: 1,
     backgroundColor: '#000',
   },
+  pager: {
+    flex: 1,
+  },
   header: {
     position: 'absolute',
     width: '100%',
@@ -200,7 +169,12 @@ const styles = StyleSheet.create({
 })
 
 const EnhancedImageViewing = (props: Props) => (
-  <ImageViewing key={props.imageIndex} {...props} />
+  <ImageViewing key={props.initialImageIndex} {...props} />
 )
 
+function withClampedSpring(value: any) {
+  'worklet'
+  return withSpring(value, {overshootClamping: true, stiffness: 300})
+}
+
 export default EnhancedImageViewing
diff --git a/src/view/com/lightbox/ImageViewing/transforms.ts b/src/view/com/lightbox/ImageViewing/transforms.ts
new file mode 100644
index 000000000..05476678f
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/transforms.ts
@@ -0,0 +1,98 @@
+import type {Position} from './@types'
+
+export type TransformMatrix = [
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+]
+
+// These are affine transforms. See explanation of every cell here:
+// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
+
+export function createTransform(): TransformMatrix {
+  'worklet'
+  return [1, 0, 0, 0, 1, 0, 0, 0, 1]
+}
+
+export function applyRounding(t: TransformMatrix) {
+  'worklet'
+  t[2] = Math.round(t[2])
+  t[5] = Math.round(t[5])
+  // For example: 0.985, 0.99, 0.995, then 1:
+  t[0] = Math.round(t[0] * 200) / 200
+  t[4] = Math.round(t[0] * 200) / 200
+}
+
+// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
+// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
+
+// All write operations are applied in-place to avoid unnecessary allocations.
+
+export function readTransform(t: TransformMatrix): [number, number, number] {
+  'worklet'
+  const scale = t[0]
+  const translateX = t[2]
+  const translateY = t[5]
+  return [translateX, translateY, scale]
+}
+
+export function prependTranslate(t: TransformMatrix, x: number, y: number) {
+  'worklet'
+  t[2] += t[0] * x + t[1] * y
+  t[5] += t[3] * x + t[4] * y
+}
+
+export function prependScale(t: TransformMatrix, value: number) {
+  'worklet'
+  t[0] *= value
+  t[1] *= value
+  t[3] *= value
+  t[4] *= value
+}
+
+export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
+  'worklet'
+  // In-place matrix multiplication.
+  const a00 = ta[0],
+    a01 = ta[1],
+    a02 = ta[2]
+  const a10 = ta[3],
+    a11 = ta[4],
+    a12 = ta[5]
+  const a20 = ta[6],
+    a21 = ta[7],
+    a22 = ta[8]
+  ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
+  ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
+  ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
+  ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
+  ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
+  ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
+  ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
+  ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
+  ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
+}
+
+export function prependPan(t: TransformMatrix, translation: Position) {
+  'worklet'
+  prependTranslate(t, translation.x, translation.y)
+}
+
+export function prependPinch(
+  t: TransformMatrix,
+  scale: number,
+  origin: Position,
+  translation: Position,
+) {
+  'worklet'
+  prependTranslate(t, translation.x, translation.y)
+  prependTranslate(t, origin.x, origin.y)
+  prependScale(t, scale)
+  prependTranslate(t, -origin.x, -origin.y)
+}
diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts
deleted file mode 100644
index d56eea4f4..000000000
--- a/src/view/com/lightbox/ImageViewing/utils.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {Animated, NativeTouchEvent} from 'react-native'
-import {Dimensions, Position} from './@types'
-
-type CacheStorageItem = {key: string; value: any}
-
-export const createCache = (cacheSize: number) => ({
-  _storage: [] as CacheStorageItem[],
-  get(key: string): any {
-    const {value} =
-      this._storage.find(({key: storageKey}) => storageKey === key) || {}
-
-    return value
-  },
-  set(key: string, value: any) {
-    if (this._storage.length >= cacheSize) {
-      this._storage.shift()
-    }
-
-    this._storage.push({key, value})
-  },
-})
-
-export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] =>
-  arr.reduce((result, item) => {
-    const batch = result.pop() || []
-
-    if (batch.length < batchSize) {
-      batch.push(item)
-      result.push(batch)
-    } else {
-      result.push(batch, [item])
-    }
-
-    return result
-  }, [])
-
-export const getImageTransform = (
-  image: Dimensions | null,
-  screen: Dimensions,
-) => {
-  if (!image?.width || !image?.height) {
-    return [] as const
-  }
-
-  const wScale = screen.width / image.width
-  const hScale = screen.height / image.height
-  const scale = Math.min(wScale, hScale)
-  const {x, y} = getImageTranslate(image, screen)
-
-  return [{x, y}, scale] as const
-}
-
-export const getImageStyles = (
-  image: Dimensions | null,
-  translate: Animated.ValueXY,
-  scale?: Animated.Value,
-) => {
-  if (!image?.width || !image?.height) {
-    return {width: 0, height: 0}
-  }
-
-  const transform = translate.getTranslateTransform()
-
-  if (scale) {
-    // @ts-ignore TODO - is scale incorrect? might need to remove -prf
-    transform.push({scale}, {perspective: new Animated.Value(1000)})
-  }
-
-  return {
-    width: image.width,
-    height: image.height,
-    transform,
-  }
-}
-
-export const getImageTranslate = (
-  image: Dimensions,
-  screen: Dimensions,
-): Position => {
-  const getTranslateForAxis = (axis: 'x' | 'y'): number => {
-    const imageSize = axis === 'x' ? image.width : image.height
-    const screenSize = axis === 'x' ? screen.width : screen.height
-
-    return (screenSize - imageSize) / 2
-  }
-
-  return {
-    x: getTranslateForAxis('x'),
-    y: getTranslateForAxis('y'),
-  }
-}
-
-export const getImageDimensionsByTranslate = (
-  translate: Position,
-  screen: Dimensions,
-): Dimensions => ({
-  width: screen.width - translate.x * 2,
-  height: screen.height - translate.y * 2,
-})
-
-export const getImageTranslateForScale = (
-  currentTranslate: Position,
-  targetScale: number,
-  screen: Dimensions,
-): Position => {
-  const {width, height} = getImageDimensionsByTranslate(
-    currentTranslate,
-    screen,
-  )
-
-  const targetImageDimensions = {
-    width: width * targetScale,
-    height: height * targetScale,
-  }
-
-  return getImageTranslate(targetImageDimensions, screen)
-}
-
-export const getDistanceBetweenTouches = (
-  touches: NativeTouchEvent[],
-): number => {
-  const [a, b] = touches
-
-  if (a == null || b == null) {
-    return 0
-  }
-
-  return Math.sqrt(
-    Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2),
-  )
-}
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 072bfebfa..92c30f491 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -15,13 +15,48 @@ import * as MediaLibrary from 'expo-media-library'
 
 export const Lightbox = observer(function Lightbox() {
   const store = useStores()
-  const [isAltExpanded, setAltExpanded] = React.useState(false)
-  const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
-
   const onClose = React.useCallback(() => {
     store.shell.closeLightbox()
   }, [store])
 
+  if (!store.shell.activeLightbox) {
+    return null
+  } else if (store.shell.activeLightbox.name === 'profile-image') {
+    const opts = store.shell.activeLightbox as models.ProfileImageLightbox
+    return (
+      <ImageView
+        images={[{uri: opts.profileView.avatar || ''}]}
+        initialImageIndex={0}
+        visible
+        onRequestClose={onClose}
+        FooterComponent={LightboxFooter}
+      />
+    )
+  } else if (store.shell.activeLightbox.name === 'images') {
+    const opts = store.shell.activeLightbox as models.ImagesLightbox
+    return (
+      <ImageView
+        images={opts.images.map(img => ({...img}))}
+        initialImageIndex={opts.index}
+        visible
+        onRequestClose={onClose}
+        FooterComponent={LightboxFooter}
+      />
+    )
+  } else {
+    return null
+  }
+})
+
+const LightboxFooter = observer(function LightboxFooter({
+  imageIndex,
+}: {
+  imageIndex: number
+}) {
+  const store = useStores()
+  const [isAltExpanded, setAltExpanded] = React.useState(false)
+  const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
+
   const saveImageToAlbumWithToasts = React.useCallback(
     async (uri: string) => {
       if (!permissionResponse || permissionResponse.granted === false) {
@@ -46,90 +81,57 @@ export const Lightbox = observer(function Lightbox() {
     [permissionResponse, requestPermission],
   )
 
-  const LightboxFooter = React.useCallback(
-    ({imageIndex}: {imageIndex: number}) => {
-      const lightbox = store.shell.activeLightbox
-      if (!lightbox) {
-        return null
-      }
+  const lightbox = store.shell.activeLightbox
+  if (!lightbox) {
+    return null
+  }
 
-      let altText = ''
-      let uri = ''
-      if (lightbox.name === 'images') {
-        const opts = lightbox as models.ImagesLightbox
-        uri = opts.images[imageIndex].uri
-        altText = opts.images[imageIndex].alt || ''
-      } else if (lightbox.name === 'profile-image') {
-        const opts = lightbox as models.ProfileImageLightbox
-        uri = opts.profileView.avatar || ''
-      }
+  let altText = ''
+  let uri = ''
+  if (lightbox.name === 'images') {
+    const opts = lightbox as models.ImagesLightbox
+    uri = opts.images[imageIndex].uri
+    altText = opts.images[imageIndex].alt || ''
+  } else if (lightbox.name === 'profile-image') {
+    const opts = lightbox as models.ProfileImageLightbox
+    uri = opts.profileView.avatar || ''
+  }
 
-      return (
-        <View style={[styles.footer]}>
-          {altText ? (
-            <Pressable
-              onPress={() => setAltExpanded(!isAltExpanded)}
-              accessibilityRole="button">
-              <Text
-                style={[s.gray3, styles.footerText]}
-                numberOfLines={isAltExpanded ? undefined : 3}>
-                {altText}
-              </Text>
-            </Pressable>
-          ) : null}
-          <View style={styles.footerBtns}>
-            <Button
-              type="primary-outline"
-              style={styles.footerBtn}
-              onPress={() => saveImageToAlbumWithToasts(uri)}>
-              <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
-              <Text type="xl" style={s.white}>
-                Save
-              </Text>
-            </Button>
-            <Button
-              type="primary-outline"
-              style={styles.footerBtn}
-              onPress={() => shareImageModal({uri})}>
-              <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
-              <Text type="xl" style={s.white}>
-                Share
-              </Text>
-            </Button>
-          </View>
-        </View>
-      )
-    },
-    [store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts],
+  return (
+    <View style={[styles.footer]}>
+      {altText ? (
+        <Pressable
+          onPress={() => setAltExpanded(!isAltExpanded)}
+          accessibilityRole="button">
+          <Text
+            style={[s.gray3, styles.footerText]}
+            numberOfLines={isAltExpanded ? undefined : 3}>
+            {altText}
+          </Text>
+        </Pressable>
+      ) : null}
+      <View style={styles.footerBtns}>
+        <Button
+          type="primary-outline"
+          style={styles.footerBtn}
+          onPress={() => saveImageToAlbumWithToasts(uri)}>
+          <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
+          <Text type="xl" style={s.white}>
+            Save
+          </Text>
+        </Button>
+        <Button
+          type="primary-outline"
+          style={styles.footerBtn}
+          onPress={() => shareImageModal({uri})}>
+          <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
+          <Text type="xl" style={s.white}>
+            Share
+          </Text>
+        </Button>
+      </View>
+    </View>
   )
-
-  if (!store.shell.activeLightbox) {
-    return null
-  } else if (store.shell.activeLightbox.name === 'profile-image') {
-    const opts = store.shell.activeLightbox as models.ProfileImageLightbox
-    return (
-      <ImageView
-        images={[{uri: opts.profileView.avatar || ''}]}
-        imageIndex={0}
-        visible
-        onRequestClose={onClose}
-        FooterComponent={LightboxFooter}
-      />
-    )
-  } else if (store.shell.activeLightbox.name === 'images') {
-    const opts = store.shell.activeLightbox as models.ImagesLightbox
-    return (
-      <ImageView
-        images={opts.images.map(img => ({...img}))}
-        imageIndex={opts.index}
-        visible
-        onRequestClose={onClose}
-        FooterComponent={LightboxFooter}
-      />
-    )
-  } else {
-    return null
-  }
 })
 
 const styles = StyleSheet.create({
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
index c92dabdca..012570556 100644
--- a/src/view/com/modals/ChangeEmail.tsx
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -1,11 +1,5 @@
 import React, {useState} from 'react'
-import {
-  ActivityIndicator,
-  KeyboardAvoidingView,
-  SafeAreaView,
-  StyleSheet,
-  View,
-} from 'react-native'
+import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
 import {ScrollView, TextInput} from './util'
 import {observer} from 'mobx-react-lite'
 import {Text} from '../util/text/Text'
@@ -101,142 +95,134 @@ export const Component = observer(function Component({}: {}) {
   }
 
   return (
-    <KeyboardAvoidingView
-      behavior="padding"
-      style={[pal.view, styles.container]}>
-      <SafeAreaView style={s.flex1}>
-        <ScrollView
-          testID="changeEmailModal"
-          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
-          <View style={styles.titleSection}>
-            <Text type="title-lg" style={[pal.text, styles.title]}>
-              {stage === Stages.InputEmail ? 'Change Your Email' : ''}
-              {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
-              {stage === Stages.Done ? 'Email Updated' : ''}
-            </Text>
-          </View>
-
-          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
-            {stage === Stages.InputEmail ? (
-              <>Enter your new email address below.</>
-            ) : stage === Stages.ConfirmCode ? (
-              <>
-                An email has been sent to your previous address,{' '}
-                {store.session.currentSession?.email || ''}. It includes a
-                confirmation code which you can enter below.
-              </>
-            ) : (
-              <>
-                Your email has been updated but not verified. As a next step,
-                please verify your new email.
-              </>
-            )}
+    <SafeAreaView style={[pal.view, s.flex1]}>
+      <ScrollView
+        testID="changeEmailModal"
+        style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+        <View style={styles.titleSection}>
+          <Text type="title-lg" style={[pal.text, styles.title]}>
+            {stage === Stages.InputEmail ? 'Change Your Email' : ''}
+            {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
+            {stage === Stages.Done ? 'Email Updated' : ''}
           </Text>
+        </View>
 
-          {stage === Stages.InputEmail && (
-            <TextInput
-              testID="emailInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder="alice@mail.com"
-              placeholderTextColor={pal.colors.textLight}
-              value={email}
-              onChangeText={setEmail}
-              accessible={true}
-              accessibilityLabel="Email"
-              accessibilityHint=""
-              autoCapitalize="none"
-              autoComplete="email"
-              autoCorrect={false}
-            />
-          )}
-          {stage === Stages.ConfirmCode && (
-            <TextInput
-              testID="confirmCodeInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder="XXXXX-XXXXX"
-              placeholderTextColor={pal.colors.textLight}
-              value={confirmationCode}
-              onChangeText={setConfirmationCode}
-              accessible={true}
-              accessibilityLabel="Confirmation code"
-              accessibilityHint=""
-              autoCapitalize="none"
-              autoComplete="off"
-              autoCorrect={false}
-            />
+        <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+          {stage === Stages.InputEmail ? (
+            <>Enter your new email address below.</>
+          ) : stage === Stages.ConfirmCode ? (
+            <>
+              An email has been sent to your previous address,{' '}
+              {store.session.currentSession?.email || ''}. It includes a
+              confirmation code which you can enter below.
+            </>
+          ) : (
+            <>
+              Your email has been updated but not verified. As a next step,
+              please verify your new email.
+            </>
           )}
+        </Text>
 
-          {error ? (
-            <ErrorMessage message={error} style={styles.error} />
-          ) : undefined}
+        {stage === Stages.InputEmail && (
+          <TextInput
+            testID="emailInput"
+            style={[styles.textInput, pal.border, pal.text]}
+            placeholder="alice@mail.com"
+            placeholderTextColor={pal.colors.textLight}
+            value={email}
+            onChangeText={setEmail}
+            accessible={true}
+            accessibilityLabel="Email"
+            accessibilityHint=""
+            autoCapitalize="none"
+            autoComplete="email"
+            autoCorrect={false}
+          />
+        )}
+        {stage === Stages.ConfirmCode && (
+          <TextInput
+            testID="confirmCodeInput"
+            style={[styles.textInput, pal.border, pal.text]}
+            placeholder="XXXXX-XXXXX"
+            placeholderTextColor={pal.colors.textLight}
+            value={confirmationCode}
+            onChangeText={setConfirmationCode}
+            accessible={true}
+            accessibilityLabel="Confirmation code"
+            accessibilityHint=""
+            autoCapitalize="none"
+            autoComplete="off"
+            autoCorrect={false}
+          />
+        )}
 
-          <View style={[styles.btnContainer]}>
-            {isProcessing ? (
-              <View style={styles.btn}>
-                <ActivityIndicator color="#fff" />
-              </View>
-            ) : (
-              <View style={{gap: 6}}>
-                {stage === Stages.InputEmail && (
-                  <Button
-                    testID="requestChangeBtn"
-                    type="primary"
-                    onPress={onRequestChange}
-                    accessibilityLabel="Request Change"
-                    accessibilityHint=""
-                    label="Request Change"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                    labelStyle={[s.f18]}
-                  />
-                )}
-                {stage === Stages.ConfirmCode && (
-                  <Button
-                    testID="confirmBtn"
-                    type="primary"
-                    onPress={onConfirm}
-                    accessibilityLabel="Confirm Change"
-                    accessibilityHint=""
-                    label="Confirm Change"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                    labelStyle={[s.f18]}
-                  />
-                )}
-                {stage === Stages.Done && (
-                  <Button
-                    testID="verifyBtn"
-                    type="primary"
-                    onPress={onVerify}
-                    accessibilityLabel="Verify New Email"
-                    accessibilityHint=""
-                    label="Verify New Email"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                    labelStyle={[s.f18]}
-                  />
-                )}
+        {error ? (
+          <ErrorMessage message={error} style={styles.error} />
+        ) : undefined}
+
+        <View style={[styles.btnContainer]}>
+          {isProcessing ? (
+            <View style={styles.btn}>
+              <ActivityIndicator color="#fff" />
+            </View>
+          ) : (
+            <View style={{gap: 6}}>
+              {stage === Stages.InputEmail && (
+                <Button
+                  testID="requestChangeBtn"
+                  type="primary"
+                  onPress={onRequestChange}
+                  accessibilityLabel="Request Change"
+                  accessibilityHint=""
+                  label="Request Change"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              {stage === Stages.ConfirmCode && (
                 <Button
-                  testID="cancelBtn"
-                  type="default"
-                  onPress={() => store.shell.closeModal()}
-                  accessibilityLabel="Cancel"
+                  testID="confirmBtn"
+                  type="primary"
+                  onPress={onConfirm}
+                  accessibilityLabel="Confirm Change"
                   accessibilityHint=""
-                  label="Cancel"
+                  label="Confirm Change"
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
-              </View>
-            )}
-          </View>
-        </ScrollView>
-      </SafeAreaView>
-    </KeyboardAvoidingView>
+              )}
+              {stage === Stages.Done && (
+                <Button
+                  testID="verifyBtn"
+                  type="primary"
+                  onPress={onVerify}
+                  accessibilityLabel="Verify New Email"
+                  accessibilityHint=""
+                  label="Verify New Email"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              <Button
+                testID="cancelBtn"
+                type="default"
+                onPress={() => store.shell.closeModal()}
+                accessibilityLabel="Cancel"
+                accessibilityHint=""
+                label="Cancel"
+                labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                labelStyle={[s.f18]}
+              />
+            </View>
+          )}
+        </View>
+      </ScrollView>
+    </SafeAreaView>
   )
 })
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingBottom: isWeb ? 0 : 40,
-  },
   titleSection: {
     paddingTop: isWeb ? 0 : 4,
     paddingBottom: isWeb ? 14 : 10,
diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx
index 3f3cfc5f0..4a440afeb 100644
--- a/src/view/com/modals/CreateOrEditMuteList.tsx
+++ b/src/view/com/modals/CreateOrEditMuteList.tsx
@@ -18,7 +18,7 @@ import {ListModel} from 'state/models/content/list'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {compressIfNeeded} from 'lib/media/manip'
-import {UserAvatar} from '../util/UserAvatar'
+import {EditableUserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
@@ -148,7 +148,7 @@ export function Component({
         )}
         <Text style={[styles.label, pal.text]}>List Avatar</Text>
         <View style={[styles.avi, {borderColor: pal.colors.background}]}>
-          <UserAvatar
+          <EditableUserAvatar
             type="list"
             size={80}
             avatar={avatar}
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 620aad9fc..58d0857ad 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -20,7 +20,7 @@ import {enforceLen} from 'lib/strings/helpers'
 import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
 import {compressIfNeeded} from 'lib/media/manip'
 import {UserBanner} from '../util/UserBanner'
-import {UserAvatar} from '../util/UserAvatar'
+import {EditableUserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
@@ -153,7 +153,7 @@ export function Component({
             onSelectNewBanner={onSelectNewBanner}
           />
           <View style={[styles.avi, {borderColor: pal.colors.background}]}>
-            <UserAvatar
+            <EditableUserAvatar
               size={80}
               avatar={userAvatar}
               onSelectNewAvatar={onSelectNewAvatar}
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 4f3f424a3..1fe1299d7 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,11 +1,12 @@
 import React, {useRef, useEffect} from 'react'
 import {StyleSheet} from 'react-native'
-import {SafeAreaView} from 'react-native-safe-area-context'
+import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'
 import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
 import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
+import {timeout} from 'lib/async/timeout'
 import {navigate} from '../../../Navigation'
 import once from 'lodash.once'
 
@@ -36,11 +37,13 @@ import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
 
 const DEFAULT_SNAPPOINTS = ['90%']
+const HANDLE_HEIGHT = 24
 
 export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
   const bottomSheetRef = useRef<BottomSheet>(null)
   const pal = usePalette('default')
+  const safeAreaInsets = useSafeAreaInsets()
 
   const activeModal =
     store.shell.activeModals[store.shell.activeModals.length - 1]
@@ -53,12 +56,16 @@ export const ModalsContainer = observer(function ModalsContainer() {
       navigateOnce('Profile', {name: activeModal.did})
     }
   }
-  const onBottomSheetChange = (snapPoint: number) => {
+  const onBottomSheetChange = async (snapPoint: number) => {
     if (snapPoint === -1) {
       store.shell.closeModal()
     } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
-      // ensure we navigate to Profile and close the modal
-      navigateOnce('Profile', {name: activeModal.did})
+      await navigateOnce('Profile', {name: activeModal.did})
+      // There is no particular callback for when the view has actually been presented.
+      // This delay gives us a decent chance the navigation has flushed *and* images have loaded.
+      // It's acceptable because the data is already being fetched + it usually takes longer anyway.
+      // TODO: Figure out why avatar/cover don't always show instantly from cache.
+      await timeout(200)
       store.shell.closeModal()
     }
   }
@@ -75,6 +82,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
     }
   }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name])
 
+  let needsSafeTopInset = false
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
   let element
   if (activeModal?.name === 'confirm') {
@@ -86,6 +94,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'profile-preview') {
     snapPoints = ProfilePreviewModal.snapPoints
     element = <ProfilePreviewModal.Component {...activeModal} />
+    needsSafeTopInset = true // Need to align with the target profile screen.
   } else if (activeModal?.name === 'server-input') {
     snapPoints = ServerInputModal.snapPoints
     element = <ServerInputModal.Component {...activeModal} />
@@ -164,10 +173,13 @@ export const ModalsContainer = observer(function ModalsContainer() {
     )
   }
 
+  const topInset = needsSafeTopInset ? safeAreaInsets.top - HANDLE_HEIGHT : 0
   return (
     <BottomSheet
       ref={bottomSheetRef}
       snapPoints={snapPoints}
+      topInset={topInset}
+      handleHeight={HANDLE_HEIGHT}
       index={store.shell.isModalActive ? 0 : -1}
       enablePanDownToClose
       android_keyboardInputMode="adjustResize"
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index 225a3972b..dad02aa5e 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -9,7 +9,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {ProfileHeader} from '../profile/ProfileHeader'
 import {InfoCircleIcon} from 'lib/icons'
 import {useNavigationState} from '@react-navigation/native'
-import {isIOS} from 'platform/detection'
 import {s} from 'lib/styles'
 
 export const snapPoints = [520, '100%']
@@ -36,11 +35,7 @@ export const Component = observer(function ProfilePreviewImpl({
 
   return (
     <View testID="profilePreview" style={[pal.view, s.flex1]}>
-      <View
-        style={[
-          styles.headerWrapper,
-          isLoading && isIOS && styles.headerPositionAdjust,
-        ]}>
+      <View style={[styles.headerWrapper]}>
         <ProfileHeader
           view={model}
           hideBackButton
@@ -70,10 +65,6 @@ const styles = StyleSheet.create({
   headerWrapper: {
     height: 440,
   },
-  headerPositionAdjust: {
-    // HACK align the header for the profilescreen transition -prf
-    paddingTop: 23,
-  },
   hintWrapper: {
     height: 80,
   },
diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx
index 51d75e3ef..d5fa32692 100644
--- a/src/view/com/modals/SwitchAccount.tsx
+++ b/src/view/com/modals/SwitchAccount.tsx
@@ -37,74 +37,69 @@ export function Component({}: {}) {
   }, [track, store])
 
   return (
-    <View style={[styles.container, pal.view]}>
+    <BottomSheetScrollView
+      style={[styles.container, pal.view]}
+      contentContainerStyle={[styles.innerContainer, pal.view]}>
       <Text type="title-xl" style={[styles.title, pal.text]}>
         Switch Account
       </Text>
-      <BottomSheetScrollView
-        style={styles.container}
-        contentContainerStyle={[styles.innerContainer, pal.view]}>
-        {isSwitching ? (
+      {isSwitching ? (
+        <View style={[pal.view, styles.linkCard]}>
+          <ActivityIndicator />
+        </View>
+      ) : (
+        <Link href={makeProfileLink(store.me)} title="Your profile" noFeedback>
           <View style={[pal.view, styles.linkCard]}>
-            <ActivityIndicator />
-          </View>
-        ) : (
-          <Link
-            href={makeProfileLink(store.me)}
-            title="Your profile"
-            noFeedback>
-            <View style={[pal.view, styles.linkCard]}>
-              <View style={styles.avi}>
-                <UserAvatar size={40} avatar={store.me.avatar} />
-              </View>
-              <View style={[s.flex1]}>
-                <Text type="md-bold" style={pal.text} numberOfLines={1}>
-                  {store.me.displayName || store.me.handle}
-                </Text>
-                <Text type="sm" style={pal.textLight} numberOfLines={1}>
-                  {store.me.handle}
-                </Text>
-              </View>
-              <TouchableOpacity
-                testID="signOutBtn"
-                onPress={isSwitching ? undefined : onPressSignout}
-                accessibilityRole="button"
-                accessibilityLabel="Sign out"
-                accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
-                <Text type="lg" style={pal.link}>
-                  Sign out
-                </Text>
-              </TouchableOpacity>
-            </View>
-          </Link>
-        )}
-        {store.session.switchableAccounts.map(account => (
-          <TouchableOpacity
-            testID={`switchToAccountBtn-${account.handle}`}
-            key={account.did}
-            style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
-            onPress={
-              isSwitching ? undefined : () => onPressSwitchAccount(account)
-            }
-            accessibilityRole="button"
-            accessibilityLabel={`Switch to ${account.handle}`}
-            accessibilityHint="Switches the account you are logged in to">
             <View style={styles.avi}>
-              <UserAvatar size={40} avatar={account.aviUrl} />
+              <UserAvatar size={40} avatar={store.me.avatar} />
             </View>
             <View style={[s.flex1]}>
-              <Text type="md-bold" style={pal.text}>
-                {account.displayName || account.handle}
+              <Text type="md-bold" style={pal.text} numberOfLines={1}>
+                {store.me.displayName || store.me.handle}
               </Text>
-              <Text type="sm" style={pal.textLight}>
-                {account.handle}
+              <Text type="sm" style={pal.textLight} numberOfLines={1}>
+                {store.me.handle}
               </Text>
             </View>
-            <AccountDropdownBtn handle={account.handle} />
-          </TouchableOpacity>
-        ))}
-      </BottomSheetScrollView>
-    </View>
+            <TouchableOpacity
+              testID="signOutBtn"
+              onPress={isSwitching ? undefined : onPressSignout}
+              accessibilityRole="button"
+              accessibilityLabel="Sign out"
+              accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
+              <Text type="lg" style={pal.link}>
+                Sign out
+              </Text>
+            </TouchableOpacity>
+          </View>
+        </Link>
+      )}
+      {store.session.switchableAccounts.map(account => (
+        <TouchableOpacity
+          testID={`switchToAccountBtn-${account.handle}`}
+          key={account.did}
+          style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
+          onPress={
+            isSwitching ? undefined : () => onPressSwitchAccount(account)
+          }
+          accessibilityRole="button"
+          accessibilityLabel={`Switch to ${account.handle}`}
+          accessibilityHint="Switches the account you are logged in to">
+          <View style={styles.avi}>
+            <UserAvatar size={40} avatar={account.aviUrl} />
+          </View>
+          <View style={[s.flex1]}>
+            <Text type="md-bold" style={pal.text}>
+              {account.displayName || account.handle}
+            </Text>
+            <Text type="sm" style={pal.textLight}>
+              {account.handle}
+            </Text>
+          </View>
+          <AccountDropdownBtn handle={account.handle} />
+        </TouchableOpacity>
+      ))}
+    </BottomSheetScrollView>
   )
 }
 
@@ -113,7 +108,7 @@ const styles = StyleSheet.create({
     flex: 1,
   },
   innerContainer: {
-    paddingBottom: 20,
+    paddingBottom: 40,
   },
   title: {
     textAlign: 'center',
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index 0a626a4ef..9fe8811b0 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -1,7 +1,6 @@
 import React, {useState} from 'react'
 import {
   ActivityIndicator,
-  KeyboardAvoidingView,
   Pressable,
   SafeAreaView,
   StyleSheet,
@@ -82,169 +81,163 @@ export const Component = observer(function Component({
   }
 
   return (
-    <KeyboardAvoidingView
-      behavior="padding"
-      style={[pal.view, styles.container]}>
-      <SafeAreaView style={s.flex1}>
-        <ScrollView
-          testID="verifyEmailModal"
-          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
-          {stage === Stages.Reminder && <ReminderIllustration />}
-          <View style={styles.titleSection}>
-            <Text type="title-lg" style={[pal.text, styles.title]}>
-              {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
-              {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
-              {stage === Stages.Email ? 'Verify Your Email' : ''}
-            </Text>
-          </View>
-
-          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
-            {stage === Stages.Reminder ? (
-              <>
-                Your email has not yet been verified. This is an important
-                security step which we recommend.
-              </>
-            ) : stage === Stages.Email ? (
-              <>
-                This is important in case you ever need to change your email or
-                reset your password.
-              </>
-            ) : stage === Stages.ConfirmCode ? (
-              <>
-                An email has been sent to{' '}
-                {store.session.currentSession?.email || ''}. It includes a
-                confirmation code which you can enter below.
-              </>
-            ) : (
-              ''
-            )}
+    <SafeAreaView style={[pal.view, s.flex1]}>
+      <ScrollView
+        testID="verifyEmailModal"
+        style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+        {stage === Stages.Reminder && <ReminderIllustration />}
+        <View style={styles.titleSection}>
+          <Text type="title-lg" style={[pal.text, styles.title]}>
+            {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
+            {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
+            {stage === Stages.Email ? 'Verify Your Email' : ''}
           </Text>
+        </View>
 
-          {stage === Stages.Email ? (
+        <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+          {stage === Stages.Reminder ? (
             <>
-              <View style={styles.emailContainer}>
-                <FontAwesomeIcon
-                  icon="envelope"
-                  color={pal.colors.text}
-                  size={16}
-                />
-                <Text
-                  type="xl-medium"
-                  style={[pal.text, s.flex1, {minWidth: 0}]}>
-                  {store.session.currentSession?.email || ''}
-                </Text>
-              </View>
-              <Pressable
-                accessibilityRole="link"
-                accessibilityLabel="Change my email"
-                accessibilityHint=""
-                onPress={onEmailIncorrect}
-                style={styles.changeEmailLink}>
-                <Text type="lg" style={pal.link}>
-                  Change
-                </Text>
-              </Pressable>
+              Your email has not yet been verified. This is an important
+              security step which we recommend.
+            </>
+          ) : stage === Stages.Email ? (
+            <>
+              This is important in case you ever need to change your email or
+              reset your password.
             </>
           ) : stage === Stages.ConfirmCode ? (
-            <TextInput
-              testID="confirmCodeInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder="XXXXX-XXXXX"
-              placeholderTextColor={pal.colors.textLight}
-              value={confirmationCode}
-              onChangeText={setConfirmationCode}
-              accessible={true}
-              accessibilityLabel="Confirmation code"
+            <>
+              An email has been sent to{' '}
+              {store.session.currentSession?.email || ''}. It includes a
+              confirmation code which you can enter below.
+            </>
+          ) : (
+            ''
+          )}
+        </Text>
+
+        {stage === Stages.Email ? (
+          <>
+            <View style={styles.emailContainer}>
+              <FontAwesomeIcon
+                icon="envelope"
+                color={pal.colors.text}
+                size={16}
+              />
+              <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
+                {store.session.currentSession?.email || ''}
+              </Text>
+            </View>
+            <Pressable
+              accessibilityRole="link"
+              accessibilityLabel="Change my email"
               accessibilityHint=""
-              autoCapitalize="none"
-              autoComplete="off"
-              autoCorrect={false}
-            />
-          ) : undefined}
+              onPress={onEmailIncorrect}
+              style={styles.changeEmailLink}>
+              <Text type="lg" style={pal.link}>
+                Change
+              </Text>
+            </Pressable>
+          </>
+        ) : stage === Stages.ConfirmCode ? (
+          <TextInput
+            testID="confirmCodeInput"
+            style={[styles.textInput, pal.border, pal.text]}
+            placeholder="XXXXX-XXXXX"
+            placeholderTextColor={pal.colors.textLight}
+            value={confirmationCode}
+            onChangeText={setConfirmationCode}
+            accessible={true}
+            accessibilityLabel="Confirmation code"
+            accessibilityHint=""
+            autoCapitalize="none"
+            autoComplete="off"
+            autoCorrect={false}
+          />
+        ) : undefined}
 
-          {error ? (
-            <ErrorMessage message={error} style={styles.error} />
-          ) : undefined}
+        {error ? (
+          <ErrorMessage message={error} style={styles.error} />
+        ) : undefined}
 
-          <View style={[styles.btnContainer]}>
-            {isProcessing ? (
-              <View style={styles.btn}>
-                <ActivityIndicator color="#fff" />
-              </View>
-            ) : (
-              <View style={{gap: 6}}>
-                {stage === Stages.Reminder && (
+        <View style={[styles.btnContainer]}>
+          {isProcessing ? (
+            <View style={styles.btn}>
+              <ActivityIndicator color="#fff" />
+            </View>
+          ) : (
+            <View style={{gap: 6}}>
+              {stage === Stages.Reminder && (
+                <Button
+                  testID="getStartedBtn"
+                  type="primary"
+                  onPress={() => setStage(Stages.Email)}
+                  accessibilityLabel="Get Started"
+                  accessibilityHint=""
+                  label="Get Started"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              {stage === Stages.Email && (
+                <>
                   <Button
-                    testID="getStartedBtn"
+                    testID="sendEmailBtn"
                     type="primary"
-                    onPress={() => setStage(Stages.Email)}
-                    accessibilityLabel="Get Started"
+                    onPress={onSendEmail}
+                    accessibilityLabel="Send Confirmation Email"
                     accessibilityHint=""
-                    label="Get Started"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    label="Send Confirmation Email"
+                    labelContainerStyle={{
+                      justifyContent: 'center',
+                      padding: 4,
+                    }}
                     labelStyle={[s.f18]}
                   />
-                )}
-                {stage === Stages.Email && (
-                  <>
-                    <Button
-                      testID="sendEmailBtn"
-                      type="primary"
-                      onPress={onSendEmail}
-                      accessibilityLabel="Send Confirmation Email"
-                      accessibilityHint=""
-                      label="Send Confirmation Email"
-                      labelContainerStyle={{
-                        justifyContent: 'center',
-                        padding: 4,
-                      }}
-                      labelStyle={[s.f18]}
-                    />
-                    <Button
-                      testID="haveCodeBtn"
-                      type="default"
-                      accessibilityLabel="I have a code"
-                      accessibilityHint=""
-                      label="I have a confirmation code"
-                      labelContainerStyle={{
-                        justifyContent: 'center',
-                        padding: 4,
-                      }}
-                      labelStyle={[s.f18]}
-                      onPress={() => setStage(Stages.ConfirmCode)}
-                    />
-                  </>
-                )}
-                {stage === Stages.ConfirmCode && (
                   <Button
-                    testID="confirmBtn"
-                    type="primary"
-                    onPress={onConfirm}
-                    accessibilityLabel="Confirm"
+                    testID="haveCodeBtn"
+                    type="default"
+                    accessibilityLabel="I have a code"
                     accessibilityHint=""
-                    label="Confirm"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    label="I have a confirmation code"
+                    labelContainerStyle={{
+                      justifyContent: 'center',
+                      padding: 4,
+                    }}
                     labelStyle={[s.f18]}
+                    onPress={() => setStage(Stages.ConfirmCode)}
                   />
-                )}
+                </>
+              )}
+              {stage === Stages.ConfirmCode && (
                 <Button
-                  testID="cancelBtn"
-                  type="default"
-                  onPress={() => store.shell.closeModal()}
-                  accessibilityLabel={
-                    stage === Stages.Reminder ? 'Not right now' : 'Cancel'
-                  }
+                  testID="confirmBtn"
+                  type="primary"
+                  onPress={onConfirm}
+                  accessibilityLabel="Confirm"
                   accessibilityHint=""
-                  label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                  label="Confirm"
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
-              </View>
-            )}
-          </View>
-        </ScrollView>
-      </SafeAreaView>
-    </KeyboardAvoidingView>
+              )}
+              <Button
+                testID="cancelBtn"
+                type="default"
+                onPress={() => store.shell.closeModal()}
+                accessibilityLabel={
+                  stage === Stages.Reminder ? 'Not right now' : 'Cancel'
+                }
+                accessibilityHint=""
+                label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                labelStyle={[s.f18]}
+              />
+            </View>
+          )}
+        </View>
+      </ScrollView>
+    </SafeAreaView>
   )
 })
 
@@ -274,10 +267,6 @@ function ReminderIllustration() {
 }
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingBottom: isWeb ? 0 : 40,
-  },
   titleSection: {
     paddingTop: isWeb ? 0 : 4,
     paddingBottom: isWeb ? 14 : 10,
diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx
index 1104c0a39..0fb371fe4 100644
--- a/src/view/com/modals/Waitlist.tsx
+++ b/src/view/com/modals/Waitlist.tsx
@@ -77,6 +77,8 @@ export function Component({}: {}) {
           keyboardAppearance={theme.colorScheme}
           value={email}
           onChangeText={setEmail}
+          onSubmitEditing={onPressSignup}
+          enterKeyHint="done"
           accessible={true}
           accessibilityLabel="Email"
           accessibilityHint="Input your email to get on the Bluesky waitlist"
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index c5959cf4c..8e35201d1 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -100,7 +100,7 @@ export function Component({
           accessibilityHint="Sets image aspect ratio to wide">
           <RectWideIcon
             size={24}
-            style={as === AspectRatio.Wide ? s.blue3 : undefined}
+            style={as === AspectRatio.Wide ? s.blue3 : pal.text}
           />
         </TouchableOpacity>
         <TouchableOpacity
@@ -110,7 +110,7 @@ export function Component({
           accessibilityHint="Sets image aspect ratio to tall">
           <RectTallIcon
             size={24}
-            style={as === AspectRatio.Tall ? s.blue3 : undefined}
+            style={as === AspectRatio.Tall ? s.blue3 : pal.text}
           />
         </TouchableOpacity>
         <TouchableOpacity
@@ -120,7 +120,7 @@ export function Component({
           accessibilityHint="Sets image aspect ratio to square">
           <SquareIcon
             size={24}
-            style={as === AspectRatio.Square ? s.blue3 : undefined}
+            style={as === AspectRatio.Square ? s.blue3 : pal.text}
           />
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx
index 89a0da47f..aaf358b87 100644
--- a/src/view/com/notifications/InvitedUsers.tsx
+++ b/src/view/com/notifications/InvitedUsers.tsx
@@ -75,7 +75,7 @@ function InvitedUser({
           <FollowButton
             unfollowedType="primary"
             followedType="primary-light"
-            did={profile.did}
+            profile={profile}
           />
           <Button
             testID="dismissBtn"
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 02aa623cc..dc91bd296 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -1,13 +1,14 @@
 import React, {useMemo} from 'react'
-import {Animated, StyleSheet} from 'react-native'
+import {StyleSheet} from 'react-native'
+import Animated from 'react-native-reanimated'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
@@ -31,26 +32,12 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
     [store.me.savedFeeds.pinnedFeedNames],
   )
   const pal = usePalette('default')
-  const interp = useAnimatedValue(0)
-
-  React.useEffect(() => {
-    Animated.timing(interp, {
-      toValue: store.shell.minimalShellMode ? 1 : 0,
-      duration: 100,
-      useNativeDriver: true,
-      isInteraction: false,
-    }).start()
-  }, [interp, store.shell.minimalShellMode])
-  const transform = {
-    transform: [
-      {translateX: '-50%'},
-      {translateY: Animated.multiply(interp, -100)},
-    ],
-  }
+  const {headerMinimalShellTransform} = useMinimalShellMode()
 
   return (
     // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
-    <Animated.View style={[pal.view, styles.tabBar, transform]}>
+    <Animated.View
+      style={[pal.view, styles.tabBar, headerMinimalShellTransform]}>
       <TabBar
         key={items.join(',')}
         {...props}
@@ -65,7 +52,8 @@ const styles = StyleSheet.create({
   tabBar: {
     position: 'absolute',
     zIndex: 1,
-    left: '50%',
+    // @ts-ignore Web only -prf
+    left: 'calc(50% - 299px)',
     width: 598,
     top: 0,
     flexDirection: 'row',
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index e39e2dd68..d8579badc 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -1,11 +1,10 @@
 import React, {useMemo} from 'react'
-import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
@@ -13,27 +12,17 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
 import {HITSLOP_10} from 'lib/constants'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
 
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const store = useStores()
   const pal = usePalette('default')
-  const interp = useAnimatedValue(0)
-
-  React.useEffect(() => {
-    Animated.timing(interp, {
-      toValue: store.shell.minimalShellMode ? 1 : 0,
-      duration: 100,
-      useNativeDriver: true,
-      isInteraction: false,
-    }).start()
-  }, [interp, store.shell.minimalShellMode])
-  const transform = {
-    transform: [{translateY: Animated.multiply(interp, -100)}],
-  }
 
   const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
+  const {headerMinimalShellTransform} = useMinimalShellMode()
 
   const onPressAvi = React.useCallback(() => {
     store.shell.openDrawer()
@@ -44,8 +33,19 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
     [store.me.savedFeeds.pinnedFeedNames],
   )
 
+  const tabBarKey = useMemo(() => {
+    return items.join(',')
+  }, [items])
+
   return (
-    <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
+    <Animated.View
+      style={[
+        pal.view,
+        pal.border,
+        styles.tabBar,
+        headerMinimalShellTransform,
+        store.shell.minimalShellMode && styles.disabled,
+      ]}>
       <View style={[pal.view, styles.topBar]}>
         <View style={[pal.view]}>
           <TouchableOpacity
@@ -81,8 +81,11 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
         </View>
       </View>
       <TabBar
-        key={items.join(',')}
-        {...props}
+        key={tabBarKey}
+        onPressSelected={props.onPressSelected}
+        selectedPage={props.selectedPage}
+        onSelect={props.onSelect}
+        testID={props.testID}
         items={items}
         indicatorColor={pal.colors.link}
       />
@@ -113,4 +116,7 @@ const styles = StyleSheet.create({
   title: {
     fontSize: 21,
   },
+  disabled: {
+    pointerEvents: 'none',
+  },
 })
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 319d28f95..8614bdf64 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -64,6 +64,7 @@ export function TabBar({
   )
 
   const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
+
   return (
     <View testID={testID} style={[pal.view, styles.outer]}>
       <DraggableScrollView
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index c53c2686c..378ef5028 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -439,5 +439,7 @@ const styles = StyleSheet.create({
   parentSpinner: {
     paddingVertical: 10,
   },
-  childSpinner: {},
+  childSpinner: {
+    paddingBottom: 200,
+  },
 })
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 55e69a318..74883f82a 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -33,6 +33,7 @@ export const Feed = observer(function Feed({
   onScroll,
   scrollEventThrottle,
   renderEmptyState,
+  renderEndOfFeed,
   testID,
   headerOffset = 0,
   ListHeaderComponent,
@@ -44,7 +45,8 @@ export const Feed = observer(function Feed({
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
   scrollEventThrottle?: number
-  renderEmptyState?: () => JSX.Element
+  renderEmptyState: () => JSX.Element
+  renderEndOfFeed?: () => JSX.Element
   testID?: string
   headerOffset?: number
   ListHeaderComponent?: () => JSX.Element
@@ -94,7 +96,7 @@ export const Feed = observer(function Feed({
   }, [feed, track, setIsRefreshing])
 
   const onEndReached = React.useCallback(async () => {
-    if (!feed.hasLoaded) return
+    if (!feed.hasLoaded || !feed.hasMore) return
 
     track('Feed:onEndReached')
     try {
@@ -114,10 +116,7 @@ export const Feed = observer(function Feed({
   const renderItem = React.useCallback(
     ({item}: {item: any}) => {
       if (item === EMPTY_FEED_ITEM) {
-        if (renderEmptyState) {
-          return renderEmptyState()
-        }
-        return <View />
+        return renderEmptyState()
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
@@ -142,14 +141,16 @@ export const Feed = observer(function Feed({
 
   const FeedFooter = React.useCallback(
     () =>
-      feed.isLoading ? (
+      feed.isLoadingMore ? (
         <View style={styles.feedFooter}>
           <ActivityIndicator />
         </View>
+      ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? (
+        renderEndOfFeed()
       ) : (
         <View />
       ),
-    [feed],
+    [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed],
   )
 
   return (
@@ -177,7 +178,7 @@ export const Feed = observer(function Feed({
         scrollEventThrottle={scrollEventThrottle}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         onEndReached={onEndReached}
-        onEndReachedThreshold={0.6}
+        onEndReachedThreshold={2}
         removeClippedSubviews={true}
         contentOffset={{x: 0, y: headerOffset * -1}}
         extraData={extraData}
diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
index a73ffb68b..61a27e48e 100644
--- a/src/view/com/posts/FollowingEmptyState.tsx
+++ b/src/view/com/posts/FollowingEmptyState.tsx
@@ -28,60 +28,73 @@ export function FollowingEmptyState() {
   }, [navigation])
 
   const onPressDiscoverFeeds = React.useCallback(() => {
-    navigation.navigate('Feeds')
+    if (isWeb) {
+      navigation.navigate('Feeds')
+    } else {
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+    }
   }, [navigation])
 
   return (
-    <View style={styles.emptyContainer}>
-      <View style={styles.emptyIconContainer}>
-        <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
-      </View>
-      <Text type="xl-medium" style={[s.textCenter, pal.text]}>
-        Your following feed is empty! Find some accounts to follow to fix this.
-      </Text>
-      <Button
-        type="inverted"
-        style={styles.emptyBtn}
-        onPress={onPressFindAccounts}>
-        <Text type="lg-medium" style={palInverted.text}>
-          Find accounts to follow
+    <View style={styles.container}>
+      <View style={styles.inner}>
+        <View style={styles.iconContainer}>
+          <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} />
+        </View>
+        <Text type="xl-medium" style={[s.textCenter, pal.text]}>
+          Your following feed is empty! Follow more users to see what's
+          happening.
         </Text>
-        <FontAwesomeIcon
-          icon="angle-right"
-          style={palInverted.text as FontAwesomeIconStyle}
-          size={14}
-        />
-      </Button>
+        <Button
+          type="inverted"
+          style={styles.emptyBtn}
+          onPress={onPressFindAccounts}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Find accounts to follow
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
 
-      <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
-        You can also discover new Custom Feeds to follow.
-      </Text>
-      <Button
-        type="inverted"
-        style={[styles.emptyBtn, s.mt10]}
-        onPress={onPressDiscoverFeeds}>
-        <Text type="lg-medium" style={palInverted.text}>
-          Discover new custom feeds
+        <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
+          You can also discover new Custom Feeds to follow.
         </Text>
-        <FontAwesomeIcon
-          icon="angle-right"
-          style={palInverted.text as FontAwesomeIconStyle}
-          size={14}
-        />
-      </Button>
+        <Button
+          type="inverted"
+          style={[styles.emptyBtn, s.mt10]}
+          onPress={onPressDiscoverFeeds}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Discover new custom feeds
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
+      </View>
     </View>
   )
 }
 const styles = StyleSheet.create({
-  emptyContainer: {
+  container: {
     height: '100%',
+    flexDirection: 'row',
+    justifyContent: 'center',
     paddingVertical: 40,
     paddingHorizontal: 30,
   },
-  emptyIconContainer: {
+  inner: {
+    maxWidth: 460,
+  },
+  iconContainer: {
     marginBottom: 16,
   },
-  emptyIcon: {
+  icon: {
     marginLeft: 'auto',
     marginRight: 'auto',
   },
@@ -94,13 +107,4 @@ const styles = StyleSheet.create({
     paddingHorizontal: 24,
     borderRadius: 30,
   },
-
-  feedsTip: {
-    position: 'absolute',
-    left: 22,
-  },
-  feedsTipArrow: {
-    marginLeft: 32,
-    marginTop: 8,
-  },
 })
diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx
new file mode 100644
index 000000000..48724d8b3
--- /dev/null
+++ b/src/view/com/posts/FollowingEndOfFeed.tsx
@@ -0,0 +1,100 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {NavigationProp} from 'lib/routes/types'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+
+export function FollowingEndOfFeed() {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressFindAccounts = React.useCallback(() => {
+    if (isWeb) {
+      navigation.navigate('Search', {})
+    } else {
+      navigation.navigate('SearchTab')
+      navigation.popToTop()
+    }
+  }, [navigation])
+
+  const onPressDiscoverFeeds = React.useCallback(() => {
+    if (isWeb) {
+      navigation.navigate('Feeds')
+    } else {
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+    }
+  }, [navigation])
+
+  return (
+    <View style={[styles.container, pal.border]}>
+      <View style={styles.inner}>
+        <Text type="xl-medium" style={[s.textCenter, pal.text]}>
+          You've reached the end of your feed! Find some more accounts to
+          follow.
+        </Text>
+        <Button
+          type="inverted"
+          style={styles.emptyBtn}
+          onPress={onPressFindAccounts}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Find accounts to follow
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
+
+        <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
+          You can also discover new Custom Feeds to follow.
+        </Text>
+        <Button
+          type="inverted"
+          style={[styles.emptyBtn, s.mt10]}
+          onPress={onPressDiscoverFeeds}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Discover new custom feeds
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
+      </View>
+    </View>
+  )
+}
+const styles = StyleSheet.create({
+  container: {
+    flexDirection: 'row',
+    justifyContent: 'center',
+    paddingTop: 40,
+    paddingBottom: 80,
+    paddingHorizontal: 30,
+    borderTopWidth: 1,
+  },
+  inner: {
+    maxWidth: 460,
+  },
+  emptyBtn: {
+    marginVertical: 20,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    paddingVertical: 18,
+    paddingHorizontal: 24,
+    borderRadius: 30,
+  },
+})
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 217d326e8..adb496f6d 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,25 +1,26 @@
 import React from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
+import {AppBskyActorDefs} from '@atproto/api'
 import {Button, ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
 import {FollowState} from 'state/models/cache/my-follows'
-import {useFollowDid} from 'lib/hooks/useFollowDid'
+import {useFollowProfile} from 'lib/hooks/useFollowProfile'
 
 export const FollowButton = observer(function FollowButtonImpl({
   unfollowedType = 'inverted',
   followedType = 'default',
-  did,
+  profile,
   onToggleFollow,
   labelStyle,
 }: {
   unfollowedType?: ButtonType
   followedType?: ButtonType
-  did: string
+  profile: AppBskyActorDefs.ProfileViewBasic
   onToggleFollow?: (v: boolean) => void
   labelStyle?: StyleProp<TextStyle>
 }) {
-  const {state, following, toggle} = useFollowDid({did})
+  const {state, following, toggle} = useFollowProfile(profile)
 
   const onPress = React.useCallback(async () => {
     try {
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index e0c8ad21a..d1aed8934 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -200,7 +200,7 @@ export const ProfileCardWithFollowBtn = observer(
         noBorder={noBorder}
         followers={followers}
         renderButton={
-          isMe ? undefined : () => <FollowButton did={profile.did} />
+          isMe ? undefined : () => <FollowButton profile={profile} />
         }
       />
     )
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 57fa22f1e..5514bf98e 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -60,14 +60,14 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
   if (!view || !view.hasLoaded) {
     return (
       <View style={pal.view}>
-        <LoadingPlaceholder width="100%" height={120} />
+        <LoadingPlaceholder width="100%" height={153} />
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
           <LoadingPlaceholder width={80} height={80} style={styles.br40} />
         </View>
         <View style={styles.content}>
           <View style={[styles.buttonsLine]}>
-            <LoadingPlaceholder width={100} height={31} style={styles.br50} />
+            <LoadingPlaceholder width={167} height={31} style={styles.br50} />
           </View>
           <View>
             <Text type="title-2xl" style={[pal.text, styles.title]}>
@@ -132,20 +132,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
   }, [store, view])
 
   const onPressToggleFollow = React.useCallback(() => {
-    track(
-      view.viewer.following
-        ? 'ProfileHeader:FollowButtonClicked'
-        : 'ProfileHeader:UnfollowButtonClicked',
-    )
     view?.toggleFollowing().then(
       () => {
         setShowSuggestedFollows(Boolean(view.viewer.following))
-
         Toast.show(
           `${
             view.viewer.following ? 'Following' : 'No longer following'
           } ${sanitizeDisplayName(view.displayName || view.handle)}`,
         )
+        track(
+          view.viewer.following
+            ? 'ProfileHeader:FollowButtonClicked'
+            : 'ProfileHeader:UnfollowButtonClicked',
+        )
       },
       err => store.log.error('Failed to toggle follow', err),
     )
@@ -392,8 +391,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                     {
                       paddingHorizontal: 10,
                       backgroundColor: showSuggestedFollows
-                        ? colors.blue3
-                        : pal.viewLight.backgroundColor,
+                        ? pal.colors.text
+                        : pal.colors.backgroundLight,
                     },
                   ]}
                   accessibilityRole="button"
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index b9d66a6fe..cf759ddd1 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {View, StyleSheet, ScrollView, Pressable} from 'react-native'
+import {View, StyleSheet, Pressable, ScrollView} from 'react-native'
 import Animated, {
   useSharedValue,
   withTiming,
@@ -19,13 +19,14 @@ import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
-import {useFollowDid} from 'lib/hooks/useFollowDid'
+import {useFollowProfile} from 'lib/hooks/useFollowProfile'
 import {Button} from 'view/com/util/forms/Button'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {Link} from 'view/com/util/Link'
 import {useAnalytics} from 'lib/analytics/analytics'
+import {isWeb} from 'platform/detection'
 
 const OUTER_PADDING = 10
 const INNER_PADDING = 14
@@ -83,7 +84,7 @@ export function ProfileHeaderSuggestedFollows({
           return []
         }
 
-        store.me.follows.hydrateProfiles(suggestions)
+        store.me.follows.hydrateMany(suggestions)
 
         return suggestions
       } catch (e) {
@@ -100,7 +101,6 @@ export function ProfileHeaderSuggestedFollows({
             backgroundColor: pal.viewLight.backgroundColor,
             height: '100%',
             paddingTop: INNER_PADDING / 2,
-            paddingBottom: INNER_PADDING,
           }}>
           <View
             style={{
@@ -130,11 +130,15 @@ export function ProfileHeaderSuggestedFollows({
           </View>
 
           <ScrollView
-            horizontal
-            showsHorizontalScrollIndicator={false}
+            horizontal={true}
+            showsHorizontalScrollIndicator={isWeb}
+            persistentScrollbar={true}
+            scrollIndicatorInsets={{bottom: 0}}
+            scrollEnabled={true}
             contentContainerStyle={{
               alignItems: 'flex-start',
               paddingLeft: INNER_PADDING / 2,
+              paddingBottom: INNER_PADDING,
             }}>
             {isLoading ? (
               <>
@@ -218,14 +222,14 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
-  const {following, toggle} = useFollowDid({did: profile.did})
+  const {following, toggle} = useFollowProfile(profile)
   const moderation = moderateProfile(profile, store.preferences.moderationOpts)
 
   const onPress = React.useCallback(async () => {
     try {
-      const {following} = await toggle()
+      const {following: isFollowing} = await toggle()
 
-      if (following) {
+      if (isFollowing) {
         track('ProfileHeader:SuggestedFollowFollowed')
       }
     } catch (e: any) {
diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx
index f04175afd..6bd1b2f00 100644
--- a/src/view/com/search/HeaderWithInput.tsx
+++ b/src/view/com/search/HeaderWithInput.tsx
@@ -93,7 +93,7 @@ export function HeaderWithInput({
           onBlur={() => setIsInputFocused(false)}
           onChangeText={onChangeQuery}
           onSubmitEditing={onSubmitQuery}
-          autoFocus={isMobile}
+          autoFocus={false}
           accessibilityRole="search"
           accessibilityLabel="Search"
           accessibilityHint=""
diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx
index a495fcd3f..7486b212f 100644
--- a/src/view/com/util/EmptyState.tsx
+++ b/src/view/com/util/EmptyState.tsx
@@ -22,7 +22,7 @@ export function EmptyState({
 }) {
   const pal = usePalette('default')
   return (
-    <View testID={testID} style={[styles.container, style]}>
+    <View testID={testID} style={[styles.container, pal.border, style]}>
       <View style={styles.iconContainer}>
         {icon === 'user-group' ? (
           <UserGroupIcon size="64" style={styles.icon} />
@@ -50,6 +50,7 @@ const styles = StyleSheet.create({
   container: {
     paddingVertical: 20,
     paddingHorizontal: 36,
+    borderTopWidth: 1,
   },
   iconContainer: {
     flexDirection: 'row',
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index c7374e195..529435cf1 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<Props, State> {
   public render() {
     if (this.state.hasError) {
       return (
-        <CenteredView>
+        <CenteredView style={{height: '100%', flex: 1}}>
           <ErrorScreen
             title="Oh no!"
             message="There was an unexpected issue in the application. Please let us know if this happened to you!"
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 472d943e1..94fe75536 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,5 +1,4 @@
-import React, {ComponentProps, useMemo} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {ComponentProps, memo, useMemo} from 'react'
 import {
   Linking,
   GestureResponderEvent,
@@ -49,7 +48,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   anchorNoUnderline?: boolean
 }
 
-export const Link = observer(function Link({
+export const Link = memo(function Link({
   testID,
   style,
   href,
@@ -135,7 +134,7 @@ export const Link = observer(function Link({
   )
 })
 
-export const TextLink = observer(function TextLink({
+export const TextLink = memo(function TextLink({
   testID,
   type = 'md',
   style,
@@ -235,7 +234,7 @@ interface DesktopWebTextLinkProps extends TextProps {
   accessibilityHint?: string
   title?: string
 }
-export const DesktopWebTextLink = observer(function DesktopWebTextLink({
+export const DesktopWebTextLink = memo(function DesktopWebTextLink({
   testID,
   type = 'md',
   style,
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d24e47499..fbc0b5e11 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -23,14 +23,18 @@ interface BaseUserAvatarProps {
   type?: Type
   size: number
   avatar?: string | null
-  moderation?: ModerationUI
 }
 
 interface UserAvatarProps extends BaseUserAvatarProps {
-  onSelectNewAvatar?: (img: RNImage | null) => void
+  moderation?: ModerationUI
+}
+
+interface EditableUserAvatarProps extends BaseUserAvatarProps {
+  onSelectNewAvatar: (img: RNImage | null) => void
 }
 
 interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
+  moderation?: ModerationUI
   did: string
   handle: string
 }
@@ -106,8 +110,65 @@ export function UserAvatar({
   size,
   avatar,
   moderation,
-  onSelectNewAvatar,
 }: UserAvatarProps) {
+  const pal = usePalette('default')
+
+  const aviStyle = useMemo(() => {
+    if (type === 'algo' || type === 'list') {
+      return {
+        width: size,
+        height: size,
+        borderRadius: size > 32 ? 8 : 3,
+      }
+    }
+    return {
+      width: size,
+      height: size,
+      borderRadius: Math.floor(size / 2),
+    }
+  }, [type, size])
+
+  const alert = useMemo(() => {
+    if (!moderation?.alert) {
+      return null
+    }
+    return (
+      <View style={[styles.alertIconContainer, pal.view]}>
+        <FontAwesomeIcon
+          icon="exclamation-circle"
+          style={styles.alertIcon}
+          size={Math.floor(size / 3)}
+        />
+      </View>
+    )
+  }, [moderation?.alert, size, pal])
+
+  return avatar &&
+    !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
+    <View style={{width: size, height: size}}>
+      <HighPriorityImage
+        testID="userAvatarImage"
+        style={aviStyle}
+        contentFit="cover"
+        source={{uri: avatar}}
+        blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
+      />
+      {alert}
+    </View>
+  ) : (
+    <View style={{width: size, height: size}}>
+      <DefaultAvatar type={type} size={size} />
+      {alert}
+    </View>
+  )
+}
+
+export function EditableUserAvatar({
+  type = 'user',
+  size,
+  avatar,
+  onSelectNewAvatar,
+}: EditableUserAvatarProps) {
   const store = useStores()
   const pal = usePalette('default')
   const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -146,7 +207,7 @@ export function UserAvatar({
               return
             }
 
-            onSelectNewAvatar?.(
+            onSelectNewAvatar(
               await openCamera(store, {
                 width: 1000,
                 height: 1000,
@@ -186,7 +247,7 @@ export function UserAvatar({
               path: item.path,
             })
 
-            onSelectNewAvatar?.(croppedImage)
+            onSelectNewAvatar(croppedImage)
           },
         },
         !!avatar && {
@@ -203,7 +264,7 @@ export function UserAvatar({
             web: 'trash',
           },
           onPress: async () => {
-            onSelectNewAvatar?.(null)
+            onSelectNewAvatar(null)
           },
         },
       ].filter(Boolean) as DropdownItem[],
@@ -216,23 +277,7 @@ export function UserAvatar({
     ],
   )
 
-  const alert = useMemo(() => {
-    if (!moderation?.alert) {
-      return null
-    }
-    return (
-      <View style={[styles.alertIconContainer, pal.view]}>
-        <FontAwesomeIcon
-          icon="exclamation-circle"
-          style={styles.alertIcon}
-          size={Math.floor(size / 3)}
-        />
-      </View>
-    )
-  }, [moderation?.alert, size, pal])
-
-  // onSelectNewAvatar is only passed as prop on the EditProfile component
-  return onSelectNewAvatar ? (
+  return (
     <NativeDropdown
       testID="changeAvatarBtn"
       items={dropdownItems}
@@ -256,23 +301,6 @@ export function UserAvatar({
         />
       </View>
     </NativeDropdown>
-  ) : avatar &&
-    !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
-    <View style={{width: size, height: size}}>
-      <HighPriorityImage
-        testID="userAvatarImage"
-        style={aviStyle}
-        contentFit="cover"
-        source={{uri: avatar}}
-        blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
-      />
-      {alert}
-    </View>
-  ) : (
-    <View style={{width: size, height: size}}>
-      <DefaultAvatar type={type} size={size} />
-      {alert}
-    </View>
   )
 }
 
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 164028708..ec459b4eb 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -1,16 +1,17 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
-import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
 import {CenteredView} from './Views'
 import {Text} from './text/Text'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -149,30 +150,8 @@ const Container = observer(function ContainerImpl({
   hideOnScroll: boolean
   showBorder?: boolean
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-  const interp = useAnimatedValue(0)
-
-  React.useEffect(() => {
-    if (store.shell.minimalShellMode) {
-      Animated.timing(interp, {
-        toValue: 1,
-        duration: 100,
-        useNativeDriver: true,
-        isInteraction: false,
-      }).start()
-    } else {
-      Animated.timing(interp, {
-        toValue: 0,
-        duration: 100,
-        useNativeDriver: true,
-        isInteraction: false,
-      }).start()
-    }
-  }, [interp, store.shell.minimalShellMode])
-  const transform = {
-    transform: [{translateY: Animated.multiply(interp, -100)}],
-  }
+  const {headerMinimalShellTransform} = useMinimalShellMode()
 
   if (!hideOnScroll) {
     return (
@@ -195,7 +174,7 @@ const Container = observer(function ContainerImpl({
         styles.headerFloating,
         pal.view,
         pal.border,
-        transform,
+        headerMinimalShellTransform,
         showBorder && styles.border,
       ]}>
       {children}
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index 6c0e4c6cc..935d93033 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -144,8 +144,6 @@ export function Selector({
   items: string[]
   onSelect?: (index: number) => void
 }) {
-  const [height, setHeight] = useState(0)
-
   const pal = usePalette('default')
   const borderColor = useColorSchemeStyle(
     {borderColor: colors.black},
@@ -160,22 +158,13 @@ export function Selector({
     <View
       style={{
         width: '100%',
-        position: 'relative',
-        overflow: 'hidden',
-        height,
         backgroundColor: pal.colors.background,
       }}>
       <ScrollView
         testID="selector"
         horizontal
-        showsHorizontalScrollIndicator={false}
-        style={{position: 'absolute'}}>
-        <View
-          style={[pal.view, styles.outer]}
-          onLayout={e => {
-            const {height: layoutHeight} = e.nativeEvent.layout
-            setHeight(layoutHeight || 60)
-          }}>
+        showsHorizontalScrollIndicator={false}>
+        <View style={[pal.view, styles.outer]}>
           {items.map((item, i) => {
             const selected = i === selectedIndex
             return (
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 6c96eef2c..5b1d5d888 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -1,13 +1,13 @@
 import React, {ComponentProps} from 'react'
 import {observer} from 'mobx-react-lite'
-import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native'
+import {StyleSheet, TouchableWithoutFeedback} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {gradients} from 'lib/styles'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {clamp} from 'lib/numbers'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
 
 export interface FABProps
   extends ComponentProps<typeof TouchableWithoutFeedback> {
@@ -21,28 +21,30 @@ export const FABInner = observer(function FABInnerImpl({
   ...props
 }: FABProps) {
   const insets = useSafeAreaInsets()
-  const {isTablet} = useWebMediaQueries()
-  const store = useStores()
-  const interp = useAnimatedValue(0)
-  React.useEffect(() => {
-    Animated.timing(interp, {
-      toValue: store.shell.minimalShellMode ? 0 : 1,
-      duration: 100,
-      useNativeDriver: true,
-      isInteraction: false,
-    }).start()
-  }, [interp, store.shell.minimalShellMode])
-  const transform = isTablet
-    ? undefined
-    : {
-        transform: [{translateY: Animated.multiply(interp, -44)}],
-      }
-  const size = isTablet ? styles.sizeLarge : styles.sizeRegular
-  const right = isTablet ? 50 : 24
-  const bottom = isTablet ? 50 : clamp(insets.bottom, 15, 60) + 15
+  const {isMobile, isTablet} = useWebMediaQueries()
+  const {fabMinimalShellTransform} = useMinimalShellMode()
+
+  const size = React.useMemo(() => {
+    return isTablet ? styles.sizeLarge : styles.sizeRegular
+  }, [isTablet])
+  const tabletSpacing = React.useMemo(() => {
+    return isTablet
+      ? {right: 50, bottom: 50}
+      : {
+          right: 24,
+          bottom: clamp(insets.bottom, 15, 60) + 15,
+        }
+  }, [insets.bottom, isTablet])
+
   return (
     <TouchableWithoutFeedback testID={testID} {...props}>
-      <Animated.View style={[styles.outer, size, {right, bottom}, transform]}>
+      <Animated.View
+        style={[
+          styles.outer,
+          size,
+          tabletSpacing,
+          isMobile && fabMinimalShellTransform,
+        ]}>
         <LinearGradient
           colors={[gradients.blueLight.start, gradients.blueLight.end]}
           start={{x: 0, y: 0}}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 035e29c25..6cbcddc32 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -52,20 +52,20 @@ export function AutoSizedImage({
 
   if (onPress || onLongPress || onPressIn) {
     return (
+      // disable a11y rule because in this case we want the tags on the image (#1640)
+      // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
       <Pressable
         onPress={onPress}
         onLongPress={onLongPress}
         onPressIn={onPressIn}
-        style={[styles.container, style]}
-        accessible={true}
-        accessibilityRole="button"
-        accessibilityLabel={alt || 'Image'}
-        accessibilityHint="Tap to view fully">
+        style={[styles.container, style]}>
         <Image
           style={[styles.image, {aspectRatio}]}
           source={uri}
-          accessible={false} // Must set for `accessibilityLabel` to work
+          accessible={true} // Must set for `accessibilityLabel` to work
           accessibilityIgnoresInvertColors
+          accessibilityLabel={alt}
+          accessibilityHint="Tap to view fully"
         />
         {children}
       </Pressable>
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 679f71c99..094b0c56c 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -23,19 +23,19 @@ export const GalleryItem: FC<GalleryItemProps> = ({
   onLongPress,
 }) => {
   const image = images[index]
-
   return (
-    <View>
+    <View style={styles.fullWidth}>
       <Pressable
         onPress={onPress ? () => onPress(index) : undefined}
         onPressIn={onPressIn ? () => onPressIn(index) : undefined}
         onLongPress={onLongPress ? () => onLongPress(index) : undefined}
+        style={styles.fullWidth}
         accessibilityRole="button"
         accessibilityLabel={image.alt || 'Image'}
         accessibilityHint="">
         <Image
           source={{uri: image.thumb}}
-          style={imageStyle}
+          style={[styles.image, imageStyle]}
           accessible={true}
           accessibilityLabel={image.alt}
           accessibilityHint=""
@@ -54,14 +54,21 @@ export const GalleryItem: FC<GalleryItemProps> = ({
 }
 
 const styles = StyleSheet.create({
+  fullWidth: {
+    flex: 1,
+  },
+  image: {
+    flex: 1,
+    borderRadius: 4,
+  },
   altContainer: {
     backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 6,
     paddingHorizontal: 6,
     paddingVertical: 3,
     position: 'absolute',
-    left: 6,
-    bottom: 6,
+    left: 8,
+    bottom: 8,
   },
   alt: {
     color: 'white',
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 4c0901304..4aa6f28de 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,13 +1,5 @@
-import React, {useMemo, useState} from 'react'
-import {
-  LayoutChangeEvent,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {ImageStyle} from 'expo-image'
-import {Dimensions} from 'lib/media/types'
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {GalleryItem} from './Gallery'
 
@@ -20,21 +12,11 @@ interface ImageLayoutGridProps {
 }
 
 export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
-  const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
-
-  const onLayout = (evt: LayoutChangeEvent) => {
-    const {width, height} = evt.nativeEvent.layout
-    setContainerInfo({
-      width,
-      height,
-    })
-  }
-
   return (
-    <View style={style} onLayout={onLayout}>
-      {containerInfo ? (
-        <ImageLayoutGridInner {...props} containerInfo={containerInfo} />
-      ) : undefined}
+    <View style={style}>
+      <View style={styles.container}>
+        <ImageLayoutGridInner {...props} />
+      </View>
     </View>
   )
 }
@@ -44,70 +26,80 @@ interface ImageLayoutGridInnerProps {
   onPress?: (index: number) => void
   onLongPress?: (index: number) => void
   onPressIn?: (index: number) => void
-  containerInfo: Dimensions
 }
 
-function ImageLayoutGridInner({
-  containerInfo,
-  ...props
-}: ImageLayoutGridInnerProps) {
+function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
   const count = props.images.length
-  const size1 = useMemo<ImageStyle>(() => {
-    if (count === 3) {
-      const size = (containerInfo.width - 10) / 3
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    } else {
-      const size = (containerInfo.width - 5) / 2
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    }
-  }, [count, containerInfo])
-  const size2 = React.useMemo<ImageStyle>(() => {
-    if (count === 3) {
-      const size = ((containerInfo.width - 10) / 3) * 2 + 5
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    } else {
-      const size = (containerInfo.width - 5) / 2
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    }
-  }, [count, containerInfo])
 
   switch (count) {
     case 2:
       return (
         <View style={styles.flexRow}>
-          <GalleryItem index={0} {...props} imageStyle={size1} />
-          <GalleryItem index={1} {...props} imageStyle={size1} />
+          <View style={styles.smallItem}>
+            <GalleryItem {...props} index={0} imageStyle={styles.image} />
+          </View>
+          <View style={styles.smallItem}>
+            <GalleryItem {...props} index={1} imageStyle={styles.image} />
+          </View>
         </View>
       )
+
     case 3:
       return (
         <View style={styles.flexRow}>
-          <GalleryItem index={0} {...props} imageStyle={size2} />
-          <View style={styles.flexColumn}>
-            <GalleryItem index={1} {...props} imageStyle={size1} />
-            <GalleryItem index={2} {...props} imageStyle={size1} />
+          <View style={{flex: 2, aspectRatio: 1}}>
+            <GalleryItem {...props} index={0} imageStyle={styles.image} />
+          </View>
+          <View style={{flex: 1}}>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={1} imageStyle={styles.image} />
+            </View>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={2} imageStyle={styles.image} />
+            </View>
           </View>
         </View>
       )
+
     case 4:
       return (
-        <View style={styles.flexRow}>
-          <View style={styles.flexColumn}>
-            <GalleryItem index={0} {...props} imageStyle={size1} />
-            <GalleryItem index={2} {...props} imageStyle={size1} />
+        <>
+          <View style={styles.flexRow}>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={0} imageStyle={styles.image} />
+            </View>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={2} imageStyle={styles.image} />
+            </View>
           </View>
-          <View style={styles.flexColumn}>
-            <GalleryItem index={1} {...props} imageStyle={size1} />
-            <GalleryItem index={3} {...props} imageStyle={size1} />
+          <View style={styles.flexRow}>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={1} imageStyle={styles.image} />
+            </View>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={3} imageStyle={styles.image} />
+            </View>
           </View>
-        </View>
+        </>
       )
+
     default:
       return null
   }
 }
 
+// This is used to compute margins (rather than flexbox gap) due to Yoga bugs:
+// https://github.com/facebook/yoga/issues/1418
+const IMAGE_GAP = 5
+
 const styles = StyleSheet.create({
-  flexRow: {flexDirection: 'row', gap: 5},
-  flexColumn: {flexDirection: 'column', gap: 5},
+  container: {
+    marginHorizontal: -IMAGE_GAP / 2,
+    marginVertical: -IMAGE_GAP / 2,
+  },
+  flexRow: {flexDirection: 'row'},
+  smallItem: {flex: 1, aspectRatio: 1},
+  image: {
+    margin: IMAGE_GAP / 2,
+  },
 })
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index f5d12ce2c..b16a42396 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -2,14 +2,14 @@ import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {colors} from 'lib/styles'
 import {HITSLOP_20} from 'lib/constants'
-import {isWeb} from 'platform/detection'
-import {clamp} from 'lib/numbers'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
+const AnimatedTouchableOpacity =
+  Animated.createAnimatedComponent(TouchableOpacity)
 
 export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
   onPress,
@@ -19,26 +19,20 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
   onPress: () => void
   label: string
   showIndicator: boolean
-  minimalShellMode?: boolean // NOTE not used on mobile -prf
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-  const {isDesktop, isTablet} = useWebMediaQueries()
-  const safeAreaInsets = useSafeAreaInsets()
-  const minMode = store.shell.minimalShellMode
-  const bottom = isTablet
-    ? 50
-    : (minMode || isDesktop ? 16 : 60) +
-      (isWeb ? 20 : clamp(safeAreaInsets.bottom, 15, 60))
+  const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
+  const {fabMinimalShellTransform} = useMinimalShellMode()
+
   return (
-    <TouchableOpacity
+    <AnimatedTouchableOpacity
       style={[
         styles.loadLatest,
         isDesktop && styles.loadLatestDesktop,
         isTablet && styles.loadLatestTablet,
         pal.borderDark,
         pal.view,
-        {bottom},
+        isMobile && fabMinimalShellTransform,
       ]}
       onPress={onPress}
       hitSlop={HITSLOP_20}
@@ -47,7 +41,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
       accessibilityHint="">
       <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
       {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
-    </TouchableOpacity>
+    </AnimatedTouchableOpacity>
   )
 })
 
@@ -66,15 +60,11 @@ const styles = StyleSheet.create({
   },
   loadLatestTablet: {
     // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-282px)',
+    left: 'calc(50vw - 282px)',
   },
   loadLatestDesktop: {
     // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-382px)',
+    left: 'calc(50vw - 382px)',
   },
   indicator: {
     position: 'absolute',
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index e53d4a08e..ad47e9f9b 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,32 +1,22 @@
 import React from 'react'
-import {FlatList, View, useWindowDimensions} from 'react-native'
-import {useFocusEffect, useIsFocused} from '@react-navigation/native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {useWindowDimensions} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
 import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
 import {observer} from 'mobx-react-lite'
-import useAppState from 'react-native-appstate-hook'
 import isEqual from 'lodash.isequal'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {TextLink} from 'view/com/util/Link'
-import {Feed} from '../com/posts/Feed'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
+import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
-import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
 import {FeedsTabBar} from '../com/pager/FeedsTabBar'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {FAB} from '../com/util/fab/FAB'
 import {useStores} from 'state/index'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s, colors} from 'lib/styles'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
-import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {ComposeIcon2} from 'lib/icons'
+import {FeedPage} from 'view/com/feeds/FeedPage'
 
-const POLL_FREQ = 30e3 // 30sec
+export const POLL_FREQ = 30e3 // 30sec
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export const HomeScreen = withAuthRequired(
@@ -97,7 +87,9 @@ export const HomeScreen = withAuthRequired(
       (props: RenderTabBarFnProps) => {
         return (
           <FeedsTabBar
-            {...props}
+            key="FEEDS_TAB_BAR"
+            selectedPage={props.selectedPage}
+            onSelect={props.onSelect}
             testID="homeScreenFeedTabs"
             onPressSelected={onPressSelected}
           />
@@ -127,6 +119,7 @@ export const HomeScreen = withAuthRequired(
           isPageFocused={selectedPage === 0}
           feed={store.me.mainFeed}
           renderEmptyState={renderFollowingEmptyState}
+          renderEndOfFeed={FollowingEndOfFeed}
         />
         {customFeeds.map((f, index) => {
           return (
@@ -144,193 +137,7 @@ export const HomeScreen = withAuthRequired(
   }),
 )
 
-const FeedPage = observer(function FeedPageImpl({
-  testID,
-  isPageFocused,
-  feed,
-  renderEmptyState,
-}: {
-  testID?: string
-  feed: PostsFeedModel
-  isPageFocused: boolean
-  renderEmptyState?: () => JSX.Element
-}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const {isDesktop} = useWebMediaQueries()
-  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
-  const {screen, track} = useAnalytics()
-  const headerOffset = useHeaderOffset()
-  const scrollElRef = React.useRef<FlatList>(null)
-  const {appState} = useAppState({
-    onForeground: () => doPoll(true),
-  })
-  const isScreenFocused = useIsFocused()
-  const hasNew = feed.hasNewLatest && !feed.isRefreshing
-
-  React.useEffect(() => {
-    // called on first load
-    if (!feed.hasLoaded && isPageFocused) {
-      feed.setup()
-    }
-  }, [isPageFocused, feed])
-
-  const doPoll = React.useCallback(
-    (knownActive = false) => {
-      if (
-        (!knownActive && appState !== 'active') ||
-        !isScreenFocused ||
-        !isPageFocused
-      ) {
-        return
-      }
-      if (feed.isLoading) {
-        return
-      }
-      store.log.debug('HomeScreen: Polling for new posts')
-      feed.checkForLatest()
-    },
-    [appState, isScreenFocused, isPageFocused, store, feed],
-  )
-
-  const scrollToTop = React.useCallback(() => {
-    scrollElRef.current?.scrollToOffset({offset: -headerOffset})
-    resetMainScroll()
-  }, [headerOffset, resetMainScroll])
-
-  const onSoftReset = React.useCallback(() => {
-    if (isPageFocused) {
-      scrollToTop()
-      feed.refresh()
-    }
-  }, [isPageFocused, scrollToTop, feed])
-
-  // fires when page within screen is activated/deactivated
-  // - check for latest
-  React.useEffect(() => {
-    if (!isPageFocused || !isScreenFocused) {
-      return
-    }
-
-    const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const feedCleanup = feed.registerListeners()
-    const pollInterval = setInterval(doPoll, POLL_FREQ)
-
-    screen('Feed')
-    store.log.debug('HomeScreen: Updating feed')
-    feed.checkForLatest()
-
-    return () => {
-      clearInterval(pollInterval)
-      softResetSub.remove()
-      feedCleanup()
-    }
-  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
-
-  const onPressCompose = React.useCallback(() => {
-    track('HomeScreen:PressCompose')
-    store.shell.openComposer({})
-  }, [store, track])
-
-  const onPressTryAgain = React.useCallback(() => {
-    feed.refresh()
-  }, [feed])
-
-  const onPressLoadLatest = React.useCallback(() => {
-    scrollToTop()
-    feed.refresh()
-  }, [feed, scrollToTop])
-
-  const ListHeaderComponent = React.useCallback(() => {
-    if (isDesktop) {
-      return (
-        <View
-          style={[
-            pal.view,
-            {
-              flexDirection: 'row',
-              alignItems: 'center',
-              justifyContent: 'space-between',
-              paddingHorizontal: 18,
-              paddingVertical: 12,
-            },
-          ]}>
-          <TextLink
-            type="title-lg"
-            href="/"
-            style={[pal.text, {fontWeight: 'bold'}]}
-            text={
-              <>
-                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
-                {hasNew && (
-                  <View
-                    style={{
-                      top: -8,
-                      backgroundColor: colors.blue3,
-                      width: 8,
-                      height: 8,
-                      borderRadius: 4,
-                    }}
-                  />
-                )}
-              </>
-            }
-            onPress={() => store.emitScreenSoftReset()}
-          />
-          <TextLink
-            type="title-lg"
-            href="/settings/home-feed"
-            style={{fontWeight: 'bold'}}
-            accessibilityLabel="Feed Preferences"
-            accessibilityHint=""
-            text={
-              <FontAwesomeIcon
-                icon="sliders"
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            }
-          />
-        </View>
-      )
-    }
-    return <></>
-  }, [isDesktop, pal, store, hasNew])
-
-  return (
-    <View testID={testID} style={s.h100pct}>
-      <Feed
-        testID={testID ? `${testID}-feed` : undefined}
-        key="default"
-        feed={feed}
-        scrollElRef={scrollElRef}
-        onPressTryAgain={onPressTryAgain}
-        onScroll={onMainScroll}
-        scrollEventThrottle={100}
-        renderEmptyState={renderEmptyState}
-        ListHeaderComponent={ListHeaderComponent}
-        headerOffset={headerOffset}
-      />
-      {(isScrolledDown || hasNew) && (
-        <LoadLatestBtn
-          onPress={onPressLoadLatest}
-          label="Load new posts"
-          showIndicator={hasNew}
-          minimalShellMode={store.shell.minimalShellMode}
-        />
-      )}
-      <FAB
-        testID="composeFAB"
-        onPress={onPressCompose}
-        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-        accessibilityRole="button"
-        accessibilityLabel="New post"
-        accessibilityHint=""
-      />
-    </View>
-  )
-})
-
-function useHeaderOffset() {
+export function useHeaderOffset() {
   const {isDesktop, isTablet} = useWebMediaQueries()
   const {fontScale} = useWindowDimensions()
   if (isDesktop) {
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 977401350..b00bfb765 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -156,7 +156,6 @@ export const NotificationsScreen = withAuthRequired(
             onPress={onPressLoadLatest}
             label="Load new notifications"
             showIndicator={hasNew}
-            minimalShellMode={true}
           />
         )}
       </View>
diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
index b545a643d..b80c1667f 100644
--- a/src/view/screens/SearchMobile.tsx
+++ b/src/view/screens/SearchMobile.tsx
@@ -148,18 +148,18 @@ export const SearchScreen = withAuthRequired(
               style={pal.view}
               onScroll={onMainScroll}
               scrollEventThrottle={100}>
-              {query && autocompleteView.searchRes.length ? (
+              {query && autocompleteView.suggestions.length ? (
                 <>
-                  {autocompleteView.searchRes.map((profile, index) => (
+                  {autocompleteView.suggestions.map((suggestion, index) => (
                     <ProfileCard
-                      key={profile.did}
-                      testID={`searchAutoCompleteResult-${profile.handle}`}
-                      profile={profile}
+                      key={suggestion.did}
+                      testID={`searchAutoCompleteResult-${suggestion.handle}`}
+                      profile={suggestion}
                       noBorder={index === 0}
                     />
                   ))}
                 </>
-              ) : query && !autocompleteView.searchRes.length ? (
+              ) : query && !autocompleteView.suggestions.length ? (
                 <View>
                   <Text style={[pal.textLight, styles.searchPrompt]}>
                     No results found for {autocompleteView.prefix}
diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx
index de1b38b84..dc00d473d 100644
--- a/src/view/screens/Support.tsx
+++ b/src/view/screens/Support.tsx
@@ -9,6 +9,7 @@ import {TextLink} from 'view/com/util/Link'
 import {CenteredView} from 'view/com/util/Views'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
+import {HELP_DESK_URL} from 'lib/constants'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'>
 export const SupportScreen = (_props: Props) => {
@@ -29,14 +30,13 @@ export const SupportScreen = (_props: Props) => {
           Support
         </Text>
         <Text style={[pal.text, s.p20]}>
-          If you need help, email us at{' '}
+          The support form has been moved. If you need help, please
           <TextLink
-            href="mailto:support@bsky.app"
-            text="support@bsky.app"
+            href={HELP_DESK_URL}
+            text=" click here"
             style={pal.link}
           />{' '}
-          with a description of your issue and information about how we can help
-          you.
+          or visit {HELP_DESK_URL} to get in touch with us.
         </Text>
       </CenteredView>
     </View>
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 4758c5e01..cfd4d46d0 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -1,10 +1,6 @@
 import React, {ComponentProps} from 'react'
-import {
-  Animated,
-  GestureResponderEvent,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {GestureResponderEvent, TouchableOpacity, View} from 'react-native'
+import Animated from 'react-native-reanimated'
 import {StackActions} from '@react-navigation/native'
 import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -87,6 +83,7 @@ export const BottomBar = observer(function BottomBarImpl({
         pal.border,
         {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
         footerMinimalShellTransform,
+        store.shell.minimalShellMode && styles.disabled,
       ]}>
       <Btn
         testID="bottomBarHomeBtn"
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index ae9381440..c175ed848 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -65,4 +65,7 @@ export const styles = StyleSheet.create({
     borderWidth: 1,
     borderRadius: 100,
   },
+  disabled: {
+    pointerEvents: 'none',
+  },
 })
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index e20214235..ebcc527a1 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {Animated} from 'react-native'
 import {useNavigationState} from '@react-navigation/native'
+import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {getCurrentRoute, isTab} from 'lib/routes/helpers'
 import {styles} from './BottomBarStyles'
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index dfd4f50bf..caecea4a8 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -22,6 +22,13 @@ export const DesktopSearch = observer(function DesktopSearch() {
   )
   const navigation = useNavigation<NavigationProp>()
 
+  // initial setup
+  React.useEffect(() => {
+    if (store.me.did) {
+      autocompleteView.setup()
+    }
+  }, [autocompleteView, store.me.did])
+
   const onChangeQuery = React.useCallback(
     (text: string) => {
       setQuery(text)
@@ -90,9 +97,9 @@ export const DesktopSearch = observer(function DesktopSearch() {
 
       {query !== '' && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {autocompleteView.searchRes.length ? (
+          {autocompleteView.suggestions.length ? (
             <>
-              {autocompleteView.searchRes.map((item, i) => (
+              {autocompleteView.suggestions.map((item, i) => (
                 <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
               ))}
             </>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 3119715e9..b564f99f8 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -21,7 +21,10 @@ import {usePalette} from 'lib/hooks/usePalette'
 import * as backHandler from 'lib/routes/back-handler'
 import {RoutesContainer, TabsNavigator} from '../../Navigation'
 import {isStateAtTabRoot} from 'lib/routes/helpers'
-import {SafeAreaProvider} from 'react-native-safe-area-context'
+import {
+  SafeAreaProvider,
+  initialWindowMetrics,
+} from 'react-native-safe-area-context'
 import {useOTAUpdate} from 'lib/hooks/useOTAUpdate'
 
 const ShellInner = observer(function ShellInnerImpl() {
@@ -87,7 +90,7 @@ export const Shell: React.FC = observer(function ShellImpl() {
   const pal = usePalette('default')
   const theme = useTheme()
   return (
-    <SafeAreaProvider style={pal.view}>
+    <SafeAreaProvider initialMetrics={initialWindowMetrics} style={pal.view}>
       <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
         <StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
         <RoutesContainer>