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/SplashScreen.tsx1
-rw-r--r--src/view/com/auth/util/HelpTip.tsx1
-rw-r--r--src/view/com/auth/util/TextInput.tsx2
-rw-r--r--src/view/com/composer/Composer.tsx106
-rw-r--r--src/view/com/composer/ExternalEmbedRemoveBtn.tsx1
-rw-r--r--src/view/com/composer/GifAltText.tsx2
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx1
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx39
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx2
-rw-r--r--src/view/com/composer/photos/SelectGifBtn.tsx2
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx2
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx2
-rw-r--r--src/view/com/composer/select-language/SuggestedLanguage.tsx2
-rw-r--r--src/view/com/composer/state/video.ts13
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx2
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx33
-rw-r--r--src/view/com/composer/text-input/hooks/useGrapheme.tsx2
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx57
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx15
-rw-r--r--src/view/com/composer/threadgate/ThreadgateBtn.tsx1
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx44
-rw-r--r--src/view/com/composer/videos/SubtitleDialog.tsx2
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx1
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx86
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.tsx18
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx4
-rw-r--r--src/view/com/composer/videos/VideoTranscodeProgress.tsx1
-rw-r--r--src/view/com/composer/videos/pickVideo.ts21
-rw-r--r--src/view/com/composer/videos/pickVideo.web.ts94
-rw-r--r--src/view/com/feeds/FeedPage.tsx4
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx5
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx18
-rw-r--r--src/view/com/home/HomeHeaderLayout.web.tsx2
-rw-r--r--src/view/com/home/HomeHeaderLayoutMobile.tsx7
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx1
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx87
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx34
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx95
-rw-r--r--src/view/com/lists/ProfileLists.tsx18
-rw-r--r--src/view/com/modals/ChangeEmail.tsx2
-rw-r--r--src/view/com/modals/ChangePassword.tsx2
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx2
-rw-r--r--src/view/com/modals/EditProfile.tsx2
-rw-r--r--src/view/com/modals/Modal.tsx2
-rw-r--r--src/view/com/modals/Modal.web.tsx1
-rw-r--r--src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx1
-rw-r--r--src/view/com/modals/lang-settings/LanguageToggle.tsx1
-rw-r--r--src/view/com/notifications/FeedItem.tsx8
-rw-r--r--src/view/com/pager/Pager.tsx13
-rw-r--r--src/view/com/pager/Pager.web.tsx9
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx12
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx2
-rw-r--r--src/view/com/post-thread/PostQuotes.tsx2
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadComposePrompt.tsx1
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx289
-rw-r--r--src/view/com/post-thread/PostThreadShowHiddenReplies.tsx1
-rw-r--r--src/view/com/posts/AviFollowButton.tsx22
-rw-r--r--src/view/com/posts/DiscoverFallbackHeader.tsx1
-rw-r--r--src/view/com/posts/Feed.tsx290
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx10
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/posts/FeedSlice.tsx166
-rw-r--r--src/view/com/posts/ViewFullThread.tsx72
-rw-r--r--src/view/com/profile/FollowButton.tsx1
-rw-r--r--src/view/com/profile/ProfileSubpageHeader.tsx18
-rw-r--r--src/view/com/testing/TestCtrls.e2e.tsx1
-rw-r--r--src/view/com/util/BottomSheetCustomBackdrop.tsx2
-rw-r--r--src/view/com/util/EmptyState.tsx1
-rw-r--r--src/view/com/util/EmptyStateWithButton.tsx1
-rw-r--r--src/view/com/util/ErrorBoundary.tsx2
-rw-r--r--src/view/com/util/FeedInfoText.tsx1
-rw-r--r--src/view/com/util/Link.tsx19
-rw-r--r--src/view/com/util/List.tsx10
-rw-r--r--src/view/com/util/List.web.tsx4
-rw-r--r--src/view/com/util/LoadMoreRetryBtn.tsx1
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx13
-rw-r--r--src/view/com/util/LoadingScreen.tsx1
-rw-r--r--src/view/com/util/MainScrollProvider.tsx66
-rw-r--r--src/view/com/util/PostMeta.tsx6
-rw-r--r--src/view/com/util/PressableWithHover.tsx2
-rw-r--r--src/view/com/util/Selector.tsx148
-rw-r--r--src/view/com/util/Toast.tsx203
-rw-r--r--src/view/com/util/Toast.web.tsx17
-rw-r--r--src/view/com/util/UserInfoText.tsx1
-rw-r--r--src/view/com/util/anim/TriggerableAnimated.tsx74
-rw-r--r--src/view/com/util/error/ErrorMessage.tsx1
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx1
-rw-r--r--src/view/com/util/fab/FAB.web.tsx1
-rw-r--r--src/view/com/util/fab/FABInner.tsx2
-rw-r--r--src/view/com/util/forms/DateInput.tsx2
-rw-r--r--src/view/com/util/forms/DateInput.web.tsx2
-rw-r--r--src/view/com/util/forms/NativeDropdown.tsx24
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx702
-rw-r--r--src/view/com/util/forms/PostDropdownBtnMenuItems.tsx751
-rw-r--r--src/view/com/util/forms/RadioButton.tsx1
-rw-r--r--src/view/com/util/forms/RadioGroup.tsx2
-rw-r--r--src/view/com/util/forms/SelectableBtn.tsx1
-rw-r--r--src/view/com/util/forms/ToggleButton.tsx1
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx40
-rw-r--r--src/view/com/util/images/Gallery.tsx13
-rw-r--r--src/view/com/util/images/Image.tsx1
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx14
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx1
-rw-r--r--src/view/com/util/numeric/__tests__/format-test.ts92
-rw-r--r--src/view/com/util/numeric/format.ts47
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx196
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx4
-rw-r--r--src/view/com/util/post-embeds/ExternalGifEmbed.tsx30
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx54
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.web.tsx2
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx16
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx6
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx19
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx7
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx4
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx5
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx1
-rw-r--r--src/view/com/util/post-embeds/index.tsx14
-rw-r--r--src/view/com/util/text/RichText.tsx201
-rw-r--r--src/view/com/util/text/Text.tsx31
-rw-r--r--src/view/icons/Logomark.tsx1
-rw-r--r--src/view/icons/Logotype.tsx1
-rw-r--r--src/view/screens/Home.tsx9
-rw-r--r--src/view/screens/Lists.tsx22
-rw-r--r--src/view/screens/ModerationModlists.tsx22
-rw-r--r--src/view/screens/Storybook/Admonitions.tsx1
-rw-r--r--src/view/screens/Storybook/Breakpoints.tsx1
-rw-r--r--src/view/screens/Storybook/Icons.tsx1
-rw-r--r--src/view/screens/Storybook/Links.tsx1
-rw-r--r--src/view/screens/Storybook/Menus.tsx1
-rw-r--r--src/view/screens/Storybook/Palette.tsx1
-rw-r--r--src/view/screens/Storybook/Settings.tsx1
-rw-r--r--src/view/screens/Storybook/Shadows.tsx1
-rw-r--r--src/view/screens/Storybook/Spacing.tsx1
-rw-r--r--src/view/screens/Storybook/Theming.tsx1
-rw-r--r--src/view/screens/Storybook/Typography.tsx1
-rw-r--r--src/view/shell/Composer.tsx2
-rw-r--r--src/view/shell/Drawer.tsx69
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx2
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx2
-rw-r--r--src/view/shell/desktop/Feeds.tsx1
-rw-r--r--src/view/shell/desktop/RightNav.tsx1
-rw-r--r--src/view/shell/index.tsx8
144 files changed, 2476 insertions, 2299 deletions
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx
index ae18f1390..e205bb540 100644
--- a/src/view/com/auth/SplashScreen.tsx
+++ b/src/view/com/auth/SplashScreen.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {msg, Trans} from '@lingui/macro'
diff --git a/src/view/com/auth/util/HelpTip.tsx b/src/view/com/auth/util/HelpTip.tsx
index 0fac86bec..196f30412 100644
--- a/src/view/com/auth/util/HelpTip.tsx
+++ b/src/view/com/auth/util/HelpTip.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, View} from 'react-native'
 
 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
diff --git a/src/view/com/auth/util/TextInput.tsx b/src/view/com/auth/util/TextInput.tsx
index 0ccbe6ac4..083dda555 100644
--- a/src/view/com/auth/util/TextInput.tsx
+++ b/src/view/com/auth/util/TextInput.tsx
@@ -1,4 +1,4 @@
-import React, {ComponentProps} from 'react'
+import {ComponentProps} from 'react'
 import {StyleSheet, TextInput as RNTextInput, View} from 'react-native'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1899966dc..e4b09cf0f 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -56,12 +56,18 @@ import {useQueryClient} from '@tanstack/react-query'
 import * as apilib from '#/lib/api/index'
 import {EmbeddingDisabledError} from '#/lib/api/resolve'
 import {until} from '#/lib/async/until'
-import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
+import {
+  MAX_GRAPHEME_LENGTH,
+  SUPPORTED_MIME_TYPES,
+  SupportedMimeTypes,
+} from '#/lib/constants'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useEmail} from '#/lib/hooks/useEmail'
 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {mimeToExt} from '#/lib/media/video/util'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {colors, s} from '#/lib/styles'
@@ -110,6 +116,8 @@ import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -127,6 +135,8 @@ import {
   ThreadDraft,
 } from './state/composer'
 import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
+import {getVideoMetadata} from './videos/pickVideo'
+import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
 
 type CancelRef = {
   onPressCancel: () => void
@@ -246,7 +256,8 @@ export const ComposePost = ({
 
   const onClose = useCallback(() => {
     closeComposer()
-  }, [closeComposer])
+    clearThumbnailCache(queryClient)
+  }, [closeComposer, queryClient])
 
   const insets = useSafeAreaInsets()
   const viewStyles = useMemo(
@@ -297,6 +308,15 @@ export const ComposePost = ({
     }
   }, [onPressCancel, closeAllDialogs, closeAllModals])
 
+  const {needsEmailVerification} = useEmail()
+  const emailVerificationControl = useDialogControl()
+
+  useEffect(() => {
+    if (needsEmailVerification) {
+      emailVerificationControl.open()
+    }
+  }, [needsEmailVerification, emailVerificationControl])
+
   const missingAltError = useMemo(() => {
     if (!requireAltTextEnabled) {
       return
@@ -570,6 +590,15 @@ export const ComposePost = ({
   const isWebFooterSticky = !isNative && thread.posts.length > 1
   return (
     <BottomSheetPortalProvider>
+      <VerifyEmailDialog
+        control={emailVerificationControl}
+        onCloseWithoutVerifying={() => {
+          onClose()
+        }}
+        reasonText={_(
+          msg`Before creating a post, you must first verify your email.`,
+        )}
+      />
       <KeyboardAvoidingView
         testID="composePostView"
         behavior={isIOS ? 'padding' : 'height'}
@@ -723,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({
 
   const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (uri.startsWith('data:video/')) {
-        onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
+      if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
+        if (isNative) return // web only
+        const [mimeType] = uri.slice('data:'.length).split(';')
+        if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
+          Toast.show(_(msg`Unsupported video type`), 'xmark')
+          return
+        }
+        const name = `pasted.${mimeToExt(mimeType)}`
+        const file = await fetch(uri)
+          .then(res => res.blob())
+          .then(blob => new File([blob], name, {type: mimeType}))
+        onSelectVideo(post.id, await getVideoMetadata(file))
       } else {
         const res = await pasteImage(uri)
         onImageAdd([res])
       }
     },
-    [post.id, onSelectVideo, onImageAdd],
+    [post.id, onSelectVideo, onImageAdd, _],
   )
 
   return (
@@ -986,17 +1025,6 @@ function ComposerEmbeds({
                   asset={video.asset}
                   video={video.video}
                   isActivePost={isActivePost}
-                  setDimensions={(width: number, height: number) => {
-                    dispatch({
-                      type: 'embed_update_video',
-                      videoAction: {
-                        type: 'update_dimensions',
-                        width,
-                        height,
-                        signal: video.abortController.signal,
-                      },
-                    })
-                  }}
                   clear={clearVideo}
                 />
               ) : null)}
@@ -1244,12 +1272,12 @@ function useScrollTracker({
   const contentHeight = useSharedValue(0)
 
   const hasScrolledToTop = useDerivedValue(() =>
-    withTiming(contentOffset.value === 0 ? 1 : 0),
+    withTiming(contentOffset.get() === 0 ? 1 : 0),
   )
 
   const hasScrolledToBottom = useDerivedValue(() =>
     withTiming(
-      contentHeight.value - contentOffset.value - 5 <= scrollViewHeight.value
+      contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get()
         ? 1
         : 0,
     ),
@@ -1267,11 +1295,11 @@ function useScrollTracker({
     }) => {
       'worklet'
       if (typeof newContentHeight === 'number')
-        contentHeight.value = Math.floor(newContentHeight)
+        contentHeight.set(Math.floor(newContentHeight))
       if (typeof newContentOffset === 'number')
-        contentOffset.value = Math.floor(newContentOffset)
+        contentOffset.set(Math.floor(newContentOffset))
       if (typeof newScrollViewHeight === 'number')
-        scrollViewHeight.value = Math.floor(newScrollViewHeight)
+        scrollViewHeight.set(Math.floor(newScrollViewHeight))
     },
     [contentHeight, contentOffset, scrollViewHeight],
   )
@@ -1287,21 +1315,22 @@ function useScrollTracker({
     },
   })
 
-  const onScrollViewContentSizeChange = useCallback(
-    (_width: number, height: number) => {
-      if (stickyBottom && height > contentHeight.value) {
+  const onScrollViewContentSizeChangeUIThread = useCallback(
+    (newContentHeight: number) => {
+      'worklet'
+      const oldContentHeight = contentHeight.get()
+      let shouldScrollToBottom = false
+      if (stickyBottom && newContentHeight > oldContentHeight) {
         const isFairlyCloseToBottom =
-          contentHeight.value - contentOffset.value - 100 <=
-          scrollViewHeight.value
+          oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get()
         if (isFairlyCloseToBottom) {
-          runOnUI(() => {
-            scrollTo(scrollViewRef, 0, contentHeight.value, true)
-          })()
+          shouldScrollToBottom = true
         }
       }
-      showHideBottomBorder({
-        newContentHeight: height,
-      })
+      showHideBottomBorder({newContentHeight})
+      if (shouldScrollToBottom) {
+        scrollTo(scrollViewRef, 0, newContentHeight, true)
+      }
     },
     [
       showHideBottomBorder,
@@ -1313,6 +1342,13 @@ function useScrollTracker({
     ],
   )
 
+  const onScrollViewContentSizeChange = useCallback(
+    (_width: number, height: number) => {
+      runOnUI(onScrollViewContentSizeChangeUIThread)(height)
+    },
+    [onScrollViewContentSizeChangeUIThread],
+  )
+
   const onScrollViewLayout = useCallback(
     (evt: LayoutChangeEvent) => {
       showHideBottomBorder({
@@ -1326,7 +1362,7 @@ function useScrollTracker({
     return {
       borderBottomWidth: StyleSheet.hairlineWidth,
       borderColor: interpolateColor(
-        hasScrolledToTop.value,
+        hasScrolledToTop.get(),
         [0, 1],
         [t.atoms.border_contrast_medium.borderColor, 'transparent'],
       ),
@@ -1336,7 +1372,7 @@ function useScrollTracker({
     return {
       borderTopWidth: StyleSheet.hairlineWidth,
       borderColor: interpolateColor(
-        hasScrolledToBottom.value,
+        hasScrolledToBottom.get(),
         [0, 1],
         [t.atoms.border_contrast_medium.borderColor, 'transparent'],
       ),
@@ -1581,7 +1617,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
 
   const animatedStyle = useAnimatedStyle(() => {
     return {
-      transform: [{rotateZ: `${rotate.value}deg`}],
+      transform: [{rotateZ: `${rotate.get()}deg`}],
     }
   })
 
diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
index 3ef9dad47..92102f847 100644
--- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
+++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index ef5f8a3a5..bd99b9f28 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react'
+import {useState} from 'react'
 import {TouchableOpacity, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx
index c7d9628d6..f2734e4ec 100644
--- a/src/view/com/composer/char-progress/CharProgress.tsx
+++ b/src/view/com/composer/char-progress/CharProgress.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
 // @ts-ignore no type definition -prf
 import ProgressCircle from 'react-native-progress/Circle'
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 75eaa33d7..0718a1928 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Keyboard, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -158,22 +157,26 @@ function DialogInner({
                   <Toggle.Item name="porn" label={_(msg`Porn`)}>
                     <Toggle.Checkbox />
                     <Toggle.LabelText>
-                      <Trans>Porn</Trans>
+                      <Trans>Adult</Trans>
                     </Toggle.LabelText>
                   </Toggle.Item>
                 </View>
               </Toggle.Group>
-              <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
-                {labels.includes('sexual') ? (
-                  <Trans>Pictures meant for adults.</Trans>
-                ) : labels.includes('nudity') ? (
-                  <Trans>Artistic or non-erotic nudity.</Trans>
-                ) : labels.includes('porn') ? (
-                  <Trans>Sexual activity or erotic nudity.</Trans>
-                ) : (
-                  <Trans>Does not contain adult content.</Trans>
-                )}
-              </Text>
+              {labels.includes('sexual') ||
+              labels.includes('nudity') ||
+              labels.includes('porn') ? (
+                <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
+                  {labels.includes('sexual') ? (
+                    <Trans>Pictures meant for adults.</Trans>
+                  ) : labels.includes('nudity') ? (
+                    <Trans>Artistic or non-erotic nudity.</Trans>
+                  ) : labels.includes('porn') ? (
+                    <Trans>Sexual activity or erotic nudity.</Trans>
+                  ) : (
+                    ''
+                  )}
+                </Text>
+              ) : null}
             </View>
           </View>
           <View>
@@ -203,16 +206,14 @@ function DialogInner({
                   </Toggle.LabelText>
                 </Toggle.Item>
               </Toggle.Group>
-              <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
-                {labels.includes('graphic-media') ? (
+              {labels.includes('graphic-media') ? (
+                <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
                   <Trans>
                     Media that may be disturbing or inappropriate for some
                     audiences.
                   </Trans>
-                ) : (
-                  <Trans>Does not contain graphic or disturbing content.</Trans>
-                )}
-              </Text>
+                </Text>
+              ) : null}
             </View>
           </View>
         </View>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 79d59a92d..fb3ab5c8f 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import * as MediaLibrary from 'expo-media-library'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx
index 74f9acdc6..3fb0e00d2 100644
--- a/src/view/com/composer/photos/SelectGifBtn.tsx
+++ b/src/view/com/composer/photos/SelectGifBtn.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useRef} from 'react'
+import {useCallback, useRef} from 'react'
 import {Keyboard} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 37bfbafe6..f4c6aa328 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -1,5 +1,5 @@
 /* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 94dbc35c6..cd3cb608d 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo} from 'react'
+import {useCallback, useMemo} from 'react'
 import {Keyboard, StyleSheet} from 'react-native'
 import {
   FontAwesomeIcon,
diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx
index e915f4c66..6d55aeb53 100644
--- a/src/view/com/composer/select-language/SuggestedLanguage.tsx
+++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useState} from 'react'
+import {useEffect, useState} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {
   FontAwesomeIcon,
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index 8814a7e61..7ce4a0cf8 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -37,12 +37,6 @@ export type VideoAction =
     }
   | {type: 'update_progress'; progress: number; signal: AbortSignal}
   | {
-      type: 'update_dimensions'
-      width: number
-      height: number
-      signal: AbortSignal
-    }
-  | {
       type: 'update_alt_text'
       altText: string
       signal: AbortSignal
@@ -185,13 +179,6 @@ export function videoReducer(
         progress: action.progress,
       }
     }
-  } else if (action.type === 'update_dimensions') {
-    if (state.asset) {
-      return {
-        ...state,
-        asset: {...state.asset, width: action.width, height: action.height},
-      }
-    }
   } else if (action.type === 'update_alt_text') {
     return {
       ...state,
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 10cf1a931..96cecb37c 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -31,7 +31,7 @@ import {
   suggestLinkCardUri,
 } from '#/view/com/composer/text-input/text-input-util'
 import {atoms as a, useAlf} from '#/alf'
-import {normalizeTextStyles} from '#/components/Typography'
+import {normalizeTextStyles} from '#/alf/typography'
 import {Autocomplete} from './mobile/Autocomplete'
 
 export interface TextInputRef {
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index fa742d258..8ec4fefa8 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -11,6 +11,7 @@ import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
 import {Text as TiptapText} from '@tiptap/extension-text'
 import {generateJSON} from '@tiptap/html'
+import {Fragment, Node, Slice} from '@tiptap/pm/model'
 import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
 
 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
@@ -23,8 +24,8 @@ import {
 } from '#/view/com/composer/text-input/text-input-util'
 import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
 import {atoms as a, useAlf} from '#/alf'
+import {normalizeTextStyles} from '#/alf/typography'
 import {Portal} from '#/components/Portal'
-import {normalizeTextStyles} from '#/components/Typography'
 import {Text} from '../../util/text/Text'
 import {createSuggestion} from './web/Autocomplete'
 import {Emoji} from './web/EmojiPicker.web'
@@ -166,6 +167,11 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   const editor = useEditor(
     {
       extensions,
+      coreExtensionOptions: {
+        clipboardTextSerializer: {
+          blockSeparator: '\n',
+        },
+      },
       onFocus() {
         onFocus?.()
       },
@@ -173,6 +179,20 @@ export const TextInput = React.forwardRef(function TextInputImpl(
         attributes: {
           class: modeClass,
         },
+        clipboardTextParser: (text, context) => {
+          const blocks = text.split(/(?:\r\n?|\n)/)
+          const nodes: Node[] = blocks.map(line => {
+            return Node.fromJSON(
+              context.doc.type.schema,
+              line.length > 0
+                ? {type: 'paragraph', content: [{type: 'text', text: line}]}
+                : {type: 'paragraph', content: []},
+            )
+          })
+
+          const fragment = Fragment.fromArray(nodes)
+          return Slice.maxOpen(fragment)
+        },
         handlePaste: (view, event) => {
           const clipboardData = event.clipboardData
           let preventDefault = false
@@ -205,6 +225,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       autofocus: 'end',
       editable: true,
       injectCSS: true,
+      shouldRerenderOnTransaction: false,
       onCreate({editor: editorProp}) {
         // HACK
         // the 'enter' animation sometimes causes autofocus to fail
@@ -297,15 +318,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     style.lineHeight = style.lineHeight
       ? ((style.lineHeight + 'px') as unknown as number)
       : undefined
+    style.minHeight = webForceMinHeight ? 140 : undefined
     return style
-  }, [t, fonts])
-
-  React.useLayoutEffect(() => {
-    let node = editor?.view.dom
-    if (node) {
-      node.style.minHeight = webForceMinHeight ? '140px' : ''
-    }
-  }, [editor, webForceMinHeight])
+  }, [t, fonts, webForceMinHeight])
 
   return (
     <>
diff --git a/src/view/com/composer/text-input/hooks/useGrapheme.tsx b/src/view/com/composer/text-input/hooks/useGrapheme.tsx
index 01b5b9698..aa375ff47 100644
--- a/src/view/com/composer/text-input/hooks/useGrapheme.tsx
+++ b/src/view/com/composer/text-input/hooks/useGrapheme.tsx
@@ -13,7 +13,7 @@ export const useGrapheme = () => {
 
         if (graphemes.length > length) {
           remainingCharacters = 0
-          name = `${graphemes.slice(0, length).join('')}...`
+          name = `${graphemes.slice(0, length).join('')}…`
         } else {
           remainingCharacters = length - graphemes.length
           name = graphemes.join('')
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index 3d2bcfa61..0fda6843b 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -1,7 +1,5 @@
-import React, {useRef} from 'react'
 import {View} from 'react-native'
 import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated'
-import {AppBskyActorDefs} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 
 import {PressableScale} from '#/lib/custom-animations/PressableScale'
@@ -11,7 +9,6 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
-import {useGrapheme} from '../hooks/useGrapheme'
 
 export function Autocomplete({
   prefix,
@@ -22,15 +19,11 @@ export function Autocomplete({
 }) {
   const t = useTheme()
 
-  const {getGraphemeString} = useGrapheme()
   const isActive = !!prefix
-  const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
-  const suggestionsRef = useRef<
-    AppBskyActorDefs.ProfileViewBasic[] | undefined
-  >(undefined)
-  if (suggestions) {
-    suggestionsRef.current = suggestions
-  }
+  const {data: suggestions, isFetching} = useActorAutocompleteQuery(
+    prefix,
+    true,
+  )
 
   if (!isActive) return null
 
@@ -46,26 +39,8 @@ export function Autocomplete({
         t.atoms.border_contrast_high,
         {marginLeft: -62},
       ]}>
-      {suggestionsRef.current?.length ? (
-        suggestionsRef.current.slice(0, 5).map((item, index, arr) => {
-          // Eventually use an average length
-          const MAX_CHARS = 40
-          const MAX_HANDLE_CHARS = 20
-
-          // Using this approach because styling is not respecting
-          // bounding box wrapping (before converting to ellipsis)
-          const {name: displayHandle, remainingCharacters} = getGraphemeString(
-            item.handle,
-            MAX_HANDLE_CHARS,
-          )
-
-          const {name: displayName} = getGraphemeString(
-            item.displayName || item.handle,
-            MAX_CHARS -
-              MAX_HANDLE_CHARS +
-              (remainingCharacters > 0 ? remainingCharacters : 0),
-          )
-
+      {suggestions?.length ? (
+        suggestions.slice(0, 5).map((item, index, arr) => {
           return (
             <View
               style={[
@@ -93,15 +68,23 @@ export function Autocomplete({
                     type={item.associated?.labeler ? 'labeler' : 'user'}
                   />
                   <Text
-                    style={[a.text_md, a.font_bold]}
-                    emoji={true}
+                    style={[a.flex_1, a.text_md, a.font_bold]}
+                    emoji
+                    numberOfLines={1}>
+                    {sanitizeDisplayName(
+                      item.displayName || sanitizeHandle(item.handle),
+                    )}
+                  </Text>
+                  <Text
+                    style={[
+                      t.atoms.text_contrast_medium,
+                      a.text_right,
+                      {maxWidth: '50%'},
+                    ]}
                     numberOfLines={1}>
-                    {sanitizeDisplayName(displayName)}
+                    {sanitizeHandle(item.handle, '@')}
                   </Text>
                 </View>
-                <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}>
-                  {sanitizeHandle(displayHandle, '@')}
-                </Text>
               </PressableScale>
             </View>
           )
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index a43e67c04..f40c2ee8d 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -1,9 +1,4 @@
-import React, {
-  forwardRef,
-  useEffect,
-  useImperativeHandle,
-  useState,
-} from 'react'
+import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
 import {Trans} from '@lingui/macro'
 import {ReactRenderer} from '@tiptap/react'
@@ -15,6 +10,8 @@ import {
 import tippy, {Instance as TippyInstance} from 'tippy.js'
 
 import {usePalette} from '#/lib/hooks/usePalette'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
 import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {Text} from '#/view/com/util/text/Text'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -153,7 +150,9 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
           {items.length > 0 ? (
             items.map((item, index) => {
               const {name: displayName} = getGraphemeString(
-                item.displayName ?? item.handle,
+                sanitizeDisplayName(
+                  item.displayName || sanitizeHandle(item.handle),
+                ),
                 30, // Heuristic value; can be modified
               )
               const isSelected = selectedIndex === index
@@ -186,7 +185,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
                     </Text>
                   </View>
                   <Text type="xs" style={pal.textLight} numberOfLines={1}>
-                    @{item.handle}
+                    {sanitizeHandle(item.handle, '@')}
                   </Text>
                 </Pressable>
               )
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index 78bf8c06f..4130cc7e4 100644
--- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Keyboard, StyleProp, ViewStyle} from 'react-native'
 import {AnimatedStyle} from 'react-native-reanimated'
 import {AppBskyFeedPostgate} from '@atproto/api'
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index 2ba003a6d..1b052ccdd 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,11 +1,6 @@
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import {Keyboard} from 'react-native'
-import {
-  ImagePickerAsset,
-  launchImageLibraryAsync,
-  MediaTypeOptions,
-  UIImagePickerPreferredAssetRepresentationMode,
-} from 'expo-image-picker'
+import {ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog'
 import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
 import * as Prompt from '#/components/Prompt'
+import {pickVideo} from './pickVideo'
 
 const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
 
@@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
       Keyboard.dismiss()
       control.open()
     } else {
-      const response = await launchImageLibraryAsync({
-        exif: false,
-        mediaTypes: MediaTypeOptions.Videos,
-        quality: 1,
-        legacy: true,
-        preferredAssetRepresentationMode:
-          UIImagePickerPreferredAssetRepresentationMode.Current,
-      })
+      const response = await pickVideo()
       if (response.assets && response.assets.length > 0) {
         const asset = response.assets[0]
         try {
           if (isWeb) {
+            // asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
+            if (asset.duration && asset.duration > VIDEO_MAX_DURATION) {
+              throw Error(_(msg`Videos must be less than 60 seconds long`))
+            }
             // compression step on native converts to mp4, so no need to check there
-            const mimeType = getMimeType(asset)
             if (
-              !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
+              !SUPPORTED_MIME_TYPES.includes(
+                asset.mimeType as SupportedMimeTypes,
+              )
             ) {
-              throw Error(_(msg`Unsupported video type: ${mimeType}`))
+              throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
             }
           } else {
             if (typeof asset.duration !== 'number') {
@@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
     </>
   )
 }
-
-function getMimeType(asset: ImagePickerAsset) {
-  if (isWeb) {
-    const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
-    if (!mimeType) {
-      throw new Error('Could not determine mime type')
-    }
-    return mimeType
-  }
-  if (!asset.mimeType) {
-    throw new Error('Could not determine mime type')
-  }
-  return asset.mimeType
-}
diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx
index 27c3de02b..e907dc41c 100644
--- a/src/view/com/composer/videos/SubtitleDialog.tsx
+++ b/src/view/com/composer/videos/SubtitleDialog.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import {useCallback, useState} from 'react'
 import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
 import RNPickerSelect from 'react-native-picker-select'
 import {msg, Trans} from '@lingui/macro'
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index fff7545a5..255174bea 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -20,7 +20,6 @@ export function VideoPreview({
   asset: ImagePickerAsset
   video: CompressedVideo
   isActivePost: boolean
-  setDimensions: (width: number, height: number) => void
   clear: () => void
 }) {
   const t = useTheme()
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index ccb3391c2..f20f8b383 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -1,4 +1,3 @@
-import React, {useEffect, useRef} from 'react'
 import {View} from 'react-native'
 import {ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
@@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
 
-const MAX_DURATION = 60
-
 export function VideoPreview({
   asset,
   video,
-  setDimensions,
+
   clear,
 }: {
   asset: ImagePickerAsset
   video: CompressedVideo
-  setDimensions: (width: number, height: number) => void
+
   clear: () => void
 }) {
-  const ref = useRef<HTMLVideoElement>(null)
   const {_} = useLingui()
+  // TODO: figure out how to pause a GIF for reduced motion
+  // it's not possible using an img tag -sfn
   const autoplayDisabled = useAutoplayDisabled()
 
-  useEffect(() => {
-    if (!ref.current) return
-
-    const abortController = new AbortController()
-    const {signal} = abortController
-    ref.current.addEventListener(
-      'loadedmetadata',
-      function () {
-        setDimensions(this.videoWidth, this.videoHeight)
-        if (!isNaN(this.duration)) {
-          if (this.duration > MAX_DURATION) {
-            Toast.show(
-              _(msg`Videos must be less than 60 seconds long`),
-              'xmark',
-            )
-            clear()
-          }
-        }
-      },
-      {signal},
-    )
-    ref.current.addEventListener(
-      'error',
-      () => {
-        Toast.show(_(msg`Could not process your video`), 'xmark')
-        clear()
-      },
-      {signal},
-    )
-
-    return () => {
-      abortController.abort()
-    }
-  }, [setDimensions, _, clear])
-
   let aspectRatio = asset.width / asset.height
 
   if (isNaN(aspectRatio)) {
@@ -83,19 +46,34 @@ export function VideoPreview({
         a.relative,
       ]}>
       <ExternalEmbedRemoveBtn onRemove={clear} />
-      <video
-        ref={ref}
-        src={video.uri}
-        style={{width: '100%', height: '100%', objectFit: 'cover'}}
-        autoPlay={!autoplayDisabled}
-        loop
-        muted
-        playsInline
-      />
-      {autoplayDisabled && (
-        <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
-          <PlayButtonIcon />
-        </View>
+      {video.mimeType === 'image/gif' ? (
+        <img
+          src={video.uri}
+          style={{width: '100%', height: '100%', objectFit: 'cover'}}
+          alt="GIF"
+        />
+      ) : (
+        <>
+          <video
+            src={video.uri}
+            style={{width: '100%', height: '100%', objectFit: 'cover'}}
+            autoPlay={!autoplayDisabled}
+            loop
+            muted
+            playsInline
+            onError={err => {
+              console.error('Error loading video', err)
+              Toast.show(_(msg`Could not process your video`), 'xmark')
+              clear()
+            }}
+          />
+          {autoplayDisabled && (
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <PlayButtonIcon />
+            </View>
+          )}
+        </>
       )}
     </View>
   )
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
index ef38e62af..caf0b38e2 100644
--- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
@@ -1,25 +1,25 @@
-import React, {useEffect} from 'react'
 import {clearCache, createVideoThumbnail} from 'react-native-compressor'
 import Animated, {FadeIn} from 'react-native-reanimated'
 import {Image} from 'expo-image'
-import {useQuery} from '@tanstack/react-query'
+import {QueryClient, useQuery} from '@tanstack/react-query'
 
 import {atoms as a} from '#/alf'
 
+export const RQKEY = 'video-thumbnail'
+
+export function clearThumbnailCache(queryClient: QueryClient) {
+  clearCache()
+  queryClient.resetQueries({queryKey: [RQKEY]})
+}
+
 export function VideoTranscodeBackdrop({uri}: {uri: string}) {
   const {data: thumbnail} = useQuery({
-    queryKey: ['thumbnail', uri],
+    queryKey: [RQKEY, uri],
     queryFn: async () => {
       return await createVideoThumbnail(uri)
     },
   })
 
-  useEffect(() => {
-    return () => {
-      clearCache()
-    }
-  }, [])
-
   return (
     thumbnail && (
       <Animated.View style={a.flex_1} entering={FadeIn}>
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
index d4090d853..a04200f53 100644
--- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
@@ -1,3 +1,7 @@
+export function clearThumbnailCache() {
+  // no-op
+}
+
 export function VideoTranscodeBackdrop() {
   return null
 }
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index f6f0f7ccf..f408be720 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 // @ts-expect-error no type definition
 import ProgressPie from 'react-native-progress/Pie'
diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts
new file mode 100644
index 000000000..0edf7d0de
--- /dev/null
+++ b/src/view/com/composer/videos/pickVideo.ts
@@ -0,0 +1,21 @@
+import {
+  ImagePickerAsset,
+  launchImageLibraryAsync,
+  MediaTypeOptions,
+  UIImagePickerPreferredAssetRepresentationMode,
+} from 'expo-image-picker'
+
+export async function pickVideo() {
+  return await launchImageLibraryAsync({
+    exif: false,
+    mediaTypes: MediaTypeOptions.Videos,
+    quality: 1,
+    legacy: true,
+    preferredAssetRepresentationMode:
+      UIImagePickerPreferredAssetRepresentationMode.Current,
+  })
+}
+
+export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
+  throw new Error('getVideoMetadata is web only')
+}
diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts
new file mode 100644
index 000000000..56a38fa56
--- /dev/null
+++ b/src/view/com/composer/videos/pickVideo.web.ts
@@ -0,0 +1,94 @@
+import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker'
+
+import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
+
+// mostly copied from expo-image-picker and adapted to support gifs
+// also adds support for reading video metadata
+
+export async function pickVideo(): Promise<ImagePickerResult> {
+  const input = document.createElement('input')
+  input.style.display = 'none'
+  input.setAttribute('type', 'file')
+  // TODO: do we need video/* here? -sfn
+  input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
+  input.setAttribute('id', String(Math.random()))
+
+  document.body.appendChild(input)
+
+  return new Promise(resolve => {
+    input.addEventListener('change', async () => {
+      if (input.files) {
+        const file = input.files[0]
+        resolve({
+          canceled: false,
+          assets: [await getVideoMetadata(file)],
+        })
+      } else {
+        resolve({canceled: true, assets: null})
+      }
+      document.body.removeChild(input)
+    })
+
+    const event = new MouseEvent('click')
+    input.dispatchEvent(event)
+  })
+}
+
+// TODO: we're converting to a dataUrl here, and then converting back to an
+// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
+// lets us use the ImagePickerAsset type, which the rest of the code expects.
+// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
+// instead of a string -sfn
+export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => {
+      const uri = reader.result as string
+
+      if (file.type === 'image/gif') {
+        const img = new Image()
+        img.onload = () => {
+          resolve({
+            uri,
+            mimeType: 'image/gif',
+            width: img.width,
+            height: img.height,
+            // todo: calculate gif duration. seems possible if you read the bytes
+            // https://codepen.io/Ryman/pen/nZpYwY
+            // for now let's just let the server reject it, since that seems uncommon -sfn
+            duration: null,
+          })
+        }
+        img.onerror = (_ev, _source, _lineno, _colno, error) => {
+          console.log('Failed to grab GIF metadata', error)
+          reject(new Error('Failed to grab GIF metadata'))
+        }
+        img.src = uri
+      } else {
+        const video = document.createElement('video')
+        const blobUrl = URL.createObjectURL(file)
+
+        video.preload = 'metadata'
+        video.src = blobUrl
+
+        video.onloadedmetadata = () => {
+          URL.revokeObjectURL(blobUrl)
+          resolve({
+            uri,
+            mimeType: file.type,
+            width: video.videoWidth,
+            height: video.videoHeight,
+            // convert seconds to ms
+            duration: video.duration * 1000,
+          })
+        }
+        video.onerror = (_ev, _source, _lineno, _colno, error) => {
+          URL.revokeObjectURL(blobUrl)
+          console.log('Failed to grab video metadata', error)
+          reject(new Error('Failed to grab video metadata'))
+        }
+      }
+    }
+    reader.readAsDataURL(file)
+  })
+}
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index d61a81498..1028d7e64 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -74,7 +74,7 @@ export function FeedPage({
       scrollToTop()
       truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
       setHasNew(false)
-      logEvent('feed:refresh:sampled', {
+      logEvent('feed:refresh', {
         feedType: feed.split('|')[0],
         feedUrl: feed,
         reason: 'soft-reset',
@@ -98,7 +98,7 @@ export function FeedPage({
     scrollToTop()
     truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
     setHasNew(false)
-    logEvent('feed:refresh:sampled', {
+    logEvent('feed:refresh', {
       feedType: feed.split('|')[0],
       feedUrl: feed,
       reason: 'load-latest',
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 3276cf882..707aad7fb 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -162,7 +162,10 @@ export function FeedSourceCardLoaded({
         style={[
           pal.border,
           {
-            borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1,
+            borderTopWidth:
+              showMinimalPlaceholder || hideTopBorder
+                ? 0
+                : StyleSheet.hairlineWidth,
             flexDirection: 'row',
             alignItems: 'center',
             flex: 1,
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 693a8e361..64705ded8 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -1,8 +1,10 @@
 import React from 'react'
 import {
+  ActivityIndicator,
   findNodeHandle,
   ListRenderItemInfo,
   StyleProp,
+  StyleSheet,
   View,
   ViewStyle,
 } from 'react-native'
@@ -10,6 +12,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
 import {isNative, isWeb} from '#/platform/detection'
@@ -57,6 +60,7 @@ export const ProfileFeedgens = React.forwardRef<
     data,
     isFetching,
     isFetched,
+    isFetchingNextPage,
     hasNextPage,
     fetchNextPage,
     isError,
@@ -65,6 +69,7 @@ export const ProfileFeedgens = React.forwardRef<
   } = useProfileFeedgensQuery(did, opts)
   const isEmpty = !isFetching && !data?.pages[0]?.feeds.length
   const {data: preferences} = usePreferencesQuery()
+  const {isMobile} = useWebMediaQueries()
 
   const items = React.useMemo(() => {
     let items: any[] = []
@@ -180,6 +185,12 @@ export const ProfileFeedgens = React.forwardRef<
     }
   }, [enabled, scrollElRef, setScrollViewTag])
 
+  const ProfileFeedgensFooter = React.useCallback(() => {
+    return isFetchingNextPage ? (
+      <ActivityIndicator style={[styles.footer]} />
+    ) : null
+  }, [isFetchingNextPage])
+
   return (
     <View testID={testID} style={style}>
       <List
@@ -188,11 +199,12 @@ export const ProfileFeedgens = React.forwardRef<
         data={items}
         keyExtractor={(item: any) => item._reactKey || item.uri}
         renderItem={renderItem}
+        ListFooterComponent={ProfileFeedgensFooter}
         refreshing={isPTRing}
         onRefresh={onRefresh}
         headerOffset={headerOffset}
         progressViewOffset={ios(0)}
-        contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
+        contentContainerStyle={isMobile && {paddingBottom: headerOffset + 100}}
         indicatorStyle={t.name === 'light' ? 'black' : 'white'}
         removeClippedSubviews={true}
         // @ts-ignore our .web version only -prf
@@ -202,3 +214,7 @@ export const ProfileFeedgens = React.forwardRef<
     </View>
   )
 })
+
+const styles = StyleSheet.create({
+  footer: {paddingTop: 20},
+})
diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx
index 7049306eb..bdfc2c7ff 100644
--- a/src/view/com/home/HomeHeaderLayout.web.tsx
+++ b/src/view/com/home/HomeHeaderLayout.web.tsx
@@ -93,7 +93,7 @@ function HomeHeaderLayoutDesktopAndTablet({
       {tabBarAnchor}
       <Animated.View
         onLayout={e => {
-          headerHeight.value = e.nativeEvent.layout.height
+          headerHeight.set(e.nativeEvent.layout.height)
         }}
         style={[
           t.atoms.bg,
diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx
index f5397d717..832396092 100644
--- a/src/view/com/home/HomeHeaderLayoutMobile.tsx
+++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx
@@ -41,12 +41,12 @@ export function HomeHeaderLayoutMobile({
 
   return (
     <Animated.View
-      style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
+      style={[pal.border, styles.tabBar, headerMinimalShellTransform]}
       onLayout={e => {
-        headerHeight.value = e.nativeEvent.layout.height
+        headerHeight.set(e.nativeEvent.layout.height)
       }}>
       <View style={[pal.view, styles.topBar]}>
-        <View style={[pal.view, {width: 100}]}>
+        <View style={[{width: 100}]}>
           <TouchableOpacity
             testID="viewHeaderDrawerBtn"
             onPress={onPressAvi}
@@ -68,7 +68,6 @@ export function HomeHeaderLayoutMobile({
             atoms.justify_end,
             atoms.align_center,
             atoms.gap_md,
-            pal.view,
             {width: 100},
           ]}>
           {IS_DEV && (
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
index e7caa58a8..7a37c7e41 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
@@ -5,7 +5,6 @@
  * LICENSE file in the root directory of this source tree.
  *
  */
-import React from 'react'
 import {
   SafeAreaView,
   StyleSheet,
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 260787d2f..8e046e5ba 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -87,11 +87,11 @@ const ImageItem = ({
   // Note: DO NOT move any logic reading animated values outside this function.
   useAnimatedReaction(
     () => {
-      if (pinchScale.value !== 1) {
+      if (pinchScale.get() !== 1) {
         // We're currently pinching.
         return true
       }
-      const [, , committedScale] = readTransform(committedTransform.value)
+      const [, , committedScale] = readTransform(committedTransform.get())
       if (committedScale !== 1) {
         // We started from a pinched in state.
         return true
@@ -147,10 +147,10 @@ const ImageItem = ({
     .onStart(e => {
       'worklet'
       const screenSize = measureSafeArea()
-      pinchOrigin.value = {
+      pinchOrigin.set({
         x: e.focalX - screenSize.width / 2,
         y: e.focalY - screenSize.height / 2,
-      }
+      })
     })
     .onChange(e => {
       'worklet'
@@ -160,7 +160,7 @@ const ImageItem = ({
       }
       // 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 [, , committedScale] = readTransform(committedTransform.get())
       const maxCommittedScale = Math.max(
         MIN_SCREEN_ZOOM,
         (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
@@ -171,20 +171,21 @@ const ImageItem = ({
         Math.max(minPinchScale, e.scale),
         maxPinchScale,
       )
-      pinchScale.value = nextPinchScale
+      pinchScale.set(nextPinchScale)
 
       // 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)
+      prependPan(t, panTranslation.get())
+      prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get())
+      prependTransform(t, committedTransform.get())
       const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
       if (dx !== 0 || dy !== 0) {
-        pinchTranslation.value = {
-          x: pinchTranslation.value.x + dx,
-          y: pinchTranslation.value.y + dy,
-        }
+        const pt = pinchTranslation.get()
+        pinchTranslation.set({
+          x: pt.x + dx,
+          y: pt.y + dy,
+        })
       }
     })
     .onEnd(() => {
@@ -193,18 +194,18 @@ const ImageItem = ({
       let t = createTransform()
       prependPinch(
         t,
-        pinchScale.value,
-        pinchOrigin.value,
-        pinchTranslation.value,
+        pinchScale.get(),
+        pinchOrigin.get(),
+        pinchTranslation.get(),
       )
-      prependTransform(t, committedTransform.value)
+      prependTransform(t, committedTransform.get())
       applyRounding(t)
-      committedTransform.value = t
+      committedTransform.set(t)
 
       // Reset just the pinch.
-      pinchScale.value = 1
-      pinchOrigin.value = {x: 0, y: 0}
-      pinchTranslation.value = {x: 0, y: 0}
+      pinchScale.set(1)
+      pinchOrigin.set({x: 0, y: 0})
+      pinchTranslation.set({x: 0, y: 0})
     })
 
   const pan = Gesture.Pan()
@@ -223,29 +224,29 @@ const ImageItem = ({
       prependPan(t, nextPanTranslation)
       prependPinch(
         t,
-        pinchScale.value,
-        pinchOrigin.value,
-        pinchTranslation.value,
+        pinchScale.get(),
+        pinchOrigin.get(),
+        pinchTranslation.get(),
       )
-      prependTransform(t, committedTransform.value)
+      prependTransform(t, committedTransform.get())
 
       // Prevent panning from going out of bounds.
       const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
       nextPanTranslation.x += dx
       nextPanTranslation.y += dy
-      panTranslation.value = nextPanTranslation
+      panTranslation.set(nextPanTranslation)
     })
     .onEnd(() => {
       'worklet'
       // Commit just the pan.
       let t = createTransform()
-      prependPan(t, panTranslation.value)
-      prependTransform(t, committedTransform.value)
+      prependPan(t, panTranslation.get())
+      prependTransform(t, committedTransform.get())
       applyRounding(t)
-      committedTransform.value = t
+      committedTransform.set(t)
 
       // Reset just the pan.
-      panTranslation.value = {x: 0, y: 0}
+      panTranslation.set({x: 0, y: 0})
     })
 
   const singleTap = Gesture.Tap().onEnd(() => {
@@ -261,11 +262,11 @@ const ImageItem = ({
       if (!imageDimensions || !imageAspect) {
         return
       }
-      const [, , committedScale] = readTransform(committedTransform.value)
+      const [, , committedScale] = readTransform(committedTransform.get())
       if (committedScale !== 1) {
         // Go back to 1:1 using the identity vector.
         let t = createTransform()
-        committedTransform.value = withClampedSpring(t)
+        committedTransform.set(withClampedSpring(t))
         return
       }
 
@@ -299,7 +300,7 @@ const ImageItem = ({
       )
       const finalTransform = createTransform()
       prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
-      committedTransform.value = withClampedSpring(finalTransform)
+      committedTransform.set(withClampedSpring(finalTransform))
     })
 
   const composedGesture = isScrollViewBeingDragged
@@ -313,13 +314,13 @@ const ImageItem = ({
       )
 
   const containerStyle = useAnimatedStyle(() => {
-    const {scaleAndMoveTransform, isHidden} = transforms.value
+    const {scaleAndMoveTransform, isHidden} = transforms.get()
     // 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)
+    prependPan(t, panTranslation.get())
+    prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
+    prependTransform(t, committedTransform.get())
     const [translateX, translateY, scale] = readTransform(t)
     const manipulationTransform = [
       {translateX},
@@ -338,7 +339,7 @@ const ImageItem = ({
   })
 
   const imageCropStyle = useAnimatedStyle(() => {
-    const {cropFrameTransform} = transforms.value
+    const {cropFrameTransform} = transforms.get()
     return {
       flex: 1,
       overflow: 'hidden',
@@ -347,7 +348,7 @@ const ImageItem = ({
   })
 
   const imageStyle = useAnimatedStyle(() => {
-    const {cropContentTransform} = transforms.value
+    const {cropContentTransform} = transforms.get()
     return {
       flex: 1,
       transform: cropContentTransform,
@@ -359,13 +360,13 @@ const ImageItem = ({
   const [hasLoaded, setHasLoaded] = useState(false)
   useAnimatedReaction(
     () => {
-      return transforms.value.isResting && !hasLoaded
+      return transforms.get().isResting && !hasLoaded
     },
     (show, prevShow) => {
-      if (show && !prevShow) {
-        runOnJS(setShowLoader)(false)
-      } else if (!prevShow && show) {
+      if (!prevShow && show) {
         runOnJS(setShowLoader)(true)
+      } else if (prevShow && !show) {
+        runOnJS(setShowLoader)(false)
       }
     },
   )
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 f06a59ed6..c103e131b 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -16,9 +16,11 @@ import {
 import Animated, {
   runOnJS,
   SharedValue,
+  useAnimatedProps,
   useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
+  useSharedValue,
 } from 'react-native-reanimated'
 import {useSafeAreaFrame} from 'react-native-safe-area-context'
 import {Image} from 'expo-image'
@@ -75,6 +77,7 @@ const ImageItem = ({
 }: Props) => {
   const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
   const [scaled, setScaled] = useState(false)
+  const isDragging = useSharedValue(false)
   const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
   const maxZoomScale = Math.max(
     MIN_SCREEN_ZOOM,
@@ -86,11 +89,20 @@ const ImageItem = ({
 
   const scrollHandler = useAnimatedScrollHandler({
     onScroll(e) {
+      'worklet'
       const nextIsScaled = e.zoomScale > 1
       if (scaled !== nextIsScaled) {
         runOnJS(handleZoom)(nextIsScaled)
       }
     },
+    onBeginDrag() {
+      'worklet'
+      isDragging.value = true
+    },
+    onEndDrag() {
+      'worklet'
+      isDragging.value = false
+    },
   })
 
   function handleZoom(nextIsScaled: boolean) {
@@ -148,7 +160,7 @@ const ImageItem = ({
   )
 
   const containerStyle = useAnimatedStyle(() => {
-    const {scaleAndMoveTransform, isHidden} = transforms.value
+    const {scaleAndMoveTransform, isHidden} = transforms.get()
     return {
       flex: 1,
       transform: scaleAndMoveTransform,
@@ -158,7 +170,7 @@ const ImageItem = ({
 
   const imageCropStyle = useAnimatedStyle(() => {
     const screenSize = measureSafeArea()
-    const {cropFrameTransform} = transforms.value
+    const {cropFrameTransform} = transforms.get()
     return {
       overflow: 'hidden',
       transform: cropFrameTransform,
@@ -171,7 +183,7 @@ const ImageItem = ({
   })
 
   const imageStyle = useAnimatedStyle(() => {
-    const {cropContentTransform} = transforms.value
+    const {cropContentTransform} = transforms.get()
     return {
       transform: cropContentTransform,
       width: '100%',
@@ -184,13 +196,13 @@ const ImageItem = ({
   const [hasLoaded, setHasLoaded] = useState(false)
   useAnimatedReaction(
     () => {
-      return transforms.value.isResting && !hasLoaded
+      return transforms.get().isResting && !hasLoaded
     },
     (show, prevShow) => {
-      if (show && !prevShow) {
-        runOnJS(setShowLoader)(false)
-      } else if (!prevShow && show) {
+      if (!prevShow && show) {
         runOnJS(setShowLoader)(true)
+      } else if (prevShow && !show) {
+        runOnJS(setShowLoader)(false)
       }
     },
   )
@@ -199,6 +211,11 @@ const ImageItem = ({
   const borderRadius =
     type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
 
+  const scrollViewProps = useAnimatedProps(() => ({
+    // Don't allow bounce at 1:1 rest so it can be swiped away.
+    bounces: scaled || isDragging.value,
+  }))
+
   return (
     <GestureDetector gesture={composedGesture}>
       <Animated.ScrollView
@@ -210,8 +227,7 @@ const ImageItem = ({
         maximumZoomScale={maxZoomScale}
         onScroll={scrollHandler}
         style={containerStyle}
-        bounces={scaled}
-        bouncesZoom={true}
+        animatedProps={scrollViewProps}
         centerContent>
         {showLoader && (
           <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index ab8306b36..4ba056eb0 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -32,6 +32,7 @@ import Animated, {
   useSharedValue,
   withDecay,
   withSpring,
+  WithSpringConfig,
 } from 'react-native-reanimated'
 import {
   Edge,
@@ -62,8 +63,18 @@ const EDGES =
     ? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
     : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
 
-const SLOW_SPRING = {stiffness: 120}
-const FAST_SPRING = {stiffness: 700}
+const SLOW_SPRING: WithSpringConfig = {
+  mass: isIOS ? 1.25 : 0.75,
+  damping: 300,
+  stiffness: 800,
+  restDisplacementThreshold: 0.01,
+}
+const FAST_SPRING: WithSpringConfig = {
+  mass: isIOS ? 1.25 : 0.75,
+  damping: 150,
+  stiffness: 900,
+  restDisplacementThreshold: 0.01,
+}
 
 export default function ImageViewRoot({
   lightbox: nextLightbox,
@@ -98,18 +109,22 @@ export default function ImageViewRoot({
 
     // https://github.com/software-mansion/react-native-reanimated/issues/6677
     requestAnimationFrame(() => {
-      openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1
+      openProgress.set(() =>
+        canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1,
+      )
     })
     return () => {
       // https://github.com/software-mansion/react-native-reanimated/issues/6677
       requestAnimationFrame(() => {
-        openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0
+        openProgress.set(() =>
+          canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0,
+        )
       })
     }
   }, [nextLightbox, openProgress])
 
   useAnimatedReaction(
-    () => openProgress.value === 0,
+    () => openProgress.get() === 0,
     (isGone, wasGone) => {
       if (isGone && !wasGone) {
         runOnJS(setActiveLightbox)(null)
@@ -119,7 +134,7 @@ export default function ImageViewRoot({
 
   const onFlyAway = React.useCallback(() => {
     'worklet'
-    openProgress.value = 0
+    openProgress.set(0)
     runOnJS(onRequestClose)()
   }, [onRequestClose, openProgress])
 
@@ -176,7 +191,7 @@ function ImageView({
   const isFlyingAway = useSharedValue(false)
 
   const containerStyle = useAnimatedStyle(() => {
-    if (openProgress.value < 1 || isFlyingAway.value) {
+    if (openProgress.get() < 1 || isFlyingAway.get()) {
       return {pointerEvents: 'none'}
     }
     return {pointerEvents: 'auto'}
@@ -185,11 +200,12 @@ function ImageView({
   const backdropStyle = useAnimatedStyle(() => {
     const screenSize = measure(safeAreaRef)
     let opacity = 1
-    if (openProgress.value < 1) {
-      opacity = Math.sqrt(openProgress.value)
+    const openProgressValue = openProgress.get()
+    if (openProgressValue < 1) {
+      opacity = Math.sqrt(openProgressValue)
     } else if (screenSize) {
       const dragProgress = Math.min(
-        Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
+        Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),
         1,
       )
       opacity -= dragProgress
@@ -201,11 +217,11 @@ function ImageView({
   })
 
   const animatedHeaderStyle = useAnimatedStyle(() => {
-    const show = showControls && dismissSwipeTranslateY.value === 0
+    const show = showControls && dismissSwipeTranslateY.get() === 0
     return {
       pointerEvents: show ? 'box-none' : 'none',
       opacity: withClampedSpring(
-        show && openProgress.value === 1 ? 1 : 0,
+        show && openProgress.get() === 1 ? 1 : 0,
         FAST_SPRING,
       ),
       transform: [
@@ -216,12 +232,12 @@ function ImageView({
     }
   })
   const animatedFooterStyle = useAnimatedStyle(() => {
-    const show = showControls && dismissSwipeTranslateY.value === 0
+    const show = showControls && dismissSwipeTranslateY.get() === 0
     return {
       flexGrow: 1,
       pointerEvents: show ? 'box-none' : 'none',
       opacity: withClampedSpring(
-        show && openProgress.value === 1 ? 1 : 0,
+        show && openProgress.get() === 1 ? 1 : 0,
         FAST_SPRING,
       ),
       transform: [
@@ -248,7 +264,7 @@ function ImageView({
       const screenSize = measure(safeAreaRef)
       return (
         !screenSize ||
-        Math.abs(dismissSwipeTranslateY.value) > screenSize.height
+        Math.abs(dismissSwipeTranslateY.get()) > screenSize.height
       )
     },
     (isOut, wasOut) => {
@@ -386,10 +402,11 @@ function LightboxImage({
   const transforms = useDerivedValue(() => {
     'worklet'
     const safeArea = measureSafeArea()
+    const openProgressValue = openProgress.get()
     const dismissTranslateY =
-      isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
+      isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
 
-    if (openProgress.value === 0 && isFlyingAway.value) {
+    if (openProgressValue === 0 && isFlyingAway.get()) {
       return {
         isHidden: true,
         isResting: false,
@@ -399,9 +416,9 @@ function LightboxImage({
       }
     }
 
-    if (isActive && thumbRect && imageAspect && openProgress.value < 1) {
+    if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
       return interpolateTransform(
-        openProgress.value,
+        openProgressValue,
         thumbRect,
         safeArea,
         imageAspect,
@@ -423,33 +440,37 @@ function LightboxImage({
     .maxPointers(1)
     .onUpdate(e => {
       'worklet'
-      if (openProgress.value !== 1 || isFlyingAway.value) {
+      if (openProgress.get() !== 1 || isFlyingAway.get()) {
         return
       }
-      dismissSwipeTranslateY.value = e.translationY
+      dismissSwipeTranslateY.set(e.translationY)
     })
     .onEnd(e => {
       'worklet'
-      if (openProgress.value !== 1 || isFlyingAway.value) {
+      if (openProgress.get() !== 1 || isFlyingAway.get()) {
         return
       }
-      if (Math.abs(e.velocityY) > 1000) {
-        isFlyingAway.value = true
-        if (dismissSwipeTranslateY.value === 0) {
+      if (Math.abs(e.velocityY) > 200) {
+        isFlyingAway.set(true)
+        if (dismissSwipeTranslateY.get() === 0) {
           // HACK: If the initial value is 0, withDecay() animation doesn't start.
           // This is a bug in Reanimated, but for now we'll work around it like this.
-          dismissSwipeTranslateY.value = 1
+          dismissSwipeTranslateY.set(1)
         }
-        dismissSwipeTranslateY.value = withDecay({
-          velocity: e.velocityY,
-          velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
-          deceleration: 1, // Danger! This relies on the reaction below stopping it.
-        })
+        dismissSwipeTranslateY.set(() =>
+          withDecay({
+            velocity: e.velocityY,
+            velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
+            deceleration: 1, // Danger! This relies on the reaction below stopping it.
+          }),
+        )
       } else {
-        dismissSwipeTranslateY.value = withSpring(0, {
-          stiffness: 700,
-          damping: 50,
-        })
+        dismissSwipeTranslateY.set(() =>
+          withSpring(0, {
+            stiffness: 700,
+            damping: 50,
+          }),
+        )
       }
     })
 
@@ -706,7 +727,7 @@ function interpolateTransform(
   }
 }
 
-function withClampedSpring(value: any, {stiffness}: {stiffness: number}) {
+function withClampedSpring(value: any, config: WithSpringConfig) {
   'worklet'
-  return withSpring(value, {overshootClamping: true, stiffness})
+  return withSpring(value, {...config, overshootClamping: true})
 }
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index 27b7f94df..2f63fd172 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -1,8 +1,10 @@
 import React from 'react'
 import {
+  ActivityIndicator,
   findNodeHandle,
   ListRenderItemInfo,
   StyleProp,
+  StyleSheet,
   View,
   ViewStyle,
 } from 'react-native'
@@ -10,6 +12,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
 import {isNative, isWeb} from '#/platform/detection'
@@ -56,10 +59,12 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
       isFetched,
       hasNextPage,
       fetchNextPage,
+      isFetchingNextPage,
       isError,
       error,
       refetch,
     } = useProfileListsQuery(did, opts)
+    const {isMobile} = useWebMediaQueries()
     const isEmpty = !isFetching && !data?.pages[0]?.lists.length
 
     const items = React.useMemo(() => {
@@ -176,6 +181,12 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
       }
     }, [enabled, scrollElRef, setScrollViewTag])
 
+    const ProfileListsFooter = React.useCallback(() => {
+      return isFetchingNextPage ? (
+        <ActivityIndicator style={[styles.footer]} />
+      ) : null
+    }, [isFetchingNextPage])
+
     return (
       <View testID={testID} style={style}>
         <List
@@ -184,12 +195,13 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
           data={items}
           keyExtractor={(item: any) => item._reactKey || item.uri}
           renderItem={renderItemInner}
+          ListFooterComponent={ProfileListsFooter}
           refreshing={isPTRing}
           onRefresh={onRefresh}
           headerOffset={headerOffset}
           progressViewOffset={ios(0)}
           contentContainerStyle={
-            isNative && {paddingBottom: headerOffset + 100}
+            isMobile && {paddingBottom: headerOffset + 100}
           }
           indicatorStyle={t.name === 'light' ? 'black' : 'white'}
           removeClippedSubviews={true}
@@ -201,3 +213,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
     )
   },
 )
+
+const styles = StyleSheet.create({
+  footer: {paddingTop: 20},
+})
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
index dc450705e..647a08c0e 100644
--- a/src/view/com/modals/ChangeEmail.tsx
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react'
+import {useState} from 'react'
 import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx
index c40fcb5e3..d68b4e453 100644
--- a/src/view/com/modals/ChangePassword.tsx
+++ b/src/view/com/modals/ChangePassword.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react'
+import {useState} from 'react'
 import {
   ActivityIndicator,
   SafeAreaView,
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index 8f5487733..a9acd4c62 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo, useState} from 'react'
+import {useCallback, useMemo, useState} from 'react'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 1e94f483e..af55e4b7f 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import {useCallback, useState} from 'react'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 78f4a0117..1cbb9dd9d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,4 +1,4 @@
-import React, {Fragment, useEffect, useRef} from 'react'
+import {Fragment, useEffect, useRef} from 'react'
 import {StyleSheet} from 'react-native'
 import {SafeAreaView} from 'react-native-safe-area-context'
 import BottomSheet from '@discord/bottom-sheet/src'
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index e9d9c01dd..8d93c21b4 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 
diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
index 8755a2fbb..f61fe73fe 100644
--- a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
+++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Pressable, StyleSheet, Text, View} from 'react-native'
 import {LinearGradient} from 'expo-linear-gradient'
 import {msg, Trans} from '@lingui/macro'
diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx
index b8c0121e6..165a70ba2 100644
--- a/src/view/com/modals/lang-settings/LanguageToggle.tsx
+++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet} from 'react-native'
 
 import {usePalette} from '#/lib/hooks/usePalette'
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 5473fff85..b90f2ecd6 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -467,7 +467,12 @@ let FeedItem = ({
         {item.type === 'feedgen-like' && item.subjectUri ? (
           <FeedSourceCard
             feedUri={item.subjectUri}
-            style={[pal.view, pal.border, styles.feedcard]}
+            style={[
+              t.atoms.bg,
+              t.atoms.border_contrast_low,
+              a.border,
+              styles.feedcard,
+            ]}
             showLikes
           />
         ) : null}
@@ -778,7 +783,6 @@ const styles = StyleSheet.create({
     opacity: 0.8,
   },
   feedcard: {
-    borderWidth: 1,
     borderRadius: 8,
     paddingVertical: 12,
     marginTop: 6,
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 4d5da960c..de0409991 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -1,5 +1,5 @@
 import React, {forwardRef} from 'react'
-import {Animated, View} from 'react-native'
+import {View} from 'react-native'
 import PagerView, {
   PagerViewOnPageScrollEvent,
   PagerViewOnPageSelectedEvent,
@@ -10,12 +10,11 @@ import {LogEvents} from '#/lib/statsig/events'
 import {atoms as a, native} from '#/alf'
 
 export type PageSelectedEvent = PagerViewOnPageSelectedEvent
-const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
 
 export interface PagerRef {
   setPage: (
     index: number,
-    reason: LogEvents['home:feedDisplayed:sampled']['reason'],
+    reason: LogEvents['home:feedDisplayed']['reason'],
   ) => void
 }
 
@@ -32,7 +31,7 @@ interface Props {
   onPageSelected?: (index: number) => void
   onPageSelecting?: (
     index: number,
-    reason: LogEvents['home:feedDisplayed:sampled']['reason'],
+    reason: LogEvents['home:feedDisplayed']['reason'],
   ) => void
   onPageScrollStateChanged?: (
     scrollState: 'idle' | 'dragging' | 'settling',
@@ -61,7 +60,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
     React.useImperativeHandle(ref, () => ({
       setPage: (
         index: number,
-        reason: LogEvents['home:feedDisplayed:sampled']['reason'],
+        reason: LogEvents['home:feedDisplayed']['reason'],
       ) => {
         pagerView.current?.setPage(index)
         onPageSelecting?.(index, reason)
@@ -138,7 +137,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
           selectedPage,
           onSelect: onTabBarSelect,
         })}
-        <AnimatedPagerView
+        <PagerView
           ref={pagerView}
           style={[a.flex_1]}
           initialPage={initialPage}
@@ -146,7 +145,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
           onPageSelected={onPageSelectedInner}
           onPageScroll={onPageScroll}>
           {children}
-        </AnimatedPagerView>
+        </PagerView>
       </View>
     )
   },
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index 2cce727c0..e6909fe10 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -18,7 +18,7 @@ interface Props {
   onPageSelected?: (index: number) => void
   onPageSelecting?: (
     index: number,
-    reason: LogEvents['home:feedDisplayed:sampled']['reason'],
+    reason: LogEvents['home:feedDisplayed']['reason'],
   ) => void
 }
 export const Pager = React.forwardRef(function PagerImpl(
@@ -38,17 +38,14 @@ export const Pager = React.forwardRef(function PagerImpl(
   React.useImperativeHandle(ref, () => ({
     setPage: (
       index: number,
-      reason: LogEvents['home:feedDisplayed:sampled']['reason'],
+      reason: LogEvents['home:feedDisplayed']['reason'],
     ) => {
       onTabBarSelect(index, reason)
     },
   }))
 
   const onTabBarSelect = React.useCallback(
-    (
-      index: number,
-      reason: LogEvents['home:feedDisplayed:sampled']['reason'],
-    ) => {
+    (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => {
       const scrollY = window.scrollY
       // We want to determine if the tabbar is already "sticking" at the top (in which
       // case we should preserve and restore scroll), or if it is somewhere below in the
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 6d601c289..92b98dc2e 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -131,11 +131,11 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
     const lastForcedScrollY = useSharedValue(0)
     const adjustScrollForOtherPages = () => {
       'worklet'
-      const currentScrollY = scrollY.value
+      const currentScrollY = scrollY.get()
       const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
-      if (lastForcedScrollY.value !== forcedScrollY) {
-        lastForcedScrollY.value = forcedScrollY
-        const refs = scrollRefs.value
+      if (lastForcedScrollY.get() !== forcedScrollY) {
+        lastForcedScrollY.set(forcedScrollY)
+        const refs = scrollRefs.get()
         for (let i = 0; i < refs.length; i++) {
           const scollRef = refs[i]
           if (i !== currentPage && scollRef != null) {
@@ -167,7 +167,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
         const isPossiblyInvalid =
           headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
         if (!isPossiblyInvalid) {
-          scrollY.value = nextScrollY
+          scrollY.set(nextScrollY)
           runOnJS(queueThrottledOnScroll)()
         }
       },
@@ -246,7 +246,7 @@ let PagerTabBar = ({
   allowHeaderOverScroll?: boolean
 }): React.ReactNode => {
   const headerTransform = useAnimatedStyle(() => {
-    const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1
+    const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1
     return {
       transform: [
         {
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 69e04e046..4c0d973a9 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo, useState} from 'react'
+import {useCallback, useMemo, useState} from 'react'
 import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx
index 56cf81a3e..10a51166c 100644
--- a/src/view/com/post-thread/PostQuotes.tsx
+++ b/src/view/com/post-thread/PostQuotes.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import {useCallback, useState} from 'react'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 67a89e435..dfaa69780 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo, useState} from 'react'
+import {useCallback, useMemo, useState} from 'react'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
index 89993beec..705572c06 100644
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5044f9621..035f7a681 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -1,5 +1,10 @@
 import React, {memo, useMemo} from 'react'
-import {StyleSheet, View} from 'react-native'
+import {
+  GestureResponderEvent,
+  StyleSheet,
+  Text as RNText,
+  View,
+} from 'react-native'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
@@ -8,7 +13,6 @@ import {
   ModerationDecision,
   RichText as RichTextAPI,
 } from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -21,6 +25,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
 import {countLines} from '#/lib/strings/helpers'
 import {niceDate} from '#/lib/strings/time'
 import {s} from '#/lib/styles'
+import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
 import {useLanguagePrefs} from '#/state/preferences'
 import {ThreadPost} from '#/state/queries/post-thread'
@@ -28,26 +33,31 @@ import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {Link, TextLink} from '#/view/com/util/Link'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme} from '#/alf'
+import {colors} from '#/components/Admonition'
+import {Button} from '#/components/Button'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
 import {InlineLinkText} from '#/components/Link'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PostHider} from '#/components/moderation/PostHider'
 import {AppModerationCause} from '#/components/Pills'
+import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
 import {SubtleWebHover} from '#/components/SubtleWebHover'
-import {Text as NewText} from '#/components/Typography'
-import {ContentHider} from '../../../components/moderation/ContentHider'
-import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
-import {PostAlerts} from '../../../components/moderation/PostAlerts'
-import {PostHider} from '../../../components/moderation/PostHider'
-import {WhoCanReply} from '../../../components/WhoCanReply'
-import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Link, TextLink} from '../util/Link'
-import {formatCount} from '../util/numeric/format'
-import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
-import {PostMeta} from '../util/PostMeta'
-import {Text} from '../util/text/Text'
-import {PreviewableUserAvatar} from '../util/UserAvatar'
+import {Text} from '#/components/Typography'
+import {WhoCanReply} from '#/components/WhoCanReply'
 
 export function PostThreadItem({
   post,
@@ -125,19 +135,20 @@ export function PostThreadItem({
 }
 
 function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) {
-  const pal = usePalette('default')
+  const t = useTheme()
   return (
     <View
       style={[
-        styles.outer,
-        pal.border,
-        pal.view,
-        s.p20,
-        s.flexRow,
-        hideTopBorder && styles.noTopBorder,
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.p_xl,
+        a.pl_lg,
+        a.flex_row,
+        a.gap_md,
+        !hideTopBorder && a.border_t,
       ]}>
-      <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
-      <Text style={[pal.textLight, s.ml10]}>
+      <TrashIcon style={[t.atoms.text]} />
+      <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
         <Trans>This post has been deleted.</Trans>
       </Text>
     </View>
@@ -308,7 +319,7 @@ let PostThreadItemLoaded = ({
             />
             <View style={[a.flex_1]}>
               <Link style={s.flex1} href={authorHref} title={authorTitle}>
-                <NewText
+                <Text
                   emoji
                   style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
                   numberOfLines={1}>
@@ -317,10 +328,10 @@ let PostThreadItemLoaded = ({
                       sanitizeHandle(post.author.handle),
                     moderation.ui('displayName'),
                   )}
-                </NewText>
+                </Text>
               </Link>
               <Link style={s.flex1} href={authorHref} title={authorTitle}>
-                <NewText
+                <Text
                   emoji
                   style={[
                     a.text_md,
@@ -329,7 +340,7 @@ let PostThreadItemLoaded = ({
                   ]}
                   numberOfLines={1}>
                   {sanitizeHandle(post.author.handle, '@')}
-                </NewText>
+                </Text>
               </Link>
             </View>
             {currentAccount?.did !== post.author.did && (
@@ -393,48 +404,48 @@ let PostThreadItemLoaded = ({
                 ]}>
                 {post.repostCount != null && post.repostCount !== 0 ? (
                   <Link href={repostsHref} title={repostsTitle}>
-                    <NewText
+                    <Text
                       testID="repostCount-expanded"
                       style={[a.text_md, t.atoms.text_contrast_medium]}>
-                      <NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
                         {formatCount(i18n, post.repostCount)}
-                      </NewText>{' '}
+                      </Text>{' '}
                       <Plural
                         value={post.repostCount}
                         one="repost"
                         other="reposts"
                       />
-                    </NewText>
+                    </Text>
                   </Link>
                 ) : null}
                 {post.quoteCount != null &&
                 post.quoteCount !== 0 &&
                 !post.viewer?.embeddingDisabled ? (
                   <Link href={quotesHref} title={quotesTitle}>
-                    <NewText
+                    <Text
                       testID="quoteCount-expanded"
                       style={[a.text_md, t.atoms.text_contrast_medium]}>
-                      <NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
                         {formatCount(i18n, post.quoteCount)}
-                      </NewText>{' '}
+                      </Text>{' '}
                       <Plural
                         value={post.quoteCount}
                         one="quote"
                         other="quotes"
                       />
-                    </NewText>
+                    </Text>
                   </Link>
                 ) : null}
                 {post.likeCount != null && post.likeCount !== 0 ? (
                   <Link href={likesHref} title={likesTitle}>
-                    <NewText
+                    <Text
                       testID="likeCount-expanded"
                       style={[a.text_md, t.atoms.text_contrast_medium]}>
-                      <NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
                         {formatCount(i18n, post.likeCount)}
-                      </NewText>{' '}
+                      </Text>{' '}
                       <Plural value={post.likeCount} one="like" other="likes" />
-                    </NewText>
+                    </Text>
                   </Link>
                 ) : null}
               </View>
@@ -617,13 +628,13 @@ let PostThreadItemLoaded = ({
               href={postHref}
               title={itemTitle}
               noFeedback>
-              <Text type="sm-medium" style={pal.textLight}>
+              <Text
+                style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}>
                 <Trans>More</Trans>
               </Text>
-              <FontAwesomeIcon
-                icon="angle-right"
-                color={pal.colors.textLight}
-                size={14}
+              <ChevronRightIcon
+                size="xs"
+                style={[t.atoms.text_contrast_medium]}
               />
             </Link>
           ) : undefined}
@@ -651,26 +662,24 @@ function PostOuterWrapper({
   hideTopBorder?: boolean
 }>) {
   const t = useTheme()
-  const [hover, setHover] = React.useState(false)
+  const {
+    state: hover,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
   if (treeView && depth > 0) {
     return (
       <View
         style={[
           a.flex_row,
           a.px_sm,
+          a.flex_row,
           t.atoms.border_contrast_low,
           styles.cursor,
-          {
-            flexDirection: 'row',
-            borderTopWidth: depth === 1 ? a.border_t.borderTopWidth : 0,
-          },
+          depth === 1 && a.border_t,
         ]}
-        onPointerEnter={() => {
-          setHover(true)
-        }}
-        onPointerLeave={() => {
-          setHover(false)
-        }}>
+        onPointerEnter={onHoverIn}
+        onPointerLeave={onHoverOut}>
         {Array.from(Array(depth - 1)).map((_, n: number) => (
           <View
             key={`${post.uri}-padding-${n}`}
@@ -684,18 +693,23 @@ function PostOuterWrapper({
             ]}
           />
         ))}
-        <View style={{flex: 1}}>{children}</View>
+        <View style={a.flex_1}>
+          <SubtleWebHover
+            hover={hover}
+            style={{
+              left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft,
+              right: -a.pr_sm.paddingRight,
+            }}
+          />
+          {children}
+        </View>
       </View>
     )
   }
   return (
     <View
-      onPointerEnter={() => {
-        setHover(true)
-      }}
-      onPointerLeave={() => {
-        setHover(false)
-      }}
+      onPointerEnter={onHoverIn}
+      onPointerLeave={onHoverOut}
       style={[
         a.border_t,
         a.px_sm,
@@ -727,37 +741,134 @@ function ExpandedPostDetails({
   const openLink = useOpenLink()
   const isRootPost = !('reply' in post.record)
 
-  const onTranslatePress = React.useCallback(() => {
-    openLink(translatorUrl)
-  }, [openLink, translatorUrl])
+  const onTranslatePress = React.useCallback(
+    (e: GestureResponderEvent) => {
+      e.preventDefault()
+      openLink(translatorUrl, true)
+      return false
+    },
+    [openLink, translatorUrl],
+  )
 
   return (
-    <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm, a.pt_md]}>
-      <NewText style={[a.text_sm, t.atoms.text_contrast_medium]}>
-        {niceDate(i18n, post.indexedAt)}
-      </NewText>
-      {isRootPost && (
-        <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
-      )}
-      {needsTranslation && (
-        <>
-          <NewText style={[a.text_sm, t.atoms.text_contrast_medium]}>
-            &middot;
-          </NewText>
+    <View style={[a.gap_md, a.pt_md, a.align_start]}>
+      <BackdatedPostIndicator post={post} />
+      <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
+        <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+          {niceDate(i18n, post.indexedAt)}
+        </Text>
+        {isRootPost && (
+          <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+        )}
+        {needsTranslation && (
+          <>
+            <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+              &middot;
+            </Text>
 
-          <InlineLinkText
-            to="#"
-            label={_(msg`Translate`)}
-            style={[a.text_sm, pal.link]}
-            onPress={onTranslatePress}>
-            <Trans>Translate</Trans>
-          </InlineLinkText>
-        </>
-      )}
+            <InlineLinkText
+              to={translatorUrl}
+              label={_(msg`Translate`)}
+              style={[a.text_sm, pal.link]}
+              onPress={onTranslatePress}>
+              <Trans>Translate</Trans>
+            </InlineLinkText>
+          </>
+        )}
+      </View>
     </View>
   )
 }
 
+function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const control = Prompt.usePromptControl()
+
+  const indexedAt = new Date(post.indexedAt)
+  const createdAt = AppBskyFeedPost.isRecord(post.record)
+    ? new Date(post.record.createdAt)
+    : new Date(post.indexedAt)
+
+  // backdated if createdAt is 24 hours or more before indexedAt
+  const isBackdated =
+    indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
+
+  if (!isBackdated) return null
+
+  const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
+
+  return (
+    <>
+      <Button
+        label={_(msg`Archived post`)}
+        accessibilityHint={_(
+          msg`Show information about when this post was created`,
+        )}
+        onPress={e => {
+          e.preventDefault()
+          e.stopPropagation()
+          control.open()
+        }}>
+        {({hovered, pressed}) => (
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              a.rounded_full,
+              t.atoms.bg_contrast_25,
+              (hovered || pressed) && t.atoms.bg_contrast_50,
+              {
+                gap: 3,
+                paddingHorizontal: 6,
+                paddingVertical: 3,
+              },
+            ]}>
+            <CalendarClockIcon fill={orange} size="sm" aria-hidden />
+            <Text
+              style={[
+                a.text_xs,
+                a.font_bold,
+                a.leading_tight,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
+            </Text>
+          </View>
+        )}
+      </Button>
+
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Archived post</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>
+            This post claims to have been created on{' '}
+            <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
+            but was first seen by Bluesky on{' '}
+            <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
+          </Trans>
+        </Prompt.DescriptionText>
+        <Text
+          style={[
+            a.text_md,
+            a.leading_snug,
+            t.atoms.text_contrast_high,
+            a.pb_xl,
+          ]}>
+          <Trans>
+            Bluesky cannot confirm the authenticity of the claimed date.
+          </Trans>
+        </Text>
+        <Prompt.Actions>
+          <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
 function getThreadAuthor(
   post: AppBskyFeedDefs.PostView,
   record: AppBskyFeedPost.Record,
diff --git a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
index 7c021d88b..030a92bc2 100644
--- a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
+++ b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
@@ -1,4 +1,3 @@
-import * as React from 'react'
 import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx
index 269d4eb5a..1c894bffe 100644
--- a/src/view/com/posts/AviFollowButton.tsx
+++ b/src/view/com/posts/AviFollowButton.tsx
@@ -84,25 +84,29 @@ export function AviFollowButton({
       {!isFollowing && (
         <Button
           label={_(msg`Open ${name} profile shortcut menu`)}
-          hitSlop={{
-            top: 0,
-            left: 0,
-            right: 5,
-            bottom: 5,
-          }}
           style={[
             a.rounded_full,
             a.absolute,
             {
-              height: 30,
-              width: 30,
               bottom: -7,
               right: -7,
             },
           ]}>
           <NativeDropdown items={items}>
             <View
-              style={[a.h_full, a.w_full, a.justify_center, a.align_center]}>
+              style={[
+                {
+                  // An asymmetric hit slop
+                  // to prioritize bottom right taps.
+                  paddingTop: 2,
+                  paddingLeft: 2,
+                  paddingBottom: 6,
+                  paddingRight: 6,
+                },
+                a.align_center,
+                a.justify_center,
+                a.rounded_full,
+              ]}>
               <View
                 style={[
                   a.rounded_full,
diff --git a/src/view/com/posts/DiscoverFallbackHeader.tsx b/src/view/com/posts/DiscoverFallbackHeader.tsx
index 0153cf5f4..e35a33aaf 100644
--- a/src/view/com/posts/DiscoverFallbackHeader.tsx
+++ b/src/view/com/posts/DiscoverFallbackHeader.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 import {Trans} from '@lingui/macro'
 
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 905c1e0e0..c623234b8 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -16,10 +16,10 @@ import {useQueryClient} from '@tanstack/react-query'
 
 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {logEvent, useGate} from '#/lib/statsig/statsig'
+import {logEvent} from '#/lib/statsig/statsig'
 import {useTheme} from '#/lib/ThemeContext'
 import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
+import {isIOS, isWeb} from '#/platform/detection'
 import {listenPostCreated} from '#/state/events'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {STALE} from '#/state/queries'
@@ -32,20 +32,17 @@ import {
   usePostFeedQuery,
 } from '#/state/queries/post-feed'
 import {useSession} from '#/state/session'
-import {
-  ProgressGuide,
-  SuggestedFeeds,
-  SuggestedFollows,
-} from '#/components/FeedInterstitials'
+import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
 import {List, ListRef} from '../util/List'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
 import {FeedErrorMessage} from './FeedErrorMessage'
+import {FeedItem} from './FeedItem'
 import {FeedShutdownMsg} from './FeedShutdownMsg'
-import {FeedSlice} from './FeedSlice'
+import {ViewFullThread} from './ViewFullThread'
 
-type FeedItem =
+type FeedRow =
   | {
       type: 'loading'
       key: string
@@ -72,76 +69,29 @@ type FeedItem =
       slice: FeedPostSlice
     }
   | {
-      type: 'interstitialFeeds'
+      type: 'sliceItem'
       key: string
-      params: {
-        variant: 'default' | string
-      }
-      slot: number
+      slice: FeedPostSlice
+      indexInSlice: number
+      showReplyTo: boolean
+    }
+  | {
+      type: 'sliceViewFullThread'
+      key: string
+      uri: string
     }
   | {
       type: 'interstitialFollows'
       key: string
-      params: {
-        variant: 'default' | string
-      }
-      slot: number
     }
   | {
       type: 'interstitialProgressGuide'
       key: string
-      params: {
-        variant: 'default' | string
-      }
-      slot: number
     }
 
-const feedInterstitialType = 'interstitialFeeds'
-const followInterstitialType = 'interstitialFollows'
-const progressGuideInterstitialType = 'interstitialProgressGuide'
-const interstials: Record<
-  'following' | 'discover' | 'profile',
-  (FeedItem & {
-    type:
-      | 'interstitialFeeds'
-      | 'interstitialFollows'
-      | 'interstitialProgressGuide'
-  })[]
-> = {
-  following: [],
-  discover: [
-    {
-      type: progressGuideInterstitialType,
-      params: {
-        variant: 'default',
-      },
-      key: progressGuideInterstitialType,
-      slot: 0,
-    },
-    {
-      type: followInterstitialType,
-      params: {
-        variant: 'default',
-      },
-      key: followInterstitialType,
-      slot: 20,
-    },
-  ],
-  profile: [
-    {
-      type: followInterstitialType,
-      params: {
-        variant: 'default',
-      },
-      key: followInterstitialType,
-      slot: 5,
-    },
-  ],
-}
-
-export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null {
-  if (feedItem.type === 'slice') {
-    return feedItem.slice
+export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
+  if (feedRow.type === 'sliceItem') {
+    return feedRow.slice
   } else {
     return null
   }
@@ -204,7 +154,6 @@ let Feed = ({
   const checkForNewRef = React.useRef<(() => void) | null>(null)
   const lastFetchRef = React.useRef<number>(Date.now())
   const [feedType, feedUri, feedTab] = feed.split('|')
-  const gate = useGate()
 
   const opts = React.useMemo(
     () => ({enabled, ignoreFilterFor}),
@@ -303,8 +252,21 @@ let Feed = ({
     }
   }, [pollInterval])
 
-  const feedItems: FeedItem[] = React.useMemo(() => {
-    let arr: FeedItem[] = []
+  const feedItems: FeedRow[] = React.useMemo(() => {
+    let feedKind: 'following' | 'discover' | 'profile' | undefined
+    if (feedType === 'following') {
+      feedKind = 'following'
+    } else if (feedUri === DISCOVER_FEED_URI) {
+      feedKind = 'discover'
+    } else if (
+      feedType === 'author' &&
+      (feedTab === 'posts_and_author_threads' ||
+        feedTab === 'posts_with_replies')
+    ) {
+      feedKind = 'profile'
+    }
+
+    let arr: FeedRow[] = []
     if (KNOWN_SHUTDOWN_FEEDS.includes(feedUri)) {
       arr.push({
         type: 'feedShutdownMsg',
@@ -323,14 +285,77 @@ let Feed = ({
           key: 'empty',
         })
       } else if (data) {
+        let sliceIndex = -1
         for (const page of data?.pages) {
-          arr = arr.concat(
-            page.slices.map(s => ({
-              type: 'slice',
-              slice: s,
-              key: s._reactKey,
-            })),
-          )
+          for (const slice of page.slices) {
+            sliceIndex++
+
+            if (hasSession) {
+              if (feedKind === 'discover') {
+                if (sliceIndex === 0) {
+                  arr.push({
+                    type: 'interstitialProgressGuide',
+                    key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+                  })
+                } else if (sliceIndex === 20) {
+                  arr.push({
+                    type: 'interstitialFollows',
+                    key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+                  })
+                }
+              } else if (feedKind === 'profile') {
+                if (sliceIndex === 5) {
+                  arr.push({
+                    type: 'interstitialFollows',
+                    key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+                  })
+                }
+              }
+            }
+
+            if (slice.isIncompleteThread && slice.items.length >= 3) {
+              const beforeLast = slice.items.length - 2
+              const last = slice.items.length - 1
+              arr.push({
+                type: 'sliceItem',
+                key: slice.items[0]._reactKey,
+                slice: slice,
+                indexInSlice: 0,
+                showReplyTo: false,
+              })
+              arr.push({
+                type: 'sliceViewFullThread',
+                key: slice._reactKey + '-viewFullThread',
+                uri: slice.items[0].uri,
+              })
+              arr.push({
+                type: 'sliceItem',
+                key: slice.items[beforeLast]._reactKey,
+                slice: slice,
+                indexInSlice: beforeLast,
+                showReplyTo:
+                  slice.items[beforeLast].parentAuthor?.did !==
+                  slice.items[beforeLast].post.author.did,
+              })
+              arr.push({
+                type: 'sliceItem',
+                key: slice.items[last]._reactKey,
+                slice: slice,
+                indexInSlice: last,
+                showReplyTo: false,
+              })
+            } else {
+              for (let i = 0; i < slice.items.length; i++) {
+                arr.push({
+                  type: 'sliceItem',
+                  key: slice.items[i]._reactKey,
+                  slice: slice,
+                  indexInSlice: i,
+                  showReplyTo: i === 0,
+                })
+              }
+            }
+          }
         }
       }
       if (isError && !isEmpty) {
@@ -346,45 +371,6 @@ let Feed = ({
       })
     }
 
-    if (hasSession) {
-      let feedKind: 'following' | 'discover' | 'profile' | undefined
-      if (feedType === 'following') {
-        feedKind = 'following'
-      } else if (feedUri === DISCOVER_FEED_URI) {
-        feedKind = 'discover'
-      } else if (
-        feedType === 'author' &&
-        (feedTab === 'posts_and_author_threads' ||
-          feedTab === 'posts_with_replies')
-      ) {
-        feedKind = 'profile'
-      }
-
-      if (feedKind) {
-        for (const interstitial of interstials[feedKind]) {
-          const shouldShow =
-            (interstitial.type === feedInterstitialType &&
-              gate('suggested_feeds_interstitial')) ||
-            interstitial.type === followInterstitialType ||
-            interstitial.type === progressGuideInterstitialType
-
-          if (shouldShow) {
-            const variant = 'default' // replace with experiment variant
-            const int = {
-              ...interstitial,
-              params: {variant},
-              // overwrite key with unique value
-              key: [interstitial.type, variant, lastFetchedAt].join(':'),
-            }
-
-            if (arr.length > interstitial.slot) {
-              arr.splice(interstitial.slot, 0, int)
-            }
-          }
-        }
-      }
-    }
-
     return arr
   }, [
     isFetched,
@@ -395,7 +381,6 @@ let Feed = ({
     feedType,
     feedUri,
     feedTab,
-    gate,
     hasSession,
   ])
 
@@ -403,7 +388,7 @@ let Feed = ({
   // =
 
   const onRefresh = React.useCallback(async () => {
-    logEvent('feed:refresh:sampled', {
+    logEvent('feed:refresh', {
       feedType: feedType,
       feedUrl: feed,
       reason: 'pull-to-refresh',
@@ -421,7 +406,7 @@ let Feed = ({
   const onEndReached = React.useCallback(async () => {
     if (isFetching || !hasNextPage || isError) return
 
-    logEvent('feed:endReached:sampled', {
+    logEvent('feed:endReached', {
       feedType: feedType,
       feedUrl: feed,
       itemCount: feedItems.length,
@@ -454,10 +439,10 @@ let Feed = ({
   // =
 
   const renderItem = React.useCallback(
-    ({item, index}: ListRenderItemInfo<FeedItem>) => {
-      if (item.type === 'empty') {
+    ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => {
+      if (row.type === 'empty') {
         return renderEmptyState()
-      } else if (item.type === 'error') {
+      } else if (row.type === 'error') {
         return (
           <FeedErrorMessage
             feedDesc={feed}
@@ -466,7 +451,7 @@ let Feed = ({
             savedFeedConfig={savedFeedConfig}
           />
         )
-      } else if (item.type === 'loadMoreError') {
+      } else if (row.type === 'loadMoreError') {
         return (
           <LoadMoreRetryBtn
             label={_(
@@ -475,25 +460,48 @@ let Feed = ({
             onPress={onPressRetryLoadMore}
           />
         )
-      } else if (item.type === 'loading') {
+      } else if (row.type === 'loading') {
         return <PostFeedLoadingPlaceholder />
-      } else if (item.type === 'feedShutdownMsg') {
+      } else if (row.type === 'feedShutdownMsg') {
         return <FeedShutdownMsg feedUri={feedUri} />
-      } else if (item.type === feedInterstitialType) {
-        return <SuggestedFeeds />
-      } else if (item.type === followInterstitialType) {
+      } else if (row.type === 'interstitialFollows') {
         return <SuggestedFollows feed={feed} />
-      } else if (item.type === progressGuideInterstitialType) {
+      } else if (row.type === 'interstitialProgressGuide') {
         return <ProgressGuide />
-      } else if (item.type === 'slice') {
-        if (item.slice.isFallbackMarker) {
+      } else if (row.type === 'sliceItem') {
+        const slice = row.slice
+        if (slice.isFallbackMarker) {
           // HACK
           // tell the user we fell back to discover
           // see home.ts (feed api) for more info
           // -prf
           return <DiscoverFallbackHeader />
         }
-        return <FeedSlice slice={item.slice} hideTopBorder={index === 0} />
+        const indexInSlice = row.indexInSlice
+        const item = slice.items[indexInSlice]
+        return (
+          <FeedItem
+            post={item.post}
+            record={item.record}
+            reason={indexInSlice === 0 ? slice.reason : undefined}
+            feedContext={slice.feedContext}
+            moderation={item.moderation}
+            parentAuthor={item.parentAuthor}
+            showReplyTo={row.showReplyTo}
+            isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
+            isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
+            isThreadLastChild={
+              isThreadChildAt(slice.items, indexInSlice) &&
+              slice.items.length === indexInSlice + 1
+            }
+            isParentBlocked={item.isParentBlocked}
+            isParentNotFound={item.isParentNotFound}
+            hideTopBorder={rowIndex === 0 && indexInSlice === 0}
+            rootPost={slice.items[0].post}
+          />
+        )
+      } else if (row.type === 'sliceViewFullThread') {
+        return <ViewFullThread uri={row.uri} />
       } else {
         return null
       }
@@ -561,7 +569,7 @@ let Feed = ({
         }
         initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
         windowSize={9}
-        maxToRenderPerBatch={5}
+        maxToRenderPerBatch={isIOS ? 5 : 1}
         updateCellsBatchingPeriod={40}
         onItemSeen={feedFeedback.onItemSeen}
       />
@@ -574,3 +582,17 @@ export {Feed}
 const styles = StyleSheet.create({
   feedFooter: {paddingTop: 20},
 })
+
+function isThreadParentAt<T>(arr: Array<T>, i: number) {
+  if (arr.length === 1) {
+    return false
+  }
+  return i < arr.length - 1
+}
+
+function isThreadChildAt<T>(arr: Array<T>, i: number) {
+  if (arr.length === 1) {
+    return false
+  }
+  return i > 0
+}
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index cc7b34750..a58216233 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -25,7 +25,7 @@ export enum KnownError {
   FeedgenBadResponse = 'FeedgenBadResponse',
   FeedgenOffline = 'FeedgenOffline',
   FeedgenUnknown = 'FeedgenUnknown',
-  FeedNSFPublic = 'FeedNSFPublic',
+  FeedSignedInOnly = 'FeedSignedInOnly',
   FeedTooManyRequests = 'FeedTooManyRequests',
   Unknown = 'Unknown',
 }
@@ -110,7 +110,7 @@ function FeedgenErrorMessage({
         [KnownError.FeedgenOffline]: _l(
           msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`,
         ),
-        [KnownError.FeedNSFPublic]: _l(
+        [KnownError.FeedSignedInOnly]: _l(
           msgLingui`This content is not viewable without a Bluesky account.`,
         ),
         [KnownError.FeedgenUnknown]: _l(
@@ -152,7 +152,7 @@ function FeedgenErrorMessage({
 
   const cta = React.useMemo(() => {
     switch (knownError) {
-      case KnownError.FeedNSFPublic: {
+      case KnownError.FeedSignedInOnly: {
         return null
       }
       case KnownError.FeedgenDoesNotExist:
@@ -249,8 +249,8 @@ function detectKnownError(
   if (typeof error !== 'string') {
     error = error.toString()
   }
-  if (error.includes(KnownError.FeedNSFPublic)) {
-    return KnownError.FeedNSFPublic
+  if (error.includes(KnownError.FeedSignedInOnly)) {
+    return KnownError.FeedSignedInOnly
   }
   if (!feedDesc.startsWith('feedgen')) {
     return KnownError.Unknown
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 049748754..c04921c68 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -618,10 +618,10 @@ const styles = StyleSheet.create({
   layout: {
     flexDirection: 'row',
     marginTop: 1,
-    gap: 10,
   },
   layoutAvi: {
     paddingLeft: 8,
+    paddingRight: 10,
     position: 'relative',
     zIndex: 999,
   },
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
deleted file mode 100644
index dc68ee7a1..000000000
--- a/src/view/com/posts/FeedSlice.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import React, {memo} from 'react'
-import {StyleSheet, View} from 'react-native'
-import Svg, {Circle, Line} from 'react-native-svg'
-import {AtUri} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {makeProfileLink} from '#/lib/routes/links'
-import {FeedPostSlice} from '#/state/queries/post-feed'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
-import {FeedItem} from './FeedItem'
-
-let FeedSlice = ({
-  slice,
-  hideTopBorder,
-}: {
-  slice: FeedPostSlice
-  hideTopBorder?: boolean
-}): React.ReactNode => {
-  if (slice.isIncompleteThread && slice.items.length >= 3) {
-    const beforeLast = slice.items.length - 2
-    const last = slice.items.length - 1
-    return (
-      <>
-        <FeedItem
-          key={slice.items[0]._reactKey}
-          post={slice.items[0].post}
-          record={slice.items[0].record}
-          reason={slice.reason}
-          feedContext={slice.feedContext}
-          parentAuthor={slice.items[0].parentAuthor}
-          showReplyTo={false}
-          moderation={slice.items[0].moderation}
-          isThreadParent={isThreadParentAt(slice.items, 0)}
-          isThreadChild={isThreadChildAt(slice.items, 0)}
-          hideTopBorder={hideTopBorder}
-          isParentBlocked={slice.items[0].isParentBlocked}
-          isParentNotFound={slice.items[0].isParentNotFound}
-          rootPost={slice.items[0].post}
-        />
-        <ViewFullThread uri={slice.items[0].uri} />
-        <FeedItem
-          key={slice.items[beforeLast]._reactKey}
-          post={slice.items[beforeLast].post}
-          record={slice.items[beforeLast].record}
-          reason={undefined}
-          feedContext={slice.feedContext}
-          parentAuthor={slice.items[beforeLast].parentAuthor}
-          showReplyTo={
-            slice.items[beforeLast].parentAuthor?.did !==
-            slice.items[beforeLast].post.author.did
-          }
-          moderation={slice.items[beforeLast].moderation}
-          isThreadParent={isThreadParentAt(slice.items, beforeLast)}
-          isThreadChild={isThreadChildAt(slice.items, beforeLast)}
-          isParentBlocked={slice.items[beforeLast].isParentBlocked}
-          isParentNotFound={slice.items[beforeLast].isParentNotFound}
-          rootPost={slice.items[0].post}
-        />
-        <FeedItem
-          key={slice.items[last]._reactKey}
-          post={slice.items[last].post}
-          record={slice.items[last].record}
-          reason={undefined}
-          feedContext={slice.feedContext}
-          parentAuthor={slice.items[last].parentAuthor}
-          showReplyTo={false}
-          moderation={slice.items[last].moderation}
-          isThreadParent={isThreadParentAt(slice.items, last)}
-          isThreadChild={isThreadChildAt(slice.items, last)}
-          isParentBlocked={slice.items[last].isParentBlocked}
-          isParentNotFound={slice.items[last].isParentNotFound}
-          isThreadLastChild
-          rootPost={slice.items[0].post}
-        />
-      </>
-    )
-  }
-
-  return (
-    <>
-      {slice.items.map((item, i) => (
-        <FeedItem
-          key={item._reactKey}
-          post={slice.items[i].post}
-          record={slice.items[i].record}
-          reason={i === 0 ? slice.reason : undefined}
-          feedContext={slice.feedContext}
-          moderation={slice.items[i].moderation}
-          parentAuthor={slice.items[i].parentAuthor}
-          showReplyTo={i === 0}
-          isThreadParent={isThreadParentAt(slice.items, i)}
-          isThreadChild={isThreadChildAt(slice.items, i)}
-          isThreadLastChild={
-            isThreadChildAt(slice.items, i) && slice.items.length === i + 1
-          }
-          isParentBlocked={slice.items[i].isParentBlocked}
-          isParentNotFound={slice.items[i].isParentNotFound}
-          hideTopBorder={hideTopBorder && i === 0}
-          rootPost={slice.items[0].post}
-        />
-      ))}
-    </>
-  )
-}
-FeedSlice = memo(FeedSlice)
-export {FeedSlice}
-
-function ViewFullThread({uri}: {uri: string}) {
-  const pal = usePalette('default')
-  const itemHref = React.useMemo(() => {
-    const urip = new AtUri(uri)
-    return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
-  }, [uri])
-
-  return (
-    <Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
-      <View style={styles.viewFullThreadDots}>
-        <Svg width="4" height="40">
-          <Line
-            x1="2"
-            y1="0"
-            x2="2"
-            y2="15"
-            stroke={pal.colors.replyLine}
-            strokeWidth="2"
-          />
-          <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} />
-          <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} />
-          <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} />
-        </Svg>
-      </View>
-
-      <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}>
-        <Trans>View full thread</Trans>
-      </Text>
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  viewFullThread: {
-    flexDirection: 'row',
-    gap: 10,
-    paddingLeft: 18,
-  },
-  viewFullThreadDots: {
-    width: 42,
-    alignItems: 'center',
-  },
-})
-
-function isThreadParentAt<T>(arr: Array<T>, i: number) {
-  if (arr.length === 1) {
-    return false
-  }
-  return i < arr.length - 1
-}
-
-function isThreadChildAt<T>(arr: Array<T>, i: number) {
-  if (arr.length === 1) {
-    return false
-  }
-  return i > 0
-}
diff --git a/src/view/com/posts/ViewFullThread.tsx b/src/view/com/posts/ViewFullThread.tsx
new file mode 100644
index 000000000..0b347f22c
--- /dev/null
+++ b/src/view/com/posts/ViewFullThread.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import Svg, {Circle, Line} from 'react-native-svg'
+import {AtUri} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import {Link} from '../util/Link'
+import {Text} from '../util/text/Text'
+
+export function ViewFullThread({uri}: {uri: string}) {
+  const {
+    state: hover,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const pal = usePalette('default')
+  const itemHref = React.useMemo(() => {
+    const urip = new AtUri(uri)
+    return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
+  }, [uri])
+
+  return (
+    <Link
+      style={[styles.viewFullThread]}
+      href={itemHref}
+      asAnchor
+      noFeedback
+      onPointerEnter={onHoverIn}
+      onPointerLeave={onHoverOut}>
+      <SubtleWebHover
+        hover={hover}
+        // adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn
+        style={{top: 8, bottom: -5}}
+      />
+      <View style={styles.viewFullThreadDots}>
+        <Svg width="4" height="40">
+          <Line
+            x1="2"
+            y1="0"
+            x2="2"
+            y2="15"
+            stroke={pal.colors.replyLine}
+            strokeWidth="2"
+          />
+          <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} />
+          <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} />
+          <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} />
+        </Svg>
+      </View>
+
+      <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}>
+        <Trans>View full thread</Trans>
+      </Text>
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  viewFullThread: {
+    flexDirection: 'row',
+    gap: 10,
+    paddingLeft: 18,
+  },
+  viewFullThreadDots: {
+    width: 42,
+    alignItems: 'center',
+  },
+})
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index aaa5d3454..c2d76316e 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
 import {AppBskyActorDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
index d73b322f2..0e25fe5e6 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -1,18 +1,13 @@
 import React from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
-import Animated, {
-  measure,
-  MeasuredDimensions,
-  runOnJS,
-  runOnUI,
-  useAnimatedRef,
-} from 'react-native-reanimated'
+import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
 import {BACK_HITSLOP} from '#/lib/constants'
+import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {makeProfileLink} from '#/lib/routes/links'
@@ -60,7 +55,7 @@ export function ProfileSubpageHeader({
   const {openLightbox} = useLightboxControls()
   const pal = usePalette('default')
   const canGoBack = navigation.canGoBack()
-  const aviRef = useAnimatedRef()
+  const aviRef = useHandleRef()
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
@@ -101,9 +96,10 @@ export function ProfileSubpageHeader({
     if (
       avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
     ) {
+      const aviHandle = aviRef.current
       runOnUI(() => {
         'worklet'
-        const rect = measure(aviRef)
+        const rect = measureHandle(aviHandle)
         runOnJS(_openLightbox)(avatar, rect)
       })()
     }
@@ -155,7 +151,7 @@ export function ProfileSubpageHeader({
           paddingBottom: 6,
           paddingHorizontal: isMobile ? 12 : 14,
         }}>
-        <Animated.View ref={aviRef} collapsable={false}>
+        <View ref={aviRef} collapsable={false}>
           <Pressable
             testID="headerAviButton"
             onPress={onPressAvi}
@@ -169,7 +165,7 @@ export function ProfileSubpageHeader({
               <UserAvatar type={avatarType} size={58} avatar={avatar} />
             )}
           </Pressable>
-        </Animated.View>
+        </View>
         <View style={{flex: 1}}>
           {isLoading ? (
             <LoadingPlaceholder
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 71c5f1da1..5c8b21373 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {LogBox, Pressable, View} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 
diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx
index 25e882e87..86751861f 100644
--- a/src/view/com/util/BottomSheetCustomBackdrop.tsx
+++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx
@@ -18,7 +18,7 @@ export function createCustomBackdrop(
     // animated variables
     const opacity = useAnimatedStyle(() => ({
       opacity: interpolate(
-        animatedIndex.value, // current snap index
+        animatedIndex.get(), // current snap index
         [-1, 0], // input range
         [0, 0.5], // output range
         Extrapolation.CLAMP,
diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx
index 587d84462..7f1632936 100644
--- a/src/view/com/util/EmptyState.tsx
+++ b/src/view/com/util/EmptyState.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {
diff --git a/src/view/com/util/EmptyStateWithButton.tsx b/src/view/com/util/EmptyStateWithButton.tsx
index 7b7aa129e..fcac6df08 100644
--- a/src/view/com/util/EmptyStateWithButton.tsx
+++ b/src/view/com/util/EmptyStateWithButton.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 46b94932b..c4211ffbc 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -1,4 +1,4 @@
-import React, {Component, ErrorInfo, ReactNode} from 'react'
+import {Component, ErrorInfo, ReactNode} from 'react'
 import {StyleProp, ViewStyle} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/util/FeedInfoText.tsx b/src/view/com/util/FeedInfoText.tsx
index da5c48af7..55eb1bad4 100644
--- a/src/view/com/util/FeedInfoText.tsx
+++ b/src/view/com/util/FeedInfoText.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 2cc3e30ca..f83258e45 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -18,6 +18,7 @@ import {
   useNavigationDeduped,
 } from '#/lib/hooks/useNavigationDeduped'
 import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {getTabState, TabState} from '#/lib/routes/helpers'
 import {
   convertBskyAppUrlIfNeeded,
   isExternalUrl,
@@ -25,6 +26,7 @@ import {
 } from '#/lib/strings/url-helpers'
 import {TypographyVariant} from '#/lib/ThemeContext'
 import {isAndroid, isWeb} from '#/platform/detection'
+import {emitSoftReset} from '#/state/events'
 import {useModalControls} from '#/state/modals'
 import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper'
 import {useTheme} from '#/alf'
@@ -254,7 +256,7 @@ export const TextLink = memo(function TextLink({
     if (isExternal) {
       return {
         target: '_blank',
-        // rel: 'noopener noreferrer',
+        // rel: 'noopener',
       }
     }
     return {}
@@ -400,15 +402,22 @@ function onPressInner(
     } else {
       closeModal() // close any active modals
 
+      const [routeName, params] = router.matchPath(href)
       if (navigationAction === 'push') {
         // @ts-ignore we're not able to type check on this one -prf
-        navigation.dispatch(StackActions.push(...router.matchPath(href)))
+        navigation.dispatch(StackActions.push(routeName, params))
       } else if (navigationAction === 'replace') {
         // @ts-ignore we're not able to type check on this one -prf
-        navigation.dispatch(StackActions.replace(...router.matchPath(href)))
+        navigation.dispatch(StackActions.replace(routeName, params))
       } else if (navigationAction === 'navigate') {
-        // @ts-ignore we're not able to type check on this one -prf
-        navigation.navigate(...router.matchPath(href))
+        const state = navigation.getState()
+        const tabState = getTabState(state, routeName)
+        if (tabState === TabState.InsideAtRoot) {
+          emitSoftReset()
+        } else {
+          // @ts-ignore we're not able to type check on this one -prf
+          navigation.navigate(routeName, params)
+        }
       } else {
         throw Error('Unsupported navigator action.')
       }
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 0425514e4..fa93ec5e6 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -7,7 +7,8 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX
 import {useDedupe} from '#/lib/hooks/useDedupe'
 import {useScrollHandlers} from '#/lib/ScrollContext'
 import {addStyle} from '#/lib/styles'
-import {isIOS} from '#/platform/detection'
+import {isAndroid, isIOS} from '#/platform/detection'
+import {useLightbox} from '#/state/lightbox'
 import {useTheme} from '#/alf'
 import {FlatList_INTERNAL} from './Views'
 
@@ -52,6 +53,7 @@ function ListImpl<ItemT>(
   const isScrolledDown = useSharedValue(false)
   const t = useTheme()
   const dedupe = useDedupe(400)
+  const {activeLightbox} = useLightbox()
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     onScrolledDownChange?.(didScrollDown)
@@ -77,8 +79,8 @@ function ListImpl<ItemT>(
       onScrollFromContext?.(e, ctx)
 
       const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT
-      if (isScrolledDown.value !== didScrollDown) {
-        isScrolledDown.value = didScrollDown
+      if (isScrolledDown.get() !== didScrollDown) {
+        isScrolledDown.set(didScrollDown)
         if (onScrolledDownChange != null) {
           runOnJS(handleScrolledDownChange)(didScrollDown)
         }
@@ -143,9 +145,11 @@ function ListImpl<ItemT>(
       contentOffset={contentOffset}
       refreshControl={refreshControl}
       onScroll={scrollHandler}
+      scrollsToTop={!activeLightbox}
       scrollEventThrottle={1}
       onViewableItemsChanged={onViewableItemsChanged}
       viewabilityConfig={viewabilityConfig}
+      showsVerticalScrollIndicator={!isAndroid}
       style={style}
       ref={ref}
     />
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index d9a2e351e..f112d2d0a 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -46,9 +46,9 @@ function ListImpl<ItemT>(
     keyExtractor,
     refreshing: _unsupportedRefreshing,
     onStartReached,
-    onStartReachedThreshold = 0,
+    onStartReachedThreshold = 2,
     onEndReached,
-    onEndReachedThreshold = 0,
+    onEndReachedThreshold = 2,
     onRefresh: _unsupportedOnRefresh,
     onScrolledDownChange,
     onContentSizeChange,
diff --git a/src/view/com/util/LoadMoreRetryBtn.tsx b/src/view/com/util/LoadMoreRetryBtn.tsx
index 863e8e2f5..07bd733ea 100644
--- a/src/view/com/util/LoadMoreRetryBtn.tsx
+++ b/src/view/com/util/LoadMoreRetryBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet} from 'react-native'
 import {
   FontAwesomeIcon,
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index 6620eb8e2..25ce460d4 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {
   DimensionValue,
   StyleProp,
@@ -140,7 +139,7 @@ export function NotificationLoadingPlaceholder({
   const pal = usePalette('default')
   return (
     <View style={[styles.notification, pal.view, style]}>
-      <View style={[{width: 70}, a.align_end, a.pr_sm, a.pt_2xs]}>
+      <View style={[{width: 60}, a.align_end, a.pr_sm, a.pt_2xs]}>
         <HeartIconFilled
           size="xl"
           style={{color: pal.colors.backgroundLight}}
@@ -149,8 +148,8 @@ export function NotificationLoadingPlaceholder({
       <View style={{flex: 1}}>
         <View style={[a.flex_row, s.mb10]}>
           <LoadingPlaceholder
-            width={30}
-            height={30}
+            width={35}
+            height={35}
             style={styles.smallAvatar}
           />
         </View>
@@ -310,7 +309,7 @@ const styles = StyleSheet.create({
     padding: 5,
   },
   avatar: {
-    borderRadius: 26,
+    borderRadius: 999,
     marginRight: 10,
     marginLeft: 8,
   },
@@ -324,11 +323,11 @@ const styles = StyleSheet.create({
     margin: 1,
   },
   profileCardAvi: {
-    borderRadius: 20,
+    borderRadius: 999,
     marginRight: 10,
   },
   smallAvatar: {
-    borderRadius: 15,
+    borderRadius: 999,
     marginRight: 10,
   },
 })
diff --git a/src/view/com/util/LoadingScreen.tsx b/src/view/com/util/LoadingScreen.tsx
index 15066d625..5d2aeb38f 100644
--- a/src/view/com/util/LoadingScreen.tsx
+++ b/src/view/com/util/LoadingScreen.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {ActivityIndicator, View} from 'react-native'
 
 import {s} from '#/lib/styles'
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 23dffc561..0d084993b 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -3,6 +3,7 @@ import {NativeScrollEvent} from 'react-native'
 import {
   cancelAnimation,
   interpolate,
+  makeMutable,
   useSharedValue,
   withSpring,
 } from 'react-native-reanimated'
@@ -20,6 +21,18 @@ function clamp(num: number, min: number, max: number) {
   return Math.min(Math.max(num, min), max)
 }
 
+const V0 = makeMutable(
+  withSpring(0, {
+    overshootClamping: true,
+  }),
+)
+
+const V1 = makeMutable(
+  withSpring(1, {
+    overshootClamping: true,
+  }),
+)
+
 export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const {headerHeight} = useShellLayout()
   const {headerMode} = useMinimalShellMode()
@@ -31,9 +44,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     (v: boolean) => {
       'worklet'
       cancelAnimation(headerMode)
-      headerMode.value = withSpring(v ? 1 : 0, {
-        overshootClamping: true,
-      })
+      headerMode.set(v ? V1.get() : V0.get())
     },
     [headerMode],
   )
@@ -41,9 +52,9 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   useEffect(() => {
     if (isWeb) {
       return listenToForcedWindowScroll(() => {
-        startDragOffset.value = null
-        startMode.value = null
-        didJustRestoreScroll.value = true
+        startDragOffset.set(null)
+        startMode.set(null)
+        didJustRestoreScroll.set(true)
       })
     }
   })
@@ -52,13 +63,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     (e: NativeScrollEvent) => {
       'worklet'
       if (isNative) {
-        if (startDragOffset.value === null) {
+        const startDragOffsetValue = startDragOffset.get()
+        if (startDragOffsetValue === null) {
           return
         }
-        const didScrollDown = e.contentOffset.y > startDragOffset.value
-        startDragOffset.value = null
-        startMode.value = null
-        if (e.contentOffset.y < headerHeight.value) {
+        const didScrollDown = e.contentOffset.y > startDragOffsetValue
+        startDragOffset.set(null)
+        startMode.set(null)
+        if (e.contentOffset.y < headerHeight.get()) {
           // If we're close to the top, show the shell.
           setMode(false)
         } else if (didScrollDown) {
@@ -66,7 +78,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
           setMode(true)
         } else {
           // Snap to whichever state is the closest.
-          setMode(Math.round(headerMode.value) === 1)
+          setMode(Math.round(headerMode.get()) === 1)
         }
       }
     },
@@ -77,8 +89,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     (e: NativeScrollEvent) => {
       'worklet'
       if (isNative) {
-        startDragOffset.value = e.contentOffset.y
-        startMode.value = headerMode.value
+        startDragOffset.set(e.contentOffset.y)
+        startMode.set(headerMode.get())
       }
     },
     [headerMode, startDragOffset, startMode],
@@ -112,10 +124,12 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     (e: NativeScrollEvent) => {
       'worklet'
       if (isNative) {
-        if (startDragOffset.value === null || startMode.value === null) {
+        const startDragOffsetValue = startDragOffset.get()
+        const startModeValue = startMode.get()
+        if (startDragOffsetValue === null || startModeValue === null) {
           if (
-            headerMode.value !== 0 &&
-            e.contentOffset.y < headerHeight.value
+            headerMode.get() !== 0 &&
+            e.contentOffset.y < headerHeight.get()
           ) {
             // If we're close enough to the top, always show the shell.
             // Even if we're not dragging.
@@ -126,29 +140,29 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
 
         // The "mode" value is always between 0 and 1.
         // Figure out how much to move it based on the current dragged distance.
-        const dy = e.contentOffset.y - startDragOffset.value
+        const dy = e.contentOffset.y - startDragOffsetValue
         const dProgress = interpolate(
           dy,
-          [-headerHeight.value, headerHeight.value],
+          [-headerHeight.get(), headerHeight.get()],
           [-1, 1],
         )
-        const newValue = clamp(startMode.value + dProgress, 0, 1)
-        if (newValue !== headerMode.value) {
+        const newValue = clamp(startModeValue + dProgress, 0, 1)
+        if (newValue !== headerMode.get()) {
           // Manually adjust the value. This won't be (and shouldn't be) animated.
           // Cancel any any existing animation
           cancelAnimation(headerMode)
-          headerMode.value = newValue
+          headerMode.set(newValue)
         }
       } else {
-        if (didJustRestoreScroll.value) {
-          didJustRestoreScroll.value = false
+        if (didJustRestoreScroll.get()) {
+          didJustRestoreScroll.set(false)
           // Don't hide/show navbar based on scroll restoratoin.
           return
         }
         // On the web, we don't try to follow the drag because we don't know when it ends.
         // Instead, show/hide immediately based on whether we're scrolling up or down.
-        const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
-        startDragOffset.value = e.contentOffset.y
+        const dy = e.contentOffset.y - (startDragOffset.get() ?? 0)
+        startDragOffset.set(e.contentOffset.y)
 
         if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
           setMode(false)
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index c0166a16e..5384f6827 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -49,6 +49,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
     precacheProfile(queryClient, opts.author)
   }, [queryClient, opts.author])
 
+  const timestampLabel = niceDate(i18n, opts.timestamp)
+
   return (
     <View
       style={[
@@ -115,8 +117,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
         {({timeElapsed}) => (
           <WebOnlyInlineLinkText
             to={opts.postHref}
-            label={niceDate(i18n, opts.timestamp)}
-            title={niceDate(i18n, opts.timestamp)}
+            label={timestampLabel}
+            title={timestampLabel}
             disableMismatchWarning
             disableUnderline
             onPress={onBeforePressPost}
diff --git a/src/view/com/util/PressableWithHover.tsx b/src/view/com/util/PressableWithHover.tsx
index 48659e229..19a1968cc 100644
--- a/src/view/com/util/PressableWithHover.tsx
+++ b/src/view/com/util/PressableWithHover.tsx
@@ -1,4 +1,4 @@
-import React, {forwardRef, PropsWithChildren} from 'react'
+import {forwardRef, PropsWithChildren} from 'react'
 import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
 import {View} from 'react-native'
 
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
deleted file mode 100644
index cf9d347af..000000000
--- a/src/view/com/util/Selector.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import React, {createRef, useMemo, useRef, useState} from 'react'
-import {Animated, Pressable, StyleSheet, View} from 'react-native'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {Text} from './text/Text'
-
-interface Layout {
-  x: number
-  width: number
-}
-
-export function Selector({
-  selectedIndex,
-  items,
-  panX,
-  onSelect,
-}: {
-  selectedIndex: number
-  items: string[]
-  panX: Animated.Value
-  onSelect?: (index: number) => void
-}) {
-  const {_} = useLingui()
-  const containerRef = useRef<View>(null)
-  const pal = usePalette('default')
-  const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
-    undefined,
-  )
-  const itemRefs = useMemo(
-    () => Array.from({length: items.length}).map(() => createRef<View>()),
-    [items.length],
-  )
-
-  const currentLayouts = useMemo(() => {
-    const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0}
-    const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0}
-    const right = itemLayouts?.[selectedIndex + 1] || {
-      x: middle.x + 20,
-      width: middle.width,
-    }
-    return [left, middle, right]
-  }, [selectedIndex, itemLayouts])
-
-  const underlineStyle = {
-    backgroundColor: pal.colors.text,
-    left: panX.interpolate({
-      inputRange: [-1, 0, 1],
-      outputRange: [
-        currentLayouts[0].x,
-        currentLayouts[1].x,
-        currentLayouts[2].x,
-      ],
-    }),
-    width: panX.interpolate({
-      inputRange: [-1, 0, 1],
-      outputRange: [
-        currentLayouts[0].width,
-        currentLayouts[1].width,
-        currentLayouts[2].width,
-      ],
-    }),
-  }
-
-  const onLayout = () => {
-    const promises = []
-    for (let i = 0; i < items.length; i++) {
-      promises.push(
-        new Promise<Layout>(resolve => {
-          if (!containerRef.current || !itemRefs[i].current) {
-            return resolve({x: 0, width: 0})
-          }
-          itemRefs[i].current?.measureLayout(
-            containerRef.current,
-            (x: number, _y: number, width: number) => {
-              resolve({x, width})
-            },
-          )
-        }),
-      )
-    }
-    Promise.all(promises).then((layouts: Layout[]) => {
-      setItemLayouts(layouts)
-    })
-  }
-
-  const onPressItem = (index: number) => {
-    onSelect?.(index)
-  }
-
-  const numItems = items.length
-
-  return (
-    <View
-      style={[pal.view, styles.outer]}
-      onLayout={onLayout}
-      ref={containerRef}>
-      <Animated.View style={[styles.underline, underlineStyle]} />
-      {items.map((item, i) => {
-        const selected = i === selectedIndex
-        return (
-          <Pressable
-            testID={`selector-${i}`}
-            key={item}
-            onPress={() => onPressItem(i)}
-            accessibilityLabel={_(msg`Select ${item}`)}
-            accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}>
-            <View style={styles.item} ref={itemRefs[i]}>
-              <Text
-                style={
-                  selected
-                    ? [styles.labelSelected, pal.text]
-                    : [styles.label, pal.textLight]
-                }>
-                {item}
-              </Text>
-            </View>
-          </Pressable>
-        )
-      })}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    flexDirection: 'row',
-    paddingTop: 8,
-    paddingBottom: 12,
-    paddingHorizontal: 14,
-  },
-  item: {
-    marginRight: 14,
-    paddingHorizontal: 10,
-  },
-  label: {
-    fontWeight: '600',
-  },
-  labelSelected: {
-    fontWeight: '600',
-  },
-  underline: {
-    position: 'absolute',
-    height: 4,
-    bottom: 0,
-  },
-})
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 51e76bdc3..b57e676ae 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -1,6 +1,20 @@
-import React, {useEffect, useState} from 'react'
-import {View} from 'react-native'
-import Animated, {FadeInUp, FadeOutUp} from 'react-native-reanimated'
+import {useEffect, useMemo, useRef, useState} from 'react'
+import {AccessibilityInfo, View} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  GestureHandlerRootView,
+} from 'react-native-gesture-handler'
+import Animated, {
+  FadeInUp,
+  FadeOutUp,
+  runOnJS,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  useSharedValue,
+  withDecay,
+  withSpring,
+} from 'react-native-reanimated'
 import RootSiblings from 'react-native-root-siblings'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {
@@ -8,6 +22,7 @@ import {
   Props as FontAwesomeProps,
 } from '@fortawesome/react-native-fontawesome'
 
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 import {IS_TEST} from '#/env'
@@ -19,74 +34,174 @@ export function show(
   icon: FontAwesomeProps['icon'] = 'check',
 ) {
   if (IS_TEST) return
-  const item = new RootSiblings(<Toast message={message} icon={icon} />)
-  // timeout has some leeway to account for the animation
-  setTimeout(() => {
-    item.destroy()
-  }, TIMEOUT + 1e3)
+  AccessibilityInfo.announceForAccessibility(message)
+  const item = new RootSiblings(
+    <Toast message={message} icon={icon} destroy={() => item.destroy()} />,
+  )
 }
 
 function Toast({
   message,
   icon,
+  destroy,
 }: {
   message: string
   icon: FontAwesomeProps['icon']
+  destroy: () => void
 }) {
   const t = useTheme()
   const {top} = useSafeAreaInsets()
+  const isPanning = useSharedValue(false)
+  const dismissSwipeTranslateY = useSharedValue(0)
+  const [cardHeight, setCardHeight] = useState(0)
 
   // for the exit animation to work on iOS the animated component
   // must not be the root component
   // so we need to wrap it in a view and unmount the toast ahead of time
   const [alive, setAlive] = useState(true)
 
-  useEffect(() => {
+  const hideAndDestroyImmediately = () => {
+    setAlive(false)
     setTimeout(() => {
-      setAlive(false)
-    }, TIMEOUT)
-  }, [])
+      destroy()
+    }, 1e3)
+  }
+
+  const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
+  const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
+    clearTimeout(destroyTimeoutRef.current)
+    destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT)
+  })
+  const pauseDestroy = useNonReactiveCallback(() => {
+    clearTimeout(destroyTimeoutRef.current)
+  })
+
+  useEffect(() => {
+    hideAndDestroyAfterTimeout()
+  }, [hideAndDestroyAfterTimeout])
+
+  const panGesture = useMemo(() => {
+    return Gesture.Pan()
+      .activeOffsetY([-10, 10])
+      .failOffsetX([-10, 10])
+      .maxPointers(1)
+      .onStart(() => {
+        'worklet'
+        if (!alive) return
+        isPanning.set(true)
+        runOnJS(pauseDestroy)()
+      })
+      .onUpdate(e => {
+        'worklet'
+        if (!alive) return
+        dismissSwipeTranslateY.value = e.translationY
+      })
+      .onEnd(e => {
+        'worklet'
+        if (!alive) return
+        runOnJS(hideAndDestroyAfterTimeout)()
+        isPanning.set(false)
+        if (e.velocityY < -100) {
+          if (dismissSwipeTranslateY.value === 0) {
+            // HACK: If the initial value is 0, withDecay() animation doesn't start.
+            // This is a bug in Reanimated, but for now we'll work around it like this.
+            dismissSwipeTranslateY.value = 1
+          }
+          dismissSwipeTranslateY.value = withDecay({
+            velocity: e.velocityY,
+            velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
+            deceleration: 1,
+          })
+        } else {
+          dismissSwipeTranslateY.value = withSpring(0, {
+            stiffness: 500,
+            damping: 50,
+          })
+        }
+      })
+  }, [
+    dismissSwipeTranslateY,
+    isPanning,
+    alive,
+    hideAndDestroyAfterTimeout,
+    pauseDestroy,
+  ])
+
+  const topOffset = top + 10
+
+  useAnimatedReaction(
+    () =>
+      !isPanning.get() &&
+      dismissSwipeTranslateY.get() < -topOffset - cardHeight,
+    (isSwipedAway, prevIsSwipedAway) => {
+      'worklet'
+      if (isSwipedAway && !prevIsSwipedAway) {
+        runOnJS(destroy)()
+      }
+    },
+  )
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const translation = dismissSwipeTranslateY.get()
+    return {
+      transform: [
+        {
+          translateY: translation > 0 ? translation ** 0.7 : translation,
+        },
+      ],
+    }
+  })
 
   return (
-    <View
-      style={[a.absolute, {top: top + 15, left: 16, right: 16}]}
-      pointerEvents="none">
+    <GestureHandlerRootView
+      style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
+      pointerEvents="box-none">
       {alive && (
         <Animated.View
           entering={FadeInUp}
           exiting={FadeOutUp}
-          style={[
-            a.flex_1,
-            t.atoms.bg,
-            a.shadow_lg,
-            t.atoms.border_contrast_medium,
-            a.rounded_sm,
-            a.px_md,
-            a.py_lg,
-            a.border,
-            a.flex_row,
-            a.gap_md,
-          ]}>
-          <View
+          style={[a.flex_1]}>
+          <Animated.View
+            onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
+            accessibilityRole="alert"
+            accessible={true}
+            accessibilityLabel={message}
+            accessibilityHint=""
+            onAccessibilityEscape={hideAndDestroyImmediately}
             style={[
-              a.flex_shrink_0,
-              a.rounded_full,
-              {width: 32, height: 32},
-              t.atoms.bg_contrast_25,
-              a.align_center,
-              a.justify_center,
+              a.flex_1,
+              t.atoms.bg,
+              a.shadow_lg,
+              t.atoms.border_contrast_medium,
+              a.rounded_sm,
+              a.border,
+              animatedStyle,
             ]}>
-            <FontAwesomeIcon
-              icon={icon}
-              size={16}
-              style={t.atoms.text_contrast_low}
-            />
-          </View>
-          <View style={[a.h_full, a.justify_center, a.flex_1]}>
-            <Text style={a.text_md}>{message}</Text>
-          </View>
+            <GestureDetector gesture={panGesture}>
+              <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}>
+                <View
+                  style={[
+                    a.flex_shrink_0,
+                    a.rounded_full,
+                    {width: 32, height: 32},
+                    {backgroundColor: t.palette.primary_50},
+                    a.align_center,
+                    a.justify_center,
+                  ]}>
+                  <FontAwesomeIcon
+                    icon={icon}
+                    size={16}
+                    style={t.atoms.text_contrast_medium}
+                  />
+                </View>
+                <View style={[a.h_full, a.justify_center, a.flex_1]}>
+                  <Text style={a.text_md}>{message}</Text>
+                </View>
+              </View>
+            </GestureDetector>
+          </Animated.View>
         </Animated.View>
       )}
-    </View>
+    </GestureHandlerRootView>
   )
 }
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index 1f9eb479b..96798e61c 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -3,7 +3,7 @@
  */
 
 import React, {useEffect, useState} from 'react'
-import {StyleSheet, Text, View} from 'react-native'
+import {Pressable, StyleSheet, Text, View} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -43,6 +43,14 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
             style={styles.icon as FontAwesomeIconStyle}
           />
           <Text style={styles.text}>{activeToast.text}</Text>
+          <Pressable
+            style={styles.dismissBackdrop}
+            accessibilityLabel="Dismiss"
+            accessibilityHint=""
+            onPress={() => {
+              setActiveToast(undefined)
+            }}
+          />
         </View>
       )}
     </>
@@ -77,6 +85,13 @@ const styles = StyleSheet.create({
     backgroundColor: '#000c',
     borderRadius: 10,
   },
+  dismissBackdrop: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    bottom: 0,
+    right: 0,
+  },
   icon: {
     color: '#fff',
     flexShrink: 0,
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index 8a444d590..64aa37ff2 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
 
diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx
deleted file mode 100644
index 97605fb46..000000000
--- a/src/view/com/util/anim/TriggerableAnimated.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react'
-import {Animated, StyleProp, View, ViewStyle} from 'react-native'
-
-import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
-
-type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation
-type FinishCb = () => void
-
-interface TriggeredAnimation {
-  start: CreateAnimFn
-  style: (
-    interp: Animated.Value,
-  ) => Animated.WithAnimatedValue<StyleProp<ViewStyle>>
-}
-
-export interface TriggerableAnimatedRef {
-  trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void
-}
-
-type TriggerableAnimatedProps = React.PropsWithChildren<{}>
-
-type PropsInner = TriggerableAnimatedProps & {
-  anim: TriggeredAnimation
-  onFinish: () => void
-}
-
-export const TriggerableAnimated = React.forwardRef<
-  TriggerableAnimatedRef,
-  TriggerableAnimatedProps
->(function TriggerableAnimatedImpl({children, ...props}, ref) {
-  const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>(
-    undefined,
-  )
-  const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>(
-    undefined,
-  )
-  React.useImperativeHandle(ref, () => ({
-    trigger(v: TriggeredAnimation, cb?: FinishCb) {
-      setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate
-      setAnim(v)
-    },
-  }))
-  const onFinish = () => {
-    finishCb?.()
-    setAnim(undefined)
-    setFinishCb(undefined)
-  }
-  return (
-    <View key="triggerable">
-      {anim ? (
-        <AnimatingView anim={anim} onFinish={onFinish} {...props}>
-          {children}
-        </AnimatingView>
-      ) : (
-        children
-      )}
-    </View>
-  )
-})
-
-function AnimatingView({
-  anim,
-  onFinish,
-  children,
-}: React.PropsWithChildren<PropsInner>) {
-  const interp = useAnimatedValue(0)
-  React.useEffect(() => {
-    anim?.start(interp).start(() => {
-      onFinish()
-    })
-  })
-  const animStyle = anim?.style(interp)
-  return <Animated.View style={animStyle}>{children}</Animated.View>
-}
diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx
index f0ef3a40f..c09d1b2e6 100644
--- a/src/view/com/util/error/ErrorMessage.tsx
+++ b/src/view/com/util/error/ErrorMessage.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {
   StyleProp,
   StyleSheet,
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index 1b23141f3..b66f43789 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {
   FontAwesomeIcon,
diff --git a/src/view/com/util/fab/FAB.web.tsx b/src/view/com/util/fab/FAB.web.tsx
index 601d505a8..b9f3a0b07 100644
--- a/src/view/com/util/fab/FAB.web.tsx
+++ b/src/view/com/util/fab/FAB.web.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 48e0005bc..77e283625 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -1,4 +1,4 @@
-import React, {ComponentProps} from 'react'
+import {ComponentProps} from 'react'
 import {StyleSheet, TouchableWithoutFeedback} from 'react-native'
 import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx
index 9df53f116..594bb48f6 100644
--- a/src/view/com/util/forms/DateInput.tsx
+++ b/src/view/com/util/forms/DateInput.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import {useCallback, useState} from 'react'
 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
 import DatePicker from 'react-native-date-picker'
 import {
diff --git a/src/view/com/util/forms/DateInput.web.tsx b/src/view/com/util/forms/DateInput.web.tsx
index ea6102356..988d8aee6 100644
--- a/src/view/com/util/forms/DateInput.web.tsx
+++ b/src/view/com/util/forms/DateInput.web.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import {useCallback, useState} from 'react'
 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
 // @ts-ignore types not available -prf
 import {unstable_createElement} from 'react-native-web'
diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx
index 22237f5e1..8fc9be6da 100644
--- a/src/view/com/util/forms/NativeDropdown.tsx
+++ b/src/view/com/util/forms/NativeDropdown.tsx
@@ -5,10 +5,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as DropdownMenu from 'zeego/dropdown-menu'
 import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
 
-import {HITSLOP_10} from '#/lib/constants'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useTheme} from '#/lib/ThemeContext'
-import {isIOS, isWeb} from '#/platform/detection'
+import {isIOS} from '#/platform/detection'
 import {Portal} from '#/components/Portal'
 
 // Custom Dropdown Menu Components
@@ -30,31 +29,18 @@ export const DropdownMenuTrigger = DropdownMenu.create(
   (props: TriggerProps) => {
     const theme = useTheme()
     const defaultCtrlColor = theme.palette.default.postCtrl
-    const ref = React.useRef<View>(null)
-
-    // HACK
-    // fire a click event on the keyboard press to trigger the dropdown
-    // -prf
-    const onPress = isWeb
-      ? (evt: any) => {
-          if (evt instanceof KeyboardEvent) {
-            // @ts-ignore web only -prf
-            ref.current?.click()
-          }
-        }
-      : undefined
 
     return (
+      // This Pressable doesn't actually do anything other than
+      // provide the "pressed state" visual feedback.
       <Pressable
         testID={props.testID}
         accessibilityRole="button"
         accessibilityLabel={props.accessibilityLabel}
         accessibilityHint={props.accessibilityHint}
-        style={({pressed}) => [{opacity: pressed ? 0.5 : 1}]}
-        hitSlop={HITSLOP_10}
-        onPress={onPress}>
+        style={({pressed}) => [{opacity: pressed ? 0.8 : 1}]}>
         <DropdownMenu.Trigger action="press">
-          <View ref={ref}>
+          <View>
             {props.children ? (
               props.children
             ) : (
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 22751d8bf..fd577605a 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,83 +1,27 @@
-import React, {memo, useCallback} from 'react'
+import React, {memo, useMemo, useState} from 'react'
 import {
-  Platform,
   Pressable,
   type PressableProps,
   type StyleProp,
   type ViewStyle,
 } from 'react-native'
-import * as Clipboard from 'expo-clipboard'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedThreadgate,
-  AtUri,
   RichText as RichTextAPI,
 } from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
 
-import {useOpenLink} from '#/lib/hooks/useOpenLink'
-import {getCurrentRoute} from '#/lib/routes/helpers'
-import {makeProfileLink} from '#/lib/routes/links'
-import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
-import {shareUrl} from '#/lib/sharing'
-import {logEvent} from '#/lib/statsig/statsig'
-import {richTextToString} from '#/lib/strings/rich-text-helpers'
-import {toShareUrl} from '#/lib/strings/url-helpers'
 import {useTheme} from '#/lib/ThemeContext'
-import {getTranslatorLink} from '#/locale/helpers'
-import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
 import {Shadow} from '#/state/cache/post-shadow'
-import {useFeedFeedbackContext} from '#/state/feed-feedback'
-import {useLanguagePrefs} from '#/state/preferences'
-import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
-import {usePinnedPostMutation} from '#/state/queries/pinned-post'
-import {
-  usePostDeleteMutation,
-  useThreadMuteMutationQueue,
-} from '#/state/queries/post'
-import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
-import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
-import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
-import {useSession} from '#/state/session'
-import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
-import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
-import {useDialogControl} from '#/components/Dialog'
-import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
-import {EmbedDialog} from '#/components/dialogs/Embed'
-import {
-  PostInteractionSettingsDialog,
-  usePrefetchPostInteractionSettings,
-} from '#/components/dialogs/PostInteractionSettingsDialog'
-import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
-import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
-import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
-import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
+import {atoms as a, useTheme as useAlf} from '#/alf'
 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
-import {
-  EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
-  EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
-} from '#/components/icons/Emoji'
-import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
-import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
-import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
-import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
-import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
-import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
-import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
-import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
-import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
-import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
-import {Loader} from '#/components/Loader'
+import {useMenuControl} from '#/components/Menu'
 import * as Menu from '#/components/Menu'
-import * as Prompt from '#/components/Prompt'
-import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
 import {EventStopper} from '../EventStopper'
-import * as Toast from '../Toast'
+import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems'
 
 let PostDropdownBtn = ({
   testID,
@@ -102,266 +46,27 @@ let PostDropdownBtn = ({
   timestamp: string
   threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
-  const {hasSession, currentAccount} = useSession()
   const theme = useTheme()
   const alf = useAlf()
-  const {gtMobile} = useBreakpoints()
   const {_} = useLingui()
   const defaultCtrlColor = theme.palette.default.postCtrl
-  const langPrefs = useLanguagePrefs()
-  const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
-  const {mutateAsync: pinPostMutate, isPending: isPinPending} =
-    usePinnedPostMutation()
-  const hiddenPosts = useHiddenPosts()
-  const {hidePost} = useHiddenPostsApi()
-  const feedFeedback = useFeedFeedbackContext()
-  const openLink = useOpenLink()
-  const navigation = useNavigation<NavigationProp>()
-  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
-  const reportDialogControl = useReportDialogControl()
-  const deletePromptControl = useDialogControl()
-  const hidePromptControl = useDialogControl()
-  const loggedOutWarningPromptControl = useDialogControl()
-  const embedPostControl = useDialogControl()
-  const sendViaChatControl = useDialogControl()
-  const postInteractionSettingsDialogControl = useDialogControl()
-  const quotePostDetachConfirmControl = useDialogControl()
-  const hideReplyConfirmControl = useDialogControl()
-  const {mutateAsync: toggleReplyVisibility} =
-    useToggleReplyVisibilityMutation()
-
-  const postUri = post.uri
-  const postCid = post.cid
-  const postAuthor = post.author
-  const quoteEmbed = React.useMemo(() => {
-    if (!currentAccount || !post.embed) return
-    return getMaybeDetachedQuoteEmbed({
-      viewerDid: currentAccount.did,
-      post,
-    })
-  }, [post, currentAccount])
-
-  const rootUri = record.reply?.root?.uri || postUri
-  const isReply = Boolean(record.reply)
-  const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
-    post,
-    rootUri,
-  )
-  const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
-  const isAuthor = postAuthor.did === currentAccount?.did
-  const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
-  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
-    threadgateRecord,
-  })
-  const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
-  const isPinned = post.viewer?.pinned
-
-  const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} =
-    useToggleQuoteDetachmentMutation()
-
-  const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
-    postUri: post.uri,
-    rootPostUri: rootUri,
-  })
-
-  const href = React.useMemo(() => {
-    const urip = new AtUri(postUri)
-    return makeProfileLink(postAuthor, 'post', urip.rkey)
-  }, [postUri, postAuthor])
-
-  const translatorUrl = getTranslatorLink(
-    record.text,
-    langPrefs.primaryLanguage,
-  )
-
-  const onDeletePost = React.useCallback(() => {
-    deletePostMutate({uri: postUri}).then(
-      () => {
-        Toast.show(_(msg`Post deleted`))
-
-        const route = getCurrentRoute(navigation.getState())
-        if (route.name === 'PostThread') {
-          const params = route.params as CommonNavigatorParams['PostThread']
-          if (
-            currentAccount &&
-            isAuthor &&
-            (params.name === currentAccount.handle ||
-              params.name === currentAccount.did)
-          ) {
-            const currentHref = makeProfileLink(postAuthor, 'post', params.rkey)
-            if (currentHref === href && navigation.canGoBack()) {
-              navigation.goBack()
-            }
-          }
-        }
-      },
-      e => {
-        logger.error('Failed to delete post', {message: e})
-        Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
+  const menuControl = useMenuControl()
+  const [hasBeenOpen, setHasBeenOpen] = useState(false)
+  const lazyMenuControl = useMemo(
+    () => ({
+      ...menuControl,
+      open() {
+        setHasBeenOpen(true)
+        // HACK. We need the state update to be flushed by the time
+        // menuControl.open() fires but RN doesn't expose flushSync.
+        setTimeout(menuControl.open)
       },
-    )
-  }, [
-    navigation,
-    postUri,
-    deletePostMutate,
-    postAuthor,
-    currentAccount,
-    isAuthor,
-    href,
-    _,
-  ])
-
-  const onToggleThreadMute = React.useCallback(() => {
-    try {
-      if (isThreadMuted) {
-        unmuteThread()
-        Toast.show(_(msg`You will now receive notifications for this thread`))
-      } else {
-        muteThread()
-        Toast.show(
-          _(msg`You will no longer receive notifications for this thread`),
-        )
-      }
-    } catch (e: any) {
-      if (e?.name !== 'AbortError') {
-        logger.error('Failed to toggle thread mute', {message: e})
-        Toast.show(
-          _(msg`Failed to toggle thread mute, please try again`),
-          'xmark',
-        )
-      }
-    }
-  }, [isThreadMuted, unmuteThread, _, muteThread])
-
-  const onCopyPostText = React.useCallback(() => {
-    const str = richTextToString(richText, true)
-
-    Clipboard.setStringAsync(str)
-    Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
-  }, [_, richText])
-
-  const onPressTranslate = React.useCallback(async () => {
-    await openLink(translatorUrl)
-  }, [openLink, translatorUrl])
-
-  const onHidePost = React.useCallback(() => {
-    hidePost({uri: postUri})
-  }, [postUri, hidePost])
-
-  const hideInPWI = React.useMemo(() => {
-    return !!postAuthor.labels?.find(
-      label => label.val === '!no-unauthenticated',
-    )
-  }, [postAuthor])
-
-  const showLoggedOutWarning =
-    postAuthor.did !== currentAccount?.did && hideInPWI
-
-  const onSharePost = React.useCallback(() => {
-    const url = toShareUrl(href)
-    shareUrl(url)
-  }, [href])
-
-  const onPressShowMore = React.useCallback(() => {
-    feedFeedback.sendInteraction({
-      event: 'app.bsky.feed.defs#requestMore',
-      item: postUri,
-      feedContext: postFeedContext,
-    })
-    Toast.show(_(msg`Feedback sent!`))
-  }, [feedFeedback, postUri, postFeedContext, _])
-
-  const onPressShowLess = React.useCallback(() => {
-    feedFeedback.sendInteraction({
-      event: 'app.bsky.feed.defs#requestLess',
-      item: postUri,
-      feedContext: postFeedContext,
-    })
-    Toast.show(_(msg`Feedback sent!`))
-  }, [feedFeedback, postUri, postFeedContext, _])
-
-  const onSelectChatToShareTo = React.useCallback(
-    (conversation: string) => {
-      navigation.navigate('MessagesConversation', {
-        conversation,
-        embed: postUri,
-      })
-    },
-    [navigation, postUri],
+    }),
+    [menuControl, setHasBeenOpen],
   )
-
-  const onToggleQuotePostAttachment = React.useCallback(async () => {
-    if (!quoteEmbed) return
-
-    const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
-    const isDetach = action === 'detach'
-
-    try {
-      await toggleQuoteDetachment({
-        post,
-        quoteUri: quoteEmbed.uri,
-        action: quoteEmbed.isDetached ? 'reattach' : 'detach',
-      })
-      Toast.show(
-        isDetach
-          ? _(msg`Quote post was successfully detached`)
-          : _(msg`Quote post was re-attached`),
-      )
-    } catch (e: any) {
-      Toast.show(_(msg`Updating quote attachment failed`))
-      logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
-    }
-  }, [_, quoteEmbed, post, toggleQuoteDetachment])
-
-  const canHidePostForMe = !isAuthor && !isPostHidden
-  const canEmbed = isWeb && gtMobile && !hideInPWI
-  const canHideReplyForEveryone =
-    !isAuthor && isRootPostAuthor && !isPostHidden && isReply
-  const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
-
-  const onToggleReplyVisibility = React.useCallback(async () => {
-    // TODO no threadgate?
-    if (!canHideReplyForEveryone) return
-
-    const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
-    const isHide = action === 'hide'
-
-    try {
-      await toggleReplyVisibility({
-        postUri: rootUri,
-        replyUri: postUri,
-        action,
-      })
-      Toast.show(
-        isHide
-          ? _(msg`Reply was successfully hidden`)
-          : _(msg`Reply visibility updated`),
-      )
-    } catch (e: any) {
-      Toast.show(_(msg`Updating reply visibility failed`))
-      logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
-    }
-  }, [
-    _,
-    isReplyHiddenByThreadgate,
-    rootUri,
-    postUri,
-    canHideReplyForEveryone,
-    toggleReplyVisibility,
-  ])
-
-  const onPressPin = useCallback(() => {
-    logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
-    pinPostMutate({
-      postUri,
-      postCid,
-      action: isPinned ? 'unpin' : 'pin',
-    })
-  }, [isPinned, pinPostMutate, postCid, postUri])
-
   return (
     <EventStopper onKeyDown={false}>
-      <Menu.Root>
+      <Menu.Root control={lazyMenuControl}>
         <Menu.Trigger label={_(msg`Open post options menu`)}>
           {({props, state}) => {
             return (
@@ -385,366 +90,19 @@ let PostDropdownBtn = ({
             )
           }}
         </Menu.Trigger>
-
-        <Menu.Outer>
-          {isAuthor && (
-            <>
-              <Menu.Group>
-                <Menu.Item
-                  testID="pinPostBtn"
-                  label={
-                    isPinned
-                      ? _(msg`Unpin from profile`)
-                      : _(msg`Pin to your profile`)
-                  }
-                  disabled={isPinPending}
-                  onPress={onPressPin}>
-                  <Menu.ItemText>
-                    {isPinned
-                      ? _(msg`Unpin from profile`)
-                      : _(msg`Pin to your profile`)}
-                  </Menu.ItemText>
-                  <Menu.ItemIcon
-                    icon={isPinPending ? Loader : PinIcon}
-                    position="right"
-                  />
-                </Menu.Item>
-              </Menu.Group>
-              <Menu.Divider />
-            </>
-          )}
-
-          <Menu.Group>
-            {(!hideInPWI || hasSession) && (
-              <>
-                <Menu.Item
-                  testID="postDropdownTranslateBtn"
-                  label={_(msg`Translate`)}
-                  onPress={onPressTranslate}>
-                  <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={Translate} position="right" />
-                </Menu.Item>
-
-                <Menu.Item
-                  testID="postDropdownCopyTextBtn"
-                  label={_(msg`Copy post text`)}
-                  onPress={onCopyPostText}>
-                  <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={ClipboardIcon} position="right" />
-                </Menu.Item>
-              </>
-            )}
-
-            {hasSession && (
-              <Menu.Item
-                testID="postDropdownSendViaDMBtn"
-                label={_(msg`Send via direct message`)}
-                onPress={() => sendViaChatControl.open()}>
-                <Menu.ItemText>
-                  <Trans>Send via direct message</Trans>
-                </Menu.ItemText>
-                <Menu.ItemIcon icon={Send} position="right" />
-              </Menu.Item>
-            )}
-
-            <Menu.Item
-              testID="postDropdownShareBtn"
-              label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
-              onPress={() => {
-                if (showLoggedOutWarning) {
-                  loggedOutWarningPromptControl.open()
-                } else {
-                  onSharePost()
-                }
-              }}>
-              <Menu.ItemText>
-                {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
-              </Menu.ItemText>
-              <Menu.ItemIcon icon={Share} position="right" />
-            </Menu.Item>
-
-            {canEmbed && (
-              <Menu.Item
-                testID="postDropdownEmbedBtn"
-                label={_(msg`Embed post`)}
-                onPress={() => embedPostControl.open()}>
-                <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
-                <Menu.ItemIcon icon={CodeBrackets} position="right" />
-              </Menu.Item>
-            )}
-          </Menu.Group>
-
-          {hasSession && feedFeedback.enabled && (
-            <>
-              <Menu.Divider />
-              <Menu.Group>
-                <Menu.Item
-                  testID="postDropdownShowMoreBtn"
-                  label={_(msg`Show more like this`)}
-                  onPress={onPressShowMore}>
-                  <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={EmojiSmile} position="right" />
-                </Menu.Item>
-
-                <Menu.Item
-                  testID="postDropdownShowLessBtn"
-                  label={_(msg`Show less like this`)}
-                  onPress={onPressShowLess}>
-                  <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={EmojiSad} position="right" />
-                </Menu.Item>
-              </Menu.Group>
-            </>
-          )}
-
-          {hasSession && (
-            <>
-              <Menu.Divider />
-              <Menu.Group>
-                <Menu.Item
-                  testID="postDropdownMuteThreadBtn"
-                  label={
-                    isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
-                  }
-                  onPress={onToggleThreadMute}>
-                  <Menu.ItemText>
-                    {isThreadMuted
-                      ? _(msg`Unmute thread`)
-                      : _(msg`Mute thread`)}
-                  </Menu.ItemText>
-                  <Menu.ItemIcon
-                    icon={isThreadMuted ? Unmute : Mute}
-                    position="right"
-                  />
-                </Menu.Item>
-
-                <Menu.Item
-                  testID="postDropdownMuteWordsBtn"
-                  label={_(msg`Mute words & tags`)}
-                  onPress={() => mutedWordsDialogControl.open()}>
-                  <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
-                  <Menu.ItemIcon icon={Filter} position="right" />
-                </Menu.Item>
-              </Menu.Group>
-            </>
-          )}
-
-          {hasSession &&
-            (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
-              <>
-                <Menu.Divider />
-                <Menu.Group>
-                  {canHidePostForMe && (
-                    <Menu.Item
-                      testID="postDropdownHideBtn"
-                      label={
-                        isReply
-                          ? _(msg`Hide reply for me`)
-                          : _(msg`Hide post for me`)
-                      }
-                      onPress={() => hidePromptControl.open()}>
-                      <Menu.ItemText>
-                        {isReply
-                          ? _(msg`Hide reply for me`)
-                          : _(msg`Hide post for me`)}
-                      </Menu.ItemText>
-                      <Menu.ItemIcon icon={EyeSlash} position="right" />
-                    </Menu.Item>
-                  )}
-                  {canHideReplyForEveryone && (
-                    <Menu.Item
-                      testID="postDropdownHideBtn"
-                      label={
-                        isReplyHiddenByThreadgate
-                          ? _(msg`Show reply for everyone`)
-                          : _(msg`Hide reply for everyone`)
-                      }
-                      onPress={
-                        isReplyHiddenByThreadgate
-                          ? onToggleReplyVisibility
-                          : () => hideReplyConfirmControl.open()
-                      }>
-                      <Menu.ItemText>
-                        {isReplyHiddenByThreadgate
-                          ? _(msg`Show reply for everyone`)
-                          : _(msg`Hide reply for everyone`)}
-                      </Menu.ItemText>
-                      <Menu.ItemIcon
-                        icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
-                        position="right"
-                      />
-                    </Menu.Item>
-                  )}
-
-                  {canDetachQuote && (
-                    <Menu.Item
-                      disabled={isDetachPending}
-                      testID="postDropdownHideBtn"
-                      label={
-                        quoteEmbed.isDetached
-                          ? _(msg`Re-attach quote`)
-                          : _(msg`Detach quote`)
-                      }
-                      onPress={
-                        quoteEmbed.isDetached
-                          ? onToggleQuotePostAttachment
-                          : () => quotePostDetachConfirmControl.open()
-                      }>
-                      <Menu.ItemText>
-                        {quoteEmbed.isDetached
-                          ? _(msg`Re-attach quote`)
-                          : _(msg`Detach quote`)}
-                      </Menu.ItemText>
-                      <Menu.ItemIcon
-                        icon={
-                          isDetachPending
-                            ? Loader
-                            : quoteEmbed.isDetached
-                            ? Eye
-                            : EyeSlash
-                        }
-                        position="right"
-                      />
-                    </Menu.Item>
-                  )}
-                </Menu.Group>
-              </>
-            )}
-
-          {hasSession && (
-            <>
-              <Menu.Divider />
-              <Menu.Group>
-                {!isAuthor && (
-                  <Menu.Item
-                    testID="postDropdownReportBtn"
-                    label={_(msg`Report post`)}
-                    onPress={() => reportDialogControl.open()}>
-                    <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
-                    <Menu.ItemIcon icon={Warning} position="right" />
-                  </Menu.Item>
-                )}
-
-                {isAuthor && (
-                  <>
-                    <Menu.Item
-                      testID="postDropdownEditPostInteractions"
-                      label={_(msg`Edit interaction settings`)}
-                      onPress={() =>
-                        postInteractionSettingsDialogControl.open()
-                      }
-                      {...(isAuthor
-                        ? Platform.select({
-                            web: {
-                              onHoverIn: prefetchPostInteractionSettings,
-                            },
-                            native: {
-                              onPressIn: prefetchPostInteractionSettings,
-                            },
-                          })
-                        : {})}>
-                      <Menu.ItemText>
-                        {_(msg`Edit interaction settings`)}
-                      </Menu.ItemText>
-                      <Menu.ItemIcon icon={Gear} position="right" />
-                    </Menu.Item>
-                    <Menu.Item
-                      testID="postDropdownDeleteBtn"
-                      label={_(msg`Delete post`)}
-                      onPress={() => deletePromptControl.open()}>
-                      <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
-                      <Menu.ItemIcon icon={Trash} position="right" />
-                    </Menu.Item>
-                  </>
-                )}
-              </Menu.Group>
-            </>
-          )}
-        </Menu.Outer>
-      </Menu.Root>
-
-      <Prompt.Basic
-        control={deletePromptControl}
-        title={_(msg`Delete this post?`)}
-        description={_(
-          msg`If you remove this post, you won't be able to recover it.`,
-        )}
-        onConfirm={onDeletePost}
-        confirmButtonCta={_(msg`Delete`)}
-        confirmButtonColor="negative"
-      />
-
-      <Prompt.Basic
-        control={hidePromptControl}
-        title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
-        description={_(
-          msg`This post will be hidden from feeds and threads. This cannot be undone.`,
+        {hasBeenOpen && (
+          // Lazily initialized. Once mounted, they stay mounted.
+          <PostDropdownMenuItems
+            testID={testID}
+            post={post}
+            postFeedContext={postFeedContext}
+            record={record}
+            richText={richText}
+            timestamp={timestamp}
+            threadgateRecord={threadgateRecord}
+          />
         )}
-        onConfirm={onHidePost}
-        confirmButtonCta={_(msg`Hide`)}
-      />
-
-      <ReportDialog
-        control={reportDialogControl}
-        params={{
-          type: 'post',
-          uri: postUri,
-          cid: postCid,
-        }}
-      />
-
-      <Prompt.Basic
-        control={loggedOutWarningPromptControl}
-        title={_(msg`Note about sharing`)}
-        description={_(
-          msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`,
-        )}
-        onConfirm={onSharePost}
-        confirmButtonCta={_(msg`Share anyway`)}
-      />
-
-      {canEmbed && (
-        <EmbedDialog
-          control={embedPostControl}
-          postCid={postCid}
-          postUri={postUri}
-          record={record}
-          postAuthor={postAuthor}
-          timestamp={timestamp}
-        />
-      )}
-
-      <SendViaChatDialog
-        control={sendViaChatControl}
-        onSelectChat={onSelectChatToShareTo}
-      />
-
-      <PostInteractionSettingsDialog
-        control={postInteractionSettingsDialogControl}
-        postUri={post.uri}
-        rootPostUri={rootUri}
-        initialThreadgateView={post.threadgate}
-      />
-
-      <Prompt.Basic
-        control={quotePostDetachConfirmControl}
-        title={_(msg`Detach quote post?`)}
-        description={_(
-          msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
-        )}
-        onConfirm={onToggleQuotePostAttachment}
-        confirmButtonCta={_(msg`Yes, detach`)}
-      />
-
-      <Prompt.Basic
-        control={hideReplyConfirmControl}
-        title={_(msg`Hide this reply?`)}
-        description={_(
-          msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
-        )}
-        onConfirm={onToggleReplyVisibility}
-        confirmButtonCta={_(msg`Yes, hide`)}
-      />
+      </Menu.Root>
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx
new file mode 100644
index 000000000..149bb9ad2
--- /dev/null
+++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx
@@ -0,0 +1,751 @@
+import React, {memo, useCallback} from 'react'
+import {
+  Platform,
+  type PressableProps,
+  type StyleProp,
+  type ViewStyle,
+} from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedThreadgate,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {getCurrentRoute} from '#/lib/routes/helpers'
+import {makeProfileLink} from '#/lib/routes/links'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {shareUrl} from '#/lib/sharing'
+import {logEvent} from '#/lib/statsig/statsig'
+import {richTextToString} from '#/lib/strings/rich-text-helpers'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {getTranslatorLink} from '#/locale/helpers'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {Shadow} from '#/state/cache/post-shadow'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {useLanguagePrefs} from '#/state/preferences'
+import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
+import {usePinnedPostMutation} from '#/state/queries/pinned-post'
+import {
+  usePostDeleteMutation,
+  useThreadMuteMutationQueue,
+} from '#/state/queries/post'
+import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
+import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
+import {useProfileBlockMutationQueue} from '#/state/queries/profile'
+import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
+import {useSession} from '#/state/session'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {useBreakpoints} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {EmbedDialog} from '#/components/dialogs/Embed'
+import {
+  PostInteractionSettingsDialog,
+  usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
+import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
+import {
+  EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
+  EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
+} from '#/components/icons/Emoji'
+import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
+import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
+import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
+import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
+import * as Toast from '../Toast'
+
+let PostDropdownMenuItems = ({
+  post,
+  postFeedContext,
+  record,
+  richText,
+  timestamp,
+  threadgateRecord,
+}: {
+  testID: string
+  post: Shadow<AppBskyFeedDefs.PostView>
+  postFeedContext: string | undefined
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  style?: StyleProp<ViewStyle>
+  hitSlop?: PressableProps['hitSlop']
+  size?: 'lg' | 'md' | 'sm'
+  timestamp: string
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}): React.ReactNode => {
+  const {hasSession, currentAccount} = useSession()
+  const {gtMobile} = useBreakpoints()
+  const {_} = useLingui()
+  const langPrefs = useLanguagePrefs()
+  const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
+  const {mutateAsync: pinPostMutate, isPending: isPinPending} =
+    usePinnedPostMutation()
+  const hiddenPosts = useHiddenPosts()
+  const {hidePost} = useHiddenPostsApi()
+  const feedFeedback = useFeedFeedbackContext()
+  const openLink = useOpenLink()
+  const navigation = useNavigation<NavigationProp>()
+  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
+  const blockPromptControl = useDialogControl()
+  const reportDialogControl = useReportDialogControl()
+  const deletePromptControl = useDialogControl()
+  const hidePromptControl = useDialogControl()
+  const loggedOutWarningPromptControl = useDialogControl()
+  const embedPostControl = useDialogControl()
+  const sendViaChatControl = useDialogControl()
+  const postInteractionSettingsDialogControl = useDialogControl()
+  const quotePostDetachConfirmControl = useDialogControl()
+  const hideReplyConfirmControl = useDialogControl()
+  const {mutateAsync: toggleReplyVisibility} =
+    useToggleReplyVisibilityMutation()
+
+  const postUri = post.uri
+  const postCid = post.cid
+  const postAuthor = useProfileShadow(post.author)
+  const quoteEmbed = React.useMemo(() => {
+    if (!currentAccount || !post.embed) return
+    return getMaybeDetachedQuoteEmbed({
+      viewerDid: currentAccount.did,
+      post,
+    })
+  }, [post, currentAccount])
+
+  const rootUri = record.reply?.root?.uri || postUri
+  const isReply = Boolean(record.reply)
+  const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
+    post,
+    rootUri,
+  )
+  const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
+  const isAuthor = postAuthor.did === currentAccount?.did
+  const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
+  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+    threadgateRecord,
+  })
+  const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
+  const isPinned = post.viewer?.pinned
+
+  const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} =
+    useToggleQuoteDetachmentMutation()
+
+  const [queueBlock] = useProfileBlockMutationQueue(postAuthor)
+
+  const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+    postUri: post.uri,
+    rootPostUri: rootUri,
+  })
+
+  const href = React.useMemo(() => {
+    const urip = new AtUri(postUri)
+    return makeProfileLink(postAuthor, 'post', urip.rkey)
+  }, [postUri, postAuthor])
+
+  const translatorUrl = getTranslatorLink(
+    record.text,
+    langPrefs.primaryLanguage,
+  )
+
+  const onDeletePost = React.useCallback(() => {
+    deletePostMutate({uri: postUri}).then(
+      () => {
+        Toast.show(_(msg`Post deleted`))
+
+        const route = getCurrentRoute(navigation.getState())
+        if (route.name === 'PostThread') {
+          const params = route.params as CommonNavigatorParams['PostThread']
+          if (
+            currentAccount &&
+            isAuthor &&
+            (params.name === currentAccount.handle ||
+              params.name === currentAccount.did)
+          ) {
+            const currentHref = makeProfileLink(postAuthor, 'post', params.rkey)
+            if (currentHref === href && navigation.canGoBack()) {
+              navigation.goBack()
+            }
+          }
+        }
+      },
+      e => {
+        logger.error('Failed to delete post', {message: e})
+        Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
+      },
+    )
+  }, [
+    navigation,
+    postUri,
+    deletePostMutate,
+    postAuthor,
+    currentAccount,
+    isAuthor,
+    href,
+    _,
+  ])
+
+  const onToggleThreadMute = React.useCallback(() => {
+    try {
+      if (isThreadMuted) {
+        unmuteThread()
+        Toast.show(_(msg`You will now receive notifications for this thread`))
+      } else {
+        muteThread()
+        Toast.show(
+          _(msg`You will no longer receive notifications for this thread`),
+        )
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to toggle thread mute', {message: e})
+        Toast.show(
+          _(msg`Failed to toggle thread mute, please try again`),
+          'xmark',
+        )
+      }
+    }
+  }, [isThreadMuted, unmuteThread, _, muteThread])
+
+  const onCopyPostText = React.useCallback(() => {
+    const str = richTextToString(richText, true)
+
+    Clipboard.setStringAsync(str)
+    Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
+  }, [_, richText])
+
+  const onPressTranslate = React.useCallback(async () => {
+    await openLink(translatorUrl, true)
+  }, [openLink, translatorUrl])
+
+  const onHidePost = React.useCallback(() => {
+    hidePost({uri: postUri})
+  }, [postUri, hidePost])
+
+  const hideInPWI = React.useMemo(() => {
+    return !!postAuthor.labels?.find(
+      label => label.val === '!no-unauthenticated',
+    )
+  }, [postAuthor])
+
+  const showLoggedOutWarning =
+    postAuthor.did !== currentAccount?.did && hideInPWI
+
+  const onSharePost = React.useCallback(() => {
+    const url = toShareUrl(href)
+    shareUrl(url)
+  }, [href])
+
+  const onPressShowMore = React.useCallback(() => {
+    feedFeedback.sendInteraction({
+      event: 'app.bsky.feed.defs#requestMore',
+      item: postUri,
+      feedContext: postFeedContext,
+    })
+    Toast.show(_(msg`Feedback sent!`))
+  }, [feedFeedback, postUri, postFeedContext, _])
+
+  const onPressShowLess = React.useCallback(() => {
+    feedFeedback.sendInteraction({
+      event: 'app.bsky.feed.defs#requestLess',
+      item: postUri,
+      feedContext: postFeedContext,
+    })
+    Toast.show(_(msg`Feedback sent!`))
+  }, [feedFeedback, postUri, postFeedContext, _])
+
+  const onSelectChatToShareTo = React.useCallback(
+    (conversation: string) => {
+      navigation.navigate('MessagesConversation', {
+        conversation,
+        embed: postUri,
+      })
+    },
+    [navigation, postUri],
+  )
+
+  const onToggleQuotePostAttachment = React.useCallback(async () => {
+    if (!quoteEmbed) return
+
+    const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
+    const isDetach = action === 'detach'
+
+    try {
+      await toggleQuoteDetachment({
+        post,
+        quoteUri: quoteEmbed.uri,
+        action: quoteEmbed.isDetached ? 'reattach' : 'detach',
+      })
+      Toast.show(
+        isDetach
+          ? _(msg`Quote post was successfully detached`)
+          : _(msg`Quote post was re-attached`),
+      )
+    } catch (e: any) {
+      Toast.show(_(msg`Updating quote attachment failed`))
+      logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
+    }
+  }, [_, quoteEmbed, post, toggleQuoteDetachment])
+
+  const canHidePostForMe = !isAuthor && !isPostHidden
+  const canEmbed = isWeb && gtMobile && !hideInPWI
+  const canHideReplyForEveryone =
+    !isAuthor && isRootPostAuthor && !isPostHidden && isReply
+  const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
+
+  const onToggleReplyVisibility = React.useCallback(async () => {
+    // TODO no threadgate?
+    if (!canHideReplyForEveryone) return
+
+    const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
+    const isHide = action === 'hide'
+
+    try {
+      await toggleReplyVisibility({
+        postUri: rootUri,
+        replyUri: postUri,
+        action,
+      })
+      Toast.show(
+        isHide
+          ? _(msg`Reply was successfully hidden`)
+          : _(msg`Reply visibility updated`),
+      )
+    } catch (e: any) {
+      Toast.show(_(msg`Updating reply visibility failed`))
+      logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
+    }
+  }, [
+    _,
+    isReplyHiddenByThreadgate,
+    rootUri,
+    postUri,
+    canHideReplyForEveryone,
+    toggleReplyVisibility,
+  ])
+
+  const onPressPin = useCallback(() => {
+    logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
+    pinPostMutate({
+      postUri,
+      postCid,
+      action: isPinned ? 'unpin' : 'pin',
+    })
+  }, [isPinned, pinPostMutate, postCid, postUri])
+
+  const onBlockAuthor = useCallback(async () => {
+    try {
+      await queueBlock()
+      Toast.show(_(msg`Account blocked`))
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to block account', {message: e})
+        Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
+      }
+    }
+  }, [_, queueBlock])
+
+  return (
+    <>
+      <Menu.Outer>
+        {isAuthor && (
+          <>
+            <Menu.Group>
+              <Menu.Item
+                testID="pinPostBtn"
+                label={
+                  isPinned
+                    ? _(msg`Unpin from profile`)
+                    : _(msg`Pin to your profile`)
+                }
+                disabled={isPinPending}
+                onPress={onPressPin}>
+                <Menu.ItemText>
+                  {isPinned
+                    ? _(msg`Unpin from profile`)
+                    : _(msg`Pin to your profile`)}
+                </Menu.ItemText>
+                <Menu.ItemIcon
+                  icon={isPinPending ? Loader : PinIcon}
+                  position="right"
+                />
+              </Menu.Item>
+            </Menu.Group>
+            <Menu.Divider />
+          </>
+        )}
+
+        <Menu.Group>
+          {(!hideInPWI || hasSession) && (
+            <>
+              <Menu.Item
+                testID="postDropdownTranslateBtn"
+                label={_(msg`Translate`)}
+                onPress={onPressTranslate}>
+                <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Translate} position="right" />
+              </Menu.Item>
+
+              <Menu.Item
+                testID="postDropdownCopyTextBtn"
+                label={_(msg`Copy post text`)}
+                onPress={onCopyPostText}>
+                <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+              </Menu.Item>
+            </>
+          )}
+
+          {hasSession && (
+            <Menu.Item
+              testID="postDropdownSendViaDMBtn"
+              label={_(msg`Send via direct message`)}
+              onPress={() => sendViaChatControl.open()}>
+              <Menu.ItemText>
+                <Trans>Send via direct message</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Send} position="right" />
+            </Menu.Item>
+          )}
+
+          <Menu.Item
+            testID="postDropdownShareBtn"
+            label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
+            onPress={() => {
+              if (showLoggedOutWarning) {
+                loggedOutWarningPromptControl.open()
+              } else {
+                onSharePost()
+              }
+            }}>
+            <Menu.ItemText>
+              {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={Share} position="right" />
+          </Menu.Item>
+
+          {canEmbed && (
+            <Menu.Item
+              testID="postDropdownEmbedBtn"
+              label={_(msg`Embed post`)}
+              onPress={() => embedPostControl.open()}>
+              <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={CodeBrackets} position="right" />
+            </Menu.Item>
+          )}
+        </Menu.Group>
+
+        {hasSession && feedFeedback.enabled && (
+          <>
+            <Menu.Divider />
+            <Menu.Group>
+              <Menu.Item
+                testID="postDropdownShowMoreBtn"
+                label={_(msg`Show more like this`)}
+                onPress={onPressShowMore}>
+                <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={EmojiSmile} position="right" />
+              </Menu.Item>
+
+              <Menu.Item
+                testID="postDropdownShowLessBtn"
+                label={_(msg`Show less like this`)}
+                onPress={onPressShowLess}>
+                <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={EmojiSad} position="right" />
+              </Menu.Item>
+            </Menu.Group>
+          </>
+        )}
+
+        {hasSession && (
+          <>
+            <Menu.Divider />
+            <Menu.Group>
+              <Menu.Item
+                testID="postDropdownMuteThreadBtn"
+                label={
+                  isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
+                }
+                onPress={onToggleThreadMute}>
+                <Menu.ItemText>
+                  {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
+                </Menu.ItemText>
+                <Menu.ItemIcon
+                  icon={isThreadMuted ? Unmute : Mute}
+                  position="right"
+                />
+              </Menu.Item>
+
+              <Menu.Item
+                testID="postDropdownMuteWordsBtn"
+                label={_(msg`Mute words & tags`)}
+                onPress={() => mutedWordsDialogControl.open()}>
+                <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Filter} position="right" />
+              </Menu.Item>
+            </Menu.Group>
+          </>
+        )}
+
+        {hasSession &&
+          (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
+            <>
+              <Menu.Divider />
+              <Menu.Group>
+                {canHidePostForMe && (
+                  <Menu.Item
+                    testID="postDropdownHideBtn"
+                    label={
+                      isReply
+                        ? _(msg`Hide reply for me`)
+                        : _(msg`Hide post for me`)
+                    }
+                    onPress={() => hidePromptControl.open()}>
+                    <Menu.ItemText>
+                      {isReply
+                        ? _(msg`Hide reply for me`)
+                        : _(msg`Hide post for me`)}
+                    </Menu.ItemText>
+                    <Menu.ItemIcon icon={EyeSlash} position="right" />
+                  </Menu.Item>
+                )}
+                {canHideReplyForEveryone && (
+                  <Menu.Item
+                    testID="postDropdownHideBtn"
+                    label={
+                      isReplyHiddenByThreadgate
+                        ? _(msg`Show reply for everyone`)
+                        : _(msg`Hide reply for everyone`)
+                    }
+                    onPress={
+                      isReplyHiddenByThreadgate
+                        ? onToggleReplyVisibility
+                        : () => hideReplyConfirmControl.open()
+                    }>
+                    <Menu.ItemText>
+                      {isReplyHiddenByThreadgate
+                        ? _(msg`Show reply for everyone`)
+                        : _(msg`Hide reply for everyone`)}
+                    </Menu.ItemText>
+                    <Menu.ItemIcon
+                      icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
+                      position="right"
+                    />
+                  </Menu.Item>
+                )}
+
+                {canDetachQuote && (
+                  <Menu.Item
+                    disabled={isDetachPending}
+                    testID="postDropdownHideBtn"
+                    label={
+                      quoteEmbed.isDetached
+                        ? _(msg`Re-attach quote`)
+                        : _(msg`Detach quote`)
+                    }
+                    onPress={
+                      quoteEmbed.isDetached
+                        ? onToggleQuotePostAttachment
+                        : () => quotePostDetachConfirmControl.open()
+                    }>
+                    <Menu.ItemText>
+                      {quoteEmbed.isDetached
+                        ? _(msg`Re-attach quote`)
+                        : _(msg`Detach quote`)}
+                    </Menu.ItemText>
+                    <Menu.ItemIcon
+                      icon={
+                        isDetachPending
+                          ? Loader
+                          : quoteEmbed.isDetached
+                          ? Eye
+                          : EyeSlash
+                      }
+                      position="right"
+                    />
+                  </Menu.Item>
+                )}
+              </Menu.Group>
+            </>
+          )}
+
+        {hasSession && (
+          <>
+            <Menu.Divider />
+            <Menu.Group>
+              {!isAuthor && (
+                <>
+                  {!postAuthor.viewer?.blocking && (
+                    <Menu.Item
+                      testID="postDropdownBlockBtn"
+                      label={_(msg`Block account`)}
+                      onPress={() => blockPromptControl.open()}>
+                      <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
+                      <Menu.ItemIcon icon={PersonX} position="right" />
+                    </Menu.Item>
+                  )}
+                  <Menu.Item
+                    testID="postDropdownReportBtn"
+                    label={_(msg`Report post`)}
+                    onPress={() => reportDialogControl.open()}>
+                    <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
+                    <Menu.ItemIcon icon={Warning} position="right" />
+                  </Menu.Item>
+                </>
+              )}
+
+              {isAuthor && (
+                <>
+                  <Menu.Item
+                    testID="postDropdownEditPostInteractions"
+                    label={_(msg`Edit interaction settings`)}
+                    onPress={() => postInteractionSettingsDialogControl.open()}
+                    {...(isAuthor
+                      ? Platform.select({
+                          web: {
+                            onHoverIn: prefetchPostInteractionSettings,
+                          },
+                          native: {
+                            onPressIn: prefetchPostInteractionSettings,
+                          },
+                        })
+                      : {})}>
+                    <Menu.ItemText>
+                      {_(msg`Edit interaction settings`)}
+                    </Menu.ItemText>
+                    <Menu.ItemIcon icon={Gear} position="right" />
+                  </Menu.Item>
+                  <Menu.Item
+                    testID="postDropdownDeleteBtn"
+                    label={_(msg`Delete post`)}
+                    onPress={() => deletePromptControl.open()}>
+                    <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
+                    <Menu.ItemIcon icon={Trash} position="right" />
+                  </Menu.Item>
+                </>
+              )}
+            </Menu.Group>
+          </>
+        )}
+      </Menu.Outer>
+
+      <Prompt.Basic
+        control={deletePromptControl}
+        title={_(msg`Delete this post?`)}
+        description={_(
+          msg`If you remove this post, you won't be able to recover it.`,
+        )}
+        onConfirm={onDeletePost}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+      />
+
+      <Prompt.Basic
+        control={hidePromptControl}
+        title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
+        description={_(
+          msg`This post will be hidden from feeds and threads. This cannot be undone.`,
+        )}
+        onConfirm={onHidePost}
+        confirmButtonCta={_(msg`Hide`)}
+      />
+
+      <ReportDialog
+        control={reportDialogControl}
+        params={{
+          type: 'post',
+          uri: postUri,
+          cid: postCid,
+        }}
+      />
+
+      <Prompt.Basic
+        control={loggedOutWarningPromptControl}
+        title={_(msg`Note about sharing`)}
+        description={_(
+          msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`,
+        )}
+        onConfirm={onSharePost}
+        confirmButtonCta={_(msg`Share anyway`)}
+      />
+
+      {canEmbed && (
+        <EmbedDialog
+          control={embedPostControl}
+          postCid={postCid}
+          postUri={postUri}
+          record={record}
+          postAuthor={postAuthor}
+          timestamp={timestamp}
+        />
+      )}
+
+      <SendViaChatDialog
+        control={sendViaChatControl}
+        onSelectChat={onSelectChatToShareTo}
+      />
+
+      <PostInteractionSettingsDialog
+        control={postInteractionSettingsDialogControl}
+        postUri={post.uri}
+        rootPostUri={rootUri}
+        initialThreadgateView={post.threadgate}
+      />
+
+      <Prompt.Basic
+        control={quotePostDetachConfirmControl}
+        title={_(msg`Detach quote post?`)}
+        description={_(
+          msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
+        )}
+        onConfirm={onToggleQuotePostAttachment}
+        confirmButtonCta={_(msg`Yes, detach`)}
+      />
+
+      <Prompt.Basic
+        control={hideReplyConfirmControl}
+        title={_(msg`Hide this reply?`)}
+        description={_(
+          msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
+        )}
+        onConfirm={onToggleReplyVisibility}
+        confirmButtonCta={_(msg`Yes, hide`)}
+      />
+
+      <Prompt.Basic
+        control={blockPromptControl}
+        title={_(msg`Block Account?`)}
+        description={_(
+          msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+        )}
+        onConfirm={onBlockAuthor}
+        confirmButtonCta={_(msg`Block`)}
+        confirmButtonColor="negative"
+      />
+    </>
+  )
+}
+PostDropdownMenuItems = memo(PostDropdownMenuItems)
+export {PostDropdownMenuItems}
diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx
index e2bf3c9ac..7cf0f2d73 100644
--- a/src/view/com/util/forms/RadioButton.tsx
+++ b/src/view/com/util/forms/RadioButton.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
 
 import {choose} from '#/lib/functions'
diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx
index c6cf63930..e2a26dc49 100644
--- a/src/view/com/util/forms/RadioGroup.tsx
+++ b/src/view/com/util/forms/RadioGroup.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react'
+import {useState} from 'react'
 import {View} from 'react-native'
 
 import {s} from '#/lib/styles'
diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx
index 1d74b935a..76161b433 100644
--- a/src/view/com/util/forms/SelectableBtn.tsx
+++ b/src/view/com/util/forms/SelectableBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
 
 import {usePalette} from '#/lib/hooks/usePalette'
diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx
index 706796fc4..31222aafe 100644
--- a/src/view/com/util/forms/ToggleButton.tsx
+++ b/src/view/com/util/forms/ToggleButton.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
 
 import {choose} from '#/lib/functions'
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index fe8911e31..617b9bec4 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,11 +1,11 @@
-import React from 'react'
+import React, {useRef} from 'react'
 import {DimensionValue, Pressable, View} from 'react-native'
-import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
 import {Image} from 'expo-image'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef'
 import type {Dimensions} from '#/lib/media/types'
 import {isNative} from '#/platform/detection'
 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
@@ -68,26 +68,27 @@ export function AutoSizedImage({
   image: AppBskyEmbedImages.ViewImage
   crop?: 'none' | 'square' | 'constrained'
   hideBadge?: boolean
-  onPress?: (
-    containerRef: AnimatedRef<React.Component<{}, {}, any>>,
-    fetchedDims: Dimensions | null,
-  ) => void
+  onPress?: (containerRef: HandleRef, fetchedDims: Dimensions | null) => void
   onLongPress?: () => void
   onPressIn?: () => void
 }) {
   const t = useTheme()
   const {_} = useLingui()
   const largeAlt = useLargeAltBadgeEnabled()
-  const containerRef = useAnimatedRef()
+  const containerRef = useHandleRef()
+  const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
 
-  const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null)
-  const dims = fetchedDims ?? image.aspectRatio
   let aspectRatio: number | undefined
+  const dims = image.aspectRatio
   if (dims) {
     aspectRatio = dims.width / dims.height
     if (Number.isNaN(aspectRatio)) {
       aspectRatio = undefined
     }
+  } else {
+    // If we don't know it synchronously, treat it like a square.
+    // We won't use fetched dimensions to avoid a layout shift.
+    aspectRatio = 1
   }
 
   let constrained: number | undefined
@@ -105,7 +106,7 @@ export function AutoSizedImage({
   const hasAlt = !!image.alt
 
   const contents = (
-    <Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}>
+    <View ref={containerRef} collapsable={false} style={{flex: 1}}>
       <Image
         style={[a.w_full, a.h_full]}
         source={image.thumb}
@@ -113,13 +114,12 @@ export function AutoSizedImage({
         accessibilityIgnoresInvertColors
         accessibilityLabel={image.alt}
         accessibilityHint=""
-        onLoad={
-          fetchedDims
-            ? undefined
-            : e => {
-                setFetchedDims({width: e.source.width, height: e.source.height})
-              }
-        }
+        onLoad={e => {
+          fetchedDimsRef.current = {
+            width: e.source.width,
+            height: e.source.height,
+          }
+        }}
       />
       <MediaInsetBorder />
 
@@ -185,13 +185,13 @@ export function AutoSizedImage({
           )}
         </View>
       ) : null}
-    </Animated.View>
+    </View>
   )
 
   if (cropDisabled) {
     return (
       <Pressable
-        onPress={() => onPress?.(containerRef, fetchedDims)}
+        onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
         onLongPress={onLongPress}
         onPressIn={onPressIn}
         // alt here is what screen readers actually use
@@ -213,7 +213,7 @@ export function AutoSizedImage({
         fullBleed={crop === 'square'}
         aspectRatio={constrained ?? 1}>
         <Pressable
-          onPress={() => onPress?.(containerRef, fetchedDims)}
+          onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
           onLongPress={onLongPress}
           onPressIn={onPressIn}
           // alt here is what screen readers actually use
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 9d0817bd2..cc3eda68d 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
 import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
-import Animated, {AnimatedRef} from 'react-native-reanimated'
 import {Image, ImageStyle} from 'expo-image'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HandleRef} from '#/lib/hooks/useHandleRef'
 import {Dimensions} from '#/lib/media/types'
 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
@@ -20,7 +20,7 @@ interface Props {
   index: number
   onPress?: (
     index: number,
-    containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
+    containerRefs: HandleRef[],
     fetchedDims: (Dimensions | null)[],
   ) => void
   onLongPress?: EventFunction
@@ -28,7 +28,7 @@ interface Props {
   imageStyle?: StyleProp<ImageStyle>
   viewContext?: PostEmbedViewContext
   insetBorderStyle?: StyleProp<ViewStyle>
-  containerRefs: AnimatedRef<React.Component<{}, {}, any>>[]
+  containerRefs: HandleRef[]
   thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]>
 }
 
@@ -52,10 +52,7 @@ export function GalleryItem({
   const hideBadges =
     viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
   return (
-    <Animated.View
-      style={a.flex_1}
-      ref={containerRefs[index]}
-      collapsable={false}>
+    <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
       <Pressable
         onPress={
           onPress
@@ -118,6 +115,6 @@ export function GalleryItem({
           </Text>
         </View>
       ) : null}
-    </Animated.View>
+    </View>
   )
 }
diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx
index e779fa378..94563ef9c 100644
--- a/src/view/com/util/images/Image.tsx
+++ b/src/view/com/util/images/Image.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Image, ImageProps, ImageSource} from 'expo-image'
 
 interface HighPriorityImageProps extends ImageProps {
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index dcc330dac..16ea9d453 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
 import {AppBskyEmbedImages} from '@atproto/api'
 
+import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef'
 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
 import {atoms as a, useBreakpoints} from '#/alf'
 import {Dimensions} from '../../lightbox/ImageViewing/@types'
@@ -12,7 +12,7 @@ interface ImageLayoutGridProps {
   images: AppBskyEmbedImages.ViewImage[]
   onPress?: (
     index: number,
-    containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
+    containerRefs: HandleRef[],
     fetchedDims: (Dimensions | null)[],
   ) => void
   onLongPress?: (index: number) => void
@@ -43,7 +43,7 @@ interface ImageLayoutGridInnerProps {
   images: AppBskyEmbedImages.ViewImage[]
   onPress?: (
     index: number,
-    containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
+    containerRefs: HandleRef[],
     fetchedDims: (Dimensions | null)[],
   ) => void
   onLongPress?: (index: number) => void
@@ -56,10 +56,10 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
   const gap = props.gap
   const count = props.images.length
 
-  const containerRef1 = useAnimatedRef()
-  const containerRef2 = useAnimatedRef()
-  const containerRef3 = useAnimatedRef()
-  const containerRef4 = useAnimatedRef()
+  const containerRef1 = useHandleRef()
+  const containerRef2 = useHandleRef()
+  const containerRef3 = useHandleRef()
+  const containerRef4 = useHandleRef()
   const thumbDimsRef = React.useRef<(Dimensions | null)[]>([])
 
   switch (count) {
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index 2310b1f27..d98aa0fa7 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
diff --git a/src/view/com/util/numeric/__tests__/format-test.ts b/src/view/com/util/numeric/__tests__/format-test.ts
new file mode 100644
index 000000000..74df4be4c
--- /dev/null
+++ b/src/view/com/util/numeric/__tests__/format-test.ts
@@ -0,0 +1,92 @@
+import {describe, expect, it} from '@jest/globals'
+
+import {APP_LANGUAGES} from '#/locale/languages'
+import {formatCount} from '../format'
+
+const formatCountRound = (locale: string, num: number) => {
+  const options: Intl.NumberFormatOptions = {
+    notation: 'compact',
+    maximumFractionDigits: 1,
+  }
+  return new Intl.NumberFormat(locale, options).format(num)
+}
+
+const formatCountTrunc = (locale: string, num: number) => {
+  const options: Intl.NumberFormatOptions = {
+    notation: 'compact',
+    maximumFractionDigits: 1,
+    // @ts-ignore
+    roundingMode: 'trunc',
+  }
+  return new Intl.NumberFormat(locale, options).format(num)
+}
+
+// prettier-ignore
+const testNums = [
+  1,
+  5,
+  9,
+  11,
+  55,
+  99,
+  111,
+  555,
+  999,
+  1111,
+  5555,
+  9999,
+  11111,
+  55555,
+  99999,
+  111111,
+  555555,
+  999999,
+  1111111,
+  5555555,
+  9999999,
+  11111111,
+  55555555,
+  99999999,
+  111111111,
+  555555555,
+  999999999,
+  1111111111,
+  5555555555,
+  9999999999,
+  11111111111,
+  55555555555,
+  99999999999,
+  111111111111,
+  555555555555,
+  999999999999,
+  1111111111111,
+  5555555555555,
+  9999999999999,
+  11111111111111,
+  55555555555555,
+  99999999999999,
+  111111111111111,
+  555555555555555,
+  999999999999999,
+  1111111111111111,
+  5555555555555555,
+]
+
+describe('formatCount', () => {
+  for (const appLanguage of APP_LANGUAGES) {
+    const locale = appLanguage.code2
+    it('truncates for ' + locale, () => {
+      const mockI8nn = {
+        locale,
+        number(num: number) {
+          return formatCountRound(locale, num)
+        },
+      }
+      for (const num of testNums) {
+        const formatManual = formatCount(mockI8nn as any, num)
+        const formatOriginal = formatCountTrunc(locale, num)
+        expect(formatManual).toEqual(formatOriginal)
+      }
+    })
+  }
+})
diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts
index cca9fc7e7..0c3d24957 100644
--- a/src/view/com/util/numeric/format.ts
+++ b/src/view/com/util/numeric/format.ts
@@ -1,12 +1,47 @@
-import type {I18n} from '@lingui/core'
+import {I18n} from '@lingui/core'
+
+const truncateRounding = (num: number, factors: Array<number>): number => {
+  for (let i = factors.length - 1; i >= 0; i--) {
+    let factor = factors[i]
+    if (num >= 10 ** factor) {
+      if (factor === 10) {
+        // CA and ES abruptly jump from "9999,9 M" to "10 mil M"
+        factor--
+      }
+      const precision = 1
+      const divisor = 10 ** (factor - precision)
+      return Math.floor(num / divisor) * divisor
+    }
+  }
+  return num
+}
+
+const koFactors = [3, 4, 8, 12]
+const hiFactors = [3, 5, 7, 9, 11, 13]
+const esCaFactors = [3, 6, 10, 12]
+const itDeFactors = [6, 9, 12]
+const jaZhFactors = [4, 8, 12]
+const restFactors = [3, 6, 9, 12]
 
 export const formatCount = (i18n: I18n, num: number) => {
-  return i18n.number(num, {
+  const locale = i18n.locale
+  let truncatedNum: number
+  if (locale === 'hi') {
+    truncatedNum = truncateRounding(num, hiFactors)
+  } else if (locale === 'ko') {
+    truncatedNum = truncateRounding(num, koFactors)
+  } else if (locale === 'es' || locale === 'ca') {
+    truncatedNum = truncateRounding(num, esCaFactors)
+  } else if (locale === 'ja' || locale === 'zh-CN' || locale === 'zh-TW') {
+    truncatedNum = truncateRounding(num, jaZhFactors)
+  } else if (locale === 'it' || locale === 'de') {
+    truncatedNum = truncateRounding(num, itDeFactors)
+  } else {
+    truncatedNum = truncateRounding(num, restFactors)
+  }
+  return i18n.number(truncatedNum, {
     notation: 'compact',
     maximumFractionDigits: 1,
-    // `1,953` shouldn't be rounded up to 2k, it should be truncated.
-    // @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet
-    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
-    roundingMode: 'trunc',
+    // Ideally we'd use roundingMode: 'trunc' but it isn't supported on RN.
   })
 }
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 28889429f..06b1fcaf6 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -1,6 +1,6 @@
 import React, {memo, useCallback} from 'react'
 import {View} from 'react-native'
-import {msg, plural} from '@lingui/macro'
+import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {POST_CTRL_HITSLOP} from '#/lib/constants'
@@ -36,16 +36,12 @@ let RepostButton = ({
   const requireAuth = useRequireAuth()
   const dialogControl = Dialog.useDialogControl()
   const playHaptic = useHaptics()
-
   const color = React.useMemo(
     () => ({
       color: isReposted ? t.palette.positive_600 : t.palette.contrast_500,
     }),
     [t, isReposted],
   )
-
-  const close = useCallback(() => dialogControl.close(), [dialogControl])
-
   return (
     <>
       <Button
@@ -92,84 +88,124 @@ let RepostButton = ({
         control={dialogControl}
         nativeOptions={{preventExpansion: true}}>
         <Dialog.Handle />
-        <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}>
-          <View style={a.gap_xl}>
-            <View style={a.gap_xs}>
-              <Button
-                style={[a.justify_start, a.px_md]}
-                label={
-                  isReposted
-                    ? _(msg`Remove repost`)
-                    : _(msg({message: `Repost`, context: 'action'}))
-                }
-                onPress={() => {
-                  if (!isReposted) playHaptic()
-
-                  dialogControl.close(() => {
-                    onRepost()
-                  })
-                }}
-                size="large"
-                variant="ghost"
-                color="primary">
-                <Repost size="lg" fill={t.palette.primary_500} />
-                <Text style={[a.font_bold, a.text_xl]}>
-                  {isReposted
-                    ? _(msg`Remove repost`)
-                    : _(msg({message: `Repost`, context: 'action'}))}
-                </Text>
-              </Button>
-              <Button
-                disabled={embeddingDisabled}
-                testID="quoteBtn"
-                style={[a.justify_start, a.px_md]}
-                label={
-                  embeddingDisabled
-                    ? _(msg`Quote posts disabled`)
-                    : _(msg`Quote post`)
-                }
-                onPress={() => {
-                  playHaptic()
-                  dialogControl.close(() => {
-                    onQuote()
-                  })
-                }}
-                size="large"
-                variant="ghost"
-                color="primary">
-                <Quote
-                  size="lg"
-                  fill={
-                    embeddingDisabled
-                      ? t.atoms.text_contrast_low.color
-                      : t.palette.primary_500
-                  }
-                />
-                <Text
-                  style={[
-                    a.font_bold,
-                    a.text_xl,
-                    embeddingDisabled && t.atoms.text_contrast_low,
-                  ]}>
-                  {embeddingDisabled
-                    ? _(msg`Quote posts disabled`)
-                    : _(msg`Quote post`)}
-                </Text>
-              </Button>
-            </View>
-            <Button
-              label={_(msg`Cancel quote post`)}
-              onPress={close}
-              size="large"
-              variant="solid"
-              color="primary">
-              <ButtonText>{_(msg`Cancel`)}</ButtonText>
-            </Button>
-          </View>
-        </Dialog.ScrollableInner>
+        <RepostButtonDialogInner
+          isReposted={isReposted}
+          onRepost={onRepost}
+          onQuote={onQuote}
+          embeddingDisabled={embeddingDisabled}
+        />
       </Dialog.Outer>
     </>
   )
 }
 RepostButton = memo(RepostButton)
 export {RepostButton}
+
+let RepostButtonDialogInner = ({
+  isReposted,
+  onRepost,
+  onQuote,
+  embeddingDisabled,
+}: {
+  isReposted: boolean
+  onRepost: () => void
+  onQuote: () => void
+  embeddingDisabled: boolean
+}): React.ReactNode => {
+  const t = useTheme()
+  const {_} = useLingui()
+  const playHaptic = useHaptics()
+  const control = Dialog.useDialogContext()
+
+  const onPressRepost = useCallback(() => {
+    if (!isReposted) playHaptic()
+
+    control.close(() => {
+      onRepost()
+    })
+  }, [control, isReposted, onRepost, playHaptic])
+
+  const onPressQuote = useCallback(() => {
+    playHaptic()
+    control.close(() => {
+      onQuote()
+    })
+  }, [control, onQuote, playHaptic])
+
+  const onPressClose = useCallback(() => control.close(), [control])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}>
+      <View style={a.gap_xl}>
+        <View style={a.gap_xs}>
+          <Button
+            style={[a.justify_start, a.px_md]}
+            label={
+              isReposted
+                ? _(msg`Remove repost`)
+                : _(msg({message: `Repost`, context: 'action'}))
+            }
+            onPress={onPressRepost}
+            size="large"
+            variant="ghost"
+            color="primary">
+            <Repost size="lg" fill={t.palette.primary_500} />
+            <Text style={[a.font_bold, a.text_xl]}>
+              {isReposted ? (
+                <Trans>Remove repost</Trans>
+              ) : (
+                <Trans context="action">Repost</Trans>
+              )}
+            </Text>
+          </Button>
+          <Button
+            disabled={embeddingDisabled}
+            testID="quoteBtn"
+            style={[a.justify_start, a.px_md]}
+            label={
+              embeddingDisabled
+                ? _(msg`Quote posts disabled`)
+                : _(msg`Quote post`)
+            }
+            onPress={onPressQuote}
+            size="large"
+            variant="ghost"
+            color="primary">
+            <Quote
+              size="lg"
+              fill={
+                embeddingDisabled
+                  ? t.atoms.text_contrast_low.color
+                  : t.palette.primary_500
+              }
+            />
+            <Text
+              style={[
+                a.font_bold,
+                a.text_xl,
+                embeddingDisabled && t.atoms.text_contrast_low,
+              ]}>
+              {embeddingDisabled ? (
+                <Trans>Quote posts disabled</Trans>
+              ) : (
+                <Trans>Quote post</Trans>
+              )}
+            </Text>
+          </Button>
+        </View>
+        <Button
+          label={_(msg`Cancel quote post`)}
+          onPress={onPressClose}
+          size="large"
+          variant="outline"
+          color="primary">
+          <ButtonText>
+            <Trans>Cancel</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </Dialog.ScrollableInner>
+  )
+}
+RepostButtonDialogInner = memo(RepostButtonDialogInner)
+export {RepostButtonDialogInner}
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 111b41dd7..54119b532 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -104,9 +104,7 @@ export const RepostButton = ({
       label={_(msg`Repost or quote post`)}
       style={{padding: 0}}
       hoverStyle={t.atoms.bg_contrast_25}
-      shape="round"
-      variant="ghost"
-      color="secondary">
+      shape="round">
       <RepostInner
         isReposted={isReposted}
         color={color}
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
index 6db4d6fef..39c1d109e 100644
--- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
@@ -1,16 +1,11 @@
 import React from 'react'
-import {
-  ActivityIndicator,
-  GestureResponderEvent,
-  LayoutChangeEvent,
-  Pressable,
-} from 'react-native'
-import {Image, ImageLoadEventData} from 'expo-image'
+import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native'
+import {Image} from 'expo-image'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {EmbedPlayerParams, getGifDims} from '#/lib/strings/embed-player'
+import {EmbedPlayerParams} from '#/lib/strings/embed-player'
 import {isIOS, isNative, isWeb} from '#/platform/detection'
 import {useExternalEmbedsPrefs} from '#/state/preferences'
 import {atoms as a, useTheme} from '#/alf'
@@ -28,20 +23,15 @@ export function ExternalGifEmbed({
 }) {
   const t = useTheme()
   const externalEmbedsPrefs = useExternalEmbedsPrefs()
-
   const {_} = useLingui()
   const consentDialogControl = useDialogControl()
 
-  const thumbHasLoaded = React.useRef(false)
-  const viewWidth = React.useRef(0)
-
   // Tracking if the placer has been activated
   const [isPlayerActive, setIsPlayerActive] = React.useState(false)
   // Tracking whether the gif has been loaded yet
   const [isPrefetched, setIsPrefetched] = React.useState(false)
   // Tracking whether the image is animating
   const [isAnimating, setIsAnimating] = React.useState(true)
-  const [imageDims, setImageDims] = React.useState({height: 100, width: 1})
 
   // Used for controlling animation
   const imageRef = React.useRef<Image>(null)
@@ -93,16 +83,6 @@ export function ExternalGifEmbed({
     ],
   )
 
-  const onLoad = React.useCallback((e: ImageLoadEventData) => {
-    if (thumbHasLoaded.current) return
-    setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current))
-    thumbHasLoaded.current = true
-  }, [])
-
-  const onLayout = React.useCallback((e: LayoutChangeEvent) => {
-    viewWidth.current = e.nativeEvent.layout.width
-  }, [])
-
   return (
     <>
       <EmbedConsentDialog
@@ -113,7 +93,7 @@ export function ExternalGifEmbed({
 
       <Pressable
         style={[
-          {height: imageDims.height},
+          {height: 300},
           a.w_full,
           a.overflow_hidden,
           {
@@ -122,7 +102,6 @@ export function ExternalGifEmbed({
           },
         ]}
         onPress={onPlayPress}
-        onLayout={onLayout}
         accessibilityRole="button"
         accessibilityHint={_(msg`Plays the GIF`)}
         accessibilityLabel={_(msg`Play ${link.title}`)}>
@@ -135,7 +114,6 @@ export function ExternalGifEmbed({
           }} // Web uses the thumb to control playback
           style={{flex: 1}}
           ref={imageRef}
-          onLoad={onLoad}
           autoplay={isAnimating}
           contentFit="contain"
           accessibilityIgnoresInvertColors
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 24802d188..f268bf8db 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,5 +1,5 @@
 import React, {useCallback, useState} from 'react'
-import {View} from 'react-native'
+import {ActivityIndicator, View} from 'react-native'
 import {ImageBackground} from 'expo-image'
 import {AppBskyEmbedVideo} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
@@ -10,7 +10,6 @@ import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner
 import {atoms as a} from '#/alf'
 import {Button} from '#/components/Button'
 import {useThrottledValue} from '#/components/hooks/useThrottledValue'
-import {Loader} from '#/components/Loader'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
 import {ErrorBoundary} from '../ErrorBoundary'
 import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -89,12 +88,9 @@ function InnerWrapper({embed}: Props) {
         source={{uri: embed.thumbnail}}
         accessibilityIgnoresInvertColors
         style={[
+          a.absolute,
+          a.inset_0,
           {
-            position: 'absolute',
-            top: 0,
-            left: 0,
-            right: 0,
-            bottom: 0,
             backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
             // the play button won't show up on the first render on android 🥴😮‍💨
             display: showOverlay ? 'flex' : 'none',
@@ -102,27 +98,29 @@ function InnerWrapper({embed}: Props) {
         ]}
         cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
       >
-        <Button
-          style={[a.flex_1, a.align_center, a.justify_center]}
-          onPress={() => {
-            ref.current?.togglePlayback()
-          }}
-          label={_(msg`Play video`)}
-          color="secondary">
-          {showSpinner ? (
-            <View
-              style={[
-                a.rounded_full,
-                a.p_xs,
-                a.align_center,
-                a.justify_center,
-              ]}>
-              <Loader size="2xl" style={{color: 'white'}} />
-            </View>
-          ) : (
-            <PlayButtonIcon />
-          )}
-        </Button>
+        {showOverlay && (
+          <Button
+            style={[a.flex_1, a.align_center, a.justify_center]}
+            onPress={() => {
+              ref.current?.togglePlayback()
+            }}
+            label={_(msg`Play video`)}
+            color="secondary">
+            {showSpinner ? (
+              <View
+                style={[
+                  a.rounded_full,
+                  a.p_xs,
+                  a.align_center,
+                  a.justify_center,
+                ]}>
+                <ActivityIndicator size="large" color="white" />
+              </View>
+            ) : (
+              <PlayButtonIcon />
+            )}
+          </Button>
+        )}
       </ImageBackground>
     </>
   )
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
index 3180dd99e..a1f4652ac 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -24,6 +24,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
     useActiveVideoWeb()
   const [onScreen, setOnScreen] = useState(false)
   const [isFullscreen] = useFullscreen()
+  const lastKnownTime = useRef<number | undefined>()
 
   useEffect(() => {
     if (!ref.current) return
@@ -82,6 +83,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
               active={active}
               setActive={setActive}
               onScreen={onScreen}
+              lastKnownTime={lastKnownTime}
             />
           </ViewportObserver>
         </ErrorBoundary>
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
index 66e1df50d..75e544aca 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
@@ -1,8 +1,9 @@
-import React from 'react'
 import {StyleProp, ViewStyle} from 'react-native'
-import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {atoms as a, native, useTheme} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 
 /**
@@ -17,6 +18,7 @@ export function TimeIndicator({
   style?: StyleProp<ViewStyle>
 }) {
   const t = useTheme()
+  const {_} = useLingui()
 
   if (isNaN(time)) {
     return null
@@ -26,10 +28,10 @@ export function TimeIndicator({
   const seconds = String(time % 60).padStart(2, '0')
 
   return (
-    <Animated.View
-      entering={native(FadeInDown.duration(300))}
-      exiting={native(FadeOutDown.duration(500))}
+    <View
       pointerEvents="none"
+      accessibilityLabel={_(msg`Time remaining: ${time} seconds`)}
+      accessibilityHint=""
       style={[
         {
           backgroundColor: 'rgba(0, 0, 0, 0.5)',
@@ -52,6 +54,6 @@ export function TimeIndicator({
         ]}>
         {`${minutes}:${seconds}`}
       </Text>
-    </Animated.View>
+    </View>
   )
 }
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 21db54322..215e4c406 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -1,6 +1,5 @@
 import React, {useRef} from 'react'
 import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
-import Animated, {FadeInDown} from 'react-native-reanimated'
 import {AppBskyEmbedVideo} from '@atproto/api'
 import {BlueskyVideoView} from '@haileyok/bluesky-video'
 import {msg} from '@lingui/macro'
@@ -182,8 +181,7 @@ function ControlButton({
   style?: StyleProp<ViewStyle>
 }) {
   return (
-    <Animated.View
-      entering={FadeInDown.duration(300)}
+    <View
       style={[
         a.absolute,
         a.rounded_full,
@@ -207,6 +205,6 @@ function ControlButton({
         hitSlop={HITSLOP_30}>
         {children}
       </Pressable>
-    </Animated.View>
+    </View>
   )
 }
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index ef989c4a4..e6882a2f6 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -1,6 +1,8 @@
 import React, {useEffect, useId, useRef, useState} from 'react'
 import {View} from 'react-native'
 import {AppBskyEmbedVideo} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import type * as HlsTypes from 'hls.js'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -13,11 +15,13 @@ export function VideoEmbedInnerWeb({
   active,
   setActive,
   onScreen,
+  lastKnownTime,
 }: {
   embed: AppBskyEmbedVideo.View
   active: boolean
   setActive: () => void
   onScreen: boolean
+  lastKnownTime: React.MutableRefObject<number | undefined>
 }) {
   const containerRef = useRef<HTMLDivElement>(null)
   const videoRef = useRef<HTMLVideoElement>(null)
@@ -25,6 +29,7 @@ export function VideoEmbedInnerWeb({
   const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
   const [hlsLoading, setHlsLoading] = React.useState(false)
   const figId = useId()
+  const {_} = useLingui()
 
   // send error up to error boundary
   const [error, setError] = useState<Error | null>(null)
@@ -40,8 +45,17 @@ export function VideoEmbedInnerWeb({
     setHlsLoading,
   })
 
+  useEffect(() => {
+    if (lastKnownTime.current && videoRef.current) {
+      videoRef.current.currentTime = lastKnownTime.current
+    }
+  }, [lastKnownTime])
+
   return (
-    <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
+    <View
+      style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
+      accessibilityLabel={_(msg`Embedded video player`)}
+      accessibilityHint="">
       <div ref={containerRef} style={{height: '100%', width: '100%'}}>
         <figure style={{margin: 0, position: 'absolute', inset: 0}}>
           <video
@@ -52,6 +66,9 @@ export function VideoEmbedInnerWeb({
             preload="none"
             muted={!focused}
             aria-labelledby={embed.alt ? figId : undefined}
+            onTimeUpdate={e => {
+              lastKnownTime.current = e.currentTarget.currentTime
+            }}
           />
           {embed.alt && (
             <figcaption
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
index 8ffe482a8..651046445 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
@@ -23,7 +23,8 @@ export function ControlButton({
   return (
     <PressableWithHover
       accessibilityRole="button"
-      accessibilityHint={active ? activeLabel : inactiveLabel}
+      accessibilityLabel={active ? activeLabel : inactiveLabel}
+      accessibilityHint=""
       onPress={onPress}
       style={[
         a.p_xs,
@@ -32,9 +33,9 @@ export function ControlButton({
       ]}
       hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}>
       {active ? (
-        <ActiveIcon fill={t.palette.white} width={20} />
+        <ActiveIcon fill={t.palette.white} width={20} aria-hidden />
       ) : (
-        <InactiveIcon fill={t.palette.white} width={20} />
+        <InactiveIcon fill={t.palette.white} width={20} aria-hidden />
       )}
     </PressableWithHover>
   )
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
index 44978ad51..74aad64e1 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
@@ -186,7 +186,9 @@ export function Scrubber({
         </View>
         <div
           ref={circleRef}
-          aria-label={_(msg`Seek slider`)}
+          aria-label={_(
+            msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`,
+          )}
           role="slider"
           aria-valuemax={duration}
           aria-valuemin={0}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
index acd4d1aae..8e134d221 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -313,13 +313,14 @@ export function Controls({
         onPointerEnter={onPointerMoveEmptySpace}
         onPointerMove={onPointerMoveEmptySpace}
         onPointerLeave={onPointerLeaveEmptySpace}
-        accessibilityHint={_(
+        accessibilityLabel={_(
           !focused
             ? msg`Unmute video`
             : playing
             ? msg`Pause video`
             : msg`Play video`,
         )}
+        accessibilityHint=""
         style={[
           a.flex_1,
           web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
@@ -401,7 +402,7 @@ export function Controls({
             <ControlButton
               active={isFullscreen}
               activeLabel={_(msg`Exit fullscreen`)}
-              inactiveLabel={_(msg`Fullscreen`)}
+              inactiveLabel={_(msg`Enter fullscreen`)}
               activeIcon={ArrowsInIcon}
               inactiveIcon={ArrowsOutIcon}
               onPress={onPressFullscreen}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
index 63ac32b10..90ffb9e6b 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
@@ -77,6 +77,7 @@ export function VolumeControl({
               min={0}
               max={100}
               value={sliderVolume}
+              aria-label={_(msg`Volume`)}
               style={
                 // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h
                 isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'}
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 1351a2cbc..9dc43da8e 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -6,13 +6,7 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {
-  AnimatedRef,
-  measure,
-  MeasuredDimensions,
-  runOnJS,
-  runOnUI,
-} from 'react-native-reanimated'
+import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
 import {Image} from 'expo-image'
 import {
   AppBskyEmbedExternal,
@@ -27,6 +21,7 @@ import {
   ModerationDecision,
 } from '@atproto/api'
 
+import {HandleRef, measureHandle} from '#/lib/hooks/useHandleRef'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useLightboxControls} from '#/state/lightbox'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -163,12 +158,13 @@ export function PostEmbeds({
       }
       const onPress = (
         index: number,
-        refs: AnimatedRef<React.Component<{}, {}, any>>[],
+        refs: HandleRef[],
         fetchedDims: (Dimensions | null)[],
       ) => {
+        const handles = refs.map(r => r.current)
         runOnUI(() => {
           'worklet'
-          const rects = refs.map(ref => (ref ? measure(ref) : null))
+          const rects = handles.map(measureHandle)
           runOnJS(_openLightbox)(index, rects, fetchedDims)
         })()
       }
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
deleted file mode 100644
index a4cf517a4..000000000
--- a/src/view/com/util/text/RichText.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-import React from 'react'
-import {StyleProp, TextStyle} from 'react-native'
-import {AppBskyRichtextFacet, RichText as RichTextObj} from '@atproto/api'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {makeTagLink} from '#/lib/routes/links'
-import {toShortUrl} from '#/lib/strings/url-helpers'
-import {lh} from '#/lib/styles'
-import {TypographyVariant, useTheme} from '#/lib/ThemeContext'
-import {isNative} from '#/platform/detection'
-import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
-import {TextLink} from '../Link'
-import {Text} from './Text'
-
-const WORD_WRAP = {wordWrap: 1}
-
-/**
- * @deprecated use `#/components/RichText`
- */
-export function RichText({
-  testID,
-  type = 'md',
-  richText,
-  lineHeight = 1.2,
-  style,
-  numberOfLines,
-  selectable,
-  noLinks,
-}: {
-  testID?: string
-  type?: TypographyVariant
-  richText?: RichTextObj
-  lineHeight?: number
-  style?: StyleProp<TextStyle>
-  numberOfLines?: number
-  selectable?: boolean
-  noLinks?: boolean
-}) {
-  const theme = useTheme()
-  const pal = usePalette('default')
-  const lineHeightStyle = lh(theme, type, lineHeight)
-
-  if (!richText) {
-    return null
-  }
-
-  const {text, facets} = richText
-  if (!facets?.length) {
-    if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
-      style = {
-        fontSize: 26,
-        lineHeight: 30,
-      }
-      return (
-        // @ts-ignore web only -prf
-        <Text
-          testID={testID}
-          style={[style, pal.text]}
-          dataSet={WORD_WRAP}
-          selectable={selectable}>
-          {text}
-        </Text>
-      )
-    }
-    return (
-      <Text
-        testID={testID}
-        type={type}
-        style={[style, pal.text, lineHeightStyle]}
-        numberOfLines={numberOfLines}
-        // @ts-ignore web only -prf
-        dataSet={WORD_WRAP}
-        selectable={selectable}>
-        {text}
-      </Text>
-    )
-  }
-  if (!style) {
-    style = []
-  } else if (!Array.isArray(style)) {
-    style = [style]
-  }
-
-  const els = []
-  let key = 0
-  for (const segment of richText.segments()) {
-    const link = segment.link
-    const mention = segment.mention
-    const tag = segment.tag
-    if (
-      !noLinks &&
-      mention &&
-      AppBskyRichtextFacet.validateMention(mention).success
-    ) {
-      els.push(
-        <TextLink
-          key={key}
-          type={type}
-          text={segment.text}
-          href={`/profile/${mention.did}`}
-          style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
-          dataSet={WORD_WRAP}
-          selectable={selectable}
-        />,
-      )
-    } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
-      if (noLinks) {
-        els.push(toShortUrl(segment.text))
-      } else {
-        els.push(
-          <TextLink
-            key={key}
-            type={type}
-            text={toShortUrl(segment.text)}
-            href={link.uri}
-            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
-            dataSet={WORD_WRAP}
-            selectable={selectable}
-          />,
-        )
-      }
-    } else if (
-      !noLinks &&
-      tag &&
-      AppBskyRichtextFacet.validateTag(tag).success
-    ) {
-      els.push(
-        <RichTextTag
-          key={key}
-          text={segment.text}
-          type={type}
-          style={style}
-          lineHeightStyle={lineHeightStyle}
-          selectable={selectable}
-        />,
-      )
-    } else {
-      els.push(segment.text)
-    }
-    key++
-  }
-  return (
-    <Text
-      testID={testID}
-      type={type}
-      style={[style, pal.text, lineHeightStyle]}
-      numberOfLines={numberOfLines}
-      // @ts-ignore web only -prf
-      dataSet={WORD_WRAP}
-      selectable={selectable}>
-      {els}
-    </Text>
-  )
-}
-
-function RichTextTag({
-  text: tag,
-  type,
-  style,
-  lineHeightStyle,
-  selectable,
-}: {
-  text: string
-  type?: TypographyVariant
-  style?: StyleProp<TextStyle>
-  lineHeightStyle?: TextStyle
-  selectable?: boolean
-}) {
-  const pal = usePalette('default')
-  const control = useTagMenuControl()
-
-  const open = React.useCallback(() => {
-    control.open()
-  }, [control])
-
-  return (
-    <React.Fragment>
-      <TagMenu control={control} tag={tag}>
-        {isNative ? (
-          <TextLink
-            type={type}
-            text={tag}
-            // segment.text has the leading "#" while tag.tag does not
-            href={makeTagLink(tag)}
-            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
-            dataSet={WORD_WRAP}
-            selectable={selectable}
-            onPress={open}
-          />
-        ) : (
-          <Text
-            selectable={selectable}
-            type={type}
-            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
-            {tag}
-          </Text>
-        )}
-      </TagMenu>
-    </React.Fragment>
-  )
-}
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index dbf5e2e13..f05274f44 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {StyleSheet, Text as RNText, TextProps} from 'react-native'
+import {StyleSheet, TextProps} from 'react-native'
 import {UITextView} from 'react-native-uitextview'
 
 import {lh, s} from '#/lib/styles'
@@ -9,10 +9,9 @@ import {isIOS, isWeb} from '#/platform/detection'
 import {applyFonts, useAlf} from '#/alf'
 import {
   childHasEmoji,
-  childIsString,
   renderChildrenWithEmoji,
   StringChild,
-} from '#/components/Typography'
+} from '#/alf/typography'
 import {IS_DEV} from '#/env'
 
 export type CustomTextProps = Omit<TextProps, 'children'> & {
@@ -32,7 +31,11 @@ export type CustomTextProps = Omit<TextProps, 'children'> & {
       }
   )
 
-export function Text({
+export {Text_DEPRECATED as Text}
+/**
+ * @deprecated use Text from Typography instead.
+ */
+function Text_DEPRECATED({
   type = 'md',
   children,
   emoji,
@@ -52,10 +55,6 @@ export function Text({
         `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`,
       )
     }
-
-    if (emoji && !childIsString(children)) {
-      logger.error('Text: when <Text emoji />, children can only be strings.')
-    }
   }
 
   const textProps = React.useMemo(() => {
@@ -103,19 +102,9 @@ export function Text({
     type,
   ])
 
-  if (selectable && isIOS) {
-    return (
-      <UITextView {...textProps}>
-        {isIOS && emoji
-          ? renderChildrenWithEmoji(children, textProps)
-          : children}
-      </UITextView>
-    )
-  }
-
   return (
-    <RNText {...textProps}>
-      {isIOS && emoji ? renderChildrenWithEmoji(children, textProps) : children}
-    </RNText>
+    <UITextView {...textProps}>
+      {renderChildrenWithEmoji(children, textProps, emoji ?? false)}
+    </UITextView>
   )
 }
diff --git a/src/view/icons/Logomark.tsx b/src/view/icons/Logomark.tsx
index 5715a1a40..b777992d4 100644
--- a/src/view/icons/Logomark.tsx
+++ b/src/view/icons/Logomark.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import Svg, {Path, PathProps, SvgProps} from 'react-native-svg'
 
 import {usePalette} from '#/lib/hooks/usePalette'
diff --git a/src/view/icons/Logotype.tsx b/src/view/icons/Logotype.tsx
index d6c35f6d9..8be4980e6 100644
--- a/src/view/icons/Logotype.tsx
+++ b/src/view/icons/Logotype.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import Svg, {Path, PathProps, SvgProps} from 'react-native-svg'
 
 import {usePalette} from '#/lib/hooks/usePalette'
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 237449383..cadfb4890 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -141,7 +141,7 @@ function HomeScreenReady({
   useFocusEffect(
     useNonReactiveCallback(() => {
       if (selectedFeed) {
-        logEvent('home:feedDisplayed:sampled', {
+        logEvent('home:feedDisplayed', {
           index: selectedIndex,
           feedType: selectedFeed.split('|')[0],
           feedUrl: selectedFeed,
@@ -163,12 +163,9 @@ function HomeScreenReady({
   )
 
   const onPageSelecting = React.useCallback(
-    (
-      index: number,
-      reason: LogEvents['home:feedDisplayed:sampled']['reason'],
-    ) => {
+    (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => {
       const feed = allFeeds[index]
-      logEvent('home:feedDisplayed:sampled', {
+      logEvent('home:feedDisplayed', {
         index,
         feedType: feed.split('|')[0],
         feedUrl: feed,
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index b79da6d54..f654f2bd9 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -2,9 +2,11 @@ import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {AtUri} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
+import {useEmail} from '#/lib/hooks/useEmail'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
@@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists'
 import {Button} from '#/view/com/util/forms/Button'
 import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
 import {Text} from '#/view/com/util/text/Text'
+import {useDialogControl} from '#/components/Dialog'
+import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import * as Layout from '#/components/Layout'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
 export function ListsScreen({}: Props) {
+  const {_} = useLingui()
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const {isMobile} = useWebMediaQueries()
   const navigation = useNavigation<NavigationProp>()
   const {openModal} = useModalControls()
+  const {needsEmailVerification} = useEmail()
+  const control = useDialogControl()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -33,6 +40,11 @@ export function ListsScreen({}: Props) {
   )
 
   const onPressNewList = React.useCallback(() => {
+    if (needsEmailVerification) {
+      control.open()
+      return
+    }
+
     openModal({
       name: 'create-or-edit-list',
       purpose: 'app.bsky.graph.defs#curatelist',
@@ -46,7 +58,7 @@ export function ListsScreen({}: Props) {
         } catch {}
       },
     })
-  }, [openModal, navigation])
+  }, [needsEmailVerification, control, openModal, navigation])
 
   return (
     <Layout.Screen testID="listsScreen">
@@ -87,6 +99,12 @@ export function ListsScreen({}: Props) {
         </View>
       </SimpleViewHeader>
       <MyLists filter="curate" style={s.flexGrow1} />
+      <VerifyEmailDialog
+        reasonText={_(
+          msg`Before creating a list, you must first verify your email.`,
+        )}
+        control={control}
+      />
     </Layout.Screen>
   )
 }
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index b147ba502..c623c5376 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -2,9 +2,11 @@ import React from 'react'
 import {View} from 'react-native'
 import {AtUri} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
+import {useEmail} from '#/lib/hooks/useEmail'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
@@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists'
 import {Button} from '#/view/com/util/forms/Button'
 import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
 import {Text} from '#/view/com/util/text/Text'
+import {useDialogControl} from '#/components/Dialog'
+import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import * as Layout from '#/components/Layout'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
 export function ModerationModlistsScreen({}: Props) {
+  const {_} = useLingui()
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const {isMobile} = useWebMediaQueries()
   const navigation = useNavigation<NavigationProp>()
   const {openModal} = useModalControls()
+  const {needsEmailVerification} = useEmail()
+  const control = useDialogControl()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -33,6 +40,11 @@ export function ModerationModlistsScreen({}: Props) {
   )
 
   const onPressNewList = React.useCallback(() => {
+    if (needsEmailVerification) {
+      control.open()
+      return
+    }
+
     openModal({
       name: 'create-or-edit-list',
       purpose: 'app.bsky.graph.defs#modlist',
@@ -46,7 +58,7 @@ export function ModerationModlistsScreen({}: Props) {
         } catch {}
       },
     })
-  }, [openModal, navigation])
+  }, [needsEmailVerification, control, openModal, navigation])
 
   return (
     <Layout.Screen testID="moderationModlistsScreen">
@@ -83,6 +95,12 @@ export function ModerationModlistsScreen({}: Props) {
         </View>
       </SimpleViewHeader>
       <MyLists filter="mod" style={s.flexGrow1} />
+      <VerifyEmailDialog
+        reasonText={_(
+          msg`Before creating a list, you must first verify your email.`,
+        )}
+        control={control}
+      />
     </Layout.Screen>
   )
 }
diff --git a/src/view/screens/Storybook/Admonitions.tsx b/src/view/screens/Storybook/Admonitions.tsx
index ca97ebb23..988342f17 100644
--- a/src/view/screens/Storybook/Admonitions.tsx
+++ b/src/view/screens/Storybook/Admonitions.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a} from '#/alf'
diff --git a/src/view/screens/Storybook/Breakpoints.tsx b/src/view/screens/Storybook/Breakpoints.tsx
index 5dd8a89fc..6c79e2c9e 100644
--- a/src/view/screens/Storybook/Breakpoints.tsx
+++ b/src/view/screens/Storybook/Breakpoints.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx
index 9de126d6b..97a588a5a 100644
--- a/src/view/screens/Storybook/Icons.tsx
+++ b/src/view/screens/Storybook/Icons.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx
index 465ce0d6f..37e316401 100644
--- a/src/view/screens/Storybook/Links.tsx
+++ b/src/view/screens/Storybook/Links.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx
index 3e5c74d86..28689b727 100644
--- a/src/view/screens/Storybook/Menus.tsx
+++ b/src/view/screens/Storybook/Menus.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx
index 42000aa81..268ce5935 100644
--- a/src/view/screens/Storybook/Palette.tsx
+++ b/src/view/screens/Storybook/Palette.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Settings.tsx b/src/view/screens/Storybook/Settings.tsx
index 6bc293c73..fe47b2c74 100644
--- a/src/view/screens/Storybook/Settings.tsx
+++ b/src/view/screens/Storybook/Settings.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import * as Toast from '#/view/com/util/Toast'
diff --git a/src/view/screens/Storybook/Shadows.tsx b/src/view/screens/Storybook/Shadows.tsx
index f92112395..e9c23f03e 100644
--- a/src/view/screens/Storybook/Shadows.tsx
+++ b/src/view/screens/Storybook/Shadows.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Spacing.tsx b/src/view/screens/Storybook/Spacing.tsx
index 9b97e92ad..94c62d2f9 100644
--- a/src/view/screens/Storybook/Spacing.tsx
+++ b/src/view/screens/Storybook/Spacing.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Theming.tsx b/src/view/screens/Storybook/Theming.tsx
index 5b6763370..673425b47 100644
--- a/src/view/screens/Storybook/Theming.tsx
+++ b/src/view/screens/Storybook/Theming.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx
index 03f86fd46..9286d4b3d 100644
--- a/src/view/screens/Storybook/Typography.tsx
+++ b/src/view/screens/Storybook/Typography.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a} from '#/alf'
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index 4c357acc4..21ab9ec21 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react'
+import {useEffect} from 'react'
 import {Animated, Easing, StyleSheet, View} from 'react-native'
 
 import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 257506dd0..3dc2b076c 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -122,9 +122,8 @@ let DrawerProfileCard = ({
 DrawerProfileCard = React.memo(DrawerProfileCard)
 export {DrawerProfileCard}
 
-let DrawerContent = ({}: {}): React.ReactNode => {
+let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
   const t = useTheme()
-  const {_} = useLingui()
   const insets = useSafeAreaInsets()
   const setDrawerOpen = useSetDrawerOpen()
   const navigation = useNavigation<NavigationProp>()
@@ -137,7 +136,6 @@ let DrawerContent = ({}: {}): React.ReactNode => {
     isAtMessages,
   } = useNavigationTabState()
   const {hasSession, currentAccount} = useSession()
-  const kawaii = useKawaiiMode()
 
   // events
   // =
@@ -277,34 +275,7 @@ let DrawerContent = ({}: {}): React.ReactNode => {
 
         <View style={[a.px_xl]}>
           <Divider style={[a.mb_xl, a.mt_sm]} />
-
-          <View style={[a.flex_col, a.gap_md, a.flex_wrap]}>
-            <InlineLinkText
-              style={[a.text_md]}
-              label={_(msg`Terms of Service`)}
-              to="https://bsky.social/about/support/tos">
-              <Trans>Terms of Service</Trans>
-            </InlineLinkText>
-            <InlineLinkText
-              style={[a.text_md]}
-              to="https://bsky.social/about/support/privacy-policy"
-              label={_(msg`Privacy Policy`)}>
-              <Trans>Privacy Policy</Trans>
-            </InlineLinkText>
-            {kawaii && (
-              <Text style={t.atoms.text_contrast_medium}>
-                <Trans>
-                  Logo by{' '}
-                  <InlineLinkText
-                    style={[a.text_md]}
-                    to="/profile/sawaratsuki.bsky.social"
-                    label="@sawaratsuki.bsky.social">
-                    @sawaratsuki.bsky.social
-                  </InlineLinkText>
-                </Trans>
-              </Text>
-            )}
-          </View>
+          <ExtraLinks />
         </View>
       </ScrollView>
 
@@ -633,3 +604,39 @@ function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) {
     </Button>
   )
 }
+
+function ExtraLinks() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const kawaii = useKawaiiMode()
+
+  return (
+    <View style={[a.flex_col, a.gap_md, a.flex_wrap]}>
+      <InlineLinkText
+        style={[a.text_md]}
+        label={_(msg`Terms of Service`)}
+        to="https://bsky.social/about/support/tos">
+        <Trans>Terms of Service</Trans>
+      </InlineLinkText>
+      <InlineLinkText
+        style={[a.text_md]}
+        to="https://bsky.social/about/support/privacy-policy"
+        label={_(msg`Privacy Policy`)}>
+        <Trans>Privacy Policy</Trans>
+      </InlineLinkText>
+      {kawaii && (
+        <Text style={t.atoms.text_contrast_medium}>
+          <Trans>
+            Logo by{' '}
+            <InlineLinkText
+              style={[a.text_md]}
+              to="/profile/sawaratsuki.bsky.social"
+              label="@sawaratsuki.bsky.social">
+              @sawaratsuki.bsky.social
+            </InlineLinkText>
+          </Trans>
+        </Text>
+      )}
+    </View>
+  )
+}
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 855ba21b2..1d1023c2b 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -134,7 +134,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
           footerMinimalShellTransform,
         ]}
         onLayout={e => {
-          footerHeight.value = e.nativeEvent.layout.height
+          footerHeight.set(e.nativeEvent.layout.height)
         }}>
         {hasSession ? (
           <>
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 9b34159d7..81855c97d 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -177,7 +177,7 @@ export function BottomBarWeb() {
               alignItems: 'center',
               justifyContent: 'space-between',
               paddingTop: 14,
-              paddingBottom: 2,
+              paddingBottom: 14,
               paddingLeft: 14,
               paddingRight: 6,
               gap: 8,
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index bb6b8cadd..383d8f953 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 5f75c220c..4f413211f 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 6dc4f95a5..1ab045d75 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {BackHandler, StyleSheet, useWindowDimensions, View} from 'react-native'
 import {Drawer} from 'react-native-drawer-layout'
-import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import * as NavigationBar from 'expo-navigation-bar'
 import {StatusBar} from 'expo-status-bar'
@@ -95,7 +94,7 @@ function ShellInner() {
 
   return (
     <>
-      <Animated.View style={[a.h_full]}>
+      <View style={[a.h_full]}>
         <ErrorBoundary
           style={{paddingTop: insets.top, paddingBottom: insets.bottom}}>
           <Drawer
@@ -105,6 +104,7 @@ function ShellInner() {
             onOpen={onOpenDrawer}
             onClose={onCloseDrawer}
             swipeEdgeWidth={winDim.width / 2}
+            drawerType={isIOS ? 'slide' : 'front'}
             swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled}
             overlayStyle={{
               backgroundColor: select(t.name, {
@@ -118,7 +118,7 @@ function ShellInner() {
             <TabsNavigator />
           </Drawer>
         </ErrorBoundary>
-      </Animated.View>
+      </View>
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
       <MutedWordsDialog />
@@ -132,8 +132,8 @@ function ShellInner() {
 
 export const Shell: React.FC = function ShellImpl() {
   const {fullyExpandedCount} = useDialogStateControlContext()
-  const pal = usePalette('default')
   const theme = useTheme()
+  const pal = usePalette('default')
   useIntentHandler()
 
   React.useEffect(() => {