Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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