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