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