diff options
Diffstat (limited to 'src/components/PostControls/ShareMenu')
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} |