about summary refs log tree commit diff
path: root/src/components/PostControls/ShareMenu
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/PostControls/ShareMenu')
-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
5 files changed, 730 insertions, 0 deletions
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}