this repo has no description
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}