about summary refs log tree commit diff
path: root/src/components/PostControls
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/PostControls')
-rw-r--r--src/components/PostControls/DiscoverDebug.tsx54
-rw-r--r--src/components/PostControls/PostControlButton.tsx126
-rw-r--r--src/components/PostControls/PostMenu/PostMenuItems.tsx745
-rw-r--r--src/components/PostControls/PostMenu/index.tsx95
-rw-r--r--src/components/PostControls/RepostButton.tsx206
-rw-r--r--src/components/PostControls/RepostButton.web.tsx107
-rw-r--r--src/components/PostControls/ShareMenu/RecentChats.tsx200
-rw-r--r--src/components/PostControls/ShareMenu/ShareMenuItems.tsx197
-rw-r--r--src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx22
-rw-r--r--src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx192
-rw-r--r--src/components/PostControls/ShareMenu/index.tsx119
-rw-r--r--src/components/PostControls/index.tsx292
12 files changed, 2355 insertions, 0 deletions
diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx
new file mode 100644
index 000000000..796981f0c
--- /dev/null
+++ b/src/components/PostControls/DiscoverDebug.tsx
@@ -0,0 +1,54 @@
+import {Pressable} from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+import {t} from '@lingui/macro'
+
+import {IS_INTERNAL} from '#/lib/app-info'
+import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
+import {useGate} from '#/lib/statsig/statsig'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function DiscoverDebug({
+  feedContext,
+}: {
+  feedContext: string | undefined
+}) {
+  const {currentAccount} = useSession()
+  const {gtMobile} = useBreakpoints()
+  const gate = useGate()
+  const isDiscoverDebugUser =
+    IS_INTERNAL ||
+    DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
+    gate('debug_show_feedcontext')
+  const theme = useTheme()
+
+  return (
+    isDiscoverDebugUser &&
+    feedContext && (
+      <Pressable
+        accessible={false}
+        hitSlop={10}
+        style={[
+          a.absolute,
+          a.bottom_0,
+          {zIndex: 1000},
+          gtMobile ? a.right_0 : a.left_0,
+        ]}
+        onPress={e => {
+          e.stopPropagation()
+          Clipboard.setStringAsync(feedContext)
+          Toast.show(t`Copied to clipboard`, 'clipboard-check')
+        }}>
+        <Text
+          style={{
+            color: theme.palette.contrast_400,
+            fontSize: 7,
+          }}>
+          {feedContext}
+        </Text>
+      </Pressable>
+    )
+  )
+}
diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx
new file mode 100644
index 000000000..1585d429d
--- /dev/null
+++ b/src/components/PostControls/PostControlButton.tsx
@@ -0,0 +1,126 @@
+import {createContext, useContext, useMemo} from 'react'
+import {type GestureResponderEvent, type View} from 'react-native'
+
+import {POST_CTRL_HITSLOP} from '#/lib/constants'
+import {useHaptics} from '#/lib/haptics'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, type ButtonProps} from '#/components/Button'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+import {Text, type TextProps} from '#/components/Typography'
+
+const PostControlContext = createContext<{
+  big?: boolean
+  active?: boolean
+  color?: {color: string}
+}>({})
+
+// Base button style, which the the other ones extend
+export function PostControlButton({
+  ref,
+  onPress,
+  onLongPress,
+  children,
+  big,
+  active,
+  activeColor,
+  ...props
+}: ButtonProps & {
+  ref?: React.Ref<View>
+  active?: boolean
+  big?: boolean
+  color?: string
+  activeColor?: string
+}) {
+  const t = useTheme()
+  const playHaptic = useHaptics()
+
+  const ctx = useMemo(
+    () => ({
+      big,
+      active,
+      color: {
+        color: activeColor && active ? activeColor : t.palette.contrast_500,
+      },
+    }),
+    [big, active, activeColor, t.palette.contrast_500],
+  )
+
+  const style = useMemo(
+    () => [
+      a.flex_row,
+      a.align_center,
+      a.gap_xs,
+      a.bg_transparent,
+      {padding: 5},
+    ],
+    [],
+  )
+
+  const handlePress = useMemo(() => {
+    if (!onPress) return
+    return (evt: GestureResponderEvent) => {
+      playHaptic('Light')
+      onPress(evt)
+    }
+  }, [onPress, playHaptic])
+
+  const handleLongPress = useMemo(() => {
+    if (!onLongPress) return
+    return (evt: GestureResponderEvent) => {
+      playHaptic('Heavy')
+      onLongPress(evt)
+    }
+  }, [onLongPress, playHaptic])
+
+  return (
+    <Button
+      ref={ref}
+      onPress={handlePress}
+      onLongPress={handleLongPress}
+      style={style}
+      hoverStyle={t.atoms.bg_contrast_25}
+      shape="round"
+      variant="ghost"
+      color="secondary"
+      hitSlop={POST_CTRL_HITSLOP}
+      {...props}>
+      {typeof children === 'function' ? (
+        args => (
+          <PostControlContext.Provider value={ctx}>
+            {children(args)}
+          </PostControlContext.Provider>
+        )
+      ) : (
+        <PostControlContext.Provider value={ctx}>
+          {children}
+        </PostControlContext.Provider>
+      )}
+    </Button>
+  )
+}
+
+export function PostControlButtonIcon({
+  icon: Comp,
+}: {
+  icon: React.ComponentType<SVGIconProps>
+}) {
+  const {big, color} = useContext(PostControlContext)
+
+  return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} />
+}
+
+export function PostControlButtonText({style, ...props}: TextProps) {
+  const {big, active, color} = useContext(PostControlContext)
+
+  return (
+    <Text
+      style={[
+        color,
+        big ? a.text_md : {fontSize: 15},
+        active && a.font_bold,
+        style,
+      ]}
+      {...props}
+    />
+  )
+}
diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
new file mode 100644
index 000000000..51991589f
--- /dev/null
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -0,0 +1,745 @@
+import {memo, useMemo} from 'react'
+import {
+  Platform,
+  type PressableProps,
+  type StyleProp,
+  type ViewStyle,
+} from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+import {
+  type AppBskyFeedDefs,
+  AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  AtUri,
+  type RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {IS_INTERNAL} from '#/lib/app-info'
+import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {getCurrentRoute} from '#/lib/routes/helpers'
+import {makeProfileLink} from '#/lib/routes/links'
+import {
+  type CommonNavigatorParams,
+  type NavigationProp,
+} from '#/lib/routes/types'
+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 {type 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,
+  useProfileMuteMutationQueue,
+} from '#/state/queries/profile'
+import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
+import {useSession} from '#/state/session'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import * as Toast from '#/view/com/util/Toast'
+import {useDialogControl} from '#/components/Dialog'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {
+  PostInteractionSettingsDialog,
+  usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
+import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom'
+import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+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 MuteIcon} from '#/components/icons/Mute'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+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 UnmuteIcon} from '#/components/icons/Speaker'
+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 {
+  ReportDialog,
+  useReportDialogControl,
+} from '#/components/moderation/ReportDialog'
+import * as Prompt from '#/components/Prompt'
+import * as bsky from '#/types/bsky'
+
+let PostMenuItems = ({
+  post,
+  postFeedContext,
+  postReqId,
+  record,
+  richText,
+  threadgateRecord,
+  onShowLess,
+}: {
+  testID: string
+  post: Shadow<AppBskyFeedDefs.PostView>
+  postFeedContext: string | undefined
+  postReqId: string | undefined
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  style?: StyleProp<ViewStyle>
+  hitSlop?: PressableProps['hitSlop']
+  size?: 'lg' | 'md' | 'sm'
+  timestamp: string
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
+}): React.ReactNode => {
+  const {hasSession, currentAccount} = useSession()
+  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 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 = 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 [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor)
+
+  const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+    postUri: post.uri,
+    rootPostUri: rootUri,
+  })
+
+  const href = useMemo(() => {
+    const urip = new AtUri(postUri)
+    return makeProfileLink(postAuthor, 'post', urip.rkey)
+  }, [postUri, postAuthor])
+
+  const translatorUrl = getTranslatorLink(
+    record.text,
+    langPrefs.primaryLanguage,
+  )
+
+  const onDeletePost = () => {
+    deletePostMutate({uri: postUri}).then(
+      () => {
+        Toast.show(_(msg({message: 'Post deleted', context: 'toast'})))
+
+        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 onToggleThreadMute = () => {
+    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',
+        )
+      }
+    }
+  }
+
+  const onCopyPostText = () => {
+    const str = richTextToString(richText, true)
+
+    Clipboard.setStringAsync(str)
+    Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
+  }
+
+  const onPressTranslate = async () => {
+    await openLink(translatorUrl, true)
+
+    if (
+      bsky.dangerousIsType<AppBskyFeedPost.Record>(
+        post.record,
+        AppBskyFeedPost.isRecord,
+      )
+    ) {
+      logger.metric('translate', {
+        sourceLanguages: post.record.langs ?? [],
+        targetLanguage: langPrefs.primaryLanguage,
+        textLength: post.record.text.length,
+      })
+    }
+  }
+
+  const onHidePost = () => {
+    hidePost({uri: postUri})
+  }
+
+  const hideInPWI = !!postAuthor.labels?.find(
+    label => label.val === '!no-unauthenticated',
+  )
+
+  const onPressShowMore = () => {
+    feedFeedback.sendInteraction({
+      event: 'app.bsky.feed.defs#requestMore',
+      item: postUri,
+      feedContext: postFeedContext,
+      reqId: postReqId,
+    })
+    Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
+  }
+
+  const onPressShowLess = () => {
+    feedFeedback.sendInteraction({
+      event: 'app.bsky.feed.defs#requestLess',
+      item: postUri,
+      feedContext: postFeedContext,
+      reqId: postReqId,
+    })
+    if (onShowLess) {
+      onShowLess({
+        item: postUri,
+        feedContext: postFeedContext,
+      })
+    } else {
+      Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
+    }
+  }
+
+  const onToggleQuotePostAttachment = 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({message: 'Updating quote attachment failed', context: 'toast'})),
+      )
+      logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
+    }
+  }
+
+  const canHidePostForMe = !isAuthor && !isPostHidden
+  const canHideReplyForEveryone =
+    !isAuthor && isRootPostAuthor && !isPostHidden && isReply
+  const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
+
+  const onToggleReplyVisibility = 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({message: 'Reply visibility updated', context: 'toast'})),
+      )
+    } catch (e: any) {
+      Toast.show(
+        _(msg({message: 'Updating reply visibility failed', context: 'toast'})),
+      )
+      logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
+    }
+  }
+
+  const onPressPin = () => {
+    logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
+    pinPostMutate({
+      postUri,
+      postCid,
+      action: isPinned ? 'unpin' : 'pin',
+    })
+  }
+
+  const onBlockAuthor = async () => {
+    try {
+      await queueBlock()
+      Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
+    } 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')
+      }
+    }
+  }
+
+  const onMuteAuthor = async () => {
+    if (postAuthor.viewer?.muted) {
+      try {
+        await queueUnmute()
+        Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to unmute account', {message: e})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
+        }
+      }
+    } else {
+      try {
+        await queueMute()
+        Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to mute account', {message: e})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
+        }
+      }
+    }
+  }
+
+  const onReportMisclassification = () => {
+    const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
+      href,
+    )}`
+    openLink(url)
+  }
+
+  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>
+            </>
+          )}
+        </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 &&
+          IS_INTERNAL &&
+          DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && (
+            <Menu.Item
+              testID="postDropdownReportMisclassificationBtn"
+              label={_(msg`Assign topic for algo`)}
+              onPress={onReportMisclassification}>
+              <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={AtomIcon} position="right" />
+            </Menu.Item>
+          )}
+
+        {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="postDropdownMuteBtn"
+                    label={
+                      postAuthor.viewer?.muted
+                        ? _(msg`Unmute account`)
+                        : _(msg`Mute account`)
+                    }
+                    onPress={onMuteAuthor}>
+                    <Menu.ItemText>
+                      {postAuthor.viewer?.muted
+                        ? _(msg`Unmute account`)
+                        : _(msg`Mute account`)}
+                    </Menu.ItemText>
+                    <Menu.ItemIcon
+                      icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
+                      position="right"
+                    />
+                  </Menu.Item>
+
+                  {!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}
+        subject={{
+          ...post,
+          $type: 'app.bsky.feed.defs#postView',
+        }}
+      />
+
+      <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"
+      />
+    </>
+  )
+}
+PostMenuItems = memo(PostMenuItems)
+export {PostMenuItems}
diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx
new file mode 100644
index 000000000..63aa460fb
--- /dev/null
+++ b/src/components/PostControls/PostMenu/index.tsx
@@ -0,0 +1,95 @@
+import {memo, useMemo, useState} from 'react'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  type RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type React from 'react'
+
+import {type Shadow} from '#/state/cache/post-shadow'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
+import {useMenuControl} from '#/components/Menu'
+import * as Menu from '#/components/Menu'
+import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
+import {PostMenuItems} from './PostMenuItems'
+
+let PostMenuButton = ({
+  testID,
+  post,
+  postFeedContext,
+  postReqId,
+  big,
+  record,
+  richText,
+  timestamp,
+  threadgateRecord,
+  onShowLess,
+}: {
+  testID: string
+  post: Shadow<AppBskyFeedDefs.PostView>
+  postFeedContext: string | undefined
+  postReqId: string | undefined
+  big?: boolean
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  timestamp: string
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
+}): React.ReactNode => {
+  const {_} = useLingui()
+
+  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)
+      },
+    }),
+    [menuControl, setHasBeenOpen],
+  )
+  return (
+    <EventStopper onKeyDown={false}>
+      <Menu.Root control={lazyMenuControl}>
+        <Menu.Trigger label={_(msg`Open post options menu`)}>
+          {({props}) => {
+            return (
+              <PostControlButton
+                testID="postDropdownBtn"
+                big={big}
+                label={props.accessibilityLabel}
+                {...props}>
+                <PostControlButtonIcon icon={DotsHorizontal} />
+              </PostControlButton>
+            )
+          }}
+        </Menu.Trigger>
+        {hasBeenOpen && (
+          // Lazily initialized. Once mounted, they stay mounted.
+          <PostMenuItems
+            testID={testID}
+            post={post}
+            postFeedContext={postFeedContext}
+            postReqId={postReqId}
+            record={record}
+            richText={richText}
+            timestamp={timestamp}
+            threadgateRecord={threadgateRecord}
+            onShowLess={onShowLess}
+          />
+        )}
+      </Menu.Root>
+    </EventStopper>
+  )
+}
+
+PostMenuButton = memo(PostMenuButton)
+export {PostMenuButton}
diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx
new file mode 100644
index 000000000..db63a7383
--- /dev/null
+++ b/src/components/PostControls/RepostButton.tsx
@@ -0,0 +1,206 @@
+import {memo, useCallback} from 'react'
+import {View} from 'react-native'
+import {msg, plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useHaptics} from '#/lib/haptics'
+import {useRequireAuth} from '#/state/session'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
+import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
+import {Text} from '#/components/Typography'
+import {
+  PostControlButton,
+  PostControlButtonIcon,
+  PostControlButtonText,
+} from './PostControlButton'
+
+interface Props {
+  isReposted: boolean
+  repostCount?: number
+  onRepost: () => void
+  onQuote: () => void
+  big?: boolean
+  embeddingDisabled: boolean
+}
+
+let RepostButton = ({
+  isReposted,
+  repostCount,
+  onRepost,
+  onQuote,
+  big,
+  embeddingDisabled,
+}: Props): React.ReactNode => {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const requireAuth = useRequireAuth()
+  const dialogControl = Dialog.useDialogControl()
+
+  return (
+    <>
+      <PostControlButton
+        testID="repostBtn"
+        active={isReposted}
+        activeColor={t.palette.positive_600}
+        big={big}
+        onPress={() => requireAuth(() => dialogControl.open())}
+        onLongPress={() => requireAuth(() => onQuote())}
+        label={
+          isReposted
+            ? _(
+                msg({
+                  message: `Undo repost (${plural(repostCount || 0, {
+                    one: '# repost',
+                    other: '# reposts',
+                  })})`,
+                  comment:
+                    'Accessibility label for the repost button when the post has been reposted, verb followed by number of reposts and noun',
+                }),
+              )
+            : _(
+                msg({
+                  message: `Repost (${plural(repostCount || 0, {
+                    one: '# repost',
+                    other: '# reposts',
+                  })})`,
+                  comment:
+                    'Accessibility label for the repost button when the post has not been reposted, verb form followed by number of reposts and noun form',
+                }),
+              )
+        }>
+        <PostControlButtonIcon icon={Repost} />
+        {typeof repostCount !== 'undefined' && repostCount > 0 && (
+          <PostControlButtonText testID="repostCount">
+            {formatCount(i18n, repostCount)}
+          </PostControlButtonText>
+        )}
+      </PostControlButton>
+      <Dialog.Outer
+        control={dialogControl}
+        nativeOptions={{preventExpansion: true}}>
+        <Dialog.Handle />
+        <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/components/PostControls/RepostButton.web.tsx b/src/components/PostControls/RepostButton.web.tsx
new file mode 100644
index 000000000..48720b753
--- /dev/null
+++ b/src/components/PostControls/RepostButton.web.tsx
@@ -0,0 +1,107 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useRequireAuth} from '#/state/session'
+import {useSession} from '#/state/session'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {useTheme} from '#/alf'
+import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
+import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
+import * as Menu from '#/components/Menu'
+import {
+  PostControlButton,
+  PostControlButtonIcon,
+  PostControlButtonText,
+} from './PostControlButton'
+
+interface Props {
+  isReposted: boolean
+  repostCount?: number
+  onRepost: () => void
+  onQuote: () => void
+  big?: boolean
+  embeddingDisabled: boolean
+}
+
+export const RepostButton = ({
+  isReposted,
+  repostCount,
+  onRepost,
+  onQuote,
+  big,
+  embeddingDisabled,
+}: Props) => {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const {hasSession} = useSession()
+  const requireAuth = useRequireAuth()
+
+  return hasSession ? (
+    <EventStopper onKeyDown={false}>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Repost or quote post`)}>
+          {({props}) => {
+            return (
+              <PostControlButton
+                testID="repostBtn"
+                active={isReposted}
+                activeColor={t.palette.positive_600}
+                label={props.accessibilityLabel}
+                big={big}
+                {...props}>
+                <PostControlButtonIcon icon={Repost} />
+                {typeof repostCount !== 'undefined' && repostCount > 0 && (
+                  <PostControlButtonText testID="repostCount">
+                    {formatCount(i18n, repostCount)}
+                  </PostControlButtonText>
+                )}
+              </PostControlButton>
+            )
+          }}
+        </Menu.Trigger>
+        <Menu.Outer style={{minWidth: 170}}>
+          <Menu.Item
+            label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
+            testID="repostDropdownRepostBtn"
+            onPress={onRepost}>
+            <Menu.ItemText>
+              {isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={Repost} position="right" />
+          </Menu.Item>
+          <Menu.Item
+            disabled={embeddingDisabled}
+            label={
+              embeddingDisabled
+                ? _(msg`Quote posts disabled`)
+                : _(msg`Quote post`)
+            }
+            testID="repostDropdownQuoteBtn"
+            onPress={onQuote}>
+            <Menu.ItemText>
+              {embeddingDisabled
+                ? _(msg`Quote posts disabled`)
+                : _(msg`Quote post`)}
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={Quote} position="right" />
+          </Menu.Item>
+        </Menu.Outer>
+      </Menu.Root>
+    </EventStopper>
+  ) : (
+    <PostControlButton
+      onPress={() => requireAuth(() => {})}
+      active={isReposted}
+      activeColor={t.palette.positive_600}
+      label={_(msg`Repost or quote post`)}
+      big={big}>
+      <PostControlButtonIcon icon={Repost} />
+      {typeof repostCount !== 'undefined' && repostCount > 0 && (
+        <PostControlButtonText testID="repostCount">
+          {formatCount(i18n, repostCount)}
+        </PostControlButtonText>
+      )}
+    </PostControlButton>
+  )
+}
diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx
new file mode 100644
index 000000000..ca5d0029e
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/RecentChats.tsx
@@ -0,0 +1,200 @@
+import {ScrollView, View} from 'react-native'
+import {moderateProfile, type ModerationOpts} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {type NavigationProp} from '#/lib/routes/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useSession} from '#/state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+import {useSimpleVerificationState} from '#/components/verification'
+import {VerificationCheck} from '#/components/verification/VerificationCheck'
+import type * as bsky from '#/types/bsky'
+
+export function RecentChats({postUri}: {postUri: string}) {
+  const control = useDialogContext()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {data} = useListConvosQuery({status: 'accepted'})
+  const convos = data?.pages[0]?.convos?.slice(0, 10)
+  const moderationOpts = useModerationOpts()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onSelectChat = (convoId: string) => {
+    control.close(() => {
+      logger.metric('share:press:recentDm', {}, {statsig: true})
+      navigation.navigate('MessagesConversation', {
+        conversation: convoId,
+        embed: postUri,
+      })
+    })
+  }
+
+  if (!moderationOpts) return null
+
+  return (
+    <View
+      style={[a.relative, a.flex_1, {marginHorizontal: tokens.space.md * -1}]}>
+      <ScrollView
+        horizontal
+        style={[a.flex_1, a.pt_2xs, {minHeight: 98}]}
+        contentContainerStyle={[a.gap_sm, a.px_md]}
+        showsHorizontalScrollIndicator={false}
+        fadingEdgeLength={64}
+        nestedScrollEnabled>
+        {convos && convos.length > 0 ? (
+          convos.map(convo => {
+            const otherMember = convo.members.find(
+              member => member.did !== currentAccount?.did,
+            )
+
+            if (!otherMember || otherMember.handle === 'missing.invalid')
+              return null
+
+            return (
+              <RecentChatItem
+                key={convo.id}
+                profile={otherMember}
+                onPress={() => onSelectChat(convo.id)}
+                moderationOpts={moderationOpts}
+              />
+            )
+          })
+        ) : (
+          <>
+            <ConvoSkeleton />
+            <ConvoSkeleton />
+            <ConvoSkeleton />
+            <ConvoSkeleton />
+            <ConvoSkeleton />
+          </>
+        )}
+      </ScrollView>
+      {convos && convos.length === 0 && <NoConvos />}
+    </View>
+  )
+}
+
+const WIDTH = 80
+
+function RecentChatItem({
+  profile,
+  onPress,
+  moderationOpts,
+}: {
+  profile: bsky.profile.AnyProfileView
+  onPress: () => void
+  moderationOpts: ModerationOpts
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const moderation = moderateProfile(profile, moderationOpts)
+  const name = sanitizeDisplayName(
+    profile.displayName || sanitizeHandle(profile.handle),
+    moderation.ui('displayName'),
+  )
+  const verification = useSimpleVerificationState({profile})
+
+  return (
+    <Button
+      onPress={onPress}
+      label={_(msg`Send post to ${name}`)}
+      style={[
+        a.flex_col,
+        {width: WIDTH},
+        a.gap_sm,
+        a.justify_start,
+        a.align_center,
+      ]}>
+      <UserAvatar
+        avatar={profile.avatar}
+        size={WIDTH - 8}
+        type={profile.associated?.labeler ? 'labeler' : 'user'}
+        moderation={moderation.ui('avatar')}
+      />
+      <View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}>
+        <Text
+          emoji
+          style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}
+          numberOfLines={1}>
+          {name}
+        </Text>
+        {verification.showBadge && (
+          <View style={[a.pl_2xs]}>
+            <VerificationCheck
+              width={10}
+              verifier={verification.role === 'verifier'}
+            />
+          </View>
+        )}
+      </View>
+    </Button>
+  )
+}
+
+function ConvoSkeleton() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_col,
+        {width: WIDTH, height: WIDTH + 15},
+        a.gap_xs,
+        a.justify_start,
+        a.align_center,
+      ]}>
+      <View
+        style={[
+          t.atoms.bg_contrast_50,
+          {width: WIDTH - 8, height: WIDTH - 8},
+          a.rounded_full,
+        ]}
+      />
+      <View
+        style={[
+          t.atoms.bg_contrast_50,
+          {width: WIDTH - 8, height: 10},
+          a.rounded_xs,
+        ]}
+      />
+    </View>
+  )
+}
+
+function NoConvos() {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.inset_0,
+        a.justify_center,
+        a.align_center,
+        a.px_2xl,
+      ]}>
+      <View
+        style={[a.absolute, a.inset_0, t.atoms.bg_contrast_25, {opacity: 0.5}]}
+      />
+      <Text
+        style={[
+          a.text_sm,
+          t.atoms.text_contrast_high,
+          a.text_center,
+          a.font_bold,
+        ]}>
+        <Trans>Start a conversation, and it will appear here.</Trans>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
new file mode 100644
index 000000000..94369fcff
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
@@ -0,0 +1,197 @@
+import {memo, useMemo} from 'react'
+import * as ExpoClipboard from 'expo-clipboard'
+import {AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {type NavigationProp} from '#/lib/routes/types'
+import {shareText, shareUrl} from '#/lib/sharing'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {useDialogControl} from '#/components/Dialog'
+import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {useDevMode} from '#/storage/hooks/dev-mode'
+import {RecentChats} from './RecentChats'
+import {type ShareMenuItemsProps} from './ShareMenuItems.types'
+
+let ShareMenuItems = ({
+  post,
+  onShare: onShareProp,
+}: ShareMenuItemsProps): React.ReactNode => {
+  const {hasSession, currentAccount} = useSession()
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+  const pwiWarningShareControl = useDialogControl()
+  const pwiWarningCopyControl = useDialogControl()
+  const sendViaChatControl = useDialogControl()
+  const [devModeEnabled] = useDevMode()
+
+  const postUri = post.uri
+  const postAuthor = useProfileShadow(post.author)
+
+  const href = useMemo(() => {
+    const urip = new AtUri(postUri)
+    return makeProfileLink(postAuthor, 'post', urip.rkey)
+  }, [postUri, postAuthor])
+
+  const hideInPWI = useMemo(() => {
+    return !!postAuthor.labels?.find(
+      label => label.val === '!no-unauthenticated',
+    )
+  }, [postAuthor])
+
+  const showLoggedOutWarning =
+    postAuthor.did !== currentAccount?.did && hideInPWI
+
+  const onSharePost = () => {
+    logger.metric('share:press:nativeShare', {}, {statsig: true})
+    const url = toShareUrl(href)
+    shareUrl(url)
+    onShareProp()
+  }
+
+  const onCopyLink = () => {
+    logger.metric('share:press:copyLink', {}, {statsig: true})
+    const url = toShareUrl(href)
+    ExpoClipboard.setUrlAsync(url).then(() =>
+      Toast.show(_(msg`Copied to clipboard`), 'clipboard-check'),
+    )
+    onShareProp()
+  }
+
+  const onSelectChatToShareTo = (conversation: string) => {
+    navigation.navigate('MessagesConversation', {
+      conversation,
+      embed: postUri,
+    })
+  }
+
+  const onShareATURI = () => {
+    shareText(postUri)
+  }
+
+  const onShareAuthorDID = () => {
+    shareText(postAuthor.did)
+  }
+
+  return (
+    <>
+      <Menu.Outer>
+        {hasSession && (
+          <Menu.Group>
+            <Menu.ContainerItem>
+              <RecentChats postUri={postUri} />
+            </Menu.ContainerItem>
+            <Menu.Item
+              testID="postDropdownSendViaDMBtn"
+              label={_(msg`Send via direct message`)}
+              onPress={() => {
+                logger.metric('share:press:openDmSearch', {}, {statsig: true})
+                sendViaChatControl.open()
+              }}>
+              <Menu.ItemText>
+                <Trans>Send via direct message</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={PaperPlaneIcon} position="right" />
+            </Menu.Item>
+          </Menu.Group>
+        )}
+
+        <Menu.Group>
+          <Menu.Item
+            testID="postDropdownShareBtn"
+            label={_(msg`Share via...`)}
+            onPress={() => {
+              if (showLoggedOutWarning) {
+                pwiWarningShareControl.open()
+              } else {
+                onSharePost()
+              }
+            }}>
+            <Menu.ItemText>
+              <Trans>Share via...</Trans>
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={ArrowOutOfBoxIcon} position="right" />
+          </Menu.Item>
+
+          <Menu.Item
+            testID="postDropdownShareBtn"
+            label={_(msg`Copy link to post`)}
+            onPress={() => {
+              if (showLoggedOutWarning) {
+                pwiWarningCopyControl.open()
+              } else {
+                onCopyLink()
+              }
+            }}>
+            <Menu.ItemText>
+              <Trans>Copy link to post</Trans>
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={ChainLinkIcon} position="right" />
+          </Menu.Item>
+        </Menu.Group>
+
+        {devModeEnabled && (
+          <Menu.Group>
+            <Menu.Item
+              testID="postAtUriShareBtn"
+              label={_(msg`Share post at:// URI`)}
+              onPress={onShareATURI}>
+              <Menu.ItemText>
+                <Trans>Share post at:// URI</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+            </Menu.Item>
+            <Menu.Item
+              testID="postAuthorDIDShareBtn"
+              label={_(msg`Share author DID`)}
+              onPress={onShareAuthorDID}>
+              <Menu.ItemText>
+                <Trans>Share author DID</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+            </Menu.Item>
+          </Menu.Group>
+        )}
+      </Menu.Outer>
+
+      <Prompt.Basic
+        control={pwiWarningShareControl}
+        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 signed in.`,
+        )}
+        onConfirm={onSharePost}
+        confirmButtonCta={_(msg`Share anyway`)}
+      />
+
+      <Prompt.Basic
+        control={pwiWarningCopyControl}
+        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 signed in.`,
+        )}
+        onConfirm={onCopyLink}
+        confirmButtonCta={_(msg`Copy anyway`)}
+      />
+
+      <SendViaChatDialog
+        control={sendViaChatControl}
+        onSelectChat={onSelectChatToShareTo}
+      />
+    </>
+  )
+}
+ShareMenuItems = memo(ShareMenuItems)
+export {ShareMenuItems}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx
new file mode 100644
index 000000000..5bc2a8fb6
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx
@@ -0,0 +1,22 @@
+import {type PressableProps, type StyleProp, type ViewStyle} from 'react-native'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  type RichText as RichTextAPI,
+} from '@atproto/api'
+
+import {type Shadow} from '#/state/cache/post-shadow'
+
+export interface ShareMenuItemsProps {
+  testID: string
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  style?: StyleProp<ViewStyle>
+  hitSlop?: PressableProps['hitSlop']
+  size?: 'lg' | 'md' | 'sm'
+  timestamp: string
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  onShare: () => void
+}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
new file mode 100644
index 000000000..0da259678
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
@@ -0,0 +1,192 @@
+import {memo, useMemo} from 'react'
+import {AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import type React from 'react'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {type NavigationProp} from '#/lib/routes/types'
+import {shareText, shareUrl} from '#/lib/sharing'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
+import {useBreakpoints} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {EmbedDialog} from '#/components/dialogs/Embed'
+import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
+import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {useDevMode} from '#/storage/hooks/dev-mode'
+import {type ShareMenuItemsProps} from './ShareMenuItems.types'
+
+let ShareMenuItems = ({
+  post,
+  record,
+  timestamp,
+  onShare: onShareProp,
+}: ShareMenuItemsProps): React.ReactNode => {
+  const {hasSession, currentAccount} = useSession()
+  const {gtMobile} = useBreakpoints()
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+  const loggedOutWarningPromptControl = useDialogControl()
+  const embedPostControl = useDialogControl()
+  const sendViaChatControl = useDialogControl()
+  const [devModeEnabled] = useDevMode()
+
+  const postUri = post.uri
+  const postCid = post.cid
+  const postAuthor = useProfileShadow(post.author)
+
+  const href = useMemo(() => {
+    const urip = new AtUri(postUri)
+    return makeProfileLink(postAuthor, 'post', urip.rkey)
+  }, [postUri, postAuthor])
+
+  const hideInPWI = useMemo(() => {
+    return !!postAuthor.labels?.find(
+      label => label.val === '!no-unauthenticated',
+    )
+  }, [postAuthor])
+
+  const showLoggedOutWarning =
+    postAuthor.did !== currentAccount?.did && hideInPWI
+
+  const onCopyLink = () => {
+    logger.metric('share:press:copyLink', {}, {statsig: true})
+    const url = toShareUrl(href)
+    shareUrl(url)
+    onShareProp()
+  }
+
+  const onSelectChatToShareTo = (conversation: string) => {
+    logger.metric('share:press:dmSelected', {}, {statsig: true})
+    navigation.navigate('MessagesConversation', {
+      conversation,
+      embed: postUri,
+    })
+  }
+
+  const canEmbed = isWeb && gtMobile && !hideInPWI
+
+  const onShareATURI = () => {
+    shareText(postUri)
+  }
+
+  const onShareAuthorDID = () => {
+    shareText(postAuthor.did)
+  }
+
+  return (
+    <>
+      <Menu.Outer>
+        <Menu.Group>
+          <Menu.Item
+            testID="postDropdownShareBtn"
+            label={_(msg`Copy link to post`)}
+            onPress={() => {
+              if (showLoggedOutWarning) {
+                loggedOutWarningPromptControl.open()
+              } else {
+                onCopyLink()
+              }
+            }}>
+            <Menu.ItemText>
+              <Trans>Copy link to post</Trans>
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={ChainLinkIcon} position="right" />
+          </Menu.Item>
+
+          {hasSession && (
+            <Menu.Item
+              testID="postDropdownSendViaDMBtn"
+              label={_(msg`Send via direct message`)}
+              onPress={() => {
+                logger.metric('share:press:openDmSearch', {}, {statsig: true})
+                sendViaChatControl.open()
+              }}>
+              <Menu.ItemText>
+                <Trans>Send via direct message</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Send} position="right" />
+            </Menu.Item>
+          )}
+
+          {canEmbed && (
+            <Menu.Item
+              testID="postDropdownEmbedBtn"
+              label={_(msg`Embed post`)}
+              onPress={() => {
+                logger.metric('share:press:embed', {}, {statsig: true})
+                embedPostControl.open()
+              }}>
+              <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={CodeBracketsIcon} position="right" />
+            </Menu.Item>
+          )}
+        </Menu.Group>
+
+        {devModeEnabled && (
+          <>
+            <Menu.Divider />
+            <Menu.Group>
+              <Menu.Item
+                testID="postAtUriShareBtn"
+                label={_(msg`Copy post at:// URI`)}
+                onPress={onShareATURI}>
+                <Menu.ItemText>
+                  <Trans>Copy post at:// URI</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+              </Menu.Item>
+              <Menu.Item
+                testID="postAuthorDIDShareBtn"
+                label={_(msg`Copy author DID`)}
+                onPress={onShareAuthorDID}>
+                <Menu.ItemText>
+                  <Trans>Copy author DID</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+              </Menu.Item>
+            </Menu.Group>
+          </>
+        )}
+      </Menu.Outer>
+
+      <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 signed in.`,
+        )}
+        onConfirm={onCopyLink}
+        confirmButtonCta={_(msg`Share anyway`)}
+      />
+
+      {canEmbed && (
+        <EmbedDialog
+          control={embedPostControl}
+          postCid={postCid}
+          postUri={postUri}
+          record={record}
+          postAuthor={postAuthor}
+          timestamp={timestamp}
+        />
+      )}
+
+      <SendViaChatDialog
+        control={sendViaChatControl}
+        onSelectChat={onSelectChatToShareTo}
+      />
+    </>
+  )
+}
+ShareMenuItems = memo(ShareMenuItems)
+export {ShareMenuItems}
diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx
new file mode 100644
index 000000000..d4ea18bb0
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/index.tsx
@@ -0,0 +1,119 @@
+import {memo, useMemo, useState} from 'react'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  AtUri,
+  type RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type React from 'react'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {shareUrl} from '#/lib/sharing'
+import {useGate} from '#/lib/statsig/statsig'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {type Shadow} from '#/state/cache/post-shadow'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {native} from '#/alf'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
+import {useMenuControl} from '#/components/Menu'
+import * as Menu from '#/components/Menu'
+import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
+import {ShareMenuItems} from './ShareMenuItems'
+
+let ShareMenuButton = ({
+  testID,
+  post,
+  big,
+  record,
+  richText,
+  timestamp,
+  threadgateRecord,
+  onShare,
+}: {
+  testID: string
+  post: Shadow<AppBskyFeedDefs.PostView>
+  big?: boolean
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  timestamp: string
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  onShare: () => void
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const gate = useGate()
+
+  const ShareIcon = gate('alt_share_icon')
+    ? ArrowShareRightIcon
+    : ArrowOutOfBoxIcon
+
+  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)
+
+        logger.metric(
+          'share:open',
+          {context: big ? 'thread' : 'feed'},
+          {statsig: true},
+        )
+      },
+    }),
+    [menuControl, setHasBeenOpen, big],
+  )
+
+  const onNativeLongPress = () => {
+    logger.metric('share:press:nativeShare', {}, {statsig: true})
+    const urip = new AtUri(post.uri)
+    const href = makeProfileLink(post.author, 'post', urip.rkey)
+    const url = toShareUrl(href)
+    shareUrl(url)
+    onShare()
+  }
+
+  return (
+    <EventStopper onKeyDown={false}>
+      <Menu.Root control={lazyMenuControl}>
+        <Menu.Trigger label={_(msg`Open share menu`)}>
+          {({props}) => {
+            return (
+              <PostControlButton
+                testID="postShareBtn"
+                big={big}
+                label={props.accessibilityLabel}
+                {...props}
+                onLongPress={native(onNativeLongPress)}>
+                <PostControlButtonIcon icon={ShareIcon} />
+              </PostControlButton>
+            )
+          }}
+        </Menu.Trigger>
+        {hasBeenOpen && (
+          // Lazily initialized. Once mounted, they stay mounted.
+          <ShareMenuItems
+            testID={testID}
+            post={post}
+            record={record}
+            richText={richText}
+            timestamp={timestamp}
+            threadgateRecord={threadgateRecord}
+            onShare={onShare}
+          />
+        )}
+      </Menu.Root>
+    </EventStopper>
+  )
+}
+
+ShareMenuButton = memo(ShareMenuButton)
+export {ShareMenuButton}
diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx
new file mode 100644
index 000000000..7739da56b
--- /dev/null
+++ b/src/components/PostControls/index.tsx
@@ -0,0 +1,292 @@
+import {memo, useState} from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedPost,
+  type AppBskyFeedThreadgate,
+  type RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {CountWheel} from '#/lib/custom-animations/CountWheel'
+import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
+import {useHaptics} from '#/lib/haptics'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {type Shadow} from '#/state/cache/types'
+import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {
+  usePostLikeMutationQueue,
+  usePostRepostMutationQueue,
+} from '#/state/queries/post'
+import {useRequireAuth} from '#/state/session'
+import {
+  ProgressGuideAction,
+  useProgressGuideControls,
+} from '#/state/shell/progress-guide'
+import {formatCount} from '#/view/com/util/numeric/format'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
+import {
+  PostControlButton,
+  PostControlButtonIcon,
+  PostControlButtonText,
+} from './PostControlButton'
+import {PostMenuButton} from './PostMenu'
+import {RepostButton} from './RepostButton'
+import {ShareMenuButton} from './ShareMenu'
+
+let PostControls = ({
+  big,
+  post,
+  record,
+  richText,
+  feedContext,
+  reqId,
+  style,
+  onPressReply,
+  onPostReply,
+  logContext,
+  threadgateRecord,
+  onShowLess,
+}: {
+  big?: boolean
+  post: Shadow<AppBskyFeedDefs.PostView>
+  record: AppBskyFeedPost.Record
+  richText: RichTextAPI
+  feedContext?: string | undefined
+  reqId?: string | undefined
+  style?: StyleProp<ViewStyle>
+  onPressReply: () => void
+  onPostReply?: (postUri: string | undefined) => void
+  logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+  onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
+}): React.ReactNode => {
+  const {_, i18n} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const {openComposer} = useOpenComposer()
+  const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
+  const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
+    post,
+    logContext,
+  )
+  const requireAuth = useRequireAuth()
+  const {sendInteraction} = useFeedFeedbackContext()
+  const {captureAction} = useProgressGuideControls()
+  const playHaptic = useHaptics()
+  const isBlocked = Boolean(
+    post.author.viewer?.blocking ||
+      post.author.viewer?.blockedBy ||
+      post.author.viewer?.blockingByList,
+  )
+  const replyDisabled = post.viewer?.replyDisabled
+
+  const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
+
+  const onPressToggleLike = async () => {
+    if (isBlocked) {
+      Toast.show(
+        _(msg`Cannot interact with a blocked user`),
+        'exclamation-circle',
+      )
+      return
+    }
+
+    try {
+      setHasLikeIconBeenToggled(true)
+      if (!post.viewer?.like) {
+        playHaptic('Light')
+        sendInteraction({
+          item: post.uri,
+          event: 'app.bsky.feed.defs#interactionLike',
+          feedContext,
+          reqId,
+        })
+        captureAction(ProgressGuideAction.Like)
+        await queueLike()
+      } else {
+        await queueUnlike()
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        throw e
+      }
+    }
+  }
+
+  const onRepost = async () => {
+    if (isBlocked) {
+      Toast.show(
+        _(msg`Cannot interact with a blocked user`),
+        'exclamation-circle',
+      )
+      return
+    }
+
+    try {
+      if (!post.viewer?.repost) {
+        sendInteraction({
+          item: post.uri,
+          event: 'app.bsky.feed.defs#interactionRepost',
+          feedContext,
+          reqId,
+        })
+        await queueRepost()
+      } else {
+        await queueUnrepost()
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        throw e
+      }
+    }
+  }
+
+  const onQuote = () => {
+    if (isBlocked) {
+      Toast.show(
+        _(msg`Cannot interact with a blocked user`),
+        'exclamation-circle',
+      )
+      return
+    }
+
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#interactionQuote',
+      feedContext,
+      reqId,
+    })
+    openComposer({
+      quote: post,
+      onPost: onPostReply,
+    })
+  }
+
+  const onShare = () => {
+    sendInteraction({
+      item: post.uri,
+      event: 'app.bsky.feed.defs#interactionShare',
+      feedContext,
+      reqId,
+    })
+  }
+
+  return (
+    <View style={[a.flex_row, a.justify_between, a.align_center, style]}>
+      <View
+        style={[
+          big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
+          replyDisabled ? {opacity: 0.5} : undefined,
+        ]}>
+        <PostControlButton
+          testID="replyBtn"
+          onPress={
+            !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
+          }
+          label={_(
+            msg({
+              message: `Reply (${plural(post.replyCount || 0, {
+                one: '# reply',
+                other: '# replies',
+              })})`,
+              comment:
+                'Accessibility label for the reply button, verb form followed by number of replies and noun form',
+            }),
+          )}
+          big={big}>
+          <PostControlButtonIcon icon={Bubble} />
+          {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
+            <PostControlButtonText>
+              {formatCount(i18n, post.replyCount)}
+            </PostControlButtonText>
+          )}
+        </PostControlButton>
+      </View>
+      <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
+        <RepostButton
+          isReposted={!!post.viewer?.repost}
+          repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
+          onRepost={onRepost}
+          onQuote={onQuote}
+          big={big}
+          embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
+        />
+      </View>
+      <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
+        <PostControlButton
+          testID="likeBtn"
+          big={big}
+          onPress={() => requireAuth(() => onPressToggleLike())}
+          label={
+            post.viewer?.like
+              ? _(
+                  msg({
+                    message: `Unlike (${plural(post.likeCount || 0, {
+                      one: '# like',
+                      other: '# likes',
+                    })})`,
+                    comment:
+                      'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
+                  }),
+                )
+              : _(
+                  msg({
+                    message: `Like (${plural(post.likeCount || 0, {
+                      one: '# like',
+                      other: '# likes',
+                    })})`,
+                    comment:
+                      'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
+                  }),
+                )
+          }>
+          <AnimatedLikeIcon
+            isLiked={Boolean(post.viewer?.like)}
+            big={big}
+            hasBeenToggled={hasLikeIconBeenToggled}
+          />
+          <CountWheel
+            likeCount={post.likeCount ?? 0}
+            big={big}
+            isLiked={Boolean(post.viewer?.like)}
+            hasBeenToggled={hasLikeIconBeenToggled}
+          />
+        </PostControlButton>
+      </View>
+      <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
+        <View style={[!big && a.ml_sm]}>
+          <ShareMenuButton
+            testID="postShareBtn"
+            post={post}
+            big={big}
+            record={record}
+            richText={richText}
+            timestamp={post.indexedAt}
+            threadgateRecord={threadgateRecord}
+            onShare={onShare}
+          />
+        </View>
+      </View>
+      <View
+        style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}>
+        <PostMenuButton
+          testID="postDropdownBtn"
+          post={post}
+          postFeedContext={feedContext}
+          postReqId={reqId}
+          big={big}
+          record={record}
+          richText={richText}
+          timestamp={post.indexedAt}
+          threadgateRecord={threadgateRecord}
+          onShowLess={onShowLess}
+        />
+      </View>
+    </View>
+  )
+}
+PostControls = memo(PostControls)
+export {PostControls}