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