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 {useTerminology} from '#/lib/hooks/useTerminology'
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 {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
64import {
65 InvalidInteractionSettingsError,
66 MAX_HIDDEN_REPLIES,
67 MaxHiddenRepliesError,
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 {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
90import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
91import {Pencil_Stroke2_Corner0_Rounded as Pen} from '#/components/icons/Pencil'
92import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
93import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
94import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
95import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
96import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
97import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
98import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
99import {Loader} from '#/components/Loader'
100import * as Menu from '#/components/Menu'
101import {
102 ReportDialog,
103 useReportDialogControl,
104} from '#/components/moderation/ReportDialog'
105import * as Prompt from '#/components/Prompt'
106import {IS_INTERNAL} from '#/env'
107import * as bsky from '#/types/bsky'
108
109let PostMenuItems = ({
110 post,
111 postFeedContext,
112 postReqId,
113 record,
114 richText,
115 threadgateRecord,
116 onShowLess,
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}): React.ReactNode => {
131 const {hasSession, currentAccount} = useSession()
132 const {_} = useLingui()
133 const langPrefs = useLanguagePrefs()
134 const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
135 const {mutateAsync: pinPostMutate, isPending: isPinPending} =
136 usePinnedPostMutation()
137 const requireSignIn = useRequireAuth()
138 const hiddenPosts = useHiddenPosts()
139 const {hidePost} = useHiddenPostsApi()
140 const feedFeedback = useFeedFeedbackContext()
141 const openLink = useOpenLink()
142 const translate = useTranslate()
143 const navigation = useNavigation<NavigationProp>()
144 const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
145 const blockPromptControl = useDialogControl()
146 const reportDialogControl = useReportDialogControl()
147 const deletePromptControl = useDialogControl()
148 const hidePromptControl = useDialogControl()
149 const postInteractionSettingsDialogControl = useDialogControl()
150 const quotePostDetachConfirmControl = useDialogControl()
151 const hideReplyConfirmControl = useDialogControl()
152 const redraftPromptControl = useDialogControl()
153 const {mutateAsync: toggleReplyVisibility} =
154 useToggleReplyVisibilityMutation()
155
156 const postUri = post.uri
157 const postCid = post.cid
158 const postAuthor = useProfileShadow(post.author)
159 const quoteEmbed = useMemo(() => {
160 if (!currentAccount || !post.embed) return
161 return getMaybeDetachedQuoteEmbed({
162 viewerDid: currentAccount.did,
163 post,
164 })
165 }, [post, currentAccount])
166
167 const rootUri = record.reply?.root?.uri || postUri
168 const isReply = Boolean(record.reply)
169 const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
170 post,
171 rootUri,
172 )
173 const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
174 const isAuthor = postAuthor.did === currentAccount?.did
175 const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
176 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
177 threadgateRecord,
178 })
179 const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
180 const isPinned = post.viewer?.pinned
181
182 const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} =
183 useToggleQuoteDetachmentMutation()
184
185 const [queueBlock] = useProfileBlockMutationQueue(postAuthor)
186 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor)
187
188 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
189 postUri: post.uri,
190 rootPostUri: rootUri,
191 })
192 const terminology = useTerminology()
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: `${_(terminology.post)} 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 ${_(terminology.post)}, 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 Toast.show(_(msg`You will now receive notifications for this thread`))
381 } else {
382 muteThread()
383 Toast.show(
384 _(msg`You will no longer receive notifications for this thread`),
385 )
386 }
387 } catch (e: any) {
388 if (e?.name !== 'AbortError') {
389 logger.error('Failed to toggle thread mute', {message: e})
390 Toast.show(
391 _(msg`Failed to toggle thread mute, please try again`),
392 'xmark',
393 )
394 }
395 }
396 }
397
398 const onCopyPostText = () => {
399 const str = richTextToString(richText, true)
400
401 Clipboard.setStringAsync(str)
402 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
403 }
404
405 const onPressTranslate = () => {
406 translate(record.text, langPrefs.primaryLanguage)
407
408 if (
409 bsky.dangerousIsType<AppBskyFeedPost.Record>(
410 post.record,
411 AppBskyFeedPost.isRecord,
412 )
413 ) {
414 logger.metric(
415 'translate',
416 {
417 sourceLanguages: post.record.langs ?? [],
418 targetLanguage: langPrefs.primaryLanguage,
419 textLength: post.record.text.length,
420 },
421 {statsig: false},
422 )
423 }
424 }
425
426 const onHidePost = () => {
427 hidePost({uri: postUri})
428 logEvent('thread:click:hideReplyForMe', {})
429 }
430
431 const hideInPWI = !!postAuthor.labels?.find(
432 label => label.val === '!no-unauthenticated',
433 )
434
435 const onPressShowMore = () => {
436 feedFeedback.sendInteraction({
437 event: 'app.bsky.feed.defs#requestMore',
438 item: postUri,
439 feedContext: postFeedContext,
440 reqId: postReqId,
441 })
442 Toast.show(
443 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
444 )
445 }
446
447 const onPressShowLess = () => {
448 feedFeedback.sendInteraction({
449 event: 'app.bsky.feed.defs#requestLess',
450 item: postUri,
451 feedContext: postFeedContext,
452 reqId: postReqId,
453 })
454 if (onShowLess) {
455 onShowLess({
456 item: postUri,
457 feedContext: postFeedContext,
458 })
459 } else {
460 Toast.show(
461 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
462 )
463 }
464 }
465
466 const onToggleQuotePostAttachment = async () => {
467 if (!quoteEmbed) return
468
469 const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
470 const isDetach = action === 'detach'
471
472 try {
473 await toggleQuoteDetachment({
474 post,
475 quoteUri: quoteEmbed.uri,
476 action: quoteEmbed.isDetached ? 'reattach' : 'detach',
477 })
478 Toast.show(
479 isDetach
480 ? _(msg`Quote ${_(terminology.post)} was successfully detached`)
481 : _(msg`Quote ${_(terminology.post)} was re-attached`),
482 )
483 } catch (e: any) {
484 Toast.show(
485 _(msg({message: 'Updating quote attachment failed', context: 'toast'})),
486 )
487 logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
488 }
489 }
490
491 const canHidePostForMe = !isAuthor && !isPostHidden
492 const canHideReplyForEveryone =
493 !isAuthor && isRootPostAuthor && !isPostHidden && isReply
494 const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
495
496 const onToggleReplyVisibility = async () => {
497 // TODO no threadgate?
498 if (!canHideReplyForEveryone) return
499
500 const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
501 const isHide = action === 'hide'
502
503 try {
504 await toggleReplyVisibility({
505 postUri: rootUri,
506 replyUri: postUri,
507 action,
508 })
509
510 // Log metric only when hiding (not when showing)
511 if (isHide) {
512 logEvent('thread:click:hideReplyForEveryone', {})
513 }
514
515 Toast.show(
516 isHide
517 ? _(msg`Reply was successfully hidden`)
518 : _(msg({message: 'Reply visibility updated', context: 'toast'})),
519 )
520 } catch (e: any) {
521 if (e instanceof MaxHiddenRepliesError) {
522 Toast.show(
523 _(
524 msg({
525 message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`,
526 context: 'toast',
527 }),
528 ),
529 )
530 } else if (e instanceof InvalidInteractionSettingsError) {
531 Toast.show(
532 _(msg({message: 'Invalid interaction settings.', context: 'toast'})),
533 )
534 } else {
535 Toast.show(
536 _(
537 msg({
538 message: 'Updating reply visibility failed',
539 context: 'toast',
540 }),
541 ),
542 )
543 logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
544 }
545 }
546 }
547
548 const onPressPin = () => {
549 logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
550 pinPostMutate({
551 postUri,
552 postCid,
553 action: isPinned ? 'unpin' : 'pin',
554 })
555 }
556
557 const videoEmbed: AppBskyEmbedVideo.View | undefined = useMemo(() => {
558 if (post.embed?.$type === 'app.bsky.embed.video#view')
559 return post.embed as AppBskyEmbedVideo.View
560 if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
561 const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined
562 if (embed?.media.$type === 'app.bsky.embed.video#view')
563 return embed?.media as AppBskyEmbedVideo.View
564 }
565 return undefined
566 }, [post])
567
568 const gifEmbed: AppBskyEmbedExternal.View | undefined = useMemo(() => {
569 if (post.embed?.$type === 'app.bsky.embed.external#view')
570 return post.embed as AppBskyEmbedExternal.View
571 if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
572 const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined
573 if (embed?.media.$type === 'app.bsky.embed.external#view')
574 return embed?.media as AppBskyEmbedExternal.View
575 }
576 return undefined
577 }, [post])
578
579 const onPressDownloadVideo = async () => {
580 if (!videoEmbed) return
581 const did = post.author.did
582 const cid = videoEmbed.cid
583 if (!isDid(did)) return
584 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}:${string}`)
585 const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
586
587 Toast.show(_(msg({message: 'Downloading video...', context: 'toast'})))
588
589 let success
590 if (isWeb) success = await downloadVideoWeb({uri: uri})
591 else success = await saveVideoToMediaLibrary({uri: uri})
592
593 if (success) Toast.show('Video downloaded', 'check')
594 else Toast.show('Failed to download video', 'xmark')
595 }
596
597 const onPressDownloadGif = async () => {
598 if (!gifEmbed) return
599
600 Toast.show(_(msg({message: 'Downloading GIF...', context: 'toast'})))
601
602 let success
603 if (isWeb) success = await downloadVideoWeb({uri: gifEmbed.external.uri})
604 else success = await saveVideoToMediaLibrary({uri: gifEmbed.external.uri})
605
606 if (success) Toast.show('GIF downloaded', 'check')
607 else Toast.show('Failed to download GIF', 'xmark')
608 }
609
610 const isEmbedGif = () => {
611 if (!gifEmbed) return false
612 // Janky workaround by checking if the domain is tenor.com
613 const url = new URL(gifEmbed.external.uri)
614 return url.host === 'media.tenor.com'
615 }
616
617 const onBlockAuthor = async () => {
618 try {
619 await queueBlock()
620 Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
621 } catch (e: any) {
622 if (e?.name !== 'AbortError') {
623 logger.error('Failed to block account', {message: e})
624 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
625 }
626 }
627 }
628
629 const onMuteAuthor = async () => {
630 if (postAuthor.viewer?.muted) {
631 try {
632 await queueUnmute()
633 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
634 } catch (e: any) {
635 if (e?.name !== 'AbortError') {
636 logger.error('Failed to unmute account', {message: e})
637 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
638 }
639 }
640 } else {
641 try {
642 await queueMute()
643 Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
644 } catch (e: any) {
645 if (e?.name !== 'AbortError') {
646 logger.error('Failed to mute account', {message: e})
647 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
648 }
649 }
650 }
651 }
652
653 const onReportMisclassification = () => {
654 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
655 href,
656 )}`
657 openLink(url)
658 }
659
660 const onSignIn = () => requireSignIn(() => {})
661
662 const gate = useGate()
663 const isDiscoverDebugUser =
664 IS_INTERNAL ||
665 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
666 gate('debug_show_feedcontext')
667
668 return (
669 <>
670 <Prompt.Basic
671 control={redraftPromptControl}
672 title={_(msg`Redraft this ${_(terminology.post)}?`)}
673 description={_(
674 msg`This will delete the original ${_(terminology.post)} and open the composer with its content.`,
675 )}
676 onConfirm={onConfirmRedraft}
677 confirmButtonCta={_(msg`Redraft`)}
678 confirmButtonColor="primary"
679 />
680 <Menu.Outer>
681 {isAuthor && (
682 <>
683 <Menu.Group>
684 <Menu.Item
685 testID="pinPostBtn"
686 label={
687 isPinned
688 ? _(msg`Unpin from profile`)
689 : _(msg`Pin to your profile`)
690 }
691 disabled={isPinPending}
692 onPress={onPressPin}>
693 <Menu.ItemText>
694 {isPinned
695 ? _(msg`Unpin from profile`)
696 : _(msg`Pin to your profile`)}
697 </Menu.ItemText>
698 <Menu.ItemIcon
699 icon={isPinPending ? Loader : PinIcon}
700 position="right"
701 />
702 </Menu.Item>
703 <Menu.Item
704 testID="redraftPostBtn"
705 label={_(msg`Redraft`)}
706 onPress={onRedraftPost}>
707 <Menu.ItemText>{_(msg`Redraft`)}</Menu.ItemText>
708 <Menu.ItemIcon icon={Pen} position="right" />
709 </Menu.Item>
710 </Menu.Group>
711 <Menu.Divider />
712 </>
713 )}
714
715 {videoEmbed && (
716 <>
717 <Menu.Group>
718 <Menu.Item
719 testID="postDropdownDownloadVideoBtn"
720 label={_(msg`Download Video`)}
721 onPress={onPressDownloadVideo}>
722 <Menu.ItemText>{_(msg`Download Video`)}</Menu.ItemText>
723 <Menu.ItemIcon icon={Download} position="right" />
724 </Menu.Item>
725 </Menu.Group>
726 <Menu.Divider />
727 </>
728 )}
729
730 {isEmbedGif() && (
731 <>
732 <Menu.Group>
733 <Menu.Item
734 testID="postDropdownDownloadGifBtn"
735 label={_(msg`Download GIF`)}
736 onPress={onPressDownloadGif}>
737 <Menu.ItemText>{_(msg`Download GIF`)}</Menu.ItemText>
738 <Menu.ItemIcon icon={Download} position="right" />
739 </Menu.Item>
740 </Menu.Group>
741 <Menu.Divider />
742 </>
743 )}
744
745 <Menu.Group>
746 {!hideInPWI || hasSession ? (
747 <>
748 <Menu.Item
749 testID="postDropdownTranslateBtn"
750 label={_(msg`Translate`)}
751 onPress={onPressTranslate}>
752 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
753 <Menu.ItemIcon icon={Translate} position="right" />
754 </Menu.Item>
755
756 <Menu.Item
757 testID="postDropdownCopyTextBtn"
758 label={_(msg`Copy ${_(terminology.post)} text`)}
759 onPress={onCopyPostText}>
760 <Menu.ItemText>{_(msg`Copy ${_(terminology.post)} text`)}</Menu.ItemText>
761 <Menu.ItemIcon icon={ClipboardIcon} position="right" />
762 </Menu.Item>
763 </>
764 ) : (
765 <Menu.Item
766 testID="postDropdownSignInBtn"
767 label={_(msg`Sign in to view ${_(terminology.post)}`)}
768 onPress={onSignIn}>
769 <Menu.ItemText>{_(msg`Sign in to view ${_(terminology.post)}`)}</Menu.ItemText>
770 <Menu.ItemIcon icon={Eye} position="right" />
771 </Menu.Item>
772 )}
773 </Menu.Group>
774
775 {hasSession && feedFeedback.enabled && (
776 <>
777 <Menu.Divider />
778 <Menu.Group>
779 <Menu.Item
780 testID="postDropdownShowMoreBtn"
781 label={_(msg`Show more like this`)}
782 onPress={onPressShowMore}>
783 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
784 <Menu.ItemIcon icon={EmojiSmile} position="right" />
785 </Menu.Item>
786
787 <Menu.Item
788 testID="postDropdownShowLessBtn"
789 label={_(msg`Show less like this`)}
790 onPress={onPressShowLess}>
791 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
792 <Menu.ItemIcon icon={EmojiSad} position="right" />
793 </Menu.Item>
794 </Menu.Group>
795 </>
796 )}
797
798 {isDiscoverDebugUser && (
799 <>
800 <Menu.Divider />
801 <Menu.Item
802 testID="postDropdownReportMisclassificationBtn"
803 label={_(msg`Assign topic for algo`)}
804 onPress={onReportMisclassification}>
805 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
806 <Menu.ItemIcon icon={AtomIcon} position="right" />
807 </Menu.Item>
808 </>
809 )}
810
811 {hasSession && (
812 <>
813 <Menu.Divider />
814 <Menu.Group>
815 <Menu.Item
816 testID="postDropdownMuteThreadBtn"
817 label={
818 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
819 }
820 onPress={onToggleThreadMute}>
821 <Menu.ItemText>
822 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
823 </Menu.ItemText>
824 <Menu.ItemIcon
825 icon={isThreadMuted ? Unmute : Mute}
826 position="right"
827 />
828 </Menu.Item>
829
830 <Menu.Item
831 testID="postDropdownMuteWordsBtn"
832 label={_(msg`Mute words & tags`)}
833 onPress={() => mutedWordsDialogControl.open()}>
834 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
835 <Menu.ItemIcon icon={Filter} position="right" />
836 </Menu.Item>
837 </Menu.Group>
838 </>
839 )}
840
841 {hasSession &&
842 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
843 <>
844 <Menu.Divider />
845 <Menu.Group>
846 {canHidePostForMe && (
847 <Menu.Item
848 testID="postDropdownHideBtn"
849 label={
850 isReply
851 ? _(msg`Hide reply for me`)
852 : _(msg`Hide ${_(terminology.post)} for me`)
853 }
854 onPress={() => hidePromptControl.open()}>
855 <Menu.ItemText>
856 {isReply
857 ? _(msg`Hide reply for me`)
858 : _(msg`Hide ${_(terminology.post)} for me`)}
859 </Menu.ItemText>
860 <Menu.ItemIcon icon={EyeSlash} position="right" />
861 </Menu.Item>
862 )}
863 {canHideReplyForEveryone && (
864 <Menu.Item
865 testID="postDropdownHideBtn"
866 label={
867 isReplyHiddenByThreadgate
868 ? _(msg`Show reply for everyone`)
869 : _(msg`Hide reply for everyone`)
870 }
871 onPress={
872 isReplyHiddenByThreadgate
873 ? onToggleReplyVisibility
874 : () => hideReplyConfirmControl.open()
875 }>
876 <Menu.ItemText>
877 {isReplyHiddenByThreadgate
878 ? _(msg`Show reply for everyone`)
879 : _(msg`Hide reply for everyone`)}
880 </Menu.ItemText>
881 <Menu.ItemIcon
882 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
883 position="right"
884 />
885 </Menu.Item>
886 )}
887
888 {canDetachQuote && (
889 <Menu.Item
890 disabled={isDetachPending}
891 testID="postDropdownHideBtn"
892 label={
893 quoteEmbed.isDetached
894 ? _(msg`Re-attach quote`)
895 : _(msg`Detach quote`)
896 }
897 onPress={
898 quoteEmbed.isDetached
899 ? onToggleQuotePostAttachment
900 : () => quotePostDetachConfirmControl.open()
901 }>
902 <Menu.ItemText>
903 {quoteEmbed.isDetached
904 ? _(msg`Re-attach quote`)
905 : _(msg`Detach quote`)}
906 </Menu.ItemText>
907 <Menu.ItemIcon
908 icon={
909 isDetachPending
910 ? Loader
911 : quoteEmbed.isDetached
912 ? Eye
913 : EyeSlash
914 }
915 position="right"
916 />
917 </Menu.Item>
918 )}
919 </Menu.Group>
920 </>
921 )}
922
923 {hasSession && (
924 <>
925 <Menu.Divider />
926 <Menu.Group>
927 {!isAuthor && (
928 <>
929 <Menu.Item
930 testID="postDropdownMuteBtn"
931 label={
932 postAuthor.viewer?.muted
933 ? _(msg`Unmute account`)
934 : _(msg`Mute account`)
935 }
936 onPress={onMuteAuthor}>
937 <Menu.ItemText>
938 {postAuthor.viewer?.muted
939 ? _(msg`Unmute account`)
940 : _(msg`Mute account`)}
941 </Menu.ItemText>
942 <Menu.ItemIcon
943 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
944 position="right"
945 />
946 </Menu.Item>
947
948 {!postAuthor.viewer?.blocking && (
949 <Menu.Item
950 testID="postDropdownBlockBtn"
951 label={_(msg`Block account`)}
952 onPress={() => blockPromptControl.open()}>
953 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
954 <Menu.ItemIcon icon={PersonX} position="right" />
955 </Menu.Item>
956 )}
957
958 <Menu.Item
959 testID="postDropdownReportBtn"
960 label={_(msg`Report ${_(terminology.post)}`)}
961 onPress={() => reportDialogControl.open()}>
962 <Menu.ItemText>{_(msg`Report ${_(terminology.post)}`)}</Menu.ItemText>
963 <Menu.ItemIcon icon={Warning} position="right" />
964 </Menu.Item>
965 </>
966 )}
967
968 {isAuthor && (
969 <>
970 <Menu.Item
971 testID="postDropdownEditPostInteractions"
972 label={_(msg`Edit interaction settings`)}
973 onPress={() => postInteractionSettingsDialogControl.open()}
974 {...(isAuthor
975 ? Platform.select({
976 web: {
977 onHoverIn: prefetchPostInteractionSettings,
978 },
979 native: {
980 onPressIn: prefetchPostInteractionSettings,
981 },
982 })
983 : {})}>
984 <Menu.ItemText>
985 {_(msg`Edit interaction settings`)}
986 </Menu.ItemText>
987 <Menu.ItemIcon icon={Gear} position="right" />
988 </Menu.Item>
989 <Menu.Item
990 testID="postDropdownDeleteBtn"
991 label={_(msg`Delete ${_(terminology.post)}`)}
992 onPress={() => deletePromptControl.open()}>
993 <Menu.ItemText>{_(msg`Delete ${_(terminology.post)}`)}</Menu.ItemText>
994 <Menu.ItemIcon icon={Trash} position="right" />
995 </Menu.Item>
996 </>
997 )}
998 </Menu.Group>
999 </>
1000 )}
1001 </Menu.Outer>
1002
1003 <Prompt.Basic
1004 control={deletePromptControl}
1005 title={_(msg`Delete this ${_(terminology.post)}?`)}
1006 description={_(
1007 msg`If you remove this ${_(terminology.post)}, you won't be able to recover it.`,
1008 )}
1009 onConfirm={onDeletePost}
1010 confirmButtonCta={_(msg`Delete`)}
1011 confirmButtonColor="negative"
1012 />
1013
1014 <Prompt.Basic
1015 control={hidePromptControl}
1016 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this ${_(terminology.post)}?`)}
1017 description={_(
1018 msg`This ${_(terminology.post)} will be hidden from feeds and threads. This cannot be undone.`,
1019 )}
1020 onConfirm={onHidePost}
1021 confirmButtonCta={_(msg`Hide`)}
1022 />
1023
1024 <ReportDialog
1025 control={reportDialogControl}
1026 subject={{
1027 ...post,
1028 $type: 'app.bsky.feed.defs#postView',
1029 }}
1030 />
1031
1032 <PostInteractionSettingsDialog
1033 control={postInteractionSettingsDialogControl}
1034 postUri={post.uri}
1035 rootPostUri={rootUri}
1036 initialThreadgateView={post.threadgate}
1037 />
1038
1039 <Prompt.Basic
1040 control={quotePostDetachConfirmControl}
1041 title={_(msg`Detach quote ${_(terminology.post)}?`)}
1042 description={_(
1043 msg`This will remove your ${_(terminology.post)} from this quote ${_(terminology.post)} for all users, and replace it with a placeholder.`,
1044 )}
1045 onConfirm={onToggleQuotePostAttachment}
1046 confirmButtonCta={_(msg`Yes, detach`)}
1047 />
1048
1049 <Prompt.Basic
1050 control={hideReplyConfirmControl}
1051 title={_(msg`Hide this reply?`)}
1052 description={_(
1053 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.`,
1054 )}
1055 onConfirm={onToggleReplyVisibility}
1056 confirmButtonCta={_(msg`Yes, hide`)}
1057 />
1058
1059 <Prompt.Basic
1060 control={blockPromptControl}
1061 title={_(msg`Block Account?`)}
1062 description={_(
1063 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
1064 )}
1065 onConfirm={onBlockAuthor}
1066 confirmButtonCta={_(msg`Block`)}
1067 confirmButtonColor="negative"
1068 />
1069 </>
1070 )
1071}
1072PostMenuItems = memo(PostMenuItems)
1073export {PostMenuItems}