this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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