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