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