Bluesky app fork with some witchin' additions 馃挮
at 5ee667f307bc459ba53cdaabdad00a0ea1ee6846 833 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/core/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 void 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 void 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 (err) { 243 const e = err as Error 244 if (e?.name !== 'AbortError') { 245 logger.error('Failed to toggle thread mute', {message: e}) 246 Toast.show( 247 _(msg`Failed to toggle thread mute, please try again`), 248 'xmark', 249 ) 250 } 251 } 252 } 253 254 const onCopyPostText = () => { 255 const str = richTextToString(richText, true) 256 257 void Clipboard.setStringAsync(str) 258 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 259 } 260 261 const onPressTranslate = () => { 262 void translate(record.text, langPrefs.primaryLanguage) 263 264 if ( 265 bsky.dangerousIsType<AppBskyFeedPost.Record>( 266 post.record, 267 AppBskyFeedPost.isRecord, 268 ) 269 ) { 270 ax.metric('translate', { 271 sourceLanguages: post.record.langs ?? [], 272 targetLanguage: langPrefs.primaryLanguage, 273 textLength: post.record.text.length, 274 }) 275 } 276 } 277 278 const onHidePost = () => { 279 hidePost({uri: postUri}) 280 ax.metric('thread:click:hideReplyForMe', {}) 281 } 282 283 const hideInPWI = !!postAuthor.labels?.find( 284 label => label.val === '!no-unauthenticated', 285 ) 286 287 const onPressShowMore = () => { 288 feedFeedback.sendInteraction({ 289 event: 'app.bsky.feed.defs#requestMore', 290 item: postUri, 291 feedContext: postFeedContext, 292 reqId: postReqId, 293 }) 294 ax.metric('post:showMore', { 295 uri: postUri, 296 authorDid: postAuthor.did, 297 logContext, 298 feedDescriptor: feedFeedback.feedDescriptor, 299 }) 300 Toast.show( 301 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 302 ) 303 } 304 305 const onPressShowLess = () => { 306 feedFeedback.sendInteraction({ 307 event: 'app.bsky.feed.defs#requestLess', 308 item: postUri, 309 feedContext: postFeedContext, 310 reqId: postReqId, 311 }) 312 ax.metric('post:showLess', { 313 uri: postUri, 314 authorDid: postAuthor.did, 315 logContext, 316 feedDescriptor: feedFeedback.feedDescriptor, 317 }) 318 if (onShowLess) { 319 onShowLess({ 320 item: postUri, 321 feedContext: postFeedContext, 322 }) 323 } else { 324 Toast.show( 325 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 326 ) 327 } 328 } 329 330 const onToggleQuotePostAttachment = async () => { 331 if (!quoteEmbed) return 332 333 const action = quoteEmbed.isDetached ? 'reattach' : 'detach' 334 const isDetach = action === 'detach' 335 336 try { 337 await toggleQuoteDetachment({ 338 post, 339 quoteUri: quoteEmbed.uri, 340 action: quoteEmbed.isDetached ? 'reattach' : 'detach', 341 }) 342 Toast.show( 343 isDetach 344 ? _(msg`Quote post was successfully detached`) 345 : _(msg`Quote post was re-attached`), 346 ) 347 } catch (err) { 348 const e = err as Error 349 Toast.show( 350 _(msg({message: 'Updating quote attachment failed', context: 'toast'})), 351 ) 352 logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) 353 } 354 } 355 356 const canHidePostForMe = !isAuthor && !isPostHidden 357 const canHideReplyForEveryone = 358 !isAuthor && isRootPostAuthor && !isPostHidden && isReply 359 const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer 360 361 const onToggleReplyVisibility = async () => { 362 // TODO no threadgate? 363 if (!canHideReplyForEveryone) return 364 365 const action = isReplyHiddenByThreadgate ? 'show' : 'hide' 366 const isHide = action === 'hide' 367 368 try { 369 await toggleReplyVisibility({ 370 postUri: rootUri, 371 replyUri: postUri, 372 action, 373 }) 374 375 // Log metric only when hiding (not when showing) 376 if (isHide) { 377 ax.metric('thread:click:hideReplyForEveryone', {}) 378 } 379 380 Toast.show( 381 isHide 382 ? _(msg`Reply was successfully hidden`) 383 : _(msg({message: 'Reply visibility updated', context: 'toast'})), 384 ) 385 } catch (err) { 386 const e = err as Error 387 if (e instanceof MaxHiddenRepliesError) { 388 Toast.show( 389 _( 390 plural(MAX_HIDDEN_REPLIES, { 391 other: 'You can hide a maximum of # replies.', 392 }), 393 ), 394 ) 395 } else if (e instanceof InvalidInteractionSettingsError) { 396 Toast.show( 397 _(msg({message: 'Invalid interaction settings.', context: 'toast'})), 398 ) 399 } else { 400 Toast.show( 401 _( 402 msg({ 403 message: 'Updating reply visibility failed', 404 context: 'toast', 405 }), 406 ), 407 ) 408 logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) 409 } 410 } 411 } 412 413 const onPressPin = () => { 414 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) 415 void pinPostMutate({ 416 postUri, 417 postCid, 418 action: isPinned ? 'unpin' : 'pin', 419 }) 420 } 421 422 const onBlockAuthor = async () => { 423 try { 424 await queueBlock() 425 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 426 } catch (err) { 427 const e = err as Error 428 if (e?.name !== 'AbortError') { 429 logger.error('Failed to block account', {message: e}) 430 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 431 } 432 } 433 } 434 435 const onMuteAuthor = async () => { 436 if (postAuthor.viewer?.muted) { 437 try { 438 await queueUnmute() 439 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 440 } catch (err) { 441 const e = err as Error 442 if (e?.name !== 'AbortError') { 443 logger.error('Failed to unmute account', {message: e}) 444 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 445 } 446 } 447 } else { 448 try { 449 await queueMute() 450 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 451 } catch (err) { 452 const e = err as Error 453 if (e?.name !== 'AbortError') { 454 logger.error('Failed to mute account', {message: e}) 455 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 456 } 457 } 458 } 459 } 460 461 const onReportMisclassification = () => { 462 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( 463 href, 464 )}` 465 void openLink(url) 466 } 467 468 const onSignIn = () => requireSignIn(() => {}) 469 470 const isDiscoverDebugUser = 471 IS_INTERNAL || 472 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 473 ax.features.enabled(ax.features.DebugFeedContext) 474 475 return ( 476 <> 477 <Menu.Outer> 478 {isAuthor && ( 479 <> 480 <Menu.Group> 481 <Menu.Item 482 testID="pinPostBtn" 483 label={ 484 isPinned 485 ? _(msg`Unpin from profile`) 486 : _(msg`Pin to your profile`) 487 } 488 disabled={isPinPending} 489 onPress={onPressPin}> 490 <Menu.ItemText> 491 {isPinned 492 ? _(msg`Unpin from profile`) 493 : _(msg`Pin to your profile`)} 494 </Menu.ItemText> 495 <Menu.ItemIcon 496 icon={isPinPending ? Loader : PinIcon} 497 position="right" 498 /> 499 </Menu.Item> 500 </Menu.Group> 501 <Menu.Divider /> 502 </> 503 )} 504 505 <Menu.Group> 506 {!hideInPWI || hasSession ? ( 507 <> 508 <Menu.Item 509 testID="postDropdownTranslateBtn" 510 label={_(msg`Translate`)} 511 onPress={onPressTranslate}> 512 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> 513 <Menu.ItemIcon icon={Translate} position="right" /> 514 </Menu.Item> 515 516 <Menu.Item 517 testID="postDropdownCopyTextBtn" 518 label={_(msg`Copy post text`)} 519 onPress={onCopyPostText}> 520 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> 521 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 522 </Menu.Item> 523 </> 524 ) : ( 525 <Menu.Item 526 testID="postDropdownSignInBtn" 527 label={_(msg`Sign in to view post`)} 528 onPress={onSignIn}> 529 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText> 530 <Menu.ItemIcon icon={Eye} position="right" /> 531 </Menu.Item> 532 )} 533 </Menu.Group> 534 535 {hasSession && feedFeedback.enabled && ( 536 <> 537 <Menu.Divider /> 538 <Menu.Group> 539 <Menu.Item 540 testID="postDropdownShowMoreBtn" 541 label={_(msg`Show more like this`)} 542 onPress={onPressShowMore}> 543 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> 544 <Menu.ItemIcon icon={EmojiSmile} position="right" /> 545 </Menu.Item> 546 547 <Menu.Item 548 testID="postDropdownShowLessBtn" 549 label={_(msg`Show less like this`)} 550 onPress={onPressShowLess}> 551 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> 552 <Menu.ItemIcon icon={EmojiSad} position="right" /> 553 </Menu.Item> 554 </Menu.Group> 555 </> 556 )} 557 558 {isDiscoverDebugUser && ( 559 <> 560 <Menu.Divider /> 561 <Menu.Item 562 testID="postDropdownReportMisclassificationBtn" 563 label={_(msg`Assign topic for algo`)} 564 onPress={onReportMisclassification}> 565 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 566 <Menu.ItemIcon icon={AtomIcon} position="right" /> 567 </Menu.Item> 568 </> 569 )} 570 571 {hasSession && ( 572 <> 573 <Menu.Divider /> 574 <Menu.Group> 575 <Menu.Item 576 testID="postDropdownMuteThreadBtn" 577 label={ 578 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) 579 } 580 onPress={onToggleThreadMute}> 581 <Menu.ItemText> 582 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} 583 </Menu.ItemText> 584 <Menu.ItemIcon 585 icon={isThreadMuted ? Unmute : Mute} 586 position="right" 587 /> 588 </Menu.Item> 589 590 <Menu.Item 591 testID="postDropdownMuteWordsBtn" 592 label={_(msg`Mute words & tags`)} 593 onPress={() => mutedWordsDialogControl.open()}> 594 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> 595 <Menu.ItemIcon icon={Filter} position="right" /> 596 </Menu.Item> 597 </Menu.Group> 598 </> 599 )} 600 601 {hasSession && 602 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( 603 <> 604 <Menu.Divider /> 605 <Menu.Group> 606 {canHidePostForMe && ( 607 <Menu.Item 608 testID="postDropdownHideBtn" 609 label={ 610 isReply 611 ? _(msg`Hide reply for me`) 612 : _(msg`Hide post for me`) 613 } 614 onPress={() => hidePromptControl.open()}> 615 <Menu.ItemText> 616 {isReply 617 ? _(msg`Hide reply for me`) 618 : _(msg`Hide post for me`)} 619 </Menu.ItemText> 620 <Menu.ItemIcon icon={EyeSlash} position="right" /> 621 </Menu.Item> 622 )} 623 {canHideReplyForEveryone && ( 624 <Menu.Item 625 testID="postDropdownHideBtn" 626 label={ 627 isReplyHiddenByThreadgate 628 ? _(msg`Show reply for everyone`) 629 : _(msg`Hide reply for everyone`) 630 } 631 onPress={ 632 isReplyHiddenByThreadgate 633 ? onToggleReplyVisibility 634 : () => hideReplyConfirmControl.open() 635 }> 636 <Menu.ItemText> 637 {isReplyHiddenByThreadgate 638 ? _(msg`Show reply for everyone`) 639 : _(msg`Hide reply for everyone`)} 640 </Menu.ItemText> 641 <Menu.ItemIcon 642 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} 643 position="right" 644 /> 645 </Menu.Item> 646 )} 647 648 {canDetachQuote && ( 649 <Menu.Item 650 disabled={isDetachPending} 651 testID="postDropdownHideBtn" 652 label={ 653 quoteEmbed.isDetached 654 ? _(msg`Re-attach quote`) 655 : _(msg`Detach quote`) 656 } 657 onPress={ 658 quoteEmbed.isDetached 659 ? onToggleQuotePostAttachment 660 : () => quotePostDetachConfirmControl.open() 661 }> 662 <Menu.ItemText> 663 {quoteEmbed.isDetached 664 ? _(msg`Re-attach quote`) 665 : _(msg`Detach quote`)} 666 </Menu.ItemText> 667 <Menu.ItemIcon 668 icon={ 669 isDetachPending 670 ? Loader 671 : quoteEmbed.isDetached 672 ? Eye 673 : EyeSlash 674 } 675 position="right" 676 /> 677 </Menu.Item> 678 )} 679 </Menu.Group> 680 </> 681 )} 682 683 {hasSession && ( 684 <> 685 <Menu.Divider /> 686 <Menu.Group> 687 {!isAuthor && ( 688 <> 689 <Menu.Item 690 testID="postDropdownMuteBtn" 691 label={ 692 postAuthor.viewer?.muted 693 ? _(msg`Unmute account`) 694 : _(msg`Mute account`) 695 } 696 onPress={() => void onMuteAuthor()}> 697 <Menu.ItemText> 698 {postAuthor.viewer?.muted 699 ? _(msg`Unmute account`) 700 : _(msg`Mute account`)} 701 </Menu.ItemText> 702 <Menu.ItemIcon 703 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} 704 position="right" 705 /> 706 </Menu.Item> 707 708 {!postAuthor.viewer?.blocking && ( 709 <Menu.Item 710 testID="postDropdownBlockBtn" 711 label={_(msg`Block account`)} 712 onPress={() => blockPromptControl.open()}> 713 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> 714 <Menu.ItemIcon icon={PersonX} position="right" /> 715 </Menu.Item> 716 )} 717 718 <Menu.Item 719 testID="postDropdownReportBtn" 720 label={_(msg`Report post`)} 721 onPress={() => reportDialogControl.open()}> 722 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> 723 <Menu.ItemIcon icon={Warning} position="right" /> 724 </Menu.Item> 725 </> 726 )} 727 728 {isAuthor && ( 729 <> 730 <Menu.Item 731 testID="postDropdownEditPostInteractions" 732 label={_(msg`Edit interaction settings`)} 733 onPress={() => postInteractionSettingsDialogControl.open()} 734 {...(isAuthor 735 ? Platform.select({ 736 web: { 737 onHoverIn: prefetchPostInteractionSettings, 738 }, 739 native: { 740 onPressIn: prefetchPostInteractionSettings, 741 }, 742 }) 743 : {})}> 744 <Menu.ItemText> 745 {_(msg`Edit interaction settings`)} 746 </Menu.ItemText> 747 <Menu.ItemIcon icon={Gear} position="right" /> 748 </Menu.Item> 749 <Menu.Item 750 testID="postDropdownDeleteBtn" 751 label={_(msg`Delete post`)} 752 onPress={() => deletePromptControl.open()}> 753 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 754 <Menu.ItemIcon icon={Trash} position="right" /> 755 </Menu.Item> 756 </> 757 )} 758 </Menu.Group> 759 </> 760 )} 761 </Menu.Outer> 762 763 <Prompt.Basic 764 control={deletePromptControl} 765 title={_(msg`Delete this post?`)} 766 description={_( 767 msg`If you remove this post, you won't be able to recover it.`, 768 )} 769 onConfirm={onDeletePost} 770 confirmButtonCta={_(msg`Delete`)} 771 confirmButtonColor="negative" 772 /> 773 774 <Prompt.Basic 775 control={hidePromptControl} 776 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} 777 description={_( 778 msg`This post will be hidden from feeds and threads. This cannot be undone.`, 779 )} 780 onConfirm={onHidePost} 781 confirmButtonCta={_(msg`Hide`)} 782 /> 783 784 <ReportDialog 785 control={reportDialogControl} 786 subject={{ 787 ...post, 788 $type: 'app.bsky.feed.defs#postView', 789 }} 790 /> 791 792 <PostInteractionSettingsDialog 793 control={postInteractionSettingsDialogControl} 794 postUri={post.uri} 795 rootPostUri={rootUri} 796 initialThreadgateView={post.threadgate} 797 /> 798 799 <Prompt.Basic 800 control={quotePostDetachConfirmControl} 801 title={_(msg`Detach quote post?`)} 802 description={_( 803 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, 804 )} 805 onConfirm={() => void onToggleQuotePostAttachment()} 806 confirmButtonCta={_(msg`Yes, detach`)} 807 /> 808 809 <Prompt.Basic 810 control={hideReplyConfirmControl} 811 title={_(msg`Hide this reply?`)} 812 description={_( 813 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.`, 814 )} 815 onConfirm={() => void onToggleReplyVisibility()} 816 confirmButtonCta={_(msg`Yes, hide`)} 817 /> 818 819 <Prompt.Basic 820 control={blockPromptControl} 821 title={_(msg`Block Account?`)} 822 description={_( 823 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 824 )} 825 onConfirm={() => void onBlockAuthor()} 826 confirmButtonCta={_(msg`Block`)} 827 confirmButtonColor="negative" 828 /> 829 </> 830 ) 831} 832PostMenuItems = memo(PostMenuItems) 833export {PostMenuItems}