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