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