Bluesky app fork with some witchin' additions 馃挮
at main 1097 lines 37 kB view raw
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, plural} from '@lingui/core/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: 'Post 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 post, 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 post was successfully detached`) 507 : _(msg`Quote post 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 plural(MAX_HIDDEN_REPLIES, { 551 other: 'You can hide a maximum of # replies.', 552 }), 553 ), 554 ) 555 } else if (e instanceof InvalidInteractionSettingsError) { 556 Toast.show( 557 _(msg({message: 'Invalid interaction settings.', context: 'toast'})), 558 ) 559 } else { 560 Toast.show( 561 _( 562 msg({ 563 message: 'Updating reply visibility failed', 564 context: 'toast', 565 }), 566 ), 567 ) 568 logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) 569 } 570 } 571 } 572 573 const onPressPin = () => { 574 ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) 575 pinPostMutate({ 576 postUri, 577 postCid, 578 action: isPinned ? 'unpin' : 'pin', 579 }) 580 } 581 582 const videoEmbed: AppBskyEmbedVideo.View | undefined = useMemo(() => { 583 if (post.embed?.$type === 'app.bsky.embed.video#view') 584 return post.embed as AppBskyEmbedVideo.View 585 if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 586 const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined 587 if (embed?.media.$type === 'app.bsky.embed.video#view') 588 return embed?.media as AppBskyEmbedVideo.View 589 } 590 return undefined 591 }, [post]) 592 593 const gifEmbed: AppBskyEmbedExternal.View | undefined = useMemo(() => { 594 if (post.embed?.$type === 'app.bsky.embed.external#view') 595 return post.embed as AppBskyEmbedExternal.View 596 if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 597 const embed = post.embed as AppBskyEmbedRecordWithMedia.View | undefined 598 if (embed?.media.$type === 'app.bsky.embed.external#view') 599 return embed?.media as AppBskyEmbedExternal.View 600 } 601 return undefined 602 }, [post]) 603 604 const onPressDownloadVideo = async () => { 605 if (!videoEmbed) return 606 const did = post.author.did 607 const cid = videoEmbed.cid 608 if (!did.startsWith('did:')) return 609 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) 610 const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` 611 612 Toast.show(_(msg({message: 'Downloading video...', context: 'toast'}))) 613 614 let success 615 if (IS_WEB) success = await downloadVideoWeb({uri: uri}) 616 else success = await saveVideoToMediaLibrary({uri: uri}) 617 618 if (success) Toast.show('Video downloaded', 'check') 619 else Toast.show('Failed to download video', 'xmark') 620 } 621 622 const onPressDownloadGif = async () => { 623 if (!gifEmbed) return 624 625 Toast.show(_(msg({message: 'Downloading GIF...', context: 'toast'}))) 626 627 let success 628 if (IS_WEB) success = await downloadVideoWeb({uri: gifEmbed.external.uri}) 629 else success = await saveVideoToMediaLibrary({uri: gifEmbed.external.uri}) 630 631 if (success) Toast.show('GIF downloaded', 'check') 632 else Toast.show('Failed to download GIF', 'xmark') 633 } 634 635 const isEmbedGif = () => { 636 if (!gifEmbed) return false 637 // Janky workaround by checking if the domain is tenor.com 638 const url = new URL(gifEmbed.external.uri) 639 return url.host === 'media.tenor.com' 640 } 641 642 const onBlockAuthor = async () => { 643 try { 644 await queueBlock() 645 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 646 } catch (e: any) { 647 if (e?.name !== 'AbortError') { 648 logger.error('Failed to block account', {message: e}) 649 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 650 } 651 } 652 } 653 654 const onMuteAuthor = async () => { 655 if (postAuthor.viewer?.muted) { 656 try { 657 await queueUnmute() 658 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 659 } catch (e: any) { 660 if (e?.name !== 'AbortError') { 661 logger.error('Failed to unmute account', {message: e}) 662 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 663 } 664 } 665 } else { 666 try { 667 await queueMute() 668 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 669 } catch (e: any) { 670 if (e?.name !== 'AbortError') { 671 logger.error('Failed to mute account', {message: e}) 672 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 673 } 674 } 675 } 676 } 677 678 const onReportMisclassification = () => { 679 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( 680 href, 681 )}` 682 openLink(url) 683 } 684 685 const onSignIn = () => requireSignIn(() => {}) 686 687 const isDiscoverDebugUser = 688 IS_INTERNAL || 689 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 690 ax.features.enabled(ax.features.DebugFeedContext) 691 692 return ( 693 <> 694 <Prompt.Basic 695 control={redraftPromptControl} 696 title={_(msg`Redraft this post?`)} 697 description={_( 698 msg`This will delete the original post and open the composer with its content.`, 699 )} 700 onConfirm={onConfirmRedraft} 701 confirmButtonCta={_(msg`Redraft`)} 702 confirmButtonColor="primary" 703 /> 704 <Menu.Outer> 705 {isAuthor && ( 706 <> 707 <Menu.Group> 708 <Menu.Item 709 testID="pinPostBtn" 710 label={ 711 isPinned 712 ? _(msg`Unpin from profile`) 713 : _(msg`Pin to your profile`) 714 } 715 disabled={isPinPending} 716 onPress={onPressPin}> 717 <Menu.ItemText> 718 {isPinned 719 ? _(msg`Unpin from profile`) 720 : _(msg`Pin to your profile`)} 721 </Menu.ItemText> 722 <Menu.ItemIcon 723 icon={isPinPending ? Loader : PinIcon} 724 position="right" 725 /> 726 </Menu.Item> 727 <Menu.Item 728 testID="redraftPostBtn" 729 label={_(msg`Redraft`)} 730 onPress={onRedraftPost}> 731 <Menu.ItemText>{_(msg`Redraft`)}</Menu.ItemText> 732 <Menu.ItemIcon icon={Pen} position="right" /> 733 </Menu.Item> 734 </Menu.Group> 735 <Menu.Divider /> 736 </> 737 )} 738 739 {videoEmbed && ( 740 <> 741 <Menu.Group> 742 <Menu.Item 743 testID="postDropdownDownloadVideoBtn" 744 label={_(msg`Download Video`)} 745 onPress={onPressDownloadVideo}> 746 <Menu.ItemText>{_(msg`Download Video`)}</Menu.ItemText> 747 <Menu.ItemIcon icon={Download} position="right" /> 748 </Menu.Item> 749 </Menu.Group> 750 <Menu.Divider /> 751 </> 752 )} 753 754 {isEmbedGif() && ( 755 <> 756 <Menu.Group> 757 <Menu.Item 758 testID="postDropdownDownloadGifBtn" 759 label={_(msg`Download GIF`)} 760 onPress={onPressDownloadGif}> 761 <Menu.ItemText>{_(msg`Download GIF`)}</Menu.ItemText> 762 <Menu.ItemIcon icon={Download} position="right" /> 763 </Menu.Item> 764 </Menu.Group> 765 <Menu.Divider /> 766 </> 767 )} 768 769 <Menu.Group> 770 {!hideInPWI || hasSession ? ( 771 <> 772 <Menu.Item 773 testID="postDropdownTranslateBtn" 774 label={_(msg`Translate`)} 775 onPress={onPressTranslate}> 776 <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> 777 <Menu.ItemIcon icon={Translate} position="right" /> 778 </Menu.Item> 779 780 <Menu.Item 781 testID="postDropdownCopyTextBtn" 782 label={_(msg`Copy post text`)} 783 onPress={onCopyPostText}> 784 <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> 785 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 786 </Menu.Item> 787 </> 788 ) : ( 789 <Menu.Item 790 testID="postDropdownSignInBtn" 791 label={_(msg`Sign in to view post`)} 792 onPress={onSignIn}> 793 <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText> 794 <Menu.ItemIcon icon={Eye} position="right" /> 795 </Menu.Item> 796 )} 797 </Menu.Group> 798 799 {hasSession && feedFeedback.enabled && ( 800 <> 801 <Menu.Divider /> 802 <Menu.Group> 803 <Menu.Item 804 testID="postDropdownShowMoreBtn" 805 label={_(msg`Show more like this`)} 806 onPress={onPressShowMore}> 807 <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> 808 <Menu.ItemIcon icon={EmojiSmile} position="right" /> 809 </Menu.Item> 810 811 <Menu.Item 812 testID="postDropdownShowLessBtn" 813 label={_(msg`Show less like this`)} 814 onPress={onPressShowLess}> 815 <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> 816 <Menu.ItemIcon icon={EmojiSad} position="right" /> 817 </Menu.Item> 818 </Menu.Group> 819 </> 820 )} 821 822 {isDiscoverDebugUser && ( 823 <> 824 <Menu.Divider /> 825 <Menu.Item 826 testID="postDropdownReportMisclassificationBtn" 827 label={_(msg`Assign topic for algo`)} 828 onPress={onReportMisclassification}> 829 <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 830 <Menu.ItemIcon icon={AtomIcon} position="right" /> 831 </Menu.Item> 832 </> 833 )} 834 835 {hasSession && ( 836 <> 837 <Menu.Divider /> 838 <Menu.Group> 839 <Menu.Item 840 testID="postDropdownMuteThreadBtn" 841 label={ 842 isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) 843 } 844 onPress={onToggleThreadMute}> 845 <Menu.ItemText> 846 {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} 847 </Menu.ItemText> 848 <Menu.ItemIcon 849 icon={isThreadMuted ? Unmute : Mute} 850 position="right" 851 /> 852 </Menu.Item> 853 854 <Menu.Item 855 testID="postDropdownMuteWordsBtn" 856 label={_(msg`Mute words & tags`)} 857 onPress={() => mutedWordsDialogControl.open()}> 858 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> 859 <Menu.ItemIcon icon={Filter} position="right" /> 860 </Menu.Item> 861 </Menu.Group> 862 </> 863 )} 864 865 {hasSession && 866 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( 867 <> 868 <Menu.Divider /> 869 <Menu.Group> 870 {canHidePostForMe && ( 871 <Menu.Item 872 testID="postDropdownHideBtn" 873 label={ 874 isReply 875 ? _(msg`Hide reply for me`) 876 : _(msg`Hide post for me`) 877 } 878 onPress={() => hidePromptControl.open()}> 879 <Menu.ItemText> 880 {isReply 881 ? _(msg`Hide reply for me`) 882 : _(msg`Hide post for me`)} 883 </Menu.ItemText> 884 <Menu.ItemIcon icon={EyeSlash} position="right" /> 885 </Menu.Item> 886 )} 887 {canHideReplyForEveryone && ( 888 <Menu.Item 889 testID="postDropdownHideBtn" 890 label={ 891 isReplyHiddenByThreadgate 892 ? _(msg`Show reply for everyone`) 893 : _(msg`Hide reply for everyone`) 894 } 895 onPress={ 896 isReplyHiddenByThreadgate 897 ? onToggleReplyVisibility 898 : () => hideReplyConfirmControl.open() 899 }> 900 <Menu.ItemText> 901 {isReplyHiddenByThreadgate 902 ? _(msg`Show reply for everyone`) 903 : _(msg`Hide reply for everyone`)} 904 </Menu.ItemText> 905 <Menu.ItemIcon 906 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} 907 position="right" 908 /> 909 </Menu.Item> 910 )} 911 912 {canDetachQuote && ( 913 <Menu.Item 914 disabled={isDetachPending} 915 testID="postDropdownHideBtn" 916 label={ 917 quoteEmbed.isDetached 918 ? _(msg`Re-attach quote`) 919 : _(msg`Detach quote`) 920 } 921 onPress={ 922 quoteEmbed.isDetached 923 ? onToggleQuotePostAttachment 924 : () => quotePostDetachConfirmControl.open() 925 }> 926 <Menu.ItemText> 927 {quoteEmbed.isDetached 928 ? _(msg`Re-attach quote`) 929 : _(msg`Detach quote`)} 930 </Menu.ItemText> 931 <Menu.ItemIcon 932 icon={ 933 isDetachPending 934 ? Loader 935 : quoteEmbed.isDetached 936 ? Eye 937 : EyeSlash 938 } 939 position="right" 940 /> 941 </Menu.Item> 942 )} 943 </Menu.Group> 944 </> 945 )} 946 947 {hasSession && ( 948 <> 949 <Menu.Divider /> 950 <Menu.Group> 951 {!isAuthor && ( 952 <> 953 <Menu.Item 954 testID="postDropdownMuteBtn" 955 label={ 956 postAuthor.viewer?.muted 957 ? _(msg`Unmute account`) 958 : _(msg`Mute account`) 959 } 960 onPress={onMuteAuthor}> 961 <Menu.ItemText> 962 {postAuthor.viewer?.muted 963 ? _(msg`Unmute account`) 964 : _(msg`Mute account`)} 965 </Menu.ItemText> 966 <Menu.ItemIcon 967 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} 968 position="right" 969 /> 970 </Menu.Item> 971 972 {!postAuthor.viewer?.blocking && ( 973 <Menu.Item 974 testID="postDropdownBlockBtn" 975 label={_(msg`Block account`)} 976 onPress={() => blockPromptControl.open()}> 977 <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> 978 <Menu.ItemIcon icon={PersonX} position="right" /> 979 </Menu.Item> 980 )} 981 982 <Menu.Item 983 testID="postDropdownReportBtn" 984 label={_(msg`Report post`)} 985 onPress={() => reportDialogControl.open()}> 986 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> 987 <Menu.ItemIcon icon={Warning} position="right" /> 988 </Menu.Item> 989 </> 990 )} 991 992 {isAuthor && ( 993 <> 994 <Menu.Item 995 testID="postDropdownEditPostInteractions" 996 label={_(msg`Edit interaction settings`)} 997 onPress={() => postInteractionSettingsDialogControl.open()} 998 {...(isAuthor 999 ? Platform.select({ 1000 web: { 1001 onHoverIn: prefetchPostInteractionSettings, 1002 }, 1003 native: { 1004 onPressIn: prefetchPostInteractionSettings, 1005 }, 1006 }) 1007 : {})}> 1008 <Menu.ItemText> 1009 {_(msg`Edit interaction settings`)} 1010 </Menu.ItemText> 1011 <Menu.ItemIcon icon={Gear} position="right" /> 1012 </Menu.Item> 1013 <Menu.Item 1014 testID="postDropdownDeleteBtn" 1015 label={_(msg`Delete post`)} 1016 onPress={() => deletePromptControl.open()}> 1017 <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 1018 <Menu.ItemIcon icon={Trash} position="right" /> 1019 </Menu.Item> 1020 </> 1021 )} 1022 </Menu.Group> 1023 </> 1024 )} 1025 </Menu.Outer> 1026 1027 <Prompt.Basic 1028 control={deletePromptControl} 1029 title={_(msg`Delete this post?`)} 1030 description={_( 1031 msg`If you remove this post, you won't be able to recover it.`, 1032 )} 1033 onConfirm={onDeletePost} 1034 confirmButtonCta={_(msg`Delete`)} 1035 confirmButtonColor="negative" 1036 /> 1037 1038 <Prompt.Basic 1039 control={hidePromptControl} 1040 title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} 1041 description={_( 1042 msg`This post will be hidden from feeds and threads. This cannot be undone.`, 1043 )} 1044 onConfirm={onHidePost} 1045 confirmButtonCta={_(msg`Hide`)} 1046 /> 1047 1048 <ReportDialog 1049 control={reportDialogControl} 1050 subject={{ 1051 ...post, 1052 $type: 'app.bsky.feed.defs#postView', 1053 }} 1054 /> 1055 1056 <PostInteractionSettingsDialog 1057 control={postInteractionSettingsDialogControl} 1058 postUri={post.uri} 1059 rootPostUri={rootUri} 1060 initialThreadgateView={post.threadgate} 1061 /> 1062 1063 <Prompt.Basic 1064 control={quotePostDetachConfirmControl} 1065 title={_(msg`Detach quote post?`)} 1066 description={_( 1067 msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, 1068 )} 1069 onConfirm={onToggleQuotePostAttachment} 1070 confirmButtonCta={_(msg`Yes, detach`)} 1071 /> 1072 1073 <Prompt.Basic 1074 control={hideReplyConfirmControl} 1075 title={_(msg`Hide this reply?`)} 1076 description={_( 1077 msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, 1078 )} 1079 onConfirm={onToggleReplyVisibility} 1080 confirmButtonCta={_(msg`Yes, hide`)} 1081 /> 1082 1083 <Prompt.Basic 1084 control={blockPromptControl} 1085 title={_(msg`Block Account?`)} 1086 description={_( 1087 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 1088 )} 1089 onConfirm={onBlockAuthor} 1090 confirmButtonCta={_(msg`Block`)} 1091 confirmButtonColor="negative" 1092 /> 1093 </> 1094 ) 1095} 1096PostMenuItems = memo(PostMenuItems) 1097export {PostMenuItems}