Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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