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