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, plural} 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 plural(MAX_HIDDEN_REPLIES, {
388 other: 'You can hide a maximum of # replies.',
389 }),
390 ),
391 )
392 } else if (e instanceof InvalidInteractionSettingsError) {
393 Toast.show(
394 _(msg({message: 'Invalid interaction settings.', context: 'toast'})),
395 )
396 } else {
397 Toast.show(
398 _(
399 msg({
400 message: 'Updating reply visibility failed',
401 context: 'toast',
402 }),
403 ),
404 )
405 logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
406 }
407 }
408 }
409
410 const onPressPin = () => {
411 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
412 pinPostMutate({
413 postUri,
414 postCid,
415 action: isPinned ? 'unpin' : 'pin',
416 })
417 }
418
419 const onBlockAuthor = async () => {
420 try {
421 await queueBlock()
422 Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
423 } catch (e: any) {
424 if (e?.name !== 'AbortError') {
425 logger.error('Failed to block account', {message: e})
426 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
427 }
428 }
429 }
430
431 const onMuteAuthor = async () => {
432 if (postAuthor.viewer?.muted) {
433 try {
434 await queueUnmute()
435 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
436 } catch (e: any) {
437 if (e?.name !== 'AbortError') {
438 logger.error('Failed to unmute account', {message: e})
439 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
440 }
441 }
442 } else {
443 try {
444 await queueMute()
445 Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
446 } catch (e: any) {
447 if (e?.name !== 'AbortError') {
448 logger.error('Failed to mute account', {message: e})
449 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
450 }
451 }
452 }
453 }
454
455 const onReportMisclassification = () => {
456 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
457 href,
458 )}`
459 openLink(url)
460 }
461
462 const onSignIn = () => requireSignIn(() => {})
463
464 const isDiscoverDebugUser =
465 IS_INTERNAL ||
466 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
467 ax.features.enabled(ax.features.DebugFeedContext)
468
469 return (
470 <>
471 <Menu.Outer>
472 {isAuthor && (
473 <>
474 <Menu.Group>
475 <Menu.Item
476 testID="pinPostBtn"
477 label={
478 isPinned
479 ? _(msg`Unpin from profile`)
480 : _(msg`Pin to your profile`)
481 }
482 disabled={isPinPending}
483 onPress={onPressPin}>
484 <Menu.ItemText>
485 {isPinned
486 ? _(msg`Unpin from profile`)
487 : _(msg`Pin to your profile`)}
488 </Menu.ItemText>
489 <Menu.ItemIcon
490 icon={isPinPending ? Loader : PinIcon}
491 position="right"
492 />
493 </Menu.Item>
494 </Menu.Group>
495 <Menu.Divider />
496 </>
497 )}
498
499 <Menu.Group>
500 {!hideInPWI || hasSession ? (
501 <>
502 <Menu.Item
503 testID="postDropdownTranslateBtn"
504 label={_(msg`Translate`)}
505 onPress={onPressTranslate}>
506 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
507 <Menu.ItemIcon icon={Translate} position="right" />
508 </Menu.Item>
509
510 <Menu.Item
511 testID="postDropdownCopyTextBtn"
512 label={_(msg`Copy post text`)}
513 onPress={onCopyPostText}>
514 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
515 <Menu.ItemIcon icon={ClipboardIcon} position="right" />
516 </Menu.Item>
517 </>
518 ) : (
519 <Menu.Item
520 testID="postDropdownSignInBtn"
521 label={_(msg`Sign in to view post`)}
522 onPress={onSignIn}>
523 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText>
524 <Menu.ItemIcon icon={Eye} position="right" />
525 </Menu.Item>
526 )}
527 </Menu.Group>
528
529 {hasSession && feedFeedback.enabled && (
530 <>
531 <Menu.Divider />
532 <Menu.Group>
533 <Menu.Item
534 testID="postDropdownShowMoreBtn"
535 label={_(msg`Show more like this`)}
536 onPress={onPressShowMore}>
537 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
538 <Menu.ItemIcon icon={EmojiSmile} position="right" />
539 </Menu.Item>
540
541 <Menu.Item
542 testID="postDropdownShowLessBtn"
543 label={_(msg`Show less like this`)}
544 onPress={onPressShowLess}>
545 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
546 <Menu.ItemIcon icon={EmojiSad} position="right" />
547 </Menu.Item>
548 </Menu.Group>
549 </>
550 )}
551
552 {isDiscoverDebugUser && (
553 <>
554 <Menu.Divider />
555 <Menu.Item
556 testID="postDropdownReportMisclassificationBtn"
557 label={_(msg`Assign topic for algo`)}
558 onPress={onReportMisclassification}>
559 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
560 <Menu.ItemIcon icon={AtomIcon} position="right" />
561 </Menu.Item>
562 </>
563 )}
564
565 {hasSession && (
566 <>
567 <Menu.Divider />
568 <Menu.Group>
569 <Menu.Item
570 testID="postDropdownMuteThreadBtn"
571 label={
572 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
573 }
574 onPress={onToggleThreadMute}>
575 <Menu.ItemText>
576 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
577 </Menu.ItemText>
578 <Menu.ItemIcon
579 icon={isThreadMuted ? Unmute : Mute}
580 position="right"
581 />
582 </Menu.Item>
583
584 <Menu.Item
585 testID="postDropdownMuteWordsBtn"
586 label={_(msg`Mute words & tags`)}
587 onPress={() => mutedWordsDialogControl.open()}>
588 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
589 <Menu.ItemIcon icon={Filter} position="right" />
590 </Menu.Item>
591 </Menu.Group>
592 </>
593 )}
594
595 {hasSession &&
596 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
597 <>
598 <Menu.Divider />
599 <Menu.Group>
600 {canHidePostForMe && (
601 <Menu.Item
602 testID="postDropdownHideBtn"
603 label={
604 isReply
605 ? _(msg`Hide reply for me`)
606 : _(msg`Hide post for me`)
607 }
608 onPress={() => hidePromptControl.open()}>
609 <Menu.ItemText>
610 {isReply
611 ? _(msg`Hide reply for me`)
612 : _(msg`Hide post for me`)}
613 </Menu.ItemText>
614 <Menu.ItemIcon icon={EyeSlash} position="right" />
615 </Menu.Item>
616 )}
617 {canHideReplyForEveryone && (
618 <Menu.Item
619 testID="postDropdownHideBtn"
620 label={
621 isReplyHiddenByThreadgate
622 ? _(msg`Show reply for everyone`)
623 : _(msg`Hide reply for everyone`)
624 }
625 onPress={
626 isReplyHiddenByThreadgate
627 ? onToggleReplyVisibility
628 : () => hideReplyConfirmControl.open()
629 }>
630 <Menu.ItemText>
631 {isReplyHiddenByThreadgate
632 ? _(msg`Show reply for everyone`)
633 : _(msg`Hide reply for everyone`)}
634 </Menu.ItemText>
635 <Menu.ItemIcon
636 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
637 position="right"
638 />
639 </Menu.Item>
640 )}
641
642 {canDetachQuote && (
643 <Menu.Item
644 disabled={isDetachPending}
645 testID="postDropdownHideBtn"
646 label={
647 quoteEmbed.isDetached
648 ? _(msg`Re-attach quote`)
649 : _(msg`Detach quote`)
650 }
651 onPress={
652 quoteEmbed.isDetached
653 ? onToggleQuotePostAttachment
654 : () => quotePostDetachConfirmControl.open()
655 }>
656 <Menu.ItemText>
657 {quoteEmbed.isDetached
658 ? _(msg`Re-attach quote`)
659 : _(msg`Detach quote`)}
660 </Menu.ItemText>
661 <Menu.ItemIcon
662 icon={
663 isDetachPending
664 ? Loader
665 : quoteEmbed.isDetached
666 ? Eye
667 : EyeSlash
668 }
669 position="right"
670 />
671 </Menu.Item>
672 )}
673 </Menu.Group>
674 </>
675 )}
676
677 {hasSession && (
678 <>
679 <Menu.Divider />
680 <Menu.Group>
681 {!isAuthor && (
682 <>
683 <Menu.Item
684 testID="postDropdownMuteBtn"
685 label={
686 postAuthor.viewer?.muted
687 ? _(msg`Unmute account`)
688 : _(msg`Mute account`)
689 }
690 onPress={onMuteAuthor}>
691 <Menu.ItemText>
692 {postAuthor.viewer?.muted
693 ? _(msg`Unmute account`)
694 : _(msg`Mute account`)}
695 </Menu.ItemText>
696 <Menu.ItemIcon
697 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
698 position="right"
699 />
700 </Menu.Item>
701
702 {!postAuthor.viewer?.blocking && (
703 <Menu.Item
704 testID="postDropdownBlockBtn"
705 label={_(msg`Block account`)}
706 onPress={() => blockPromptControl.open()}>
707 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
708 <Menu.ItemIcon icon={PersonX} position="right" />
709 </Menu.Item>
710 )}
711
712 <Menu.Item
713 testID="postDropdownReportBtn"
714 label={_(msg`Report post`)}
715 onPress={() => reportDialogControl.open()}>
716 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
717 <Menu.ItemIcon icon={Warning} position="right" />
718 </Menu.Item>
719 </>
720 )}
721
722 {isAuthor && (
723 <>
724 <Menu.Item
725 testID="postDropdownEditPostInteractions"
726 label={_(msg`Edit interaction settings`)}
727 onPress={() => postInteractionSettingsDialogControl.open()}
728 {...(isAuthor
729 ? Platform.select({
730 web: {
731 onHoverIn: prefetchPostInteractionSettings,
732 },
733 native: {
734 onPressIn: prefetchPostInteractionSettings,
735 },
736 })
737 : {})}>
738 <Menu.ItemText>
739 {_(msg`Edit interaction settings`)}
740 </Menu.ItemText>
741 <Menu.ItemIcon icon={Gear} position="right" />
742 </Menu.Item>
743 <Menu.Item
744 testID="postDropdownDeleteBtn"
745 label={_(msg`Delete post`)}
746 onPress={() => deletePromptControl.open()}>
747 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
748 <Menu.ItemIcon icon={Trash} position="right" />
749 </Menu.Item>
750 </>
751 )}
752 </Menu.Group>
753 </>
754 )}
755 </Menu.Outer>
756
757 <Prompt.Basic
758 control={deletePromptControl}
759 title={_(msg`Delete this post?`)}
760 description={_(
761 msg`If you remove this post, you won't be able to recover it.`,
762 )}
763 onConfirm={onDeletePost}
764 confirmButtonCta={_(msg`Delete`)}
765 confirmButtonColor="negative"
766 />
767
768 <Prompt.Basic
769 control={hidePromptControl}
770 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
771 description={_(
772 msg`This post will be hidden from feeds and threads. This cannot be undone.`,
773 )}
774 onConfirm={onHidePost}
775 confirmButtonCta={_(msg`Hide`)}
776 />
777
778 <ReportDialog
779 control={reportDialogControl}
780 subject={{
781 ...post,
782 $type: 'app.bsky.feed.defs#postView',
783 }}
784 />
785
786 <PostInteractionSettingsDialog
787 control={postInteractionSettingsDialogControl}
788 postUri={post.uri}
789 rootPostUri={rootUri}
790 initialThreadgateView={post.threadgate}
791 />
792
793 <Prompt.Basic
794 control={quotePostDetachConfirmControl}
795 title={_(msg`Detach quote post?`)}
796 description={_(
797 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
798 )}
799 onConfirm={onToggleQuotePostAttachment}
800 confirmButtonCta={_(msg`Yes, detach`)}
801 />
802
803 <Prompt.Basic
804 control={hideReplyConfirmControl}
805 title={_(msg`Hide this reply?`)}
806 description={_(
807 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.`,
808 )}
809 onConfirm={onToggleReplyVisibility}
810 confirmButtonCta={_(msg`Yes, hide`)}
811 />
812
813 <Prompt.Basic
814 control={blockPromptControl}
815 title={_(msg`Block Account?`)}
816 description={_(
817 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
818 )}
819 onConfirm={onBlockAuthor}
820 confirmButtonCta={_(msg`Block`)}
821 confirmButtonColor="negative"
822 />
823 </>
824 )
825}
826PostMenuItems = memo(PostMenuItems)
827export {PostMenuItems}