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