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