Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 1138 lines 39 kB view raw
1import {memo, useMemo} from 'react' 2import { 3 Platform, 4 type PressableProps, 5 type StyleProp, 6 type ViewStyle, 7} from 'react-native' 8import * as Clipboard from 'expo-clipboard' 9import { 10 type AppBskyEmbedExternal, 11 type AppBskyEmbedImages, 12 AppBskyEmbedRecord, 13 type AppBskyEmbedRecordWithMedia, 14 type AppBskyEmbedVideo, 15 type AppBskyFeedDefs, 16 AppBskyFeedPost, 17 type AppBskyFeedThreadgate, 18 AtUri, 19 type BlobRef, 20 isDid, 21 type RichText as RichTextAPI, 22} from '@atproto/api' 23import {plural} from '@lingui/core/macro' 24import {useLingui} from '@lingui/react/macro' 25import {useNavigation} from '@react-navigation/native' 26 27import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 28import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 29import {useOpenLink} from '#/lib/hooks/useOpenLink' 30import {saveVideoToDevice} from '#/lib/media/saveVideoToDevice' 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} 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 (!isDid(did)) return 613 const pdsUrl = await resolvePdsServiceUrl(did) 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 success = await saveVideoToDevice({uri: uri}) 620 621 if (success) 622 Toast.show(l({message: 'Video downloaded', context: 'toast'}), { 623 type: 'success', 624 }) 625 else 626 Toast.show(l({message: 'Failed to download video', context: 'toast'}), { 627 type: 'error', 628 }) 629 } 630 631 const onPressDownloadGif = async () => { 632 if (!gifEmbed) return 633 634 Toast.show(l({message: 'Downloading GIF...', context: 'toast'})) 635 636 let success 637 success = await saveVideoToDevice({uri: gifEmbed.external.uri}) 638 639 if (success) 640 Toast.show(l({message: 'GIF downloaded', context: 'toast'}), { 641 type: 'success', 642 }) 643 else 644 Toast.show(l({message: 'Failed to download GIF', context: 'toast'}), { 645 type: 'error', 646 }) 647 } 648 649 const isEmbedGif = () => { 650 if (!gifEmbed) return false 651 // Janky workaround by checking if the domain is tenor.com 652 const url = new URL(gifEmbed.external.uri) 653 return url.host === 'media.tenor.com' 654 } 655 656 const onBlockAuthor = async () => { 657 try { 658 await queueBlock() 659 Toast.show(l({message: 'Account blocked', context: 'toast'})) 660 } catch (err) { 661 const e = err as Error 662 if (e?.name !== 'AbortError') { 663 logger.error('Failed to block account', {message: e}) 664 Toast.show(l`There was an issue! ${e.toString()}`, { 665 type: 'error', 666 }) 667 } 668 } finally { 669 ax.metric('postMenu:blockAccount', { 670 uri: postUri, 671 authorDid: postAuthor.did, 672 logContext, 673 feedDescriptor: feedFeedback.feedDescriptor, 674 }) 675 } 676 } 677 678 const onMuteAuthor = async () => { 679 if (postAuthor.viewer?.muted) { 680 try { 681 await queueUnmute() 682 Toast.show(l({message: 'Account unmuted', context: 'toast'})) 683 } catch (err) { 684 const e = err as Error 685 if (e?.name !== 'AbortError') { 686 logger.error('Failed to unmute account', {message: e}) 687 Toast.show(l`There was an issue! ${e.toString()}`, { 688 type: 'error', 689 }) 690 } 691 } finally { 692 ax.metric('postMenu:unmuteAccount', { 693 uri: postUri, 694 authorDid: postAuthor.did, 695 logContext, 696 feedDescriptor: feedFeedback.feedDescriptor, 697 }) 698 } 699 } else { 700 try { 701 await queueMute() 702 Toast.show(l({message: 'Account muted', context: 'toast'})) 703 } catch (err) { 704 const e = err as Error 705 if (e?.name !== 'AbortError') { 706 logger.error('Failed to mute account', {message: e}) 707 Toast.show(l`There was an issue! ${e.toString()}`, { 708 type: 'error', 709 }) 710 } 711 } finally { 712 ax.metric('postMenu:muteAccount', { 713 uri: postUri, 714 authorDid: postAuthor.did, 715 logContext, 716 feedDescriptor: feedFeedback.feedDescriptor, 717 }) 718 } 719 } 720 } 721 722 const onReportMisclassification = () => { 723 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( 724 href, 725 )}` 726 void openLink(url) 727 } 728 729 const onSignIn = () => requireSignIn(() => {}) 730 731 const onPressHideTranslation = () => clearTranslation() 732 733 const isDiscoverDebugUser = 734 IS_INTERNAL || 735 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 736 ax.features.enabled(ax.features.DebugFeedContext) 737 738 return ( 739 <> 740 <Prompt.Basic 741 control={redraftPromptControl} 742 title={l`Redraft this post?`} 743 description={l`This will delete the original post and open the composer with its content.`} 744 onConfirm={onConfirmRedraft} 745 confirmButtonCta={l`Redraft`} 746 confirmButtonColor="primary" 747 /> 748 <Menu.Outer> 749 {isAuthor && ( 750 <> 751 <Menu.Group> 752 <Menu.Item 753 testID="pinPostBtn" 754 label={ 755 isPinned ? l`Unpin from profile` : l`Pin to your profile` 756 } 757 disabled={isPinPending} 758 onPress={onPressPin}> 759 <Menu.ItemText> 760 {isPinned ? l`Unpin from profile` : l`Pin to your profile`} 761 </Menu.ItemText> 762 <Menu.ItemIcon 763 icon={isPinPending ? Loader : PinIcon} 764 position="right" 765 /> 766 </Menu.Item> 767 <Menu.Item 768 testID="redraftPostBtn" 769 label={l`Redraft`} 770 onPress={onRedraftPost}> 771 <Menu.ItemText>{l`Redraft`}</Menu.ItemText> 772 <Menu.ItemIcon icon={Pen} position="right" /> 773 </Menu.Item> 774 </Menu.Group> 775 <Menu.Divider /> 776 </> 777 )} 778 779 {videoEmbed && ( 780 <> 781 <Menu.Group> 782 <Menu.Item 783 testID="postDropdownDownloadVideoBtn" 784 label={l`Download Video`} 785 onPress={onPressDownloadVideo}> 786 <Menu.ItemText>{l`Download Video`}</Menu.ItemText> 787 <Menu.ItemIcon icon={Download} position="right" /> 788 </Menu.Item> 789 </Menu.Group> 790 <Menu.Divider /> 791 </> 792 )} 793 794 {isEmbedGif() && ( 795 <> 796 <Menu.Group> 797 <Menu.Item 798 testID="postDropdownDownloadGifBtn" 799 label={l`Download GIF`} 800 onPress={onPressDownloadGif}> 801 <Menu.ItemText>{l`Download GIF`}</Menu.ItemText> 802 <Menu.ItemIcon icon={Download} position="right" /> 803 </Menu.Item> 804 </Menu.Group> 805 <Menu.Divider /> 806 </> 807 )} 808 809 <Menu.Group> 810 {!hideInPWI || hasSession ? ( 811 <> 812 {translationState.status === 'loading' ? ( 813 <Menu.Item 814 testID="postDropdownTranslateBtn" 815 label={l`Translating…`} 816 onPress={() => {}}> 817 <Menu.ItemText>{l`Translating…`}</Menu.ItemText> 818 <Menu.ItemIcon icon={Translate} position="right" /> 819 </Menu.Item> 820 ) : translationState.status === 'success' ? ( 821 <Menu.Item 822 testID="postDropdownTranslateBtn" 823 label={l`Hide translation`} 824 onPress={onPressHideTranslation}> 825 <Menu.ItemText>{l`Hide translation`}</Menu.ItemText> 826 <Menu.ItemIcon icon={Translate} position="right" /> 827 </Menu.Item> 828 ) : ( 829 <Menu.Item 830 testID="postDropdownTranslateBtn" 831 label={l`Translate`} 832 onPress={onPressTranslate}> 833 <Menu.ItemText>{l`Translate`}</Menu.ItemText> 834 <Menu.ItemIcon icon={Translate} position="right" /> 835 </Menu.Item> 836 )} 837 838 <Menu.Item 839 testID="postDropdownCopyTextBtn" 840 label={l`Copy post text`} 841 onPress={onCopyPostText}> 842 <Menu.ItemText>{l`Copy post text`}</Menu.ItemText> 843 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 844 </Menu.Item> 845 </> 846 ) : ( 847 <Menu.Item 848 testID="postDropdownSignInBtn" 849 label={l`Sign in to view post`} 850 onPress={onSignIn}> 851 <Menu.ItemText>{l`Sign in to view post`}</Menu.ItemText> 852 <Menu.ItemIcon icon={Eye} position="right" /> 853 </Menu.Item> 854 )} 855 </Menu.Group> 856 857 {hasSession && feedFeedback.enabled && ( 858 <> 859 <Menu.Divider /> 860 <Menu.Group> 861 <Menu.Item 862 testID="postDropdownShowMoreBtn" 863 label={l`Show more like this`} 864 onPress={onPressShowMore}> 865 <Menu.ItemText>{l`Show more like this`}</Menu.ItemText> 866 <Menu.ItemIcon icon={EmojiSmile} position="right" /> 867 </Menu.Item> 868 869 <Menu.Item 870 testID="postDropdownShowLessBtn" 871 label={l`Show less like this`} 872 onPress={onPressShowLess}> 873 <Menu.ItemText>{l`Show less like this`}</Menu.ItemText> 874 <Menu.ItemIcon icon={EmojiSad} position="right" /> 875 </Menu.Item> 876 </Menu.Group> 877 </> 878 )} 879 880 {isDiscoverDebugUser && ( 881 <> 882 <Menu.Divider /> 883 <Menu.Item 884 testID="postDropdownReportMisclassificationBtn" 885 label={l`Assign topic for algo`} 886 onPress={onReportMisclassification}> 887 <Menu.ItemText>{l`Assign topic for algo`}</Menu.ItemText> 888 <Menu.ItemIcon icon={AtomIcon} position="right" /> 889 </Menu.Item> 890 </> 891 )} 892 893 {hasSession && ( 894 <> 895 <Menu.Divider /> 896 <Menu.Group> 897 <Menu.Item 898 testID="postDropdownMuteThreadBtn" 899 label={isThreadMuted ? l`Unmute thread` : l`Mute thread`} 900 onPress={onToggleThreadMute}> 901 <Menu.ItemText> 902 {isThreadMuted ? l`Unmute thread` : l`Mute thread`} 903 </Menu.ItemText> 904 <Menu.ItemIcon 905 icon={isThreadMuted ? Unmute : Mute} 906 position="right" 907 /> 908 </Menu.Item> 909 910 <Menu.Item 911 testID="postDropdownMuteWordsBtn" 912 label={l`Mute words & tags`} 913 onPress={onToggleWordsAndTagsMute}> 914 <Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText> 915 <Menu.ItemIcon icon={Filter} position="right" /> 916 </Menu.Item> 917 </Menu.Group> 918 </> 919 )} 920 921 {hasSession && 922 (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( 923 <> 924 <Menu.Divider /> 925 <Menu.Group> 926 {canHidePostForMe && ( 927 <Menu.Item 928 testID="postDropdownHideBtn" 929 label={isReply ? l`Hide reply for me` : l`Hide post for me`} 930 onPress={() => hidePromptControl.open()}> 931 <Menu.ItemText> 932 {isReply ? l`Hide reply for me` : l`Hide post for me`} 933 </Menu.ItemText> 934 <Menu.ItemIcon icon={EyeSlash} position="right" /> 935 </Menu.Item> 936 )} 937 {canHideReplyForEveryone && ( 938 <Menu.Item 939 testID="postDropdownHideBtn" 940 label={ 941 isReplyHiddenByThreadgate 942 ? l`Show reply for everyone` 943 : l`Hide reply for everyone` 944 } 945 onPress={ 946 isReplyHiddenByThreadgate 947 ? onToggleReplyVisibility 948 : () => hideReplyConfirmControl.open() 949 }> 950 <Menu.ItemText> 951 {isReplyHiddenByThreadgate 952 ? l`Show reply for everyone` 953 : l`Hide reply for everyone`} 954 </Menu.ItemText> 955 <Menu.ItemIcon 956 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} 957 position="right" 958 /> 959 </Menu.Item> 960 )} 961 962 {canDetachQuote && ( 963 <Menu.Item 964 disabled={isDetachPending} 965 testID="postDropdownHideBtn" 966 label={ 967 quoteEmbed.isDetached 968 ? l`Re-attach quote` 969 : l`Detach quote` 970 } 971 onPress={ 972 quoteEmbed.isDetached 973 ? onToggleQuotePostAttachment 974 : () => quotePostDetachConfirmControl.open() 975 }> 976 <Menu.ItemText> 977 {quoteEmbed.isDetached 978 ? l`Re-attach quote` 979 : l`Detach quote`} 980 </Menu.ItemText> 981 <Menu.ItemIcon 982 icon={ 983 isDetachPending 984 ? Loader 985 : quoteEmbed.isDetached 986 ? Eye 987 : EyeSlash 988 } 989 position="right" 990 /> 991 </Menu.Item> 992 )} 993 </Menu.Group> 994 </> 995 )} 996 997 {hasSession && ( 998 <> 999 <Menu.Divider /> 1000 <Menu.Group> 1001 {!isAuthor && ( 1002 <> 1003 <Menu.Item 1004 testID="postDropdownMuteBtn" 1005 label={ 1006 postAuthor.viewer?.muted 1007 ? l`Unmute account` 1008 : l`Mute account` 1009 } 1010 onPress={() => void onMuteAuthor()}> 1011 <Menu.ItemText> 1012 {postAuthor.viewer?.muted 1013 ? l`Unmute account` 1014 : l`Mute account`} 1015 </Menu.ItemText> 1016 <Menu.ItemIcon 1017 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} 1018 position="right" 1019 /> 1020 </Menu.Item> 1021 1022 {!postAuthor.viewer?.blocking && ( 1023 <Menu.Item 1024 testID="postDropdownBlockBtn" 1025 label={l`Block account`} 1026 onPress={() => blockPromptControl.open()}> 1027 <Menu.ItemText>{l`Block account`}</Menu.ItemText> 1028 <Menu.ItemIcon icon={PersonX} position="right" /> 1029 </Menu.Item> 1030 )} 1031 1032 <Menu.Item 1033 testID="postDropdownReportBtn" 1034 label={l`Report post`} 1035 onPress={() => reportDialogControl.open()}> 1036 <Menu.ItemText>{l`Report post`}</Menu.ItemText> 1037 <Menu.ItemIcon icon={Warning} position="right" /> 1038 </Menu.Item> 1039 </> 1040 )} 1041 1042 {isAuthor && ( 1043 <> 1044 <Menu.Item 1045 testID="postDropdownEditPostInteractions" 1046 label={l`Edit interaction settings`} 1047 onPress={() => postInteractionSettingsDialogControl.open()} 1048 {...(isAuthor 1049 ? Platform.select({ 1050 web: { 1051 onHoverIn: prefetchPostInteractionSettings, 1052 }, 1053 native: { 1054 onPressIn: prefetchPostInteractionSettings, 1055 }, 1056 }) 1057 : {})}> 1058 <Menu.ItemText> 1059 {l`Edit interaction settings`} 1060 </Menu.ItemText> 1061 <Menu.ItemIcon icon={Gear} position="right" /> 1062 </Menu.Item> 1063 <Menu.Item 1064 testID="postDropdownDeleteBtn" 1065 label={l`Delete post`} 1066 onPress={() => deletePromptControl.open()}> 1067 <Menu.ItemText>{l`Delete post`}</Menu.ItemText> 1068 <Menu.ItemIcon icon={Trash} position="right" /> 1069 </Menu.Item> 1070 </> 1071 )} 1072 </Menu.Group> 1073 </> 1074 )} 1075 </Menu.Outer> 1076 <Prompt.Basic 1077 control={deletePromptControl} 1078 title={l`Delete this post?`} 1079 description={l`If you remove this post, you won't be able to recover it.`} 1080 onConfirm={onDeletePost} 1081 confirmButtonCta={l`Delete`} 1082 confirmButtonColor="negative" 1083 /> 1084 <Prompt.Basic 1085 control={hidePromptControl} 1086 title={isReply ? l`Hide this reply?` : l`Hide this post?`} 1087 description={l`This post will be hidden from feeds and threads. This cannot be undone.`} 1088 onConfirm={onHidePost} 1089 confirmButtonCta={l`Hide`} 1090 /> 1091 <ReportDialog 1092 control={reportDialogControl} 1093 subject={{ 1094 ...post, 1095 $type: 'app.bsky.feed.defs#postView', 1096 }} 1097 onAfterSubmit={() => { 1098 ax.metric('postMenu:reportPost', { 1099 uri: postUri, 1100 authorDid: postAuthor.did, 1101 logContext, 1102 feedDescriptor: feedFeedback.feedDescriptor, 1103 }) 1104 }} 1105 /> 1106 <PostInteractionSettingsDialog 1107 control={postInteractionSettingsDialogControl} 1108 postUri={post.uri} 1109 rootPostUri={rootUri} 1110 initialThreadgateView={post.threadgate} 1111 /> 1112 <Prompt.Basic 1113 control={quotePostDetachConfirmControl} 1114 title={l`Detach quote post?`} 1115 description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`} 1116 onConfirm={() => void onToggleQuotePostAttachment()} 1117 confirmButtonCta={l`Yes, detach`} 1118 /> 1119 <Prompt.Basic 1120 control={hideReplyConfirmControl} 1121 title={l`Hide this reply?`} 1122 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.`} 1123 onConfirm={() => void onToggleReplyVisibility()} 1124 confirmButtonCta={l`Yes, hide`} 1125 /> 1126 <Prompt.Basic 1127 control={blockPromptControl} 1128 title={l`Block Account?`} 1129 description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 1130 onConfirm={() => void onBlockAuthor()} 1131 confirmButtonCta={l`Block`} 1132 confirmButtonColor="negative" 1133 /> 1134 </> 1135 ) 1136} 1137PostMenuItems = memo(PostMenuItems) 1138export {PostMenuItems}