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