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