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/core/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 void 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 void 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 (err) {
243 const e = err as Error
244 if (e?.name !== 'AbortError') {
245 logger.error('Failed to toggle thread mute', {message: e})
246 Toast.show(
247 _(msg`Failed to toggle thread mute, please try again`),
248 'xmark',
249 )
250 }
251 }
252 }
253
254 const onCopyPostText = () => {
255 const str = richTextToString(richText, true)
256
257 void Clipboard.setStringAsync(str)
258 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
259 }
260
261 const onPressTranslate = () => {
262 void translate(record.text, langPrefs.primaryLanguage)
263
264 if (
265 bsky.dangerousIsType<AppBskyFeedPost.Record>(
266 post.record,
267 AppBskyFeedPost.isRecord,
268 )
269 ) {
270 ax.metric('translate', {
271 sourceLanguages: post.record.langs ?? [],
272 targetLanguage: langPrefs.primaryLanguage,
273 textLength: post.record.text.length,
274 })
275 }
276 }
277
278 const onHidePost = () => {
279 hidePost({uri: postUri})
280 ax.metric('thread:click:hideReplyForMe', {})
281 }
282
283 const hideInPWI = !!postAuthor.labels?.find(
284 label => label.val === '!no-unauthenticated',
285 )
286
287 const onPressShowMore = () => {
288 feedFeedback.sendInteraction({
289 event: 'app.bsky.feed.defs#requestMore',
290 item: postUri,
291 feedContext: postFeedContext,
292 reqId: postReqId,
293 })
294 ax.metric('post:showMore', {
295 uri: postUri,
296 authorDid: postAuthor.did,
297 logContext,
298 feedDescriptor: feedFeedback.feedDescriptor,
299 })
300 Toast.show(
301 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
302 )
303 }
304
305 const onPressShowLess = () => {
306 feedFeedback.sendInteraction({
307 event: 'app.bsky.feed.defs#requestLess',
308 item: postUri,
309 feedContext: postFeedContext,
310 reqId: postReqId,
311 })
312 ax.metric('post:showLess', {
313 uri: postUri,
314 authorDid: postAuthor.did,
315 logContext,
316 feedDescriptor: feedFeedback.feedDescriptor,
317 })
318 if (onShowLess) {
319 onShowLess({
320 item: postUri,
321 feedContext: postFeedContext,
322 })
323 } else {
324 Toast.show(
325 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
326 )
327 }
328 }
329
330 const onToggleQuotePostAttachment = async () => {
331 if (!quoteEmbed) return
332
333 const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
334 const isDetach = action === 'detach'
335
336 try {
337 await toggleQuoteDetachment({
338 post,
339 quoteUri: quoteEmbed.uri,
340 action: quoteEmbed.isDetached ? 'reattach' : 'detach',
341 })
342 Toast.show(
343 isDetach
344 ? _(msg`Quote post was successfully detached`)
345 : _(msg`Quote post was re-attached`),
346 )
347 } catch (err) {
348 const e = err as Error
349 Toast.show(
350 _(msg({message: 'Updating quote attachment failed', context: 'toast'})),
351 )
352 logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
353 }
354 }
355
356 const canHidePostForMe = !isAuthor && !isPostHidden
357 const canHideReplyForEveryone =
358 !isAuthor && isRootPostAuthor && !isPostHidden && isReply
359 const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
360
361 const onToggleReplyVisibility = async () => {
362 // TODO no threadgate?
363 if (!canHideReplyForEveryone) return
364
365 const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
366 const isHide = action === 'hide'
367
368 try {
369 await toggleReplyVisibility({
370 postUri: rootUri,
371 replyUri: postUri,
372 action,
373 })
374
375 // Log metric only when hiding (not when showing)
376 if (isHide) {
377 ax.metric('thread:click:hideReplyForEveryone', {})
378 }
379
380 Toast.show(
381 isHide
382 ? _(msg`Reply was successfully hidden`)
383 : _(msg({message: 'Reply visibility updated', context: 'toast'})),
384 )
385 } catch (err) {
386 const e = err as Error
387 if (e instanceof MaxHiddenRepliesError) {
388 Toast.show(
389 _(
390 plural(MAX_HIDDEN_REPLIES, {
391 other: 'You can hide a maximum of # replies.',
392 }),
393 ),
394 )
395 } else if (e instanceof InvalidInteractionSettingsError) {
396 Toast.show(
397 _(msg({message: 'Invalid interaction settings.', context: 'toast'})),
398 )
399 } else {
400 Toast.show(
401 _(
402 msg({
403 message: 'Updating reply visibility failed',
404 context: 'toast',
405 }),
406 ),
407 )
408 logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
409 }
410 }
411 }
412
413 const onPressPin = () => {
414 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
415 void pinPostMutate({
416 postUri,
417 postCid,
418 action: isPinned ? 'unpin' : 'pin',
419 })
420 }
421
422 const onBlockAuthor = async () => {
423 try {
424 await queueBlock()
425 Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
426 } catch (err) {
427 const e = err as Error
428 if (e?.name !== 'AbortError') {
429 logger.error('Failed to block account', {message: e})
430 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
431 }
432 }
433 }
434
435 const onMuteAuthor = async () => {
436 if (postAuthor.viewer?.muted) {
437 try {
438 await queueUnmute()
439 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
440 } catch (err) {
441 const e = err as Error
442 if (e?.name !== 'AbortError') {
443 logger.error('Failed to unmute account', {message: e})
444 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
445 }
446 }
447 } else {
448 try {
449 await queueMute()
450 Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
451 } catch (err) {
452 const e = err as Error
453 if (e?.name !== 'AbortError') {
454 logger.error('Failed to mute account', {message: e})
455 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
456 }
457 }
458 }
459 }
460
461 const onReportMisclassification = () => {
462 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
463 href,
464 )}`
465 void openLink(url)
466 }
467
468 const onSignIn = () => requireSignIn(() => {})
469
470 const isDiscoverDebugUser =
471 IS_INTERNAL ||
472 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
473 ax.features.enabled(ax.features.DebugFeedContext)
474
475 return (
476 <>
477 <Menu.Outer>
478 {isAuthor && (
479 <>
480 <Menu.Group>
481 <Menu.Item
482 testID="pinPostBtn"
483 label={
484 isPinned
485 ? _(msg`Unpin from profile`)
486 : _(msg`Pin to your profile`)
487 }
488 disabled={isPinPending}
489 onPress={onPressPin}>
490 <Menu.ItemText>
491 {isPinned
492 ? _(msg`Unpin from profile`)
493 : _(msg`Pin to your profile`)}
494 </Menu.ItemText>
495 <Menu.ItemIcon
496 icon={isPinPending ? Loader : PinIcon}
497 position="right"
498 />
499 </Menu.Item>
500 </Menu.Group>
501 <Menu.Divider />
502 </>
503 )}
504
505 <Menu.Group>
506 {!hideInPWI || hasSession ? (
507 <>
508 <Menu.Item
509 testID="postDropdownTranslateBtn"
510 label={_(msg`Translate`)}
511 onPress={onPressTranslate}>
512 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
513 <Menu.ItemIcon icon={Translate} position="right" />
514 </Menu.Item>
515
516 <Menu.Item
517 testID="postDropdownCopyTextBtn"
518 label={_(msg`Copy post text`)}
519 onPress={onCopyPostText}>
520 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
521 <Menu.ItemIcon icon={ClipboardIcon} position="right" />
522 </Menu.Item>
523 </>
524 ) : (
525 <Menu.Item
526 testID="postDropdownSignInBtn"
527 label={_(msg`Sign in to view post`)}
528 onPress={onSignIn}>
529 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText>
530 <Menu.ItemIcon icon={Eye} position="right" />
531 </Menu.Item>
532 )}
533 </Menu.Group>
534
535 {hasSession && feedFeedback.enabled && (
536 <>
537 <Menu.Divider />
538 <Menu.Group>
539 <Menu.Item
540 testID="postDropdownShowMoreBtn"
541 label={_(msg`Show more like this`)}
542 onPress={onPressShowMore}>
543 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
544 <Menu.ItemIcon icon={EmojiSmile} position="right" />
545 </Menu.Item>
546
547 <Menu.Item
548 testID="postDropdownShowLessBtn"
549 label={_(msg`Show less like this`)}
550 onPress={onPressShowLess}>
551 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
552 <Menu.ItemIcon icon={EmojiSad} position="right" />
553 </Menu.Item>
554 </Menu.Group>
555 </>
556 )}
557
558 {isDiscoverDebugUser && (
559 <>
560 <Menu.Divider />
561 <Menu.Item
562 testID="postDropdownReportMisclassificationBtn"
563 label={_(msg`Assign topic for algo`)}
564 onPress={onReportMisclassification}>
565 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
566 <Menu.ItemIcon icon={AtomIcon} position="right" />
567 </Menu.Item>
568 </>
569 )}
570
571 {hasSession && (
572 <>
573 <Menu.Divider />
574 <Menu.Group>
575 <Menu.Item
576 testID="postDropdownMuteThreadBtn"
577 label={
578 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
579 }
580 onPress={onToggleThreadMute}>
581 <Menu.ItemText>
582 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
583 </Menu.ItemText>
584 <Menu.ItemIcon
585 icon={isThreadMuted ? Unmute : Mute}
586 position="right"
587 />
588 </Menu.Item>
589
590 <Menu.Item
591 testID="postDropdownMuteWordsBtn"
592 label={_(msg`Mute words & tags`)}
593 onPress={() => mutedWordsDialogControl.open()}>
594 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
595 <Menu.ItemIcon icon={Filter} position="right" />
596 </Menu.Item>
597 </Menu.Group>
598 </>
599 )}
600
601 {hasSession &&
602 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
603 <>
604 <Menu.Divider />
605 <Menu.Group>
606 {canHidePostForMe && (
607 <Menu.Item
608 testID="postDropdownHideBtn"
609 label={
610 isReply
611 ? _(msg`Hide reply for me`)
612 : _(msg`Hide post for me`)
613 }
614 onPress={() => hidePromptControl.open()}>
615 <Menu.ItemText>
616 {isReply
617 ? _(msg`Hide reply for me`)
618 : _(msg`Hide post for me`)}
619 </Menu.ItemText>
620 <Menu.ItemIcon icon={EyeSlash} position="right" />
621 </Menu.Item>
622 )}
623 {canHideReplyForEveryone && (
624 <Menu.Item
625 testID="postDropdownHideBtn"
626 label={
627 isReplyHiddenByThreadgate
628 ? _(msg`Show reply for everyone`)
629 : _(msg`Hide reply for everyone`)
630 }
631 onPress={
632 isReplyHiddenByThreadgate
633 ? onToggleReplyVisibility
634 : () => hideReplyConfirmControl.open()
635 }>
636 <Menu.ItemText>
637 {isReplyHiddenByThreadgate
638 ? _(msg`Show reply for everyone`)
639 : _(msg`Hide reply for everyone`)}
640 </Menu.ItemText>
641 <Menu.ItemIcon
642 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
643 position="right"
644 />
645 </Menu.Item>
646 )}
647
648 {canDetachQuote && (
649 <Menu.Item
650 disabled={isDetachPending}
651 testID="postDropdownHideBtn"
652 label={
653 quoteEmbed.isDetached
654 ? _(msg`Re-attach quote`)
655 : _(msg`Detach quote`)
656 }
657 onPress={
658 quoteEmbed.isDetached
659 ? onToggleQuotePostAttachment
660 : () => quotePostDetachConfirmControl.open()
661 }>
662 <Menu.ItemText>
663 {quoteEmbed.isDetached
664 ? _(msg`Re-attach quote`)
665 : _(msg`Detach quote`)}
666 </Menu.ItemText>
667 <Menu.ItemIcon
668 icon={
669 isDetachPending
670 ? Loader
671 : quoteEmbed.isDetached
672 ? Eye
673 : EyeSlash
674 }
675 position="right"
676 />
677 </Menu.Item>
678 )}
679 </Menu.Group>
680 </>
681 )}
682
683 {hasSession && (
684 <>
685 <Menu.Divider />
686 <Menu.Group>
687 {!isAuthor && (
688 <>
689 <Menu.Item
690 testID="postDropdownMuteBtn"
691 label={
692 postAuthor.viewer?.muted
693 ? _(msg`Unmute account`)
694 : _(msg`Mute account`)
695 }
696 onPress={() => void onMuteAuthor()}>
697 <Menu.ItemText>
698 {postAuthor.viewer?.muted
699 ? _(msg`Unmute account`)
700 : _(msg`Mute account`)}
701 </Menu.ItemText>
702 <Menu.ItemIcon
703 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
704 position="right"
705 />
706 </Menu.Item>
707
708 {!postAuthor.viewer?.blocking && (
709 <Menu.Item
710 testID="postDropdownBlockBtn"
711 label={_(msg`Block account`)}
712 onPress={() => blockPromptControl.open()}>
713 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
714 <Menu.ItemIcon icon={PersonX} position="right" />
715 </Menu.Item>
716 )}
717
718 <Menu.Item
719 testID="postDropdownReportBtn"
720 label={_(msg`Report post`)}
721 onPress={() => reportDialogControl.open()}>
722 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
723 <Menu.ItemIcon icon={Warning} position="right" />
724 </Menu.Item>
725 </>
726 )}
727
728 {isAuthor && (
729 <>
730 <Menu.Item
731 testID="postDropdownEditPostInteractions"
732 label={_(msg`Edit interaction settings`)}
733 onPress={() => postInteractionSettingsDialogControl.open()}
734 {...(isAuthor
735 ? Platform.select({
736 web: {
737 onHoverIn: prefetchPostInteractionSettings,
738 },
739 native: {
740 onPressIn: prefetchPostInteractionSettings,
741 },
742 })
743 : {})}>
744 <Menu.ItemText>
745 {_(msg`Edit interaction settings`)}
746 </Menu.ItemText>
747 <Menu.ItemIcon icon={Gear} position="right" />
748 </Menu.Item>
749 <Menu.Item
750 testID="postDropdownDeleteBtn"
751 label={_(msg`Delete post`)}
752 onPress={() => deletePromptControl.open()}>
753 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
754 <Menu.ItemIcon icon={Trash} position="right" />
755 </Menu.Item>
756 </>
757 )}
758 </Menu.Group>
759 </>
760 )}
761 </Menu.Outer>
762
763 <Prompt.Basic
764 control={deletePromptControl}
765 title={_(msg`Delete this post?`)}
766 description={_(
767 msg`If you remove this post, you won't be able to recover it.`,
768 )}
769 onConfirm={onDeletePost}
770 confirmButtonCta={_(msg`Delete`)}
771 confirmButtonColor="negative"
772 />
773
774 <Prompt.Basic
775 control={hidePromptControl}
776 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
777 description={_(
778 msg`This post will be hidden from feeds and threads. This cannot be undone.`,
779 )}
780 onConfirm={onHidePost}
781 confirmButtonCta={_(msg`Hide`)}
782 />
783
784 <ReportDialog
785 control={reportDialogControl}
786 subject={{
787 ...post,
788 $type: 'app.bsky.feed.defs#postView',
789 }}
790 />
791
792 <PostInteractionSettingsDialog
793 control={postInteractionSettingsDialogControl}
794 postUri={post.uri}
795 rootPostUri={rootUri}
796 initialThreadgateView={post.threadgate}
797 />
798
799 <Prompt.Basic
800 control={quotePostDetachConfirmControl}
801 title={_(msg`Detach quote post?`)}
802 description={_(
803 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
804 )}
805 onConfirm={() => void onToggleQuotePostAttachment()}
806 confirmButtonCta={_(msg`Yes, detach`)}
807 />
808
809 <Prompt.Basic
810 control={hideReplyConfirmControl}
811 title={_(msg`Hide this reply?`)}
812 description={_(
813 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.`,
814 )}
815 onConfirm={() => void onToggleReplyVisibility()}
816 confirmButtonCta={_(msg`Yes, hide`)}
817 />
818
819 <Prompt.Basic
820 control={blockPromptControl}
821 title={_(msg`Block Account?`)}
822 description={_(
823 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
824 )}
825 onConfirm={() => void onBlockAuthor()}
826 confirmButtonCta={_(msg`Block`)}
827 confirmButtonColor="negative"
828 />
829 </>
830 )
831}
832PostMenuItems = memo(PostMenuItems)
833export {PostMenuItems}