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