Bluesky app fork with some witchin' additions 馃挮
at 9b830fd425e936d934e9eec128ebc50ef3e92982 828 lines 28 kB view raw
1import {memo, useMemo} from 'react' 2import { 3 Platform, 4 type PressableProps, 5 type StyleProp, 6 type ViewStyle, 7} from 'react-native' 8import * as Clipboard from 'expo-clipboard' 9import { 10 type AppBskyFeedDefs, 11 AppBskyFeedPost, 12 type AppBskyFeedThreadgate, 13 AtUri, 14 type RichText as RichTextAPI, 15} from '@atproto/api' 16import {msg} from '@lingui/macro' 17import {useLingui} from '@lingui/react' 18import {useNavigation} from '@react-navigation/native' 19 20import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 21import {useOpenLink} from '#/lib/hooks/useOpenLink' 22import {useTranslate} from '#/lib/hooks/useTranslate' 23import {getCurrentRoute} from '#/lib/routes/helpers' 24import {makeProfileLink} from '#/lib/routes/links' 25import { 26 type CommonNavigatorParams, 27 type NavigationProp, 28} from '#/lib/routes/types' 29import {richTextToString} from '#/lib/strings/rich-text-helpers' 30import {toShareUrl} from '#/lib/strings/url-helpers' 31import {logger} from '#/logger' 32import {type Shadow} from '#/state/cache/post-shadow' 33import {useProfileShadow} from '#/state/cache/profile-shadow' 34import {useFeedFeedbackContext} from '#/state/feed-feedback' 35import { 36 useHiddenPosts, 37 useHiddenPostsApi, 38 useLanguagePrefs, 39} from '#/state/preferences' 40import {usePinnedPostMutation} from '#/state/queries/pinned-post' 41import { 42 usePostDeleteMutation, 43 useThreadMuteMutationQueue, 44} from '#/state/queries/post' 45import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' 46import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' 47import { 48 useProfileBlockMutationQueue, 49 useProfileMuteMutationQueue, 50} from '#/state/queries/profile' 51import { 52 InvalidInteractionSettingsError, 53 MAX_HIDDEN_REPLIES, 54 MaxHiddenRepliesError, 55 useToggleReplyVisibilityMutation, 56} from '#/state/queries/threadgate' 57import {useRequireAuth, useSession} from '#/state/session' 58import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 59import * as Toast from '#/view/com/util/Toast' 60import {useDialogControl} from '#/components/Dialog' 61import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 62import { 63 PostInteractionSettingsDialog, 64 usePrefetchPostInteractionSettings, 65} from '#/components/dialogs/PostInteractionSettingsDialog' 66import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' 67import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 68import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 69import { 70 EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, 71 EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, 72} from '#/components/icons/Emoji' 73import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' 74import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 75import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 76import { 77 Mute_Stroke2_Corner0_Rounded as Mute, 78 Mute_Stroke2_Corner0_Rounded as MuteIcon, 79} from '#/components/icons/Mute' 80import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' 81import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 82import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 83import { 84 SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute, 85 SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon, 86} from '#/components/icons/Speaker' 87import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 88import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 89import {Loader} from '#/components/Loader' 90import * as Menu from '#/components/Menu' 91import { 92 ReportDialog, 93 useReportDialogControl, 94} from '#/components/moderation/ReportDialog' 95import * as Prompt from '#/components/Prompt' 96import {useAnalytics} from '#/analytics' 97import {IS_INTERNAL} from '#/env' 98import * as bsky from '#/types/bsky' 99 100let PostMenuItems = ({ 101 post, 102 postFeedContext, 103 postReqId, 104 record, 105 richText, 106 threadgateRecord, 107 onShowLess, 108 logContext, 109}: { 110 testID: string 111 post: Shadow<AppBskyFeedDefs.PostView> 112 postFeedContext: string | undefined 113 postReqId: string | undefined 114 record: AppBskyFeedPost.Record 115 richText: RichTextAPI 116 style?: StyleProp<ViewStyle> 117 hitSlop?: PressableProps['hitSlop'] 118 size?: 'lg' | 'md' | 'sm' 119 timestamp: string 120 threadgateRecord?: AppBskyFeedThreadgate.Record 121 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 122 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 123}): React.ReactNode => { 124 const {hasSession, currentAccount} = useSession() 125 const {_} = useLingui() 126 const ax = useAnalytics() 127 const langPrefs = useLanguagePrefs() 128 const {mutateAsync: deletePostMutate} = usePostDeleteMutation() 129 const {mutateAsync: pinPostMutate, isPending: isPinPending} = 130 usePinnedPostMutation() 131 const requireSignIn = useRequireAuth() 132 const hiddenPosts = useHiddenPosts() 133 const {hidePost} = useHiddenPostsApi() 134 const feedFeedback = useFeedFeedbackContext() 135 const openLink = useOpenLink() 136 const translate = useTranslate() 137 const navigation = useNavigation<NavigationProp>() 138 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 139 const blockPromptControl = useDialogControl() 140 const reportDialogControl = useReportDialogControl() 141 const deletePromptControl = useDialogControl() 142 const hidePromptControl = useDialogControl() 143 const postInteractionSettingsDialogControl = useDialogControl() 144 const quotePostDetachConfirmControl = useDialogControl() 145 const hideReplyConfirmControl = useDialogControl() 146 const {mutateAsync: toggleReplyVisibility} = 147 useToggleReplyVisibilityMutation() 148 149 const postUri = post.uri 150 const postCid = post.cid 151 const postAuthor = useProfileShadow(post.author) 152 const quoteEmbed = useMemo(() => { 153 if (!currentAccount || !post.embed) return 154 return getMaybeDetachedQuoteEmbed({ 155 viewerDid: currentAccount.did, 156 post, 157 }) 158 }, [post, currentAccount]) 159 160 const rootUri = record.reply?.root?.uri || postUri 161 const isReply = Boolean(record.reply) 162 const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( 163 post, 164 rootUri, 165 ) 166 const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) 167 const isAuthor = postAuthor.did === currentAccount?.did 168 const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did 169 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 170 threadgateRecord, 171 }) 172 const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) 173 const isPinned = post.viewer?.pinned 174 175 const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = 176 useToggleQuoteDetachmentMutation() 177 178 const [queueBlock] = useProfileBlockMutationQueue(postAuthor) 179 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor) 180 181 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ 182 postUri: post.uri, 183 rootPostUri: rootUri, 184 }) 185 186 const href = useMemo(() => { 187 const urip = new AtUri(postUri) 188 return makeProfileLink(postAuthor, 'post', urip.rkey) 189 }, [postUri, postAuthor]) 190 191 const onDeletePost = () => { 192 deletePostMutate({uri: postUri}).then( 193 () => { 194 Toast.show(_(msg({message: 'Post deleted', context: 'toast'}))) 195 196 const route = getCurrentRoute(navigation.getState()) 197 if (route.name === 'PostThread') { 198 const params = route.params as CommonNavigatorParams['PostThread'] 199 if ( 200 currentAccount && 201 isAuthor && 202 (params.name === currentAccount.handle || 203 params.name === currentAccount.did) 204 ) { 205 const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) 206 if (currentHref === href && navigation.canGoBack()) { 207 navigation.goBack() 208 } 209 } 210 } 211 }, 212 e => { 213 logger.error('Failed to delete post', {message: e}) 214 Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') 215 }, 216 ) 217 } 218 219 const onToggleThreadMute = () => { 220 try { 221 if (isThreadMuted) { 222 unmuteThread() 223 ax.metric('post:unmute', { 224 uri: postUri, 225 authorDid: postAuthor.did, 226 logContext, 227 feedDescriptor: feedFeedback.feedDescriptor, 228 }) 229 Toast.show(_(msg`You will now receive notifications for this thread`)) 230 } else { 231 muteThread() 232 ax.metric('post:mute', { 233 uri: postUri, 234 authorDid: postAuthor.did, 235 logContext, 236 feedDescriptor: feedFeedback.feedDescriptor, 237 }) 238 Toast.show( 239 _(msg`You will no longer receive notifications for this thread`), 240 ) 241 } 242 } catch (e: any) { 243 if (e?.name !== 'AbortError') { 244 logger.error('Failed to toggle thread mute', {message: e}) 245 Toast.show( 246 _(msg`Failed to toggle thread mute, please try again`), 247 'xmark', 248 ) 249 } 250 } 251 } 252 253 const onCopyPostText = () => { 254 const str = richTextToString(richText, true) 255 256 Clipboard.setStringAsync(str) 257 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 258 } 259 260 const onPressTranslate = () => { 261 translate(record.text, langPrefs.primaryLanguage) 262 263 if ( 264 bsky.dangerousIsType<AppBskyFeedPost.Record>( 265 post.record, 266 AppBskyFeedPost.isRecord, 267 ) 268 ) { 269 ax.metric('translate', { 270 sourceLanguages: post.record.langs ?? [], 271 targetLanguage: langPrefs.primaryLanguage, 272 textLength: post.record.text.length, 273 }) 274 } 275 } 276 277 const onHidePost = () => { 278 hidePost({uri: postUri}) 279 ax.metric('thread:click:hideReplyForMe', {}) 280 } 281 282 const hideInPWI = !!postAuthor.labels?.find( 283 label => label.val === '!no-unauthenticated', 284 ) 285 286 const onPressShowMore = () => { 287 feedFeedback.sendInteraction({ 288 event: 'app.bsky.feed.defs#requestMore', 289 item: postUri, 290 feedContext: postFeedContext, 291 reqId: postReqId, 292 }) 293 ax.metric('post:showMore', { 294 uri: postUri, 295 authorDid: postAuthor.did, 296 logContext, 297 feedDescriptor: feedFeedback.feedDescriptor, 298 }) 299 Toast.show( 300 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 301 ) 302 } 303 304 const onPressShowLess = () => { 305 feedFeedback.sendInteraction({ 306 event: 'app.bsky.feed.defs#requestLess', 307 item: postUri, 308 feedContext: postFeedContext, 309 reqId: postReqId, 310 }) 311 ax.metric('post:showLess', { 312 uri: postUri, 313 authorDid: postAuthor.did, 314 logContext, 315 feedDescriptor: feedFeedback.feedDescriptor, 316 }) 317 if (onShowLess) { 318 onShowLess({ 319 item: postUri, 320 feedContext: postFeedContext, 321 }) 322 } else { 323 Toast.show( 324 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 325 ) 326 } 327 } 328 329 const onToggleQuotePostAttachment = async () => { 330 if (!quoteEmbed) return 331 332 const action = quoteEmbed.isDetached ? 'reattach' : 'detach' 333 const isDetach = action === 'detach' 334 335 try { 336 await toggleQuoteDetachment({ 337 post, 338 quoteUri: quoteEmbed.uri, 339 action: quoteEmbed.isDetached ? 'reattach' : 'detach', 340 }) 341 Toast.show( 342 isDetach 343 ? _(msg`Quote post was successfully detached`) 344 : _(msg`Quote post was re-attached`), 345 ) 346 } catch (e: any) { 347 Toast.show( 348 _(msg({message: 'Updating quote attachment failed', context: 'toast'})), 349 ) 350 logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) 351 } 352 } 353 354 const canHidePostForMe = !isAuthor && !isPostHidden 355 const canHideReplyForEveryone = 356 !isAuthor && isRootPostAuthor && !isPostHidden && isReply 357 const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer 358 359 const onToggleReplyVisibility = async () => { 360 // TODO no threadgate? 361 if (!canHideReplyForEveryone) return 362 363 const action = isReplyHiddenByThreadgate ? 'show' : 'hide' 364 const isHide = action === 'hide' 365 366 try { 367 await toggleReplyVisibility({ 368 postUri: rootUri, 369 replyUri: postUri, 370 action, 371 }) 372 373 // Log metric only when hiding (not when showing) 374 if (isHide) { 375 ax.metric('thread:click:hideReplyForEveryone', {}) 376 } 377 378 Toast.show( 379 isHide 380 ? _(msg`Reply was successfully hidden`) 381 : _(msg({message: 'Reply visibility updated', context: 'toast'})), 382 ) 383 } catch (e: any) { 384 if (e instanceof MaxHiddenRepliesError) { 385 Toast.show( 386 _( 387 msg({ 388 message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`, 389 context: 'toast', 390 }), 391 ), 392 ) 393 } else if (e instanceof InvalidInteractionSettingsError) { 394 Toast.show( 395 _(msg({message: 'Invalid interaction settings.', context: 'toast'})), 396 ) 397 } else { 398 Toast.show( 399 _( 400 msg({ 401 message: 'Updating reply visibility failed', 402 context: 'toast', 403 }), 404 ), 405 ) 406 logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) 407 } 408 } 409 } 410 411 const onPressPin = () => { 412 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) 413 pinPostMutate({ 414 postUri, 415 postCid, 416 action: isPinned ? 'unpin' : 'pin', 417 }) 418 } 419 420 const onBlockAuthor = async () => { 421 try { 422 await queueBlock() 423 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 424 } catch (e: any) { 425 if (e?.name !== 'AbortError') { 426 logger.error('Failed to block account', {message: e}) 427 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 428 } 429 } 430 } 431 432 const onMuteAuthor = async () => { 433 if (postAuthor.viewer?.muted) { 434 try { 435 await queueUnmute() 436 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 437 } catch (e: any) { 438 if (e?.name !== 'AbortError') { 439 logger.error('Failed to unmute account', {message: e}) 440 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 441 } 442 } 443 } else { 444 try { 445 await queueMute() 446 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 447 } catch (e: any) { 448 if (e?.name !== 'AbortError') { 449 logger.error('Failed to mute account', {message: e}) 450 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 451 } 452 } 453 } 454 } 455 456 const onReportMisclassification = () => { 457 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( 458 href, 459 )}` 460 openLink(url) 461 } 462 463 const onSignIn = () => requireSignIn(() => {}) 464 465 const isDiscoverDebugUser = 466 IS_INTERNAL || 467 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 468 ax.features.enabled(ax.features.DebugFeedContext) 469 470 return ( 471 <> 472 <Menu.Outer> 473 {isAuthor && ( 474 <> 475 <Menu.Group> 476 <Menu.Item 477 testID="pinPostBtn" 478 label={ 479 isPinned 480 ? _(msg`Unpin from profile`) 481 : _(msg`Pin to your profile`) 482 } 483 disabled={isPinPending} 484 onPress={onPressPin}> 485 <Menu.ItemText> 486 {isPinned 487 ? _(msg`Unpin from profile`) 488 : _(msg`Pin to your profile`)} 489 </Menu.ItemText> 490 <Menu.ItemIcon 491 icon={isPinPending ? Loader : PinIcon} 492 position="right" 493 /> 494 </Menu.Item> 495 </Menu.Group> 496 <Menu.Divider /> 497 </> 498 )} 499 500 <Menu.Group> 501 {!hideInPWI || hasSession ? ( 502 <> 503 <Menu.Item 504 testID="postDropdownTranslateBtn" 505 label={_(msg`Translate`)} 506 onPress={onPressTranslate}> 507 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> 508 <Menu.ItemIcon icon={Translate} position="right" /> 509 </Menu.Item> 510 511 <Menu.Item 512 testID="postDropdownCopyTextBtn" 513 label={_(msg`Copy post text`)} 514 onPress={onCopyPostText}> 515 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> 516 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 517 </Menu.Item> 518 </> 519 ) : ( 520 <Menu.Item 521 testID="postDropdownSignInBtn" 522 label={_(msg`Sign in to view post`)} 523 onPress={onSignIn}> 524 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText> 525 <Menu.ItemIcon icon={Eye} position="right" /> 526 </Menu.Item> 527 )} 528 </Menu.Group> 529 530 {hasSession && feedFeedback.enabled && ( 531 <> 532 <Menu.Divider /> 533 <Menu.Group> 534 <Menu.Item 535 testID="postDropdownShowMoreBtn" 536 label={_(msg`Show more like this`)} 537 onPress={onPressShowMore}> 538 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> 539 <Menu.ItemIcon icon={EmojiSmile} position="right" /> 540 </Menu.Item> 541 542 <Menu.Item 543 testID="postDropdownShowLessBtn" 544 label={_(msg`Show less like this`)} 545 onPress={onPressShowLess}> 546 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> 547 <Menu.ItemIcon icon={EmojiSad} position="right" /> 548 </Menu.Item> 549 </Menu.Group> 550 </> 551 )} 552 553 {isDiscoverDebugUser && ( 554 <> 555 <Menu.Divider /> 556 <Menu.Item 557 testID="postDropdownReportMisclassificationBtn" 558 label={_(msg`Assign topic for algo`)} 559 onPress={onReportMisclassification}> 560 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 561 <Menu.ItemIcon icon={AtomIcon} position="right" /> 562 </Menu.Item> 563 </> 564 )} 565 566 {hasSession && ( 567 <> 568 <Menu.Divider /> 569 <Menu.Group> 570 <Menu.Item 571 testID="postDropdownMuteThreadBtn" 572 label={ 573 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) 574 } 575 onPress={onToggleThreadMute}> 576 <Menu.ItemText> 577 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} 578 </Menu.ItemText> 579 <Menu.ItemIcon 580 icon={isThreadMuted ? Unmute : Mute} 581 position="right" 582 /> 583 </Menu.Item> 584 585 <Menu.Item 586 testID="postDropdownMuteWordsBtn" 587 label={_(msg`Mute words & tags`)} 588 onPress={() => mutedWordsDialogControl.open()}> 589 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> 590 <Menu.ItemIcon icon={Filter} position="right" /> 591 </Menu.Item> 592 </Menu.Group> 593 </> 594 )} 595 596 {hasSession && 597 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( 598 <> 599 <Menu.Divider /> 600 <Menu.Group> 601 {canHidePostForMe && ( 602 <Menu.Item 603 testID="postDropdownHideBtn" 604 label={ 605 isReply 606 ? _(msg`Hide reply for me`) 607 : _(msg`Hide post for me`) 608 } 609 onPress={() => hidePromptControl.open()}> 610 <Menu.ItemText> 611 {isReply 612 ? _(msg`Hide reply for me`) 613 : _(msg`Hide post for me`)} 614 </Menu.ItemText> 615 <Menu.ItemIcon icon={EyeSlash} position="right" /> 616 </Menu.Item> 617 )} 618 {canHideReplyForEveryone && ( 619 <Menu.Item 620 testID="postDropdownHideBtn" 621 label={ 622 isReplyHiddenByThreadgate 623 ? _(msg`Show reply for everyone`) 624 : _(msg`Hide reply for everyone`) 625 } 626 onPress={ 627 isReplyHiddenByThreadgate 628 ? onToggleReplyVisibility 629 : () => hideReplyConfirmControl.open() 630 }> 631 <Menu.ItemText> 632 {isReplyHiddenByThreadgate 633 ? _(msg`Show reply for everyone`) 634 : _(msg`Hide reply for everyone`)} 635 </Menu.ItemText> 636 <Menu.ItemIcon 637 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} 638 position="right" 639 /> 640 </Menu.Item> 641 )} 642 643 {canDetachQuote && ( 644 <Menu.Item 645 disabled={isDetachPending} 646 testID="postDropdownHideBtn" 647 label={ 648 quoteEmbed.isDetached 649 ? _(msg`Re-attach quote`) 650 : _(msg`Detach quote`) 651 } 652 onPress={ 653 quoteEmbed.isDetached 654 ? onToggleQuotePostAttachment 655 : () => quotePostDetachConfirmControl.open() 656 }> 657 <Menu.ItemText> 658 {quoteEmbed.isDetached 659 ? _(msg`Re-attach quote`) 660 : _(msg`Detach quote`)} 661 </Menu.ItemText> 662 <Menu.ItemIcon 663 icon={ 664 isDetachPending 665 ? Loader 666 : quoteEmbed.isDetached 667 ? Eye 668 : EyeSlash 669 } 670 position="right" 671 /> 672 </Menu.Item> 673 )} 674 </Menu.Group> 675 </> 676 )} 677 678 {hasSession && ( 679 <> 680 <Menu.Divider /> 681 <Menu.Group> 682 {!isAuthor && ( 683 <> 684 <Menu.Item 685 testID="postDropdownMuteBtn" 686 label={ 687 postAuthor.viewer?.muted 688 ? _(msg`Unmute account`) 689 : _(msg`Mute account`) 690 } 691 onPress={onMuteAuthor}> 692 <Menu.ItemText> 693 {postAuthor.viewer?.muted 694 ? _(msg`Unmute account`) 695 : _(msg`Mute account`)} 696 </Menu.ItemText> 697 <Menu.ItemIcon 698 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} 699 position="right" 700 /> 701 </Menu.Item> 702 703 {!postAuthor.viewer?.blocking && ( 704 <Menu.Item 705 testID="postDropdownBlockBtn" 706 label={_(msg`Block account`)} 707 onPress={() => blockPromptControl.open()}> 708 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> 709 <Menu.ItemIcon icon={PersonX} position="right" /> 710 </Menu.Item> 711 )} 712 713 <Menu.Item 714 testID="postDropdownReportBtn" 715 label={_(msg`Report post`)} 716 onPress={() => reportDialogControl.open()}> 717 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> 718 <Menu.ItemIcon icon={Warning} position="right" /> 719 </Menu.Item> 720 </> 721 )} 722 723 {isAuthor && ( 724 <> 725 <Menu.Item 726 testID="postDropdownEditPostInteractions" 727 label={_(msg`Edit interaction settings`)} 728 onPress={() => postInteractionSettingsDialogControl.open()} 729 {...(isAuthor 730 ? Platform.select({ 731 web: { 732 onHoverIn: prefetchPostInteractionSettings, 733 }, 734 native: { 735 onPressIn: prefetchPostInteractionSettings, 736 }, 737 }) 738 : {})}> 739 <Menu.ItemText> 740 {_(msg`Edit interaction settings`)} 741 </Menu.ItemText> 742 <Menu.ItemIcon icon={Gear} position="right" /> 743 </Menu.Item> 744 <Menu.Item 745 testID="postDropdownDeleteBtn" 746 label={_(msg`Delete post`)} 747 onPress={() => deletePromptControl.open()}> 748 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 749 <Menu.ItemIcon icon={Trash} position="right" /> 750 </Menu.Item> 751 </> 752 )} 753 </Menu.Group> 754 </> 755 )} 756 </Menu.Outer> 757 758 <Prompt.Basic 759 control={deletePromptControl} 760 title={_(msg`Delete this post?`)} 761 description={_( 762 msg`If you remove this post, you won't be able to recover it.`, 763 )} 764 onConfirm={onDeletePost} 765 confirmButtonCta={_(msg`Delete`)} 766 confirmButtonColor="negative" 767 /> 768 769 <Prompt.Basic 770 control={hidePromptControl} 771 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} 772 description={_( 773 msg`This post will be hidden from feeds and threads. This cannot be undone.`, 774 )} 775 onConfirm={onHidePost} 776 confirmButtonCta={_(msg`Hide`)} 777 /> 778 779 <ReportDialog 780 control={reportDialogControl} 781 subject={{ 782 ...post, 783 $type: 'app.bsky.feed.defs#postView', 784 }} 785 /> 786 787 <PostInteractionSettingsDialog 788 control={postInteractionSettingsDialogControl} 789 postUri={post.uri} 790 rootPostUri={rootUri} 791 initialThreadgateView={post.threadgate} 792 /> 793 794 <Prompt.Basic 795 control={quotePostDetachConfirmControl} 796 title={_(msg`Detach quote post?`)} 797 description={_( 798 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, 799 )} 800 onConfirm={onToggleQuotePostAttachment} 801 confirmButtonCta={_(msg`Yes, detach`)} 802 /> 803 804 <Prompt.Basic 805 control={hideReplyConfirmControl} 806 title={_(msg`Hide this reply?`)} 807 description={_( 808 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.`, 809 )} 810 onConfirm={onToggleReplyVisibility} 811 confirmButtonCta={_(msg`Yes, hide`)} 812 /> 813 814 <Prompt.Basic 815 control={blockPromptControl} 816 title={_(msg`Block Account?`)} 817 description={_( 818 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 819 )} 820 onConfirm={onBlockAuthor} 821 confirmButtonCta={_(msg`Block`)} 822 confirmButtonColor="negative" 823 /> 824 </> 825 ) 826} 827PostMenuItems = memo(PostMenuItems) 828export {PostMenuItems}