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