Bluesky app fork with some witchin' additions 馃挮
at cb7e2ab976d9e2e8e2d13356b79bba7e6870a9fd 827 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, plural} 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 plural(MAX_HIDDEN_REPLIES, { 388 other: 'You can hide a maximum of # replies.', 389 }), 390 ), 391 ) 392 } else if (e instanceof InvalidInteractionSettingsError) { 393 Toast.show( 394 _(msg({message: 'Invalid interaction settings.', context: 'toast'})), 395 ) 396 } else { 397 Toast.show( 398 _( 399 msg({ 400 message: 'Updating reply visibility failed', 401 context: 'toast', 402 }), 403 ), 404 ) 405 logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) 406 } 407 } 408 } 409 410 const onPressPin = () => { 411 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) 412 pinPostMutate({ 413 postUri, 414 postCid, 415 action: isPinned ? 'unpin' : 'pin', 416 }) 417 } 418 419 const onBlockAuthor = async () => { 420 try { 421 await queueBlock() 422 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 423 } catch (e: any) { 424 if (e?.name !== 'AbortError') { 425 logger.error('Failed to block account', {message: e}) 426 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 427 } 428 } 429 } 430 431 const onMuteAuthor = async () => { 432 if (postAuthor.viewer?.muted) { 433 try { 434 await queueUnmute() 435 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 436 } catch (e: any) { 437 if (e?.name !== 'AbortError') { 438 logger.error('Failed to unmute account', {message: e}) 439 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 440 } 441 } 442 } else { 443 try { 444 await queueMute() 445 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 446 } catch (e: any) { 447 if (e?.name !== 'AbortError') { 448 logger.error('Failed to mute account', {message: e}) 449 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 450 } 451 } 452 } 453 } 454 455 const onReportMisclassification = () => { 456 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( 457 href, 458 )}` 459 openLink(url) 460 } 461 462 const onSignIn = () => requireSignIn(() => {}) 463 464 const isDiscoverDebugUser = 465 IS_INTERNAL || 466 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 467 ax.features.enabled(ax.features.DebugFeedContext) 468 469 return ( 470 <> 471 <Menu.Outer> 472 {isAuthor && ( 473 <> 474 <Menu.Group> 475 <Menu.Item 476 testID="pinPostBtn" 477 label={ 478 isPinned 479 ? _(msg`Unpin from profile`) 480 : _(msg`Pin to your profile`) 481 } 482 disabled={isPinPending} 483 onPress={onPressPin}> 484 <Menu.ItemText> 485 {isPinned 486 ? _(msg`Unpin from profile`) 487 : _(msg`Pin to your profile`)} 488 </Menu.ItemText> 489 <Menu.ItemIcon 490 icon={isPinPending ? Loader : PinIcon} 491 position="right" 492 /> 493 </Menu.Item> 494 </Menu.Group> 495 <Menu.Divider /> 496 </> 497 )} 498 499 <Menu.Group> 500 {!hideInPWI || hasSession ? ( 501 <> 502 <Menu.Item 503 testID="postDropdownTranslateBtn" 504 label={_(msg`Translate`)} 505 onPress={onPressTranslate}> 506 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> 507 <Menu.ItemIcon icon={Translate} position="right" /> 508 </Menu.Item> 509 510 <Menu.Item 511 testID="postDropdownCopyTextBtn" 512 label={_(msg`Copy post text`)} 513 onPress={onCopyPostText}> 514 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> 515 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 516 </Menu.Item> 517 </> 518 ) : ( 519 <Menu.Item 520 testID="postDropdownSignInBtn" 521 label={_(msg`Sign in to view post`)} 522 onPress={onSignIn}> 523 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText> 524 <Menu.ItemIcon icon={Eye} position="right" /> 525 </Menu.Item> 526 )} 527 </Menu.Group> 528 529 {hasSession && feedFeedback.enabled && ( 530 <> 531 <Menu.Divider /> 532 <Menu.Group> 533 <Menu.Item 534 testID="postDropdownShowMoreBtn" 535 label={_(msg`Show more like this`)} 536 onPress={onPressShowMore}> 537 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> 538 <Menu.ItemIcon icon={EmojiSmile} position="right" /> 539 </Menu.Item> 540 541 <Menu.Item 542 testID="postDropdownShowLessBtn" 543 label={_(msg`Show less like this`)} 544 onPress={onPressShowLess}> 545 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> 546 <Menu.ItemIcon icon={EmojiSad} position="right" /> 547 </Menu.Item> 548 </Menu.Group> 549 </> 550 )} 551 552 {isDiscoverDebugUser && ( 553 <> 554 <Menu.Divider /> 555 <Menu.Item 556 testID="postDropdownReportMisclassificationBtn" 557 label={_(msg`Assign topic for algo`)} 558 onPress={onReportMisclassification}> 559 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 560 <Menu.ItemIcon icon={AtomIcon} position="right" /> 561 </Menu.Item> 562 </> 563 )} 564 565 {hasSession && ( 566 <> 567 <Menu.Divider /> 568 <Menu.Group> 569 <Menu.Item 570 testID="postDropdownMuteThreadBtn" 571 label={ 572 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) 573 } 574 onPress={onToggleThreadMute}> 575 <Menu.ItemText> 576 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} 577 </Menu.ItemText> 578 <Menu.ItemIcon 579 icon={isThreadMuted ? Unmute : Mute} 580 position="right" 581 /> 582 </Menu.Item> 583 584 <Menu.Item 585 testID="postDropdownMuteWordsBtn" 586 label={_(msg`Mute words & tags`)} 587 onPress={() => mutedWordsDialogControl.open()}> 588 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> 589 <Menu.ItemIcon icon={Filter} position="right" /> 590 </Menu.Item> 591 </Menu.Group> 592 </> 593 )} 594 595 {hasSession && 596 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( 597 <> 598 <Menu.Divider /> 599 <Menu.Group> 600 {canHidePostForMe && ( 601 <Menu.Item 602 testID="postDropdownHideBtn" 603 label={ 604 isReply 605 ? _(msg`Hide reply for me`) 606 : _(msg`Hide post for me`) 607 } 608 onPress={() => hidePromptControl.open()}> 609 <Menu.ItemText> 610 {isReply 611 ? _(msg`Hide reply for me`) 612 : _(msg`Hide post for me`)} 613 </Menu.ItemText> 614 <Menu.ItemIcon icon={EyeSlash} position="right" /> 615 </Menu.Item> 616 )} 617 {canHideReplyForEveryone && ( 618 <Menu.Item 619 testID="postDropdownHideBtn" 620 label={ 621 isReplyHiddenByThreadgate 622 ? _(msg`Show reply for everyone`) 623 : _(msg`Hide reply for everyone`) 624 } 625 onPress={ 626 isReplyHiddenByThreadgate 627 ? onToggleReplyVisibility 628 : () => hideReplyConfirmControl.open() 629 }> 630 <Menu.ItemText> 631 {isReplyHiddenByThreadgate 632 ? _(msg`Show reply for everyone`) 633 : _(msg`Hide reply for everyone`)} 634 </Menu.ItemText> 635 <Menu.ItemIcon 636 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} 637 position="right" 638 /> 639 </Menu.Item> 640 )} 641 642 {canDetachQuote && ( 643 <Menu.Item 644 disabled={isDetachPending} 645 testID="postDropdownHideBtn" 646 label={ 647 quoteEmbed.isDetached 648 ? _(msg`Re-attach quote`) 649 : _(msg`Detach quote`) 650 } 651 onPress={ 652 quoteEmbed.isDetached 653 ? onToggleQuotePostAttachment 654 : () => quotePostDetachConfirmControl.open() 655 }> 656 <Menu.ItemText> 657 {quoteEmbed.isDetached 658 ? _(msg`Re-attach quote`) 659 : _(msg`Detach quote`)} 660 </Menu.ItemText> 661 <Menu.ItemIcon 662 icon={ 663 isDetachPending 664 ? Loader 665 : quoteEmbed.isDetached 666 ? Eye 667 : EyeSlash 668 } 669 position="right" 670 /> 671 </Menu.Item> 672 )} 673 </Menu.Group> 674 </> 675 )} 676 677 {hasSession && ( 678 <> 679 <Menu.Divider /> 680 <Menu.Group> 681 {!isAuthor && ( 682 <> 683 <Menu.Item 684 testID="postDropdownMuteBtn" 685 label={ 686 postAuthor.viewer?.muted 687 ? _(msg`Unmute account`) 688 : _(msg`Mute account`) 689 } 690 onPress={onMuteAuthor}> 691 <Menu.ItemText> 692 {postAuthor.viewer?.muted 693 ? _(msg`Unmute account`) 694 : _(msg`Mute account`)} 695 </Menu.ItemText> 696 <Menu.ItemIcon 697 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} 698 position="right" 699 /> 700 </Menu.Item> 701 702 {!postAuthor.viewer?.blocking && ( 703 <Menu.Item 704 testID="postDropdownBlockBtn" 705 label={_(msg`Block account`)} 706 onPress={() => blockPromptControl.open()}> 707 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> 708 <Menu.ItemIcon icon={PersonX} position="right" /> 709 </Menu.Item> 710 )} 711 712 <Menu.Item 713 testID="postDropdownReportBtn" 714 label={_(msg`Report post`)} 715 onPress={() => reportDialogControl.open()}> 716 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> 717 <Menu.ItemIcon icon={Warning} position="right" /> 718 </Menu.Item> 719 </> 720 )} 721 722 {isAuthor && ( 723 <> 724 <Menu.Item 725 testID="postDropdownEditPostInteractions" 726 label={_(msg`Edit interaction settings`)} 727 onPress={() => postInteractionSettingsDialogControl.open()} 728 {...(isAuthor 729 ? Platform.select({ 730 web: { 731 onHoverIn: prefetchPostInteractionSettings, 732 }, 733 native: { 734 onPressIn: prefetchPostInteractionSettings, 735 }, 736 }) 737 : {})}> 738 <Menu.ItemText> 739 {_(msg`Edit interaction settings`)} 740 </Menu.ItemText> 741 <Menu.ItemIcon icon={Gear} position="right" /> 742 </Menu.Item> 743 <Menu.Item 744 testID="postDropdownDeleteBtn" 745 label={_(msg`Delete post`)} 746 onPress={() => deletePromptControl.open()}> 747 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 748 <Menu.ItemIcon icon={Trash} position="right" /> 749 </Menu.Item> 750 </> 751 )} 752 </Menu.Group> 753 </> 754 )} 755 </Menu.Outer> 756 757 <Prompt.Basic 758 control={deletePromptControl} 759 title={_(msg`Delete this post?`)} 760 description={_( 761 msg`If you remove this post, you won't be able to recover it.`, 762 )} 763 onConfirm={onDeletePost} 764 confirmButtonCta={_(msg`Delete`)} 765 confirmButtonColor="negative" 766 /> 767 768 <Prompt.Basic 769 control={hidePromptControl} 770 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} 771 description={_( 772 msg`This post will be hidden from feeds and threads. This cannot be undone.`, 773 )} 774 onConfirm={onHidePost} 775 confirmButtonCta={_(msg`Hide`)} 776 /> 777 778 <ReportDialog 779 control={reportDialogControl} 780 subject={{ 781 ...post, 782 $type: 'app.bsky.feed.defs#postView', 783 }} 784 /> 785 786 <PostInteractionSettingsDialog 787 control={postInteractionSettingsDialogControl} 788 postUri={post.uri} 789 rootPostUri={rootUri} 790 initialThreadgateView={post.threadgate} 791 /> 792 793 <Prompt.Basic 794 control={quotePostDetachConfirmControl} 795 title={_(msg`Detach quote post?`)} 796 description={_( 797 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, 798 )} 799 onConfirm={onToggleQuotePostAttachment} 800 confirmButtonCta={_(msg`Yes, detach`)} 801 /> 802 803 <Prompt.Basic 804 control={hideReplyConfirmControl} 805 title={_(msg`Hide this reply?`)} 806 description={_( 807 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.`, 808 )} 809 onConfirm={onToggleReplyVisibility} 810 confirmButtonCta={_(msg`Yes, hide`)} 811 /> 812 813 <Prompt.Basic 814 control={blockPromptControl} 815 title={_(msg`Block Account?`)} 816 description={_( 817 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 818 )} 819 onConfirm={onBlockAuthor} 820 confirmButtonCta={_(msg`Block`)} 821 confirmButtonColor="negative" 822 /> 823 </> 824 ) 825} 826PostMenuItems = memo(PostMenuItems) 827export {PostMenuItems}