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