about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-11-23 16:20:24 -0800
committerGitHub <noreply@github.com>2024-11-23 16:20:24 -0800
commit32bf8122e8c8a0fbadd53b8a015cfbc9014519a2 (patch)
tree55bd24596e6fadadbf4326b26e3d14e418c5c7bb /src/view/com/util
parent523d1f01a51c0e85e49916fb42b204f7004ffac1 (diff)
parentb4d07c4112b9a62b5380948051aa4a7fd391a2d4 (diff)
downloadvoidsky-32bf8122e8c8a0fbadd53b8a015cfbc9014519a2.tar.zst
Merge branch 'main' into main
Diffstat (limited to 'src/view/com/util')
-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
54 files changed, 1410 insertions, 1451 deletions
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>
   )
 }