Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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