From 78d36ae2f2f2f471cddf82f5e08bd9f084786e03 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 6 Feb 2025 22:33:39 +0000 Subject: [PATCH] add moderation menu --- .../thumbsDown_stroke2_corner0_rounded.svg | 1 + src/components/icons/ThumbsDown.tsx | 5 + src/components/post-ctrls/PostCtrls.tsx | 124 ++-- src/components/post-ctrls/PostDropdownBtn.tsx | 6 +- .../post-ctrls/PostDropdownBtnMenuItems.tsx | 240 +------- .../post-ctrls/PostModerationBtn.tsx | 111 ++++ .../post-ctrls/PostModerationBtnMenuItems.tsx | 578 ++++++++++++++++++ 7 files changed, 808 insertions(+), 257 deletions(-) create mode 100644 assets/icons/thumbsDown_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/ThumbsDown.tsx create mode 100644 src/components/post-ctrls/PostModerationBtn.tsx create mode 100644 src/components/post-ctrls/PostModerationBtnMenuItems.tsx diff --git a/assets/icons/thumbsDown_stroke2_corner0_rounded.svg b/assets/icons/thumbsDown_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..25ab381d6c --- /dev/null +++ b/assets/icons/thumbsDown_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/icons/ThumbsDown.tsx b/src/components/icons/ThumbsDown.tsx new file mode 100644 index 0000000000..d65c1a68dd --- /dev/null +++ b/src/components/icons/ThumbsDown.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ThumbsDown_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M13.924 21.445a1 1 0 0 1-.896.555h-.613a2.998 2.998 0 0 1-2.958-3.462L9.852 16H5.99c-2.421 0-4.279-2.142-3.953-4.54l.68-5A3.99 3.99 0 0 1 6.668 3H21a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-3.38l-3.696 7.445ZM18 12h2V5h-2v7Zm-2-7H6.67c-.993 0-1.837.736-1.972 1.73l-.68 5A1.996 1.996 0 0 0 5.99 14h5.03a1 1 0 0 1 .987 1.153l-.573 3.692A.998.998 0 0 0 12.408 20L16 12.765V5Z', +}) diff --git a/src/components/post-ctrls/PostCtrls.tsx b/src/components/post-ctrls/PostCtrls.tsx index 4951bc9d6c..197e55325c 100644 --- a/src/components/post-ctrls/PostCtrls.tsx +++ b/src/components/post-ctrls/PostCtrls.tsx @@ -18,7 +18,7 @@ import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import {IS_INTERNAL} from '#/lib/app-info' -import {POST_CTRL_HITSLOP} from '#/lib/constants' +import {HITSLOP_10, POST_CTRL_HITSLOP} from '#/lib/constants' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' @@ -47,6 +47,7 @@ import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/componen import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' import * as Prompt from '#/components/Prompt' import {PostDropdownBtn} from './PostDropdownBtn' +import {PostModerationBtn} from './PostModerationBtn' import {RepostButton} from './RepostButton' let PostCtrls = ({ @@ -246,7 +247,7 @@ let PostCtrls = ({ - + - + {big && ( <> - - { - if (shouldShowLoggedOutWarning) { - loggedOutWarningPromptControl.open() - } else { - onShare() - } - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Share`)} - accessibilityHint="" - hitSlop={POST_CTRL_HITSLOP}> - - - + { + if (shouldShowLoggedOutWarning) { + loggedOutWarningPromptControl.open() + } else { + onShare() + } + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Share`)} + accessibilityHint="" + hitSlop={POST_CTRL_HITSLOP}> + + )} - - - + {big ? ( + <> + + + + ) : ( + + + + + )} {isDiscoverDebugUser && feedContext && ( { e.stopPropagation() Clipboard.setStringAsync(feedContext) diff --git a/src/components/post-ctrls/PostDropdownBtn.tsx b/src/components/post-ctrls/PostDropdownBtn.tsx index 127f487aee..ccb1f61b53 100644 --- a/src/components/post-ctrls/PostDropdownBtn.tsx +++ b/src/components/post-ctrls/PostDropdownBtn.tsx @@ -16,11 +16,11 @@ import {useLingui} from '@lingui/react' import {useTheme} from '#/lib/ThemeContext' import {Shadow} from '#/state/cache/post-shadow' +import {EventStopper} from '#/view/com/util/EventStopper' import {atoms as a, useTheme as useAlf} from '#/alf' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' -import {EventStopper} from '../../view/com/util/EventStopper' import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' let PostDropdownBtn = ({ @@ -42,7 +42,7 @@ let PostDropdownBtn = ({ richText: RichTextAPI style?: StyleProp hitSlop?: PressableProps['hitSlop'] - size?: 'lg' | 'md' | 'sm' + size?: number timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { @@ -84,7 +84,7 @@ let PostDropdownBtn = ({ ) diff --git a/src/components/post-ctrls/PostDropdownBtnMenuItems.tsx b/src/components/post-ctrls/PostDropdownBtnMenuItems.tsx index cee27106ce..c540f396c9 100644 --- a/src/components/post-ctrls/PostDropdownBtnMenuItems.tsx +++ b/src/components/post-ctrls/PostDropdownBtnMenuItems.tsx @@ -38,9 +38,6 @@ import { usePostDeleteMutation, useThreadMuteMutationQueue, } from '#/state/queries/post' -import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' -import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' -import {useProfileBlockMutationQueue} from '#/state/queries/profile' import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' @@ -62,17 +59,13 @@ 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 {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} 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' @@ -110,7 +103,6 @@ let PostDropdownMenuItems = ({ const openLink = useOpenLink() const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() - const blockPromptControl = useDialogControl() const reportDialogControl = useReportDialogControl() const deletePromptControl = useDialogControl() const hidePromptControl = useDialogControl() @@ -118,7 +110,6 @@ let PostDropdownMenuItems = ({ const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() const postInteractionSettingsDialogControl = useDialogControl() - const quotePostDetachConfirmControl = useDialogControl() const hideReplyConfirmControl = useDialogControl() const {mutateAsync: toggleReplyVisibility} = useToggleReplyVisibilityMutation() @@ -126,13 +117,6 @@ let PostDropdownMenuItems = ({ 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) @@ -149,11 +133,6 @@ let PostDropdownMenuItems = ({ const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) const isPinned = post.viewer?.pinned - const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = - useToggleQuoteDetachmentMutation() - - const [queueBlock] = useProfileBlockMutationQueue(postAuthor) - const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ postUri: post.uri, rootPostUri: rootUri, @@ -285,34 +264,9 @@ let PostDropdownMenuItems = ({ [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? @@ -354,18 +308,6 @@ let PostDropdownMenuItems = ({ }) }, [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]) - return ( <> @@ -509,143 +451,36 @@ let PostDropdownMenuItems = ({ )} - {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 && ( + {hasSession && isAuthor && ( <> - {!isAuthor && ( - <> - {!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`)} - - - - )} + postInteractionSettingsDialogControl.open()} + {...(isAuthor + ? Platform.select({ + web: { + onHoverIn: prefetchPostInteractionSettings, + }, + native: { + onPressIn: prefetchPostInteractionSettings, + }, + }) + : {})}> + + {_(msg`Edit interaction settings`)} + + + + deletePromptControl.open()}> + {_(msg`Delete post`)} + + )} @@ -714,16 +549,6 @@ let PostDropdownMenuItems = ({ initialThreadgateView={post.threadgate} /> - - - - ) } diff --git a/src/components/post-ctrls/PostModerationBtn.tsx b/src/components/post-ctrls/PostModerationBtn.tsx new file mode 100644 index 0000000000..a7dea74d38 --- /dev/null +++ b/src/components/post-ctrls/PostModerationBtn.tsx @@ -0,0 +1,111 @@ +import React, {memo, useMemo, useState} from 'react' +import { + Pressable, + type PressableProps, + type StyleProp, + type ViewStyle, +} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useTheme} from '#/lib/ThemeContext' +import {Shadow} from '#/state/cache/post-shadow' +import {EventStopper} from '#/view/com/util/EventStopper' +import {atoms as a, useTheme as useAlf} from '#/alf' +import {ThumbsDown_Stroke2_Corner0_Rounded as ThumbsDownIcon} from '#/components/icons/ThumbsDown' +import {useMenuControl} from '#/components/Menu' +import * as Menu from '#/components/Menu' +import {PostModerationMenuItems} from './PostModerationBtnMenuItems' + +let PostModerationBtn = ({ + testID, + post, + postFeedContext, + record, + richText, + style, + hitSlop, + size, + timestamp, + threadgateRecord, +}: { + testID: string + post: Shadow + postFeedContext: string | undefined + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp + hitSlop?: PressableProps['hitSlop'] + size?: number + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const theme = useTheme() + const alf = useAlf() + const {_} = useLingui() + const defaultCtrlColor = theme.palette.default.postCtrl + const menuControl = useMenuControl() + const [hasBeenOpen, setHasBeenOpen] = useState(false) + const lazyMenuControl = useMemo( + () => ({ + ...menuControl, + open() { + setHasBeenOpen(true) + // HACK. We need the state update to be flushed by the time + // menuControl.open() fires but RN doesn't expose flushSync. + setTimeout(menuControl.open) + }, + }), + [menuControl, setHasBeenOpen], + ) + return ( + + + + {({props, state}) => { + return ( + + + + ) + }} + + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + + )} + + + ) +} + +PostModerationBtn = memo(PostModerationBtn) +export {PostModerationBtn} diff --git a/src/components/post-ctrls/PostModerationBtnMenuItems.tsx b/src/components/post-ctrls/PostModerationBtnMenuItems.tsx new file mode 100644 index 0000000000..e656904c79 --- /dev/null +++ b/src/components/post-ctrls/PostModerationBtnMenuItems.tsx @@ -0,0 +1,578 @@ +import React, {memo, useCallback} from 'react' +import { + Platform, + type PressableProps, + type StyleProp, + type ViewStyle, +} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {getCurrentRoute} from '#/lib/routes/helpers' +import {makeProfileLink} from '#/lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +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 {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' +import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' +import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' +import {useProfileBlockMutationQueue} 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 {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 {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} 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 {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' +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' + +let PostModerationMenuItems = ({ + post, + postFeedContext, + record, + 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 {mutateAsync: deletePostMutate} = usePostDeleteMutation() + const hiddenPosts = useHiddenPosts() + const {hidePost} = useHiddenPostsApi() + const feedFeedback = useFeedFeedbackContext() + const navigation = useNavigation() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const blockPromptControl = useDialogControl() + const reportDialogControl = useReportDialogControl() + const deletePromptControl = useDialogControl() + const hidePromptControl = 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 = 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 {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = + useToggleQuoteDetachmentMutation() + + const [queueBlock] = useProfileBlockMutationQueue(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 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 onHidePost = React.useCallback(() => { + hidePost({uri: postUri}) + }, [postUri, hidePost]) + + const hideInPWI = React.useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + 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 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]) + + return ( + <> + + {hasSession && feedFeedback.enabled && ( + <> + + + {_(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?.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`)} + + + + )} + + + )} + + + + +