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