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