diff options
Diffstat (limited to 'src/components/PostControls')
-rw-r--r-- | src/components/PostControls/DiscoverDebug.tsx | 54 | ||||
-rw-r--r-- | src/components/PostControls/PostControlButton.tsx | 126 | ||||
-rw-r--r-- | src/components/PostControls/PostMenu/PostMenuItems.tsx | 745 | ||||
-rw-r--r-- | src/components/PostControls/PostMenu/index.tsx | 95 | ||||
-rw-r--r-- | src/components/PostControls/RepostButton.tsx | 206 | ||||
-rw-r--r-- | src/components/PostControls/RepostButton.web.tsx | 107 | ||||
-rw-r--r-- | src/components/PostControls/ShareMenu/RecentChats.tsx | 200 | ||||
-rw-r--r-- | src/components/PostControls/ShareMenu/ShareMenuItems.tsx | 197 | ||||
-rw-r--r-- | src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx | 22 | ||||
-rw-r--r-- | src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx | 192 | ||||
-rw-r--r-- | src/components/PostControls/ShareMenu/index.tsx | 119 | ||||
-rw-r--r-- | src/components/PostControls/index.tsx | 292 |
12 files changed, 2355 insertions, 0 deletions
diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx new file mode 100644 index 000000000..796981f0c --- /dev/null +++ b/src/components/PostControls/DiscoverDebug.tsx @@ -0,0 +1,54 @@ +import {Pressable} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import {t} from '@lingui/macro' + +import {IS_INTERNAL} from '#/lib/app-info' +import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import {useGate} from '#/lib/statsig/statsig' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function DiscoverDebug({ + feedContext, +}: { + feedContext: string | undefined +}) { + const {currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const gate = useGate() + const isDiscoverDebugUser = + IS_INTERNAL || + DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || + gate('debug_show_feedcontext') + const theme = useTheme() + + return ( + isDiscoverDebugUser && + feedContext && ( + <Pressable + accessible={false} + hitSlop={10} + style={[ + a.absolute, + a.bottom_0, + {zIndex: 1000}, + gtMobile ? a.right_0 : a.left_0, + ]} + onPress={e => { + e.stopPropagation() + Clipboard.setStringAsync(feedContext) + Toast.show(t`Copied to clipboard`, 'clipboard-check') + }}> + <Text + style={{ + color: theme.palette.contrast_400, + fontSize: 7, + }}> + {feedContext} + </Text> + </Pressable> + ) + ) +} diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx new file mode 100644 index 000000000..1585d429d --- /dev/null +++ b/src/components/PostControls/PostControlButton.tsx @@ -0,0 +1,126 @@ +import {createContext, useContext, useMemo} from 'react' +import {type GestureResponderEvent, type View} from 'react-native' + +import {POST_CTRL_HITSLOP} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' +import {atoms as a, useTheme} from '#/alf' +import {Button, type ButtonProps} from '#/components/Button' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Text, type TextProps} from '#/components/Typography' + +const PostControlContext = createContext<{ + big?: boolean + active?: boolean + color?: {color: string} +}>({}) + +// Base button style, which the the other ones extend +export function PostControlButton({ + ref, + onPress, + onLongPress, + children, + big, + active, + activeColor, + ...props +}: ButtonProps & { + ref?: React.Ref<View> + active?: boolean + big?: boolean + color?: string + activeColor?: string +}) { + const t = useTheme() + const playHaptic = useHaptics() + + const ctx = useMemo( + () => ({ + big, + active, + color: { + color: activeColor && active ? activeColor : t.palette.contrast_500, + }, + }), + [big, active, activeColor, t.palette.contrast_500], + ) + + const style = useMemo( + () => [ + a.flex_row, + a.align_center, + a.gap_xs, + a.bg_transparent, + {padding: 5}, + ], + [], + ) + + const handlePress = useMemo(() => { + if (!onPress) return + return (evt: GestureResponderEvent) => { + playHaptic('Light') + onPress(evt) + } + }, [onPress, playHaptic]) + + const handleLongPress = useMemo(() => { + if (!onLongPress) return + return (evt: GestureResponderEvent) => { + playHaptic('Heavy') + onLongPress(evt) + } + }, [onLongPress, playHaptic]) + + return ( + <Button + ref={ref} + onPress={handlePress} + onLongPress={handleLongPress} + style={style} + hoverStyle={t.atoms.bg_contrast_25} + shape="round" + variant="ghost" + color="secondary" + hitSlop={POST_CTRL_HITSLOP} + {...props}> + {typeof children === 'function' ? ( + args => ( + <PostControlContext.Provider value={ctx}> + {children(args)} + </PostControlContext.Provider> + ) + ) : ( + <PostControlContext.Provider value={ctx}> + {children} + </PostControlContext.Provider> + )} + </Button> + ) +} + +export function PostControlButtonIcon({ + icon: Comp, +}: { + icon: React.ComponentType<SVGIconProps> +}) { + const {big, color} = useContext(PostControlContext) + + return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} /> +} + +export function PostControlButtonText({style, ...props}: TextProps) { + const {big, active, color} = useContext(PostControlContext) + + return ( + <Text + style={[ + color, + big ? a.text_md : {fontSize: 15}, + active && a.font_bold, + style, + ]} + {...props} + /> + ) +} diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx new file mode 100644 index 000000000..51991589f --- /dev/null +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -0,0 +1,745 @@ +import {memo, useMemo} from 'react' +import { + Platform, + type PressableProps, + type StyleProp, + type ViewStyle, +} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { + type AppBskyFeedDefs, + AppBskyFeedPost, + type AppBskyFeedThreadgate, + AtUri, + type RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {IS_INTERNAL} from '#/lib/app-info' +import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getCurrentRoute} from '#/lib/routes/helpers' +import {makeProfileLink} from '#/lib/routes/links' +import { + type CommonNavigatorParams, + type NavigationProp, +} from '#/lib/routes/types' +import {logEvent} from '#/lib/statsig/statsig' +import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {getTranslatorLink} from '#/locale/helpers' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useFeedFeedbackContext} from '#/state/feed-feedback' +import {useLanguagePrefs} from '#/state/preferences' +import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' +import {usePinnedPostMutation} from '#/state/queries/pinned-post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' +import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' +import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' +import { + useProfileBlockMutationQueue, + useProfileMuteMutationQueue, +} from '#/state/queries/profile' +import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' +import {useSession} from '#/state/session' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import * as Toast from '#/view/com/util/Toast' +import {useDialogControl} from '#/components/Dialog' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import { + PostInteractionSettingsDialog, + usePrefetchPostInteractionSettings, +} from '#/components/dialogs/PostInteractionSettingsDialog' +import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' +import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import { + EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, +} from '#/components/icons/Emoji' +import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' +import * as Prompt from '#/components/Prompt' +import * as bsky from '#/types/bsky' + +let PostMenuItems = ({ + post, + postFeedContext, + postReqId, + record, + richText, + threadgateRecord, + onShowLess, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + postFeedContext: string | undefined + postReqId: string | undefined + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp<ViewStyle> + hitSlop?: PressableProps['hitSlop'] + size?: 'lg' | 'md' | 'sm' + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record + onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void +}): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const {mutateAsync: deletePostMutate} = usePostDeleteMutation() + const {mutateAsync: pinPostMutate, isPending: isPinPending} = + usePinnedPostMutation() + const hiddenPosts = useHiddenPosts() + const {hidePost} = useHiddenPostsApi() + const feedFeedback = useFeedFeedbackContext() + const openLink = useOpenLink() + const navigation = useNavigation<NavigationProp>() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const blockPromptControl = useDialogControl() + const reportDialogControl = useReportDialogControl() + const deletePromptControl = useDialogControl() + const hidePromptControl = useDialogControl() + const postInteractionSettingsDialogControl = useDialogControl() + const quotePostDetachConfirmControl = useDialogControl() + const hideReplyConfirmControl = useDialogControl() + const {mutateAsync: toggleReplyVisibility} = + useToggleReplyVisibilityMutation() + + const postUri = post.uri + const postCid = post.cid + const postAuthor = useProfileShadow(post.author) + const quoteEmbed = useMemo(() => { + if (!currentAccount || !post.embed) return + return getMaybeDetachedQuoteEmbed({ + viewerDid: currentAccount.did, + post, + }) + }, [post, currentAccount]) + + const rootUri = record.reply?.root?.uri || postUri + const isReply = Boolean(record.reply) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) + const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) + const isAuthor = postAuthor.did === currentAccount?.did + const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) + const isPinned = post.viewer?.pinned + + const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = + useToggleQuoteDetachmentMutation() + + const [queueBlock] = useProfileBlockMutationQueue(postAuthor) + const [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor) + + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ + postUri: post.uri, + rootPostUri: rootUri, + }) + + const href = useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const translatorUrl = getTranslatorLink( + record.text, + langPrefs.primaryLanguage, + ) + + const onDeletePost = () => { + deletePostMutate({uri: postUri}).then( + () => { + Toast.show(_(msg({message: 'Post deleted', context: 'toast'}))) + + const route = getCurrentRoute(navigation.getState()) + if (route.name === 'PostThread') { + const params = route.params as CommonNavigatorParams['PostThread'] + if ( + currentAccount && + isAuthor && + (params.name === currentAccount.handle || + params.name === currentAccount.did) + ) { + const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) + if (currentHref === href && navigation.canGoBack()) { + navigation.goBack() + } + } + } + }, + e => { + logger.error('Failed to delete post', {message: e}) + Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') + }, + ) + } + + const onToggleThreadMute = () => { + try { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() + Toast.show( + _(msg`You will no longer receive notifications for this thread`), + ) + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show( + _(msg`Failed to toggle thread mute, please try again`), + 'xmark', + ) + } + } + } + + const onCopyPostText = () => { + const str = richTextToString(richText, true) + + Clipboard.setStringAsync(str) + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') + } + + const onPressTranslate = async () => { + await openLink(translatorUrl, true) + + if ( + bsky.dangerousIsType<AppBskyFeedPost.Record>( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + logger.metric('translate', { + sourceLanguages: post.record.langs ?? [], + targetLanguage: langPrefs.primaryLanguage, + textLength: post.record.text.length, + }) + } + } + + const onHidePost = () => { + hidePost({uri: postUri}) + } + + const hideInPWI = !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + + const onPressShowMore = () => { + feedFeedback.sendInteraction({ + event: 'app.bsky.feed.defs#requestMore', + item: postUri, + feedContext: postFeedContext, + reqId: postReqId, + }) + Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'}))) + } + + const onPressShowLess = () => { + feedFeedback.sendInteraction({ + event: 'app.bsky.feed.defs#requestLess', + item: postUri, + feedContext: postFeedContext, + reqId: postReqId, + }) + if (onShowLess) { + onShowLess({ + item: postUri, + feedContext: postFeedContext, + }) + } else { + Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'}))) + } + } + + const onToggleQuotePostAttachment = async () => { + if (!quoteEmbed) return + + const action = quoteEmbed.isDetached ? 'reattach' : 'detach' + const isDetach = action === 'detach' + + try { + await toggleQuoteDetachment({ + post, + quoteUri: quoteEmbed.uri, + action: quoteEmbed.isDetached ? 'reattach' : 'detach', + }) + Toast.show( + isDetach + ? _(msg`Quote post was successfully detached`) + : _(msg`Quote post was re-attached`), + ) + } catch (e: any) { + Toast.show( + _(msg({message: 'Updating quote attachment failed', context: 'toast'})), + ) + logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) + } + } + + const canHidePostForMe = !isAuthor && !isPostHidden + const canHideReplyForEveryone = + !isAuthor && isRootPostAuthor && !isPostHidden && isReply + const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer + + const onToggleReplyVisibility = async () => { + // TODO no threadgate? + if (!canHideReplyForEveryone) return + + const action = isReplyHiddenByThreadgate ? 'show' : 'hide' + const isHide = action === 'hide' + + try { + await toggleReplyVisibility({ + postUri: rootUri, + replyUri: postUri, + action, + }) + Toast.show( + isHide + ? _(msg`Reply was successfully hidden`) + : _(msg({message: 'Reply visibility updated', context: 'toast'})), + ) + } catch (e: any) { + Toast.show( + _(msg({message: 'Updating reply visibility failed', context: 'toast'})), + ) + logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) + } + } + + const onPressPin = () => { + logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) + pinPostMutate({ + postUri, + postCid, + action: isPinned ? 'unpin' : 'pin', + }) + } + + const onBlockAuthor = async () => { + try { + await queueBlock() + Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + } + } + } + + const onMuteAuthor = async () => { + if (postAuthor.viewer?.muted) { + try { + await queueUnmute() + Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unmute account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + } + } + } else { + try { + await queueMute() + Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to mute account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + } + } + } + } + + const onReportMisclassification = () => { + const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( + href, + )}` + openLink(url) + } + + return ( + <> + <Menu.Outer> + {isAuthor && ( + <> + <Menu.Group> + <Menu.Item + testID="pinPostBtn" + label={ + isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`) + } + disabled={isPinPending} + onPress={onPressPin}> + <Menu.ItemText> + {isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isPinPending ? Loader : PinIcon} + position="right" + /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + </> + )} + + <Menu.Group> + {(!hideInPWI || hasSession) && ( + <> + <Menu.Item + testID="postDropdownTranslateBtn" + label={_(msg`Translate`)} + onPress={onPressTranslate}> + <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> + <Menu.ItemIcon icon={Translate} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownCopyTextBtn" + label={_(msg`Copy post text`)} + onPress={onCopyPostText}> + <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </> + )} + </Menu.Group> + + {hasSession && feedFeedback.enabled && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postDropdownShowMoreBtn" + label={_(msg`Show more like this`)} + onPress={onPressShowMore}> + <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> + <Menu.ItemIcon icon={EmojiSmile} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownShowLessBtn" + label={_(msg`Show less like this`)} + onPress={onPressShowLess}> + <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> + <Menu.ItemIcon icon={EmojiSad} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + + {hasSession && + IS_INTERNAL && + DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( + <Menu.Item + testID="postDropdownReportMisclassificationBtn" + label={_(msg`Assign topic for algo`)} + onPress={onReportMisclassification}> + <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> + <Menu.ItemIcon icon={AtomIcon} position="right" /> + </Menu.Item> + )} + + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postDropdownMuteThreadBtn" + label={ + isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) + } + onPress={onToggleThreadMute}> + <Menu.ItemText> + {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isThreadMuted ? Unmute : Mute} + position="right" + /> + </Menu.Item> + + <Menu.Item + testID="postDropdownMuteWordsBtn" + label={_(msg`Mute words & tags`)} + onPress={() => mutedWordsDialogControl.open()}> + <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> + <Menu.ItemIcon icon={Filter} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + + {hasSession && + (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( + <> + <Menu.Divider /> + <Menu.Group> + {canHidePostForMe && ( + <Menu.Item + testID="postDropdownHideBtn" + label={ + isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`) + } + onPress={() => hidePromptControl.open()}> + <Menu.ItemText> + {isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`)} + </Menu.ItemText> + <Menu.ItemIcon icon={EyeSlash} position="right" /> + </Menu.Item> + )} + {canHideReplyForEveryone && ( + <Menu.Item + testID="postDropdownHideBtn" + label={ + isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`) + } + onPress={ + isReplyHiddenByThreadgate + ? onToggleReplyVisibility + : () => hideReplyConfirmControl.open() + }> + <Menu.ItemText> + {isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} + position="right" + /> + </Menu.Item> + )} + + {canDetachQuote && ( + <Menu.Item + disabled={isDetachPending} + testID="postDropdownHideBtn" + label={ + quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`) + } + onPress={ + quoteEmbed.isDetached + ? onToggleQuotePostAttachment + : () => quotePostDetachConfirmControl.open() + }> + <Menu.ItemText> + {quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={ + isDetachPending + ? Loader + : quoteEmbed.isDetached + ? Eye + : EyeSlash + } + position="right" + /> + </Menu.Item> + )} + </Menu.Group> + </> + )} + + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + {!isAuthor && ( + <> + <Menu.Item + testID="postDropdownMuteBtn" + label={ + postAuthor.viewer?.muted + ? _(msg`Unmute account`) + : _(msg`Mute account`) + } + onPress={onMuteAuthor}> + <Menu.ItemText> + {postAuthor.viewer?.muted + ? _(msg`Unmute account`) + : _(msg`Mute account`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} + position="right" + /> + </Menu.Item> + + {!postAuthor.viewer?.blocking && ( + <Menu.Item + testID="postDropdownBlockBtn" + label={_(msg`Block account`)} + onPress={() => blockPromptControl.open()}> + <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> + <Menu.ItemIcon icon={PersonX} position="right" /> + </Menu.Item> + )} + + <Menu.Item + testID="postDropdownReportBtn" + label={_(msg`Report post`)} + onPress={() => reportDialogControl.open()}> + <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Warning} position="right" /> + </Menu.Item> + </> + )} + + {isAuthor && ( + <> + <Menu.Item + testID="postDropdownEditPostInteractions" + label={_(msg`Edit interaction settings`)} + onPress={() => postInteractionSettingsDialogControl.open()} + {...(isAuthor + ? Platform.select({ + web: { + onHoverIn: prefetchPostInteractionSettings, + }, + native: { + onPressIn: prefetchPostInteractionSettings, + }, + }) + : {})}> + <Menu.ItemText> + {_(msg`Edit interaction settings`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Gear} position="right" /> + </Menu.Item> + <Menu.Item + testID="postDropdownDeleteBtn" + label={_(msg`Delete post`)} + onPress={() => deletePromptControl.open()}> + <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + </> + )} + </Menu.Group> + </> + )} + </Menu.Outer> + + <Prompt.Basic + control={deletePromptControl} + title={_(msg`Delete this post?`)} + description={_( + msg`If you remove this post, you won't be able to recover it.`, + )} + onConfirm={onDeletePost} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> + + <Prompt.Basic + control={hidePromptControl} + title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} + description={_( + msg`This post will be hidden from feeds and threads. This cannot be undone.`, + )} + onConfirm={onHidePost} + confirmButtonCta={_(msg`Hide`)} + /> + + <ReportDialog + control={reportDialogControl} + subject={{ + ...post, + $type: 'app.bsky.feed.defs#postView', + }} + /> + + <PostInteractionSettingsDialog + control={postInteractionSettingsDialogControl} + postUri={post.uri} + rootPostUri={rootUri} + initialThreadgateView={post.threadgate} + /> + + <Prompt.Basic + control={quotePostDetachConfirmControl} + title={_(msg`Detach quote post?`)} + description={_( + msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, + )} + onConfirm={onToggleQuotePostAttachment} + confirmButtonCta={_(msg`Yes, detach`)} + /> + + <Prompt.Basic + control={hideReplyConfirmControl} + title={_(msg`Hide this reply?`)} + description={_( + msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, + )} + onConfirm={onToggleReplyVisibility} + confirmButtonCta={_(msg`Yes, hide`)} + /> + + <Prompt.Basic + control={blockPromptControl} + title={_(msg`Block Account?`)} + description={_( + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + )} + onConfirm={onBlockAuthor} + confirmButtonCta={_(msg`Block`)} + confirmButtonColor="negative" + /> + </> + ) +} +PostMenuItems = memo(PostMenuItems) +export {PostMenuItems} diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx new file mode 100644 index 000000000..63aa460fb --- /dev/null +++ b/src/components/PostControls/PostMenu/index.tsx @@ -0,0 +1,95 @@ +import {memo, useMemo, useState} from 'react' +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + type RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type React from 'react' + +import {type Shadow} from '#/state/cache/post-shadow' +import {EventStopper} from '#/view/com/util/EventStopper' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {useMenuControl} from '#/components/Menu' +import * as Menu from '#/components/Menu' +import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' +import {PostMenuItems} from './PostMenuItems' + +let PostMenuButton = ({ + testID, + post, + postFeedContext, + postReqId, + big, + record, + richText, + timestamp, + threadgateRecord, + onShowLess, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + postFeedContext: string | undefined + postReqId: string | undefined + big?: boolean + record: AppBskyFeedPost.Record + richText: RichTextAPI + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record + onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void +}): React.ReactNode => { + const {_} = useLingui() + + const menuControl = useMenuControl() + const [hasBeenOpen, setHasBeenOpen] = useState(false) + const lazyMenuControl = useMemo( + () => ({ + ...menuControl, + open() { + setHasBeenOpen(true) + // HACK. We need the state update to be flushed by the time + // menuControl.open() fires but RN doesn't expose flushSync. + setTimeout(menuControl.open) + }, + }), + [menuControl, setHasBeenOpen], + ) + return ( + <EventStopper onKeyDown={false}> + <Menu.Root control={lazyMenuControl}> + <Menu.Trigger label={_(msg`Open post options menu`)}> + {({props}) => { + return ( + <PostControlButton + testID="postDropdownBtn" + big={big} + label={props.accessibilityLabel} + {...props}> + <PostControlButtonIcon icon={DotsHorizontal} /> + </PostControlButton> + ) + }} + </Menu.Trigger> + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + <PostMenuItems + testID={testID} + post={post} + postFeedContext={postFeedContext} + postReqId={postReqId} + record={record} + richText={richText} + timestamp={timestamp} + threadgateRecord={threadgateRecord} + onShowLess={onShowLess} + /> + )} + </Menu.Root> + </EventStopper> + ) +} + +PostMenuButton = memo(PostMenuButton) +export {PostMenuButton} diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx new file mode 100644 index 000000000..db63a7383 --- /dev/null +++ b/src/components/PostControls/RepostButton.tsx @@ -0,0 +1,206 @@ +import {memo, useCallback} from 'react' +import {View} from 'react-native' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {useRequireAuth} from '#/state/session' +import {formatCount} from '#/view/com/util/numeric/format' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' +import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' +import {Text} from '#/components/Typography' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from './PostControlButton' + +interface Props { + isReposted: boolean + repostCount?: number + onRepost: () => void + onQuote: () => void + big?: boolean + embeddingDisabled: boolean +} + +let RepostButton = ({ + isReposted, + repostCount, + onRepost, + onQuote, + big, + embeddingDisabled, +}: Props): React.ReactNode => { + const t = useTheme() + const {_, i18n} = useLingui() + const requireAuth = useRequireAuth() + const dialogControl = Dialog.useDialogControl() + + return ( + <> + <PostControlButton + testID="repostBtn" + active={isReposted} + activeColor={t.palette.positive_600} + big={big} + onPress={() => requireAuth(() => dialogControl.open())} + onLongPress={() => requireAuth(() => onQuote())} + label={ + isReposted + ? _( + msg({ + message: `Undo repost (${plural(repostCount || 0, { + one: '# repost', + other: '# reposts', + })})`, + comment: + 'Accessibility label for the repost button when the post has been reposted, verb followed by number of reposts and noun', + }), + ) + : _( + msg({ + message: `Repost (${plural(repostCount || 0, { + one: '# repost', + other: '# reposts', + })})`, + comment: + 'Accessibility label for the repost button when the post has not been reposted, verb form followed by number of reposts and noun form', + }), + ) + }> + <PostControlButtonIcon icon={Repost} /> + {typeof repostCount !== 'undefined' && repostCount > 0 && ( + <PostControlButtonText testID="repostCount"> + {formatCount(i18n, repostCount)} + </PostControlButtonText> + )} + </PostControlButton> + <Dialog.Outer + control={dialogControl} + nativeOptions={{preventExpansion: true}}> + <Dialog.Handle /> + <RepostButtonDialogInner + isReposted={isReposted} + onRepost={onRepost} + onQuote={onQuote} + embeddingDisabled={embeddingDisabled} + /> + </Dialog.Outer> + </> + ) +} +RepostButton = memo(RepostButton) +export {RepostButton} + +let RepostButtonDialogInner = ({ + isReposted, + onRepost, + onQuote, + embeddingDisabled, +}: { + isReposted: boolean + onRepost: () => void + onQuote: () => void + embeddingDisabled: boolean +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const playHaptic = useHaptics() + const control = Dialog.useDialogContext() + + const onPressRepost = useCallback(() => { + if (!isReposted) playHaptic() + + control.close(() => { + onRepost() + }) + }, [control, isReposted, onRepost, playHaptic]) + + const onPressQuote = useCallback(() => { + playHaptic() + control.close(() => { + onQuote() + }) + }, [control, onQuote, playHaptic]) + + const onPressClose = useCallback(() => control.close(), [control]) + + return ( + <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}> + <View style={a.gap_xl}> + <View style={a.gap_xs}> + <Button + style={[a.justify_start, a.px_md]} + label={ + isReposted + ? _(msg`Remove repost`) + : _(msg({message: `Repost`, context: 'action'})) + } + onPress={onPressRepost} + size="large" + variant="ghost" + color="primary"> + <Repost size="lg" fill={t.palette.primary_500} /> + <Text style={[a.font_bold, a.text_xl]}> + {isReposted ? ( + <Trans>Remove repost</Trans> + ) : ( + <Trans context="action">Repost</Trans> + )} + </Text> + </Button> + <Button + disabled={embeddingDisabled} + testID="quoteBtn" + style={[a.justify_start, a.px_md]} + label={ + embeddingDisabled + ? _(msg`Quote posts disabled`) + : _(msg`Quote post`) + } + onPress={onPressQuote} + size="large" + variant="ghost" + color="primary"> + <Quote + size="lg" + fill={ + embeddingDisabled + ? t.atoms.text_contrast_low.color + : t.palette.primary_500 + } + /> + <Text + style={[ + a.font_bold, + a.text_xl, + embeddingDisabled && t.atoms.text_contrast_low, + ]}> + {embeddingDisabled ? ( + <Trans>Quote posts disabled</Trans> + ) : ( + <Trans>Quote post</Trans> + )} + </Text> + </Button> + </View> + <Button + label={_(msg`Cancel quote post`)} + onPress={onPressClose} + size="large" + variant="outline" + color="primary"> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </Dialog.ScrollableInner> + ) +} +RepostButtonDialogInner = memo(RepostButtonDialogInner) +export {RepostButtonDialogInner} diff --git a/src/components/PostControls/RepostButton.web.tsx b/src/components/PostControls/RepostButton.web.tsx new file mode 100644 index 000000000..48720b753 --- /dev/null +++ b/src/components/PostControls/RepostButton.web.tsx @@ -0,0 +1,107 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useRequireAuth} from '#/state/session' +import {useSession} from '#/state/session' +import {EventStopper} from '#/view/com/util/EventStopper' +import {formatCount} from '#/view/com/util/numeric/format' +import {useTheme} from '#/alf' +import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' +import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' +import * as Menu from '#/components/Menu' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from './PostControlButton' + +interface Props { + isReposted: boolean + repostCount?: number + onRepost: () => void + onQuote: () => void + big?: boolean + embeddingDisabled: boolean +} + +export const RepostButton = ({ + isReposted, + repostCount, + onRepost, + onQuote, + big, + embeddingDisabled, +}: Props) => { + const t = useTheme() + const {_, i18n} = useLingui() + const {hasSession} = useSession() + const requireAuth = useRequireAuth() + + return hasSession ? ( + <EventStopper onKeyDown={false}> + <Menu.Root> + <Menu.Trigger label={_(msg`Repost or quote post`)}> + {({props}) => { + return ( + <PostControlButton + testID="repostBtn" + active={isReposted} + activeColor={t.palette.positive_600} + label={props.accessibilityLabel} + big={big} + {...props}> + <PostControlButtonIcon icon={Repost} /> + {typeof repostCount !== 'undefined' && repostCount > 0 && ( + <PostControlButtonText testID="repostCount"> + {formatCount(i18n, repostCount)} + </PostControlButtonText> + )} + </PostControlButton> + ) + }} + </Menu.Trigger> + <Menu.Outer style={{minWidth: 170}}> + <Menu.Item + label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)} + testID="repostDropdownRepostBtn" + onPress={onRepost}> + <Menu.ItemText> + {isReposted ? _(msg`Undo repost`) : _(msg`Repost`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Repost} position="right" /> + </Menu.Item> + <Menu.Item + disabled={embeddingDisabled} + label={ + embeddingDisabled + ? _(msg`Quote posts disabled`) + : _(msg`Quote post`) + } + testID="repostDropdownQuoteBtn" + onPress={onQuote}> + <Menu.ItemText> + {embeddingDisabled + ? _(msg`Quote posts disabled`) + : _(msg`Quote post`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Quote} position="right" /> + </Menu.Item> + </Menu.Outer> + </Menu.Root> + </EventStopper> + ) : ( + <PostControlButton + onPress={() => requireAuth(() => {})} + active={isReposted} + activeColor={t.palette.positive_600} + label={_(msg`Repost or quote post`)} + big={big}> + <PostControlButtonIcon icon={Repost} /> + {typeof repostCount !== 'undefined' && repostCount > 0 && ( + <PostControlButtonText testID="repostCount"> + {formatCount(i18n, repostCount)} + </PostControlButtonText> + )} + </PostControlButton> + ) +} diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx new file mode 100644 index 000000000..ca5d0029e --- /dev/null +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -0,0 +1,200 @@ +import {ScrollView, View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, tokens, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' +import type * as bsky from '#/types/bsky' + +export function RecentChats({postUri}: {postUri: string}) { + const control = useDialogContext() + const {_} = useLingui() + const {currentAccount} = useSession() + const {data} = useListConvosQuery({status: 'accepted'}) + const convos = data?.pages[0]?.convos?.slice(0, 10) + const moderationOpts = useModerationOpts() + const navigation = useNavigation<NavigationProp>() + + const onSelectChat = (convoId: string) => { + control.close(() => { + logger.metric('share:press:recentDm', {}, {statsig: true}) + navigation.navigate('MessagesConversation', { + conversation: convoId, + embed: postUri, + }) + }) + } + + if (!moderationOpts) return null + + return ( + <View + style={[a.relative, a.flex_1, {marginHorizontal: tokens.space.md * -1}]}> + <ScrollView + horizontal + style={[a.flex_1, a.pt_2xs, {minHeight: 98}]} + contentContainerStyle={[a.gap_sm, a.px_md]} + showsHorizontalScrollIndicator={false} + fadingEdgeLength={64} + nestedScrollEnabled> + {convos && convos.length > 0 ? ( + convos.map(convo => { + const otherMember = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherMember || otherMember.handle === 'missing.invalid') + return null + + return ( + <RecentChatItem + key={convo.id} + profile={otherMember} + onPress={() => onSelectChat(convo.id)} + moderationOpts={moderationOpts} + /> + ) + }) + ) : ( + <> + <ConvoSkeleton /> + <ConvoSkeleton /> + <ConvoSkeleton /> + <ConvoSkeleton /> + <ConvoSkeleton /> + </> + )} + </ScrollView> + {convos && convos.length === 0 && <NoConvos />} + </View> + ) +} + +const WIDTH = 80 + +function RecentChatItem({ + profile, + onPress, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + onPress: () => void + moderationOpts: ModerationOpts +}) { + const {_} = useLingui() + const t = useTheme() + + const moderation = moderateProfile(profile, moderationOpts) + const name = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + const verification = useSimpleVerificationState({profile}) + + return ( + <Button + onPress={onPress} + label={_(msg`Send post to ${name}`)} + style={[ + a.flex_col, + {width: WIDTH}, + a.gap_sm, + a.justify_start, + a.align_center, + ]}> + <UserAvatar + avatar={profile.avatar} + size={WIDTH - 8} + type={profile.associated?.labeler ? 'labeler' : 'user'} + moderation={moderation.ui('avatar')} + /> + <View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}> + <Text + emoji + style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {name} + </Text> + {verification.showBadge && ( + <View style={[a.pl_2xs]}> + <VerificationCheck + width={10} + verifier={verification.role === 'verifier'} + /> + </View> + )} + </View> + </Button> + ) +} + +function ConvoSkeleton() { + const t = useTheme() + return ( + <View + style={[ + a.flex_col, + {width: WIDTH, height: WIDTH + 15}, + a.gap_xs, + a.justify_start, + a.align_center, + ]}> + <View + style={[ + t.atoms.bg_contrast_50, + {width: WIDTH - 8, height: WIDTH - 8}, + a.rounded_full, + ]} + /> + <View + style={[ + t.atoms.bg_contrast_50, + {width: WIDTH - 8, height: 10}, + a.rounded_xs, + ]} + /> + </View> + ) +} + +function NoConvos() { + const t = useTheme() + + return ( + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.align_center, + a.px_2xl, + ]}> + <View + style={[a.absolute, a.inset_0, t.atoms.bg_contrast_25, {opacity: 0.5}]} + /> + <Text + style={[ + a.text_sm, + t.atoms.text_contrast_high, + a.text_center, + a.font_bold, + ]}> + <Trans>Start a conversation, and it will appear here.</Trans> + </Text> + </View> + ) +} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx new file mode 100644 index 000000000..94369fcff --- /dev/null +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -0,0 +1,197 @@ +import {memo, useMemo} from 'react' +import * as ExpoClipboard from 'expo-clipboard' +import {AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {makeProfileLink} from '#/lib/routes/links' +import {type NavigationProp} from '#/lib/routes/types' +import {shareText, shareUrl} from '#/lib/sharing' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useDialogControl} from '#/components/Dialog' +import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {useDevMode} from '#/storage/hooks/dev-mode' +import {RecentChats} from './RecentChats' +import {type ShareMenuItemsProps} from './ShareMenuItems.types' + +let ShareMenuItems = ({ + post, + onShare: onShareProp, +}: ShareMenuItemsProps): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + const pwiWarningShareControl = useDialogControl() + const pwiWarningCopyControl = useDialogControl() + const sendViaChatControl = useDialogControl() + const [devModeEnabled] = useDevMode() + + const postUri = post.uri + const postAuthor = useProfileShadow(post.author) + + const href = useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const hideInPWI = useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + const showLoggedOutWarning = + postAuthor.did !== currentAccount?.did && hideInPWI + + const onSharePost = () => { + logger.metric('share:press:nativeShare', {}, {statsig: true}) + const url = toShareUrl(href) + shareUrl(url) + onShareProp() + } + + const onCopyLink = () => { + logger.metric('share:press:copyLink', {}, {statsig: true}) + const url = toShareUrl(href) + ExpoClipboard.setUrlAsync(url).then(() => + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check'), + ) + onShareProp() + } + + const onSelectChatToShareTo = (conversation: string) => { + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + } + + const onShareATURI = () => { + shareText(postUri) + } + + const onShareAuthorDID = () => { + shareText(postAuthor.did) + } + + return ( + <> + <Menu.Outer> + {hasSession && ( + <Menu.Group> + <Menu.ContainerItem> + <RecentChats postUri={postUri} /> + </Menu.ContainerItem> + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => { + logger.metric('share:press:openDmSearch', {}, {statsig: true}) + sendViaChatControl.open() + }}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={PaperPlaneIcon} position="right" /> + </Menu.Item> + </Menu.Group> + )} + + <Menu.Group> + <Menu.Item + testID="postDropdownShareBtn" + label={_(msg`Share via...`)} + onPress={() => { + if (showLoggedOutWarning) { + pwiWarningShareControl.open() + } else { + onSharePost() + } + }}> + <Menu.ItemText> + <Trans>Share via...</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ArrowOutOfBoxIcon} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownShareBtn" + label={_(msg`Copy link to post`)} + onPress={() => { + if (showLoggedOutWarning) { + pwiWarningCopyControl.open() + } else { + onCopyLink() + } + }}> + <Menu.ItemText> + <Trans>Copy link to post</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> + </Menu.Item> + </Menu.Group> + + {devModeEnabled && ( + <Menu.Group> + <Menu.Item + testID="postAtUriShareBtn" + label={_(msg`Share post at:// URI`)} + onPress={onShareATURI}> + <Menu.ItemText> + <Trans>Share post at:// URI</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + <Menu.Item + testID="postAuthorDIDShareBtn" + label={_(msg`Share author DID`)} + onPress={onShareAuthorDID}> + <Menu.ItemText> + <Trans>Share author DID</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </Menu.Group> + )} + </Menu.Outer> + + <Prompt.Basic + control={pwiWarningShareControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, + )} + onConfirm={onSharePost} + confirmButtonCta={_(msg`Share anyway`)} + /> + + <Prompt.Basic + control={pwiWarningCopyControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, + )} + onConfirm={onCopyLink} + confirmButtonCta={_(msg`Copy anyway`)} + /> + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + </> + ) +} +ShareMenuItems = memo(ShareMenuItems) +export {ShareMenuItems} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx new file mode 100644 index 000000000..5bc2a8fb6 --- /dev/null +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx @@ -0,0 +1,22 @@ +import {type PressableProps, type StyleProp, type ViewStyle} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + type RichText as RichTextAPI, +} from '@atproto/api' + +import {type Shadow} from '#/state/cache/post-shadow' + +export interface ShareMenuItemsProps { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp<ViewStyle> + hitSlop?: PressableProps['hitSlop'] + size?: 'lg' | 'md' | 'sm' + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record + onShare: () => void +} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx new file mode 100644 index 000000000..0da259678 --- /dev/null +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -0,0 +1,192 @@ +import {memo, useMemo} from 'react' +import {AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import type React from 'react' + +import {makeProfileLink} from '#/lib/routes/links' +import {type NavigationProp} from '#/lib/routes/types' +import {shareText, shareUrl} from '#/lib/sharing' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import {useBreakpoints} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {EmbedDialog} from '#/components/dialogs/Embed' +import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' +import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {useDevMode} from '#/storage/hooks/dev-mode' +import {type ShareMenuItemsProps} from './ShareMenuItems.types' + +let ShareMenuItems = ({ + post, + record, + timestamp, + onShare: onShareProp, +}: ShareMenuItemsProps): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + const loggedOutWarningPromptControl = useDialogControl() + const embedPostControl = useDialogControl() + const sendViaChatControl = useDialogControl() + const [devModeEnabled] = useDevMode() + + const postUri = post.uri + const postCid = post.cid + const postAuthor = useProfileShadow(post.author) + + const href = useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const hideInPWI = useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + const showLoggedOutWarning = + postAuthor.did !== currentAccount?.did && hideInPWI + + const onCopyLink = () => { + logger.metric('share:press:copyLink', {}, {statsig: true}) + const url = toShareUrl(href) + shareUrl(url) + onShareProp() + } + + const onSelectChatToShareTo = (conversation: string) => { + logger.metric('share:press:dmSelected', {}, {statsig: true}) + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + } + + const canEmbed = isWeb && gtMobile && !hideInPWI + + const onShareATURI = () => { + shareText(postUri) + } + + const onShareAuthorDID = () => { + shareText(postAuthor.did) + } + + return ( + <> + <Menu.Outer> + <Menu.Group> + <Menu.Item + testID="postDropdownShareBtn" + label={_(msg`Copy link to post`)} + onPress={() => { + if (showLoggedOutWarning) { + loggedOutWarningPromptControl.open() + } else { + onCopyLink() + } + }}> + <Menu.ItemText> + <Trans>Copy link to post</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> + </Menu.Item> + + {hasSession && ( + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => { + logger.metric('share:press:openDmSearch', {}, {statsig: true}) + sendViaChatControl.open() + }}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Send} position="right" /> + </Menu.Item> + )} + + {canEmbed && ( + <Menu.Item + testID="postDropdownEmbedBtn" + label={_(msg`Embed post`)} + onPress={() => { + logger.metric('share:press:embed', {}, {statsig: true}) + embedPostControl.open() + }}> + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> + <Menu.ItemIcon icon={CodeBracketsIcon} position="right" /> + </Menu.Item> + )} + </Menu.Group> + + {devModeEnabled && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postAtUriShareBtn" + label={_(msg`Copy post at:// URI`)} + onPress={onShareATURI}> + <Menu.ItemText> + <Trans>Copy post at:// URI</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + <Menu.Item + testID="postAuthorDIDShareBtn" + label={_(msg`Copy author DID`)} + onPress={onShareAuthorDID}> + <Menu.ItemText> + <Trans>Copy author DID</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + </Menu.Outer> + + <Prompt.Basic + control={loggedOutWarningPromptControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, + )} + onConfirm={onCopyLink} + confirmButtonCta={_(msg`Share anyway`)} + /> + + {canEmbed && ( + <EmbedDialog + control={embedPostControl} + postCid={postCid} + postUri={postUri} + record={record} + postAuthor={postAuthor} + timestamp={timestamp} + /> + )} + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + </> + ) +} +ShareMenuItems = memo(ShareMenuItems) +export {ShareMenuItems} diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx new file mode 100644 index 000000000..d4ea18bb0 --- /dev/null +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -0,0 +1,119 @@ +import {memo, useMemo, useState} from 'react' +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + AtUri, + type RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type React from 'react' + +import {makeProfileLink} from '#/lib/routes/links' +import {shareUrl} from '#/lib/sharing' +import {useGate} from '#/lib/statsig/statsig' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/post-shadow' +import {EventStopper} from '#/view/com/util/EventStopper' +import {native} from '#/alf' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' +import {useMenuControl} from '#/components/Menu' +import * as Menu from '#/components/Menu' +import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' +import {ShareMenuItems} from './ShareMenuItems' + +let ShareMenuButton = ({ + testID, + post, + big, + record, + richText, + timestamp, + threadgateRecord, + onShare, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + big?: boolean + record: AppBskyFeedPost.Record + richText: RichTextAPI + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record + onShare: () => void +}): React.ReactNode => { + const {_} = useLingui() + const gate = useGate() + + const ShareIcon = gate('alt_share_icon') + ? ArrowShareRightIcon + : ArrowOutOfBoxIcon + + const menuControl = useMenuControl() + const [hasBeenOpen, setHasBeenOpen] = useState(false) + const lazyMenuControl = useMemo( + () => ({ + ...menuControl, + open() { + setHasBeenOpen(true) + // HACK. We need the state update to be flushed by the time + // menuControl.open() fires but RN doesn't expose flushSync. + setTimeout(menuControl.open) + + logger.metric( + 'share:open', + {context: big ? 'thread' : 'feed'}, + {statsig: true}, + ) + }, + }), + [menuControl, setHasBeenOpen, big], + ) + + const onNativeLongPress = () => { + logger.metric('share:press:nativeShare', {}, {statsig: true}) + const urip = new AtUri(post.uri) + const href = makeProfileLink(post.author, 'post', urip.rkey) + const url = toShareUrl(href) + shareUrl(url) + onShare() + } + + return ( + <EventStopper onKeyDown={false}> + <Menu.Root control={lazyMenuControl}> + <Menu.Trigger label={_(msg`Open share menu`)}> + {({props}) => { + return ( + <PostControlButton + testID="postShareBtn" + big={big} + label={props.accessibilityLabel} + {...props} + onLongPress={native(onNativeLongPress)}> + <PostControlButtonIcon icon={ShareIcon} /> + </PostControlButton> + ) + }} + </Menu.Trigger> + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + <ShareMenuItems + testID={testID} + post={post} + record={record} + richText={richText} + timestamp={timestamp} + threadgateRecord={threadgateRecord} + onShare={onShare} + /> + )} + </Menu.Root> + </EventStopper> + ) +} + +ShareMenuButton = memo(ShareMenuButton) +export {ShareMenuButton} diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx new file mode 100644 index 000000000..7739da56b --- /dev/null +++ b/src/components/PostControls/index.tsx @@ -0,0 +1,292 @@ +import {memo, useState} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + type RichText as RichTextAPI, +} from '@atproto/api' +import {msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {CountWheel} from '#/lib/custom-animations/CountWheel' +import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' +import {useHaptics} from '#/lib/haptics' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {type Shadow} from '#/state/cache/types' +import {useFeedFeedbackContext} from '#/state/feed-feedback' +import { + usePostLikeMutationQueue, + usePostRepostMutationQueue, +} from '#/state/queries/post' +import {useRequireAuth} from '#/state/session' +import { + ProgressGuideAction, + useProgressGuideControls, +} from '#/state/shell/progress-guide' +import {formatCount} from '#/view/com/util/numeric/format' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints} from '#/alf' +import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from './PostControlButton' +import {PostMenuButton} from './PostMenu' +import {RepostButton} from './RepostButton' +import {ShareMenuButton} from './ShareMenu' + +let PostControls = ({ + big, + post, + record, + richText, + feedContext, + reqId, + style, + onPressReply, + onPostReply, + logContext, + threadgateRecord, + onShowLess, +}: { + big?: boolean + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + feedContext?: string | undefined + reqId?: string | undefined + style?: StyleProp<ViewStyle> + onPressReply: () => void + onPostReply?: (postUri: string | undefined) => void + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + threadgateRecord?: AppBskyFeedThreadgate.Record + onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void +}): React.ReactNode => { + const {_, i18n} = useLingui() + const {gtMobile} = useBreakpoints() + const {openComposer} = useOpenComposer() + const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) + const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( + post, + logContext, + ) + const requireAuth = useRequireAuth() + const {sendInteraction} = useFeedFeedbackContext() + const {captureAction} = useProgressGuideControls() + const playHaptic = useHaptics() + const isBlocked = Boolean( + post.author.viewer?.blocking || + post.author.viewer?.blockedBy || + post.author.viewer?.blockingByList, + ) + const replyDisabled = post.viewer?.replyDisabled + + const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) + + const onPressToggleLike = async () => { + if (isBlocked) { + Toast.show( + _(msg`Cannot interact with a blocked user`), + 'exclamation-circle', + ) + return + } + + try { + setHasLikeIconBeenToggled(true) + if (!post.viewer?.like) { + playHaptic('Light') + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionLike', + feedContext, + reqId, + }) + captureAction(ProgressGuideAction.Like) + await queueLike() + } else { + await queueUnlike() + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + throw e + } + } + } + + const onRepost = async () => { + if (isBlocked) { + Toast.show( + _(msg`Cannot interact with a blocked user`), + 'exclamation-circle', + ) + return + } + + try { + if (!post.viewer?.repost) { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionRepost', + feedContext, + reqId, + }) + await queueRepost() + } else { + await queueUnrepost() + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + throw e + } + } + } + + const onQuote = () => { + if (isBlocked) { + Toast.show( + _(msg`Cannot interact with a blocked user`), + 'exclamation-circle', + ) + return + } + + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionQuote', + feedContext, + reqId, + }) + openComposer({ + quote: post, + onPost: onPostReply, + }) + } + + const onShare = () => { + sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionShare', + feedContext, + reqId, + }) + } + + return ( + <View style={[a.flex_row, a.justify_between, a.align_center, style]}> + <View + style={[ + big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], + replyDisabled ? {opacity: 0.5} : undefined, + ]}> + <PostControlButton + testID="replyBtn" + onPress={ + !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined + } + label={_( + msg({ + message: `Reply (${plural(post.replyCount || 0, { + one: '# reply', + other: '# replies', + })})`, + comment: + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', + }), + )} + big={big}> + <PostControlButtonIcon icon={Bubble} /> + {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( + <PostControlButtonText> + {formatCount(i18n, post.replyCount)} + </PostControlButtonText> + )} + </PostControlButton> + </View> + <View style={big ? a.align_center : [a.flex_1, a.align_start]}> + <RepostButton + isReposted={!!post.viewer?.repost} + repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} + onRepost={onRepost} + onQuote={onQuote} + big={big} + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} + /> + </View> + <View style={big ? a.align_center : [a.flex_1, a.align_start]}> + <PostControlButton + testID="likeBtn" + big={big} + onPress={() => requireAuth(() => onPressToggleLike())} + label={ + post.viewer?.like + ? _( + msg({ + message: `Unlike (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', + }), + ) + : _( + msg({ + message: `Like (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', + }), + ) + }> + <AnimatedLikeIcon + isLiked={Boolean(post.viewer?.like)} + big={big} + hasBeenToggled={hasLikeIconBeenToggled} + /> + <CountWheel + likeCount={post.likeCount ?? 0} + big={big} + isLiked={Boolean(post.viewer?.like)} + hasBeenToggled={hasLikeIconBeenToggled} + /> + </PostControlButton> + </View> + <View style={big ? a.align_center : [a.flex_1, a.align_start]}> + <View style={[!big && a.ml_sm]}> + <ShareMenuButton + testID="postShareBtn" + post={post} + big={big} + record={record} + richText={richText} + timestamp={post.indexedAt} + threadgateRecord={threadgateRecord} + onShare={onShare} + /> + </View> + </View> + <View + style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}> + <PostMenuButton + testID="postDropdownBtn" + post={post} + postFeedContext={feedContext} + postReqId={reqId} + big={big} + record={record} + richText={richText} + timestamp={post.indexedAt} + threadgateRecord={threadgateRecord} + onShowLess={onShowLess} + /> + </View> + </View> + ) +} +PostControls = memo(PostControls) +export {PostControls} |