import {memo, useMemo} from 'react' import { Platform, type PressableProps, type StyleProp, type ViewStyle, } from 'react-native' import * as Clipboard from 'expo-clipboard' import { type AppBskyEmbedExternal, type AppBskyEmbedImages, AppBskyEmbedRecord, type AppBskyEmbedRecordWithMedia, type AppBskyEmbedVideo, type AppBskyFeedDefs, AppBskyFeedPost, type AppBskyFeedThreadgate, AtUri, type BlobRef, type RichText as RichTextAPI, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {useTranslate} from '#/lib/hooks/useTranslate' import {saveVideoToMediaLibrary} from '#/lib/media/manip' import {downloadVideoWeb} from '#/lib/media/manip.web' import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {restoreLinks} from '#/lib/strings/rich-text-manip' import {toShareUrl} from '#/lib/strings/url-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 { useHiddenPosts, useHiddenPostsApi, useLanguagePrefs, } 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 {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' import { InvalidInteractionSettingsError, MAX_HIDDEN_REPLIES, MaxHiddenRepliesError, useToggleReplyVisibilityMutation, } from '#/state/queries/threadgate' import {useRequireAuth, 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 {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download' 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 Mute, Mute_Stroke2_Corner0_Rounded as MuteIcon, } from '#/components/icons/Mute' import {Pencil_Stroke2_Corner0_Rounded as Pen} from '#/components/icons/Pencil' 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 Unmute, SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon, } 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 {useAnalytics} from '#/analytics' import {IS_INTERNAL, IS_WEB} from '#/env' import * as bsky from '#/types/bsky' let PostMenuItems = ({ post, postFeedContext, postReqId, record, richText, threadgateRecord, onShowLess, logContext, }: { 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 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' }): React.ReactNode => { const {hasSession, currentAccount} = useSession() const {_} = useLingui() const ax = useAnalytics() const langPrefs = useLanguagePrefs() const {mutateAsync: deletePostMutate} = usePostDeleteMutation() const {mutateAsync: pinPostMutate, isPending: isPinPending} = usePinnedPostMutation() const requireSignIn = useRequireAuth() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() const feedFeedback = useFeedFeedbackContext() const openLink = useOpenLink() const translate = useTranslate() 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 redraftPromptControl = 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 onDeletePost = () => { deletePostMutate({uri: postUri}).then( () => { Toast.show(_(msg({message: 'Skeet 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 skeet, please try again`), 'xmark') }, ) } const {openComposer} = useOpenComposer() const onRedraftPost = () => { redraftPromptControl.open() } const onConfirmRedraft = () => { let imageUris: { uri: string width: number height: number altText?: string blobRef?: AppBskyEmbedImages.Image['image'] }[] = [] const recordEmbed = record.embed let recordImages: AppBskyEmbedImages.Image[] = [] if (recordEmbed?.$type === 'app.bsky.embed.images') { recordImages = (recordEmbed as AppBskyEmbedImages.Main).images } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media if (media.$type === 'app.bsky.embed.images') { recordImages = (media as AppBskyEmbedImages.Main).images } } if (post.embed?.$type === 'app.bsky.embed.images#view') { const embed = post.embed as AppBskyEmbedImages.View imageUris = embed.images.map((img, i) => ({ uri: img.fullsize, width: img.aspectRatio?.width ?? 1000, height: img.aspectRatio?.height ?? 1000, altText: img.alt, blobRef: recordImages[i]?.image, })) } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { const embed = post.embed as AppBskyEmbedRecordWithMedia.View if (embed.media.$type === 'app.bsky.embed.images#view') { const images = embed.media as AppBskyEmbedImages.View imageUris = images.images.map((img, i) => ({ uri: img.fullsize, width: img.aspectRatio?.width ?? 1000, height: img.aspectRatio?.height ?? 1000, altText: img.alt, blobRef: recordImages[i]?.image, })) } } let quotePost: AppBskyFeedDefs.PostView | undefined if (post.embed?.$type === 'app.bsky.embed.record#view') { const embed = post.embed as AppBskyEmbedRecord.View if ( AppBskyEmbedRecord.isViewRecord(embed.record) && AppBskyFeedPost.isRecord(embed.record.value) ) { quotePost = { uri: embed.record.uri, cid: embed.record.cid, author: embed.record.author, record: embed.record.value, indexedAt: embed.record.indexedAt, } as AppBskyFeedDefs.PostView } } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { const embed = post.embed as AppBskyEmbedRecordWithMedia.View if ( AppBskyEmbedRecord.isViewRecord(embed.record.record) && AppBskyFeedPost.isRecord(embed.record.record.value) ) { const record = embed.record.record quotePost = { uri: record.uri, cid: record.cid, author: record.author, record: record.value, indexedAt: record.indexedAt, } as AppBskyFeedDefs.PostView } } let replyTo: any if (record.reply) { const parent = record.reply.parent || record.reply.root if (parent) { replyTo = { uri: parent.uri, cid: parent.cid, } } } let videoUri: | { uri: string width: number height: number blobRef?: BlobRef altText?: string } | undefined let recordVideo: AppBskyEmbedVideo.Main | undefined if (recordEmbed?.$type === 'app.bsky.embed.video') { recordVideo = recordEmbed as AppBskyEmbedVideo.Main } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media if (media.$type === 'app.bsky.embed.video') { recordVideo = media as AppBskyEmbedVideo.Main } } if (post.embed?.$type === 'app.bsky.embed.video#view') { const embed = post.embed as AppBskyEmbedVideo.View if (recordVideo) { videoUri = { uri: embed.playlist || '', width: embed.aspectRatio?.width ?? 1000, height: embed.aspectRatio?.height ?? 1000, blobRef: recordVideo.video, altText: embed.alt || '', } } } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { const embed = post.embed as AppBskyEmbedRecordWithMedia.View if (embed.media.$type === 'app.bsky.embed.video#view' && recordVideo) { const video = embed.media as AppBskyEmbedVideo.View videoUri = { uri: video.playlist || '', width: video.aspectRatio?.width ?? 1000, height: video.aspectRatio?.height ?? 1000, blobRef: recordVideo.video, altText: video.alt || '', } } } openComposer({ text: restoreLinks(record.text, record.facets), imageUris, videoUri, onPost: () => { onDeletePost() }, quote: quotePost, replyTo, }) } const onToggleThreadMute = () => { try { if (isThreadMuted) { unmuteThread() ax.metric('post:unmute', { uri: postUri, authorDid: postAuthor.did, logContext, feedDescriptor: feedFeedback.feedDescriptor, }) Toast.show(_(msg`You will now receive notifications for this thread`)) } else { muteThread() ax.metric('post:mute', { uri: postUri, authorDid: postAuthor.did, logContext, feedDescriptor: feedFeedback.feedDescriptor, }) 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 = () => { translate(record.text, langPrefs.primaryLanguage) if ( bsky.dangerousIsType( post.record, AppBskyFeedPost.isRecord, ) ) { ax.metric('translate', { sourceLanguages: post.record.langs ?? [], targetLanguage: langPrefs.primaryLanguage, textLength: post.record.text.length, }) } } const onHidePost = () => { hidePost({uri: postUri}) ax.metric('thread:click:hideReplyForMe', {}) } 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, }) ax.metric('post:showMore', { uri: postUri, authorDid: postAuthor.did, logContext, feedDescriptor: feedFeedback.feedDescriptor, }) Toast.show( _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), ) } const onPressShowLess = () => { feedFeedback.sendInteraction({ event: 'app.bsky.feed.defs#requestLess', item: postUri, feedContext: postFeedContext, reqId: postReqId, }) ax.metric('post:showLess', { uri: postUri, authorDid: postAuthor.did, logContext, feedDescriptor: feedFeedback.feedDescriptor, }) if (onShowLess) { onShowLess({ item: postUri, feedContext: postFeedContext, }) } else { Toast.show( _(msg({message: 'Feedback sent to feed operator', 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 skeet was successfully detached`) : _(msg`Quote skeet 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, }) // Log metric only when hiding (not when showing) if (isHide) { ax.metric('thread:click:hideReplyForEveryone', {}) } Toast.show( isHide ? _(msg`Reply was successfully hidden`) : _(msg({message: 'Reply visibility updated', context: 'toast'})), ) } catch (e: any) { if (e instanceof MaxHiddenRepliesError) { Toast.show( _( msg({ message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`, context: 'toast', }), ), ) } else if (e instanceof InvalidInteractionSettingsError) { Toast.show( _(msg({message: 'Invalid interaction settings.', context: 'toast'})), ) } else { Toast.show( _( msg({ message: 'Updating reply visibility failed', context: 'toast', }), ), ) logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) } } } const onPressPin = () => { ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) pinPostMutate({ postUri, postCid, action: isPinned ? 'unpin' : 'pin', }) } const videoEmbed: AppBskyEmbedVideo.View | undefined = useMemo(() => { if (post.embed?.$type === 'app.bsky.embed.video#view') return post.embed as AppBskyEmbedVideo.View if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined if (embed?.media.$type === 'app.bsky.embed.video#view') return embed?.media as AppBskyEmbedVideo.View } return undefined }, [post]) const gifEmbed: AppBskyEmbedExternal.View | undefined = useMemo(() => { if (post.embed?.$type === 'app.bsky.embed.external#view') return post.embed as AppBskyEmbedExternal.View if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined if (embed?.media.$type === 'app.bsky.embed.external#view') return embed?.media as AppBskyEmbedExternal.View } return undefined }, [post]) const onPressDownloadVideo = async () => { if (!videoEmbed) return const did = post.author.did const cid = videoEmbed.cid if (!did.startsWith('did:')) return const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` Toast.show(_(msg({message: 'Downloading video...', context: 'toast'}))) let success if (IS_WEB) success = await downloadVideoWeb({uri: uri}) else success = await saveVideoToMediaLibrary({uri: uri}) if (success) Toast.show('Video downloaded', 'check') else Toast.show('Failed to download video', 'xmark') } const onPressDownloadGif = async () => { if (!gifEmbed) return Toast.show(_(msg({message: 'Downloading GIF...', context: 'toast'}))) let success if (IS_WEB) success = await downloadVideoWeb({uri: gifEmbed.external.uri}) else success = await saveVideoToMediaLibrary({uri: gifEmbed.external.uri}) if (success) Toast.show('GIF downloaded', 'check') else Toast.show('Failed to download GIF', 'xmark') } const isEmbedGif = () => { if (!gifEmbed) return false // Janky workaround by checking if the domain is tenor.com const url = new URL(gifEmbed.external.uri) return url.host === 'media.tenor.com' } 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) } const onSignIn = () => requireSignIn(() => {}) const isDiscoverDebugUser = IS_INTERNAL || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || ax.features.enabled(ax.features.DebugFeedContext) return ( <> {isAuthor && ( <> {isPinned ? _(msg`Unpin from profile`) : _(msg`Pin to your profile`)} {_(msg`Redraft`)} )} {videoEmbed && ( <> {_(msg`Download Video`)} )} {isEmbedGif() && ( <> {_(msg`Download GIF`)} )} {!hideInPWI || hasSession ? ( <> {_(msg`Translate`)} {_(msg`Copy skeet text`)} ) : ( {_(msg`Sign in to view skeet`)} )} {hasSession && feedFeedback.enabled && ( <> {_(msg`Show more like this`)} {_(msg`Show less like this`)} )} {isDiscoverDebugUser && ( <> {_(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 skeet 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 skeet`)} )} {isAuthor && ( <> postInteractionSettingsDialogControl.open()} {...(isAuthor ? Platform.select({ web: { onHoverIn: prefetchPostInteractionSettings, }, native: { onPressIn: prefetchPostInteractionSettings, }, }) : {})}> {_(msg`Edit interaction settings`)} deletePromptControl.open()}> {_(msg`Delete skeet`)} )} )}