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