about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-11-19 18:27:56 +0000
committerGitHub <noreply@github.com>2024-11-19 18:27:56 +0000
commit31b0d7cedefc6aaf13356a368281857e26b56da6 (patch)
treebdf69e9682e3e5d41c662b55037bb84f55565223 /src
parent39c9f7248e4fec0c7719fce575b66bdccd844e41 (diff)
downloadvoidsky-31b0d7cedefc6aaf13356a368281857e26b56da6.tar.zst
Render post menu items lazily (take two) (#6473)
* Revert "Revert "Render dropdown menu items lazily (#6437)" (#6470)"

This reverts commit 0b38c77faa71dbc777d3d0162b86cb3e8695ff99.

* Lazily initialize menu items

* Split in two files

* Make it work on mobile

This is wonky because our useMenuDialog abstraction only has `isOpen` on web. I couldn't figure out a way to make it work xplat so I'm just tracking it myself manually.

* Fix typo in comment
Diffstat (limited to 'src')
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx702
-rw-r--r--src/view/com/util/forms/PostDropdownBtnMenuItems.tsx711
2 files changed, 741 insertions, 672 deletions
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 1cd4841a5..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, 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],
+    }),
+    [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..05d8efdbc
--- /dev/null
+++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx
@@ -0,0 +1,711 @@
+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 {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 {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 {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 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')
+      },
+    )
+  }, [
+    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])
+
+  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 && (
+                <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`)}
+      />
+    </>
+  )
+}
+PostDropdownMenuItems = memo(PostDropdownMenuItems)
+export {PostDropdownMenuItems}