Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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