import React, {memo, useCallback} from 'react' import { Platform, type PressableProps, type StyleProp, type ViewStyle, } from 'react-native' import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedThreadgate, AtUri, RichText as RichTextAPI, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {logEvent} from '#/lib/statsig/statsig' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {Shadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useLanguagePrefs} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useDevModeEnabled} from '#/state/preferences/dev-mode' 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 {useBreakpoints} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {EmbedDialog} from '#/components/dialogs/Embed' import { PostInteractionSettingsDialog, usePrefetchPostInteractionSettings, } from '#/components/dialogs/PostInteractionSettingsDialog' import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' import { EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, } from '#/components/icons/Emoji' import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 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 Toast from '../Toast' let PostDropdownMenuItems = ({ post, postFeedContext, record, richText, timestamp, threadgateRecord, }: { testID: string post: Shadow postFeedContext: string | undefined record: AppBskyFeedPost.Record richText: RichTextAPI style?: StyleProp hitSlop?: PressableProps['hitSlop'] size?: 'lg' | 'md' | 'sm' timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const {hasSession, currentAccount} = useSession() const {gtMobile} = useBreakpoints() const {_} = useLingui() const langPrefs = useLanguagePrefs() const {mutateAsync: deletePostMutate} = usePostDeleteMutation() const {mutateAsync: pinPostMutate, isPending: isPinPending} = usePinnedPostMutation() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() const feedFeedback = useFeedFeedbackContext() const openLink = useOpenLink() const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const blockPromptControl = useDialogControl() const reportDialogControl = useReportDialogControl() const deletePromptControl = useDialogControl() const hidePromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() const postInteractionSettingsDialogControl = useDialogControl() const quotePostDetachConfirmControl = useDialogControl() const hideReplyConfirmControl = useDialogControl() const {mutateAsync: toggleReplyVisibility} = useToggleReplyVisibilityMutation() const [devModeEnabled] = useDevModeEnabled() const postUri = post.uri const postCid = post.cid const postAuthor = useProfileShadow(post.author) const quoteEmbed = React.useMemo(() => { if (!currentAccount || !post.embed) return return getMaybeDetachedQuoteEmbed({ viewerDid: currentAccount.did, post, }) }, [post, currentAccount]) const rootUri = record.reply?.root?.uri || postUri const isReply = Boolean(record.reply) const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( post, rootUri, ) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ threadgateRecord, }) const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) const isPinned = post.viewer?.pinned const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = useToggleQuoteDetachmentMutation() const [queueBlock] = useProfileBlockMutationQueue(postAuthor) const [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor) const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ postUri: post.uri, rootPostUri: rootUri, }) const href = React.useMemo(() => { const urip = new AtUri(postUri) return makeProfileLink(postAuthor, 'post', urip.rkey) }, [postUri, postAuthor]) const translatorUrl = getTranslatorLink( record.text, langPrefs.primaryLanguage, ) const onDeletePost = React.useCallback(() => { deletePostMutate({uri: postUri}).then( () => { Toast.show(_(msg`Post deleted`)) const route = getCurrentRoute(navigation.getState()) if (route.name === 'PostThread') { const params = route.params as CommonNavigatorParams['PostThread'] if ( currentAccount && isAuthor && (params.name === currentAccount.handle || params.name === currentAccount.did) ) { const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) if (currentHref === href && navigation.canGoBack()) { navigation.goBack() } } } }, e => { logger.error('Failed to delete post', {message: e}) Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') }, ) }, [ navigation, postUri, deletePostMutate, postAuthor, currentAccount, isAuthor, href, _, ]) const onToggleThreadMute = React.useCallback(() => { try { if (isThreadMuted) { unmuteThread() Toast.show(_(msg`You will now receive notifications for this thread`)) } else { muteThread() Toast.show( _(msg`You will no longer receive notifications for this thread`), ) } } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to toggle thread mute', {message: e}) Toast.show( _(msg`Failed to toggle thread mute, please try again`), 'xmark', ) } } }, [isThreadMuted, unmuteThread, _, muteThread]) const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) Clipboard.setStringAsync(str) Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') }, [_, richText]) const onPressTranslate = React.useCallback(async () => { await openLink(translatorUrl, true) }, [openLink, translatorUrl]) const onHidePost = React.useCallback(() => { hidePost({uri: postUri}) }, [postUri, hidePost]) const hideInPWI = React.useMemo(() => { return !!postAuthor.labels?.find( label => label.val === '!no-unauthenticated', ) }, [postAuthor]) const showLoggedOutWarning = postAuthor.did !== currentAccount?.did && hideInPWI const onSharePost = React.useCallback(() => { const url = toShareUrl(href) shareUrl(url) }, [href]) const onPressShowMore = React.useCallback(() => { feedFeedback.sendInteraction({ event: 'app.bsky.feed.defs#requestMore', item: postUri, feedContext: postFeedContext, }) Toast.show(_(msg`Feedback sent!`)) }, [feedFeedback, postUri, postFeedContext, _]) const onPressShowLess = React.useCallback(() => { feedFeedback.sendInteraction({ event: 'app.bsky.feed.defs#requestLess', item: postUri, feedContext: postFeedContext, }) Toast.show(_(msg`Feedback sent!`)) }, [feedFeedback, postUri, postFeedContext, _]) const onSelectChatToShareTo = React.useCallback( (conversation: string) => { navigation.navigate('MessagesConversation', { conversation, embed: postUri, }) }, [navigation, postUri], ) const onToggleQuotePostAttachment = React.useCallback(async () => { if (!quoteEmbed) return const action = quoteEmbed.isDetached ? 'reattach' : 'detach' const isDetach = action === 'detach' try { await toggleQuoteDetachment({ post, quoteUri: quoteEmbed.uri, action: quoteEmbed.isDetached ? 'reattach' : 'detach', }) Toast.show( isDetach ? _(msg`Quote post was successfully detached`) : _(msg`Quote post was re-attached`), ) } catch (e: any) { Toast.show(_(msg`Updating quote attachment failed`)) logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) } }, [_, quoteEmbed, post, toggleQuoteDetachment]) const canHidePostForMe = !isAuthor && !isPostHidden const canEmbed = isWeb && gtMobile && !hideInPWI const canHideReplyForEveryone = !isAuthor && isRootPostAuthor && !isPostHidden && isReply const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer const onToggleReplyVisibility = React.useCallback(async () => { // TODO no threadgate? if (!canHideReplyForEveryone) return const action = isReplyHiddenByThreadgate ? 'show' : 'hide' const isHide = action === 'hide' try { await toggleReplyVisibility({ postUri: rootUri, replyUri: postUri, action, }) Toast.show( isHide ? _(msg`Reply was successfully hidden`) : _(msg`Reply visibility updated`), ) } catch (e: any) { Toast.show(_(msg`Updating reply visibility failed`)) logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) } }, [ _, isReplyHiddenByThreadgate, rootUri, postUri, canHideReplyForEveryone, toggleReplyVisibility, ]) const onPressPin = useCallback(() => { logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) pinPostMutate({ postUri, postCid, action: isPinned ? 'unpin' : 'pin', }) }, [isPinned, pinPostMutate, postCid, postUri]) const onBlockAuthor = useCallback(async () => { try { await queueBlock() Toast.show(_(msg`Account blocked`)) } 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') } } }, [_, queueBlock]) const onMuteAuthor = useCallback(async () => { if (postAuthor.viewer?.muted) { try { await queueUnmute() Toast.show(_(msg`Account unmuted`)) } 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`Account muted`)) } 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') } } } }, [_, queueMute, queueUnmute, postAuthor.viewer?.muted]) const onShareATURI = useCallback(() => { shareText(postUri) }, [postUri]) const onShareAuthorDID = useCallback(() => { shareText(postAuthor.did) }, [postAuthor.did]) return ( <> {isAuthor && ( <> {isPinned ? _(msg`Unpin from profile`) : _(msg`Pin to your profile`)} )} {(!hideInPWI || hasSession) && ( <> {_(msg`Translate`)} {_(msg`Copy post text`)} )} {hasSession && ( sendViaChatControl.open()}> Send via direct message )} { if (showLoggedOutWarning) { loggedOutWarningPromptControl.open() } else { onSharePost() } }}> {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} {canEmbed && ( embedPostControl.open()}> {_(msg`Embed post`)} )} {hasSession && feedFeedback.enabled && ( <> {_(msg`Show more like this`)} {_(msg`Show less like this`)} )} {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`)} )} {devModeEnabled ? ( <> {_(msg`Copy post at:// URI`)} {_(msg`Copy author DID`)} ) : null} )}