diff options
author | dan <dan.abramov@gmail.com> | 2024-11-19 18:27:56 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-19 18:27:56 +0000 |
commit | 31b0d7cedefc6aaf13356a368281857e26b56da6 (patch) | |
tree | bdf69e9682e3e5d41c662b55037bb84f55565223 /src | |
parent | 39c9f7248e4fec0c7719fce575b66bdccd844e41 (diff) | |
download | voidsky-31b0d7cedefc6aaf13356a368281857e26b56da6.tar.zst |
Render post menu items lazily (take two) (#6473)
* Revert "Revert "Render dropdown menu items lazily (#6437)" (#6470)" This reverts commit 0b38c77faa71dbc777d3d0162b86cb3e8695ff99. * Lazily initialize menu items * Split in two files * Make it work on mobile This is wonky because our useMenuDialog abstraction only has `isOpen` on web. I couldn't figure out a way to make it work xplat so I'm just tracking it myself manually. * Fix typo in comment
Diffstat (limited to 'src')
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 702 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtnMenuItems.tsx | 711 |
2 files changed, 741 insertions, 672 deletions
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1cd4841a5..fd577605a 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,83 +1,27 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useMemo, useState} from 'react' import { - Platform, Pressable, 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 {msg} 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 {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 {useTheme} from '#/lib/ThemeContext' -import {getTranslatorLink} from '#/locale/helpers' -import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {Shadow} from '#/state/cache/post-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 {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' -import {useSession} from '#/state/session' -import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {atoms as a, useBreakpoints, useTheme as useAlf} 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 {atoms as a, useTheme as useAlf} from '#/alf' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' -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} from '#/components/icons/Mute' -import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' -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} 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 {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' -import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {EventStopper} from '../EventStopper' -import * as Toast from '../Toast' +import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' let PostDropdownBtn = ({ testID, @@ -102,266 +46,27 @@ let PostDropdownBtn = ({ timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { - const {hasSession, currentAccount} = useSession() const theme = useTheme() const alf = useAlf() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl - 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 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 postUri = post.uri - const postCid = post.cid - const postAuthor = 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 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') + 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) }, - ) - }, [ - 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], + }), + [menuControl, setHasBeenOpen], ) - - 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]) - return ( <EventStopper onKeyDown={false}> - <Menu.Root> + <Menu.Root control={lazyMenuControl}> <Menu.Trigger label={_(msg`Open post options menu`)}> {({props, state}) => { return ( @@ -385,366 +90,19 @@ let PostDropdownBtn = ({ ) }} </Menu.Trigger> - - <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> - </> - )} - - {hasSession && ( - <Menu.Item - testID="postDropdownSendViaDMBtn" - label={_(msg`Send via direct message`)} - onPress={() => sendViaChatControl.open()}> - <Menu.ItemText> - <Trans>Send via direct message</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={Send} position="right" /> - </Menu.Item> - )} - - <Menu.Item - testID="postDropdownShareBtn" - label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - onPress={() => { - if (showLoggedOutWarning) { - loggedOutWarningPromptControl.open() - } else { - onSharePost() - } - }}> - <Menu.ItemText> - {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - </Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - - {canEmbed && ( - <Menu.Item - testID="postDropdownEmbedBtn" - label={_(msg`Embed post`)} - onPress={() => embedPostControl.open()}> - <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> - <Menu.ItemIcon icon={CodeBrackets} 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 && ( - <> - <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="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> - </Menu.Root> - - <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.`, + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + <PostDropdownMenuItems + testID={testID} + post={post} + postFeedContext={postFeedContext} + record={record} + richText={richText} + timestamp={timestamp} + threadgateRecord={threadgateRecord} + /> )} - onConfirm={onHidePost} - confirmButtonCta={_(msg`Hide`)} - /> - - <ReportDialog - control={reportDialogControl} - params={{ - type: 'post', - uri: postUri, - cid: postCid, - }} - /> - - <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 logged in.`, - )} - onConfirm={onSharePost} - confirmButtonCta={_(msg`Share anyway`)} - /> - - {canEmbed && ( - <EmbedDialog - control={embedPostControl} - postCid={postCid} - postUri={postUri} - record={record} - postAuthor={postAuthor} - timestamp={timestamp} - /> - )} - - <SendViaChatDialog - control={sendViaChatControl} - onSelectChat={onSelectChatToShareTo} - /> - - <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`)} - /> + </Menu.Root> </EventStopper> ) } diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx new file mode 100644 index 000000000..05d8efdbc --- /dev/null +++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx @@ -0,0 +1,711 @@ +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 {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 {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 {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 Mute} from '#/components/icons/Mute' +import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +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} 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 * as Prompt from '#/components/Prompt' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import * as Toast from '../Toast' + +let PostDropdownMenuItems = ({ + post, + postFeedContext, + record, + richText, + timestamp, + threadgateRecord, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + postFeedContext: string | undefined + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp<ViewStyle> + 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<NavigationProp>() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + 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 postUri = post.uri + const postCid = post.cid + const postAuthor = 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 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]) + + 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> + </> + )} + + {hasSession && ( + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => sendViaChatControl.open()}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Send} position="right" /> + </Menu.Item> + )} + + <Menu.Item + testID="postDropdownShareBtn" + label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + onPress={() => { + if (showLoggedOutWarning) { + loggedOutWarningPromptControl.open() + } else { + onSharePost() + } + }}> + <Menu.ItemText> + {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Share} position="right" /> + </Menu.Item> + + {canEmbed && ( + <Menu.Item + testID="postDropdownEmbedBtn" + label={_(msg`Embed post`)} + onPress={() => embedPostControl.open()}> + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> + <Menu.ItemIcon icon={CodeBrackets} 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 && ( + <> + <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="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} + params={{ + type: 'post', + uri: postUri, + cid: postCid, + }} + /> + + <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 logged in.`, + )} + onConfirm={onSharePost} + confirmButtonCta={_(msg`Share anyway`)} + /> + + {canEmbed && ( + <EmbedDialog + control={embedPostControl} + postCid={postCid} + postUri={postUri} + record={record} + postAuthor={postAuthor} + timestamp={timestamp} + /> + )} + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + + <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`)} + /> + </> + ) +} +PostDropdownMenuItems = memo(PostDropdownMenuItems) +export {PostDropdownMenuItems} |