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 AppBskyEmbedExternal,
11 type AppBskyEmbedImages,
12 AppBskyEmbedRecord,
13 type AppBskyEmbedRecordWithMedia,
14 type AppBskyEmbedVideo,
15 type AppBskyFeedDefs,
16 AppBskyFeedPost,
17 type AppBskyFeedThreadgate,
18 AtUri,
19 type BlobRef,
20 type RichText as RichTextAPI,
21} from '@atproto/api'
22import {msg, plural} from '@lingui/core/macro'
23import {useLingui} from '@lingui/react'
24import {useNavigation} from '@react-navigation/native'
25
26import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
27import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
28import {useOpenLink} from '#/lib/hooks/useOpenLink'
29import {useTranslate} from '#/lib/hooks/useTranslate'
30import {saveVideoToMediaLibrary} from '#/lib/media/manip'
31import {downloadVideoWeb} from '#/lib/media/manip.web'
32import {getCurrentRoute} from '#/lib/routes/helpers'
33import {makeProfileLink} from '#/lib/routes/links'
34import {
35 type CommonNavigatorParams,
36 type NavigationProp,
37} from '#/lib/routes/types'
38import {richTextToString} from '#/lib/strings/rich-text-helpers'
39import {restoreLinks} from '#/lib/strings/rich-text-manip'
40import {toShareUrl} from '#/lib/strings/url-helpers'
41import {logger} from '#/logger'
42import {type Shadow} from '#/state/cache/post-shadow'
43import {useProfileShadow} from '#/state/cache/profile-shadow'
44import {useFeedFeedbackContext} from '#/state/feed-feedback'
45import {
46 useHiddenPosts,
47 useHiddenPostsApi,
48 useLanguagePrefs,
49} from '#/state/preferences'
50import {usePinnedPostMutation} from '#/state/queries/pinned-post'
51import {
52 usePostDeleteMutation,
53 useThreadMuteMutationQueue,
54} from '#/state/queries/post'
55import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
56import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
57import {
58 useProfileBlockMutationQueue,
59 useProfileMuteMutationQueue,
60} from '#/state/queries/profile'
61import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity'
62import {
63 InvalidInteractionSettingsError,
64 MAX_HIDDEN_REPLIES,
65 MaxHiddenRepliesError,
66 useToggleReplyVisibilityMutation,
67} from '#/state/queries/threadgate'
68import {useRequireAuth, useSession} from '#/state/session'
69import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
70import * as Toast from '#/view/com/util/Toast'
71import {useDialogControl} from '#/components/Dialog'
72import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
73import {
74 PostInteractionSettingsDialog,
75 usePrefetchPostInteractionSettings,
76} from '#/components/dialogs/PostInteractionSettingsDialog'
77import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom'
78import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
79import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
80import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download'
81import {
82 EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
83 EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
84} from '#/components/icons/Emoji'
85import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
86import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
87import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
88import {
89 Mute_Stroke2_Corner0_Rounded as Mute,
90 Mute_Stroke2_Corner0_Rounded as MuteIcon,
91} from '#/components/icons/Mute'
92import {Pencil_Stroke2_Corner0_Rounded as Pen} from '#/components/icons/Pencil'
93import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
94import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
95import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
96import {
97 SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute,
98 SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon,
99} from '#/components/icons/Speaker'
100import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
101import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
102import {Loader} from '#/components/Loader'
103import * as Menu from '#/components/Menu'
104import {
105 ReportDialog,
106 useReportDialogControl,
107} from '#/components/moderation/ReportDialog'
108import * as Prompt from '#/components/Prompt'
109import {useAnalytics} from '#/analytics'
110import {IS_INTERNAL, IS_WEB} from '#/env'
111import * as bsky from '#/types/bsky'
112
113let PostMenuItems = ({
114 post,
115 postFeedContext,
116 postReqId,
117 record,
118 richText,
119 threadgateRecord,
120 onShowLess,
121 logContext,
122}: {
123 testID: string
124 post: Shadow<AppBskyFeedDefs.PostView>
125 postFeedContext: string | undefined
126 postReqId: string | undefined
127 record: AppBskyFeedPost.Record
128 richText: RichTextAPI
129 style?: StyleProp<ViewStyle>
130 hitSlop?: PressableProps['hitSlop']
131 size?: 'lg' | 'md' | 'sm'
132 timestamp: string
133 threadgateRecord?: AppBskyFeedThreadgate.Record
134 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
135 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
136}): React.ReactNode => {
137 const {hasSession, currentAccount} = useSession()
138 const {_} = useLingui()
139 const ax = useAnalytics()
140 const langPrefs = useLanguagePrefs()
141 const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
142 const {mutateAsync: pinPostMutate, isPending: isPinPending} =
143 usePinnedPostMutation()
144 const requireSignIn = useRequireAuth()
145 const hiddenPosts = useHiddenPosts()
146 const {hidePost} = useHiddenPostsApi()
147 const feedFeedback = useFeedFeedbackContext()
148 const openLink = useOpenLink()
149 const translate = useTranslate()
150 const navigation = useNavigation<NavigationProp>()
151 const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
152 const blockPromptControl = useDialogControl()
153 const reportDialogControl = useReportDialogControl()
154 const deletePromptControl = useDialogControl()
155 const hidePromptControl = useDialogControl()
156 const postInteractionSettingsDialogControl = useDialogControl()
157 const quotePostDetachConfirmControl = useDialogControl()
158 const hideReplyConfirmControl = useDialogControl()
159 const redraftPromptControl = useDialogControl()
160 const {mutateAsync: toggleReplyVisibility} =
161 useToggleReplyVisibilityMutation()
162
163 const postUri = post.uri
164 const postCid = post.cid
165 const postAuthor = useProfileShadow(post.author)
166 const quoteEmbed = useMemo(() => {
167 if (!currentAccount || !post.embed) return
168 return getMaybeDetachedQuoteEmbed({
169 viewerDid: currentAccount.did,
170 post,
171 })
172 }, [post, currentAccount])
173
174 const rootUri = record.reply?.root?.uri || postUri
175 const isReply = Boolean(record.reply)
176 const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
177 post,
178 rootUri,
179 )
180 const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
181 const isAuthor = postAuthor.did === currentAccount?.did
182 const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
183 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
184 threadgateRecord,
185 })
186 const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
187 const isPinned = post.viewer?.pinned
188
189 const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} =
190 useToggleQuoteDetachmentMutation()
191
192 const [queueBlock] = useProfileBlockMutationQueue(postAuthor)
193 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor)
194
195 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
196 postUri: post.uri,
197 rootPostUri: rootUri,
198 })
199
200 const href = useMemo(() => {
201 const urip = new AtUri(postUri)
202 return makeProfileLink(postAuthor, 'post', urip.rkey)
203 }, [postUri, postAuthor])
204
205 const onDeletePost = () => {
206 deletePostMutate({uri: postUri}).then(
207 () => {
208 Toast.show(_(msg({message: 'Post deleted', context: 'toast'})))
209
210 const route = getCurrentRoute(navigation.getState())
211 if (route.name === 'PostThread') {
212 const params = route.params as CommonNavigatorParams['PostThread']
213 if (
214 currentAccount &&
215 isAuthor &&
216 (params.name === currentAccount.handle ||
217 params.name === currentAccount.did)
218 ) {
219 const currentHref = makeProfileLink(postAuthor, 'post', params.rkey)
220 if (currentHref === href && navigation.canGoBack()) {
221 navigation.goBack()
222 }
223 }
224 }
225 },
226 e => {
227 logger.error('Failed to delete post', {message: e})
228 Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
229 },
230 )
231 }
232
233 const {openComposer} = useOpenComposer()
234 const onRedraftPost = () => {
235 redraftPromptControl.open()
236 }
237
238 const onConfirmRedraft = () => {
239 let imageUris: {
240 uri: string
241 width: number
242 height: number
243 altText?: string
244 blobRef?: AppBskyEmbedImages.Image['image']
245 }[] = []
246
247 const recordEmbed = record.embed
248 let recordImages: AppBskyEmbedImages.Image[] = []
249 if (recordEmbed?.$type === 'app.bsky.embed.images') {
250 recordImages = (recordEmbed as AppBskyEmbedImages.Main).images
251 } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') {
252 const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media
253 if (media.$type === 'app.bsky.embed.images') {
254 recordImages = (media as AppBskyEmbedImages.Main).images
255 }
256 }
257
258 if (post.embed?.$type === 'app.bsky.embed.images#view') {
259 const embed = post.embed as AppBskyEmbedImages.View
260 imageUris = embed.images.map((img, i) => ({
261 uri: img.fullsize,
262 width: img.aspectRatio?.width ?? 1000,
263 height: img.aspectRatio?.height ?? 1000,
264 altText: img.alt,
265 blobRef: recordImages[i]?.image,
266 }))
267 } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
268 const embed = post.embed as AppBskyEmbedRecordWithMedia.View
269 if (embed.media.$type === 'app.bsky.embed.images#view') {
270 const images = embed.media as AppBskyEmbedImages.View
271 imageUris = images.images.map((img, i) => ({
272 uri: img.fullsize,
273 width: img.aspectRatio?.width ?? 1000,
274 height: img.aspectRatio?.height ?? 1000,
275 altText: img.alt,
276 blobRef: recordImages[i]?.image,
277 }))
278 }
279 }
280
281 let quotePost: AppBskyFeedDefs.PostView | undefined
282
283 if (post.embed?.$type === 'app.bsky.embed.record#view') {
284 const embed = post.embed as AppBskyEmbedRecord.View
285 if (
286 AppBskyEmbedRecord.isViewRecord(embed.record) &&
287 AppBskyFeedPost.isRecord(embed.record.value)
288 ) {
289 quotePost = {
290 uri: embed.record.uri,
291 cid: embed.record.cid,
292 author: embed.record.author,
293 record: embed.record.value,
294 indexedAt: embed.record.indexedAt,
295 } as AppBskyFeedDefs.PostView
296 }
297 } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
298 const embed = post.embed as AppBskyEmbedRecordWithMedia.View
299 if (
300 AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
301 AppBskyFeedPost.isRecord(embed.record.record.value)
302 ) {
303 const record = embed.record.record
304 quotePost = {
305 uri: record.uri,
306 cid: record.cid,
307 author: record.author,
308 record: record.value,
309 indexedAt: record.indexedAt,
310 } as AppBskyFeedDefs.PostView
311 }
312 }
313
314 let replyTo: any
315 if (record.reply) {
316 const parent = record.reply.parent || record.reply.root
317 if (parent) {
318 replyTo = {
319 uri: parent.uri,
320 cid: parent.cid,
321 }
322 }
323 }
324
325 let videoUri:
326 | {
327 uri: string
328 width: number
329 height: number
330 blobRef?: BlobRef
331 altText?: string
332 }
333 | undefined
334 let recordVideo: AppBskyEmbedVideo.Main | undefined
335
336 if (recordEmbed?.$type === 'app.bsky.embed.video') {
337 recordVideo = recordEmbed as AppBskyEmbedVideo.Main
338 } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') {
339 const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media
340 if (media.$type === 'app.bsky.embed.video') {
341 recordVideo = media as AppBskyEmbedVideo.Main
342 }
343 }
344
345 if (post.embed?.$type === 'app.bsky.embed.video#view') {
346 const embed = post.embed as AppBskyEmbedVideo.View
347 if (recordVideo) {
348 videoUri = {
349 uri: embed.playlist || '',
350 width: embed.aspectRatio?.width ?? 1000,
351 height: embed.aspectRatio?.height ?? 1000,
352 blobRef: recordVideo.video,
353 altText: embed.alt || '',
354 }
355 }
356 } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
357 const embed = post.embed as AppBskyEmbedRecordWithMedia.View
358 if (embed.media.$type === 'app.bsky.embed.video#view' && recordVideo) {
359 const video = embed.media as AppBskyEmbedVideo.View
360 videoUri = {
361 uri: video.playlist || '',
362 width: video.aspectRatio?.width ?? 1000,
363 height: video.aspectRatio?.height ?? 1000,
364 blobRef: recordVideo.video,
365 altText: video.alt || '',
366 }
367 }
368 }
369
370 openComposer({
371 text: restoreLinks(record.text, record.facets),
372 imageUris,
373 videoUri,
374 onPost: () => {
375 onDeletePost()
376 },
377 quote: quotePost,
378 replyTo,
379 })
380 }
381
382 const onToggleThreadMute = () => {
383 try {
384 if (isThreadMuted) {
385 unmuteThread()
386 ax.metric('post:unmute', {
387 uri: postUri,
388 authorDid: postAuthor.did,
389 logContext,
390 feedDescriptor: feedFeedback.feedDescriptor,
391 })
392 Toast.show(_(msg`You will now receive notifications for this thread`))
393 } else {
394 muteThread()
395 ax.metric('post:mute', {
396 uri: postUri,
397 authorDid: postAuthor.did,
398 logContext,
399 feedDescriptor: feedFeedback.feedDescriptor,
400 })
401 Toast.show(
402 _(msg`You will no longer receive notifications for this thread`),
403 )
404 }
405 } catch (e: any) {
406 if (e?.name !== 'AbortError') {
407 logger.error('Failed to toggle thread mute', {message: e})
408 Toast.show(
409 _(msg`Failed to toggle thread mute, please try again`),
410 'xmark',
411 )
412 }
413 }
414 }
415
416 const onCopyPostText = () => {
417 const str = richTextToString(richText, true)
418
419 Clipboard.setStringAsync(str)
420 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
421 }
422
423 const onPressTranslate = () => {
424 translate(record.text, langPrefs.primaryLanguage)
425
426 if (
427 bsky.dangerousIsType<AppBskyFeedPost.Record>(
428 post.record,
429 AppBskyFeedPost.isRecord,
430 )
431 ) {
432 ax.metric('translate', {
433 sourceLanguages: post.record.langs ?? [],
434 targetLanguage: langPrefs.primaryLanguage,
435 textLength: post.record.text.length,
436 })
437 }
438 }
439
440 const onHidePost = () => {
441 hidePost({uri: postUri})
442 ax.metric('thread:click:hideReplyForMe', {})
443 }
444
445 const hideInPWI = !!postAuthor.labels?.find(
446 label => label.val === '!no-unauthenticated',
447 )
448
449 const onPressShowMore = () => {
450 feedFeedback.sendInteraction({
451 event: 'app.bsky.feed.defs#requestMore',
452 item: postUri,
453 feedContext: postFeedContext,
454 reqId: postReqId,
455 })
456 ax.metric('post:showMore', {
457 uri: postUri,
458 authorDid: postAuthor.did,
459 logContext,
460 feedDescriptor: feedFeedback.feedDescriptor,
461 })
462 Toast.show(
463 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
464 )
465 }
466
467 const onPressShowLess = () => {
468 feedFeedback.sendInteraction({
469 event: 'app.bsky.feed.defs#requestLess',
470 item: postUri,
471 feedContext: postFeedContext,
472 reqId: postReqId,
473 })
474 ax.metric('post:showLess', {
475 uri: postUri,
476 authorDid: postAuthor.did,
477 logContext,
478 feedDescriptor: feedFeedback.feedDescriptor,
479 })
480 if (onShowLess) {
481 onShowLess({
482 item: postUri,
483 feedContext: postFeedContext,
484 })
485 } else {
486 Toast.show(
487 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
488 )
489 }
490 }
491
492 const onToggleQuotePostAttachment = async () => {
493 if (!quoteEmbed) return
494
495 const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
496 const isDetach = action === 'detach'
497
498 try {
499 await toggleQuoteDetachment({
500 post,
501 quoteUri: quoteEmbed.uri,
502 action: quoteEmbed.isDetached ? 'reattach' : 'detach',
503 })
504 Toast.show(
505 isDetach
506 ? _(msg`Quote post was successfully detached`)
507 : _(msg`Quote post was re-attached`),
508 )
509 } catch (e: any) {
510 Toast.show(
511 _(msg({message: 'Updating quote attachment failed', context: 'toast'})),
512 )
513 logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
514 }
515 }
516
517 const canHidePostForMe = !isAuthor && !isPostHidden
518 const canHideReplyForEveryone =
519 !isAuthor && isRootPostAuthor && !isPostHidden && isReply
520 const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
521
522 const onToggleReplyVisibility = async () => {
523 // TODO no threadgate?
524 if (!canHideReplyForEveryone) return
525
526 const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
527 const isHide = action === 'hide'
528
529 try {
530 await toggleReplyVisibility({
531 postUri: rootUri,
532 replyUri: postUri,
533 action,
534 })
535
536 // Log metric only when hiding (not when showing)
537 if (isHide) {
538 ax.metric('thread:click:hideReplyForEveryone', {})
539 }
540
541 Toast.show(
542 isHide
543 ? _(msg`Reply was successfully hidden`)
544 : _(msg({message: 'Reply visibility updated', context: 'toast'})),
545 )
546 } catch (e: any) {
547 if (e instanceof MaxHiddenRepliesError) {
548 Toast.show(
549 _(
550 plural(MAX_HIDDEN_REPLIES, {
551 other: 'You can hide a maximum of # replies.',
552 }),
553 ),
554 )
555 } else if (e instanceof InvalidInteractionSettingsError) {
556 Toast.show(
557 _(msg({message: 'Invalid interaction settings.', context: 'toast'})),
558 )
559 } else {
560 Toast.show(
561 _(
562 msg({
563 message: 'Updating reply visibility failed',
564 context: 'toast',
565 }),
566 ),
567 )
568 logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
569 }
570 }
571 }
572
573 const onPressPin = () => {
574 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
575 pinPostMutate({
576 postUri,
577 postCid,
578 action: isPinned ? 'unpin' : 'pin',
579 })
580 }
581
582 const videoEmbed: AppBskyEmbedVideo.View | undefined = useMemo(() => {
583 if (post.embed?.$type === 'app.bsky.embed.video#view')
584 return post.embed as AppBskyEmbedVideo.View
585 if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
586 const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined
587 if (embed?.media.$type === 'app.bsky.embed.video#view')
588 return embed?.media as AppBskyEmbedVideo.View
589 }
590 return undefined
591 }, [post])
592
593 const gifEmbed: AppBskyEmbedExternal.View | undefined = useMemo(() => {
594 if (post.embed?.$type === 'app.bsky.embed.external#view')
595 return post.embed as AppBskyEmbedExternal.View
596 if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
597 const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined
598 if (embed?.media.$type === 'app.bsky.embed.external#view')
599 return embed?.media as AppBskyEmbedExternal.View
600 }
601 return undefined
602 }, [post])
603
604 const onPressDownloadVideo = async () => {
605 if (!videoEmbed) return
606 const did = post.author.did
607 const cid = videoEmbed.cid
608 if (!did.startsWith('did:')) return
609 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`)
610 const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
611
612 Toast.show(_(msg({message: 'Downloading video...', context: 'toast'})))
613
614 let success
615 if (IS_WEB) success = await downloadVideoWeb({uri: uri})
616 else success = await saveVideoToMediaLibrary({uri: uri})
617
618 if (success) Toast.show('Video downloaded', 'check')
619 else Toast.show('Failed to download video', 'xmark')
620 }
621
622 const onPressDownloadGif = async () => {
623 if (!gifEmbed) return
624
625 Toast.show(_(msg({message: 'Downloading GIF...', context: 'toast'})))
626
627 let success
628 if (IS_WEB) success = await downloadVideoWeb({uri: gifEmbed.external.uri})
629 else success = await saveVideoToMediaLibrary({uri: gifEmbed.external.uri})
630
631 if (success) Toast.show('GIF downloaded', 'check')
632 else Toast.show('Failed to download GIF', 'xmark')
633 }
634
635 const isEmbedGif = () => {
636 if (!gifEmbed) return false
637 // Janky workaround by checking if the domain is tenor.com
638 const url = new URL(gifEmbed.external.uri)
639 return url.host === 'media.tenor.com'
640 }
641
642 const onBlockAuthor = async () => {
643 try {
644 await queueBlock()
645 Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
646 } catch (e: any) {
647 if (e?.name !== 'AbortError') {
648 logger.error('Failed to block account', {message: e})
649 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
650 }
651 }
652 }
653
654 const onMuteAuthor = async () => {
655 if (postAuthor.viewer?.muted) {
656 try {
657 await queueUnmute()
658 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
659 } catch (e: any) {
660 if (e?.name !== 'AbortError') {
661 logger.error('Failed to unmute account', {message: e})
662 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
663 }
664 }
665 } else {
666 try {
667 await queueMute()
668 Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
669 } catch (e: any) {
670 if (e?.name !== 'AbortError') {
671 logger.error('Failed to mute account', {message: e})
672 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
673 }
674 }
675 }
676 }
677
678 const onReportMisclassification = () => {
679 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
680 href,
681 )}`
682 openLink(url)
683 }
684
685 const onSignIn = () => requireSignIn(() => {})
686
687 const isDiscoverDebugUser =
688 IS_INTERNAL ||
689 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
690 ax.features.enabled(ax.features.DebugFeedContext)
691
692 return (
693 <>
694 <Prompt.Basic
695 control={redraftPromptControl}
696 title={_(msg`Redraft this post?`)}
697 description={_(
698 msg`This will delete the original post and open the composer with its content.`,
699 )}
700 onConfirm={onConfirmRedraft}
701 confirmButtonCta={_(msg`Redraft`)}
702 confirmButtonColor="primary"
703 />
704 <Menu.Outer>
705 {isAuthor && (
706 <>
707 <Menu.Group>
708 <Menu.Item
709 testID="pinPostBtn"
710 label={
711 isPinned
712 ? _(msg`Unpin from profile`)
713 : _(msg`Pin to your profile`)
714 }
715 disabled={isPinPending}
716 onPress={onPressPin}>
717 <Menu.ItemText>
718 {isPinned
719 ? _(msg`Unpin from profile`)
720 : _(msg`Pin to your profile`)}
721 </Menu.ItemText>
722 <Menu.ItemIcon
723 icon={isPinPending ? Loader : PinIcon}
724 position="right"
725 />
726 </Menu.Item>
727 <Menu.Item
728 testID="redraftPostBtn"
729 label={_(msg`Redraft`)}
730 onPress={onRedraftPost}>
731 <Menu.ItemText>{_(msg`Redraft`)}</Menu.ItemText>
732 <Menu.ItemIcon icon={Pen} position="right" />
733 </Menu.Item>
734 </Menu.Group>
735 <Menu.Divider />
736 </>
737 )}
738
739 {videoEmbed && (
740 <>
741 <Menu.Group>
742 <Menu.Item
743 testID="postDropdownDownloadVideoBtn"
744 label={_(msg`Download Video`)}
745 onPress={onPressDownloadVideo}>
746 <Menu.ItemText>{_(msg`Download Video`)}</Menu.ItemText>
747 <Menu.ItemIcon icon={Download} position="right" />
748 </Menu.Item>
749 </Menu.Group>
750 <Menu.Divider />
751 </>
752 )}
753
754 {isEmbedGif() && (
755 <>
756 <Menu.Group>
757 <Menu.Item
758 testID="postDropdownDownloadGifBtn"
759 label={_(msg`Download GIF`)}
760 onPress={onPressDownloadGif}>
761 <Menu.ItemText>{_(msg`Download GIF`)}</Menu.ItemText>
762 <Menu.ItemIcon icon={Download} position="right" />
763 </Menu.Item>
764 </Menu.Group>
765 <Menu.Divider />
766 </>
767 )}
768
769 <Menu.Group>
770 {!hideInPWI || hasSession ? (
771 <>
772 <Menu.Item
773 testID="postDropdownTranslateBtn"
774 label={_(msg`Translate`)}
775 onPress={onPressTranslate}>
776 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
777 <Menu.ItemIcon icon={Translate} position="right" />
778 </Menu.Item>
779
780 <Menu.Item
781 testID="postDropdownCopyTextBtn"
782 label={_(msg`Copy post text`)}
783 onPress={onCopyPostText}>
784 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
785 <Menu.ItemIcon icon={ClipboardIcon} position="right" />
786 </Menu.Item>
787 </>
788 ) : (
789 <Menu.Item
790 testID="postDropdownSignInBtn"
791 label={_(msg`Sign in to view post`)}
792 onPress={onSignIn}>
793 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText>
794 <Menu.ItemIcon icon={Eye} position="right" />
795 </Menu.Item>
796 )}
797 </Menu.Group>
798
799 {hasSession && feedFeedback.enabled && (
800 <>
801 <Menu.Divider />
802 <Menu.Group>
803 <Menu.Item
804 testID="postDropdownShowMoreBtn"
805 label={_(msg`Show more like this`)}
806 onPress={onPressShowMore}>
807 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
808 <Menu.ItemIcon icon={EmojiSmile} position="right" />
809 </Menu.Item>
810
811 <Menu.Item
812 testID="postDropdownShowLessBtn"
813 label={_(msg`Show less like this`)}
814 onPress={onPressShowLess}>
815 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
816 <Menu.ItemIcon icon={EmojiSad} position="right" />
817 </Menu.Item>
818 </Menu.Group>
819 </>
820 )}
821
822 {isDiscoverDebugUser && (
823 <>
824 <Menu.Divider />
825 <Menu.Item
826 testID="postDropdownReportMisclassificationBtn"
827 label={_(msg`Assign topic for algo`)}
828 onPress={onReportMisclassification}>
829 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
830 <Menu.ItemIcon icon={AtomIcon} position="right" />
831 </Menu.Item>
832 </>
833 )}
834
835 {hasSession && (
836 <>
837 <Menu.Divider />
838 <Menu.Group>
839 <Menu.Item
840 testID="postDropdownMuteThreadBtn"
841 label={
842 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
843 }
844 onPress={onToggleThreadMute}>
845 <Menu.ItemText>
846 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
847 </Menu.ItemText>
848 <Menu.ItemIcon
849 icon={isThreadMuted ? Unmute : Mute}
850 position="right"
851 />
852 </Menu.Item>
853
854 <Menu.Item
855 testID="postDropdownMuteWordsBtn"
856 label={_(msg`Mute words & tags`)}
857 onPress={() => mutedWordsDialogControl.open()}>
858 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
859 <Menu.ItemIcon icon={Filter} position="right" />
860 </Menu.Item>
861 </Menu.Group>
862 </>
863 )}
864
865 {hasSession &&
866 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
867 <>
868 <Menu.Divider />
869 <Menu.Group>
870 {canHidePostForMe && (
871 <Menu.Item
872 testID="postDropdownHideBtn"
873 label={
874 isReply
875 ? _(msg`Hide reply for me`)
876 : _(msg`Hide post for me`)
877 }
878 onPress={() => hidePromptControl.open()}>
879 <Menu.ItemText>
880 {isReply
881 ? _(msg`Hide reply for me`)
882 : _(msg`Hide post for me`)}
883 </Menu.ItemText>
884 <Menu.ItemIcon icon={EyeSlash} position="right" />
885 </Menu.Item>
886 )}
887 {canHideReplyForEveryone && (
888 <Menu.Item
889 testID="postDropdownHideBtn"
890 label={
891 isReplyHiddenByThreadgate
892 ? _(msg`Show reply for everyone`)
893 : _(msg`Hide reply for everyone`)
894 }
895 onPress={
896 isReplyHiddenByThreadgate
897 ? onToggleReplyVisibility
898 : () => hideReplyConfirmControl.open()
899 }>
900 <Menu.ItemText>
901 {isReplyHiddenByThreadgate
902 ? _(msg`Show reply for everyone`)
903 : _(msg`Hide reply for everyone`)}
904 </Menu.ItemText>
905 <Menu.ItemIcon
906 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
907 position="right"
908 />
909 </Menu.Item>
910 )}
911
912 {canDetachQuote && (
913 <Menu.Item
914 disabled={isDetachPending}
915 testID="postDropdownHideBtn"
916 label={
917 quoteEmbed.isDetached
918 ? _(msg`Re-attach quote`)
919 : _(msg`Detach quote`)
920 }
921 onPress={
922 quoteEmbed.isDetached
923 ? onToggleQuotePostAttachment
924 : () => quotePostDetachConfirmControl.open()
925 }>
926 <Menu.ItemText>
927 {quoteEmbed.isDetached
928 ? _(msg`Re-attach quote`)
929 : _(msg`Detach quote`)}
930 </Menu.ItemText>
931 <Menu.ItemIcon
932 icon={
933 isDetachPending
934 ? Loader
935 : quoteEmbed.isDetached
936 ? Eye
937 : EyeSlash
938 }
939 position="right"
940 />
941 </Menu.Item>
942 )}
943 </Menu.Group>
944 </>
945 )}
946
947 {hasSession && (
948 <>
949 <Menu.Divider />
950 <Menu.Group>
951 {!isAuthor && (
952 <>
953 <Menu.Item
954 testID="postDropdownMuteBtn"
955 label={
956 postAuthor.viewer?.muted
957 ? _(msg`Unmute account`)
958 : _(msg`Mute account`)
959 }
960 onPress={onMuteAuthor}>
961 <Menu.ItemText>
962 {postAuthor.viewer?.muted
963 ? _(msg`Unmute account`)
964 : _(msg`Mute account`)}
965 </Menu.ItemText>
966 <Menu.ItemIcon
967 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
968 position="right"
969 />
970 </Menu.Item>
971
972 {!postAuthor.viewer?.blocking && (
973 <Menu.Item
974 testID="postDropdownBlockBtn"
975 label={_(msg`Block account`)}
976 onPress={() => blockPromptControl.open()}>
977 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
978 <Menu.ItemIcon icon={PersonX} position="right" />
979 </Menu.Item>
980 )}
981
982 <Menu.Item
983 testID="postDropdownReportBtn"
984 label={_(msg`Report post`)}
985 onPress={() => reportDialogControl.open()}>
986 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
987 <Menu.ItemIcon icon={Warning} position="right" />
988 </Menu.Item>
989 </>
990 )}
991
992 {isAuthor && (
993 <>
994 <Menu.Item
995 testID="postDropdownEditPostInteractions"
996 label={_(msg`Edit interaction settings`)}
997 onPress={() => postInteractionSettingsDialogControl.open()}
998 {...(isAuthor
999 ? Platform.select({
1000 web: {
1001 onHoverIn: prefetchPostInteractionSettings,
1002 },
1003 native: {
1004 onPressIn: prefetchPostInteractionSettings,
1005 },
1006 })
1007 : {})}>
1008 <Menu.ItemText>
1009 {_(msg`Edit interaction settings`)}
1010 </Menu.ItemText>
1011 <Menu.ItemIcon icon={Gear} position="right" />
1012 </Menu.Item>
1013 <Menu.Item
1014 testID="postDropdownDeleteBtn"
1015 label={_(msg`Delete post`)}
1016 onPress={() => deletePromptControl.open()}>
1017 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
1018 <Menu.ItemIcon icon={Trash} position="right" />
1019 </Menu.Item>
1020 </>
1021 )}
1022 </Menu.Group>
1023 </>
1024 )}
1025 </Menu.Outer>
1026
1027 <Prompt.Basic
1028 control={deletePromptControl}
1029 title={_(msg`Delete this post?`)}
1030 description={_(
1031 msg`If you remove this post, you won't be able to recover it.`,
1032 )}
1033 onConfirm={onDeletePost}
1034 confirmButtonCta={_(msg`Delete`)}
1035 confirmButtonColor="negative"
1036 />
1037
1038 <Prompt.Basic
1039 control={hidePromptControl}
1040 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
1041 description={_(
1042 msg`This post will be hidden from feeds and threads. This cannot be undone.`,
1043 )}
1044 onConfirm={onHidePost}
1045 confirmButtonCta={_(msg`Hide`)}
1046 />
1047
1048 <ReportDialog
1049 control={reportDialogControl}
1050 subject={{
1051 ...post,
1052 $type: 'app.bsky.feed.defs#postView',
1053 }}
1054 />
1055
1056 <PostInteractionSettingsDialog
1057 control={postInteractionSettingsDialogControl}
1058 postUri={post.uri}
1059 rootPostUri={rootUri}
1060 initialThreadgateView={post.threadgate}
1061 />
1062
1063 <Prompt.Basic
1064 control={quotePostDetachConfirmControl}
1065 title={_(msg`Detach quote post?`)}
1066 description={_(
1067 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
1068 )}
1069 onConfirm={onToggleQuotePostAttachment}
1070 confirmButtonCta={_(msg`Yes, detach`)}
1071 />
1072
1073 <Prompt.Basic
1074 control={hideReplyConfirmControl}
1075 title={_(msg`Hide this reply?`)}
1076 description={_(
1077 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.`,
1078 )}
1079 onConfirm={onToggleReplyVisibility}
1080 confirmButtonCta={_(msg`Yes, hide`)}
1081 />
1082
1083 <Prompt.Basic
1084 control={blockPromptControl}
1085 title={_(msg`Block Account?`)}
1086 description={_(
1087 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
1088 )}
1089 onConfirm={onBlockAuthor}
1090 confirmButtonCta={_(msg`Block`)}
1091 confirmButtonColor="negative"
1092 />
1093 </>
1094 )
1095}
1096PostMenuItems = memo(PostMenuItems)
1097export {PostMenuItems}