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