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