From c3f88e0a48bdf22831736ad3d44222e7c4418486 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 24 May 2025 02:02:38 +0300 Subject: Share menu (#7840) * move post ctrls to #/components * restructure post controls, basic share menu * add border radius to searchable people list for android * Revert "add border radius to searchable people list for android" This reverts commit 417449086e25b82f5683b12f6405d972f48ce50e. * add copy link to native share menu * reorg files again * open native share menu on long press * Translation comments Thanks @surfdude29 * abs path * update type imports, remove forwardRef * rm react import * equal spacing of buttons, extract disco debug * add better icon * add right offset to share button for visual alignment * Add recent chats to share menu (#7853) * add recent chats to share menu * Update RecentChats.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update RecentChats.tsx * add fading edge on andriod * tweak scrollview * Add metrics and A/B alt icon to share menu (#8401) * add metrics * add a/b tested alt icon --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * More descriptive share text/icon on web (#7854) * more descriptive share text on web * revert dev mode changes * add missing import * use modified share icon everywhere * Add back conflicting changes --------- Co-authored-by: Eric Bailey --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey --- .../PostControls/PostMenu/PostMenuItems.tsx | 745 +++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 src/components/PostControls/PostMenu/PostMenuItems.tsx (limited to 'src/components/PostControls/PostMenu/PostMenuItems.tsx') 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 + postFeedContext: string | undefined + postReqId: string | undefined + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp + 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() + 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( + 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 ( + <> + + {isAuthor && ( + <> + + + + {isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`)} + + + + + + + )} + + + {(!hideInPWI || hasSession) && ( + <> + + {_(msg`Translate`)} + + + + + {_(msg`Copy post text`)} + + + + )} + + + {hasSession && feedFeedback.enabled && ( + <> + + + + {_(msg`Show more like this`)} + + + + + {_(msg`Show less like this`)} + + + + + )} + + {hasSession && + IS_INTERNAL && + DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( + + {_(msg`Assign topic for algo`)} + + + )} + + {hasSession && ( + <> + + + + + {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} + + + + + mutedWordsDialogControl.open()}> + {_(msg`Mute words & tags`)} + + + + + )} + + {hasSession && + (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( + <> + + + {canHidePostForMe && ( + hidePromptControl.open()}> + + {isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`)} + + + + )} + {canHideReplyForEveryone && ( + hideReplyConfirmControl.open() + }> + + {isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`)} + + + + )} + + {canDetachQuote && ( + quotePostDetachConfirmControl.open() + }> + + {quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`)} + + + + )} + + + )} + + {hasSession && ( + <> + + + {!isAuthor && ( + <> + + + {postAuthor.viewer?.muted + ? _(msg`Unmute account`) + : _(msg`Mute account`)} + + + + + {!postAuthor.viewer?.blocking && ( + blockPromptControl.open()}> + {_(msg`Block account`)} + + + )} + + reportDialogControl.open()}> + {_(msg`Report post`)} + + + + )} + + {isAuthor && ( + <> + postInteractionSettingsDialogControl.open()} + {...(isAuthor + ? Platform.select({ + web: { + onHoverIn: prefetchPostInteractionSettings, + }, + native: { + onPressIn: prefetchPostInteractionSettings, + }, + }) + : {})}> + + {_(msg`Edit interaction settings`)} + + + + deletePromptControl.open()}> + {_(msg`Delete post`)} + + + + )} + + + )} + + + + +