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

Configure Feed

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

at post-text-option 605 lines 24 kB view raw
1import React, {memo} from 'react' 2import {type AppBskyActorDefs} from '@atproto/api' 3import {msg, Trans} from '@lingui/macro' 4import {useLingui} from '@lingui/react' 5import {useNavigation} from '@react-navigation/native' 6import {useQueryClient} from '@tanstack/react-query' 7 8import {useActorStatus} from '#/lib/actor-status' 9import {HITSLOP_20} from '#/lib/constants' 10import {makeProfileLink} from '#/lib/routes/links' 11import {type NavigationProp} from '#/lib/routes/types' 12import {shareText, shareUrl} from '#/lib/sharing' 13import {toShareUrl, toShareUrlBsky} from '#/lib/strings/url-helpers' 14import {logger} from '#/logger' 15import {isWeb} from '#/platform/detection' 16import {type Shadow} from '#/state/cache/types' 17import {useModalControls} from '#/state/modals' 18import { 19 useDeerVerificationEnabled, 20 useDeerVerificationTrusted, 21 useSetDeerVerificationTrust, 22} from '#/state/preferences/deer-verification' 23import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 24import { 25 RQKEY as profileQueryKey, 26 useProfileBlockMutationQueue, 27 useProfileFollowMutationQueue, 28 useProfileMuteMutationQueue, 29} from '#/state/queries/profile' 30import {useCanGoLive} from '#/state/service-config' 31import {useSession} from '#/state/session' 32import {EventStopper} from '#/view/com/util/EventStopper' 33import * as Toast from '#/view/com/util/Toast' 34import {Button, ButtonIcon} from '#/components/Button' 35import {useDialogControl} from '#/components/Dialog' 36import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' 37import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 38import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 39import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' 40import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 41import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 42import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 43import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 44import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 45import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 46import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 47import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 48import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' 49import { 50 PersonCheck_Stroke2_Corner0_Rounded as PersonCheck, 51 PersonX_Stroke2_Corner0_Rounded as PersonX, 52} from '#/components/icons/Person' 53import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 54import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 55import {StarterPack} from '#/components/icons/StarterPack' 56import {EditLiveDialog} from '#/components/live/EditLiveDialog' 57import {GoLiveDialog} from '#/components/live/GoLiveDialog' 58import * as Menu from '#/components/Menu' 59import { 60 ReportDialog, 61 useReportDialogControl, 62} from '#/components/moderation/ReportDialog' 63import * as Prompt from '#/components/Prompt' 64import {useFullVerificationState} from '#/components/verification' 65import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 66import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 67import {useDevMode} from '#/storage/hooks/dev-mode' 68 69let ProfileMenu = ({ 70 profile, 71}: { 72 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 73}): React.ReactNode => { 74 const {_} = useLingui() 75 const {currentAccount, hasSession} = useSession() 76 const {openModal} = useModalControls() 77 const reportDialogControl = useReportDialogControl() 78 const queryClient = useQueryClient() 79 const navigation = useNavigation<NavigationProp>() 80 const isSelf = currentAccount?.did === profile.did 81 const isFollowedBy = profile.viewer?.followedBy 82 const isFollowing = profile.viewer?.following 83 const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy 84 const isFollowingBlockedAccount = isFollowing && isBlocked 85 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 86 const [devModeEnabled] = useDevMode() 87 const verification = useFullVerificationState({profile}) 88 const canGoLive = useCanGoLive(currentAccount?.did) 89 90 const deerVerificationEnabled = useDeerVerificationEnabled() 91 const deerVerificationTrusted = useDeerVerificationTrusted().has(profile.did) 92 const setDeerVerificationTrust = useSetDeerVerificationTrust() 93 94 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 95 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 96 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 97 profile, 98 'ProfileMenu', 99 ) 100 101 const blockPromptControl = Prompt.usePromptControl() 102 const loggedOutWarningPromptControl = Prompt.usePromptControl() 103 const goLiveDialogControl = useDialogControl() 104 const addToStarterPacksDialogControl = useDialogControl() 105 106 const showLoggedOutWarning = React.useMemo(() => { 107 return ( 108 profile.did !== currentAccount?.did && 109 !!profile.labels?.find(label => label.val === '!no-unauthenticated') 110 ) 111 }, [currentAccount, profile]) 112 113 const invalidateProfileQuery = React.useCallback(() => { 114 queryClient.invalidateQueries({ 115 queryKey: profileQueryKey(profile.did), 116 }) 117 }, [queryClient, profile.did]) 118 119 const onPressAddToStarterPacks = React.useCallback(() => { 120 logger.metric('profile:addToStarterPack', {}) 121 addToStarterPacksDialogControl.open() 122 }, [addToStarterPacksDialogControl]) 123 124 const onPressShare = React.useCallback(() => { 125 shareUrl(toShareUrl(makeProfileLink(profile))) 126 }, [profile]) 127 128 const onPressShareBsky = React.useCallback(() => { 129 shareUrl(toShareUrlBsky(makeProfileLink(profile))) 130 }, [profile]) 131 132 const onPressAddRemoveLists = React.useCallback(() => { 133 openModal({ 134 name: 'user-add-remove-lists', 135 subject: profile.did, 136 handle: profile.handle, 137 displayName: profile.displayName || profile.handle, 138 onAdd: invalidateProfileQuery, 139 onRemove: invalidateProfileQuery, 140 }) 141 }, [profile, openModal, invalidateProfileQuery]) 142 143 const onPressMuteAccount = React.useCallback(async () => { 144 if (profile.viewer?.muted) { 145 try { 146 await queueUnmute() 147 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 148 } catch (e: any) { 149 if (e?.name !== 'AbortError') { 150 logger.error('Failed to unmute account', {message: e}) 151 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 152 } 153 } 154 } else { 155 try { 156 await queueMute() 157 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 158 } catch (e: any) { 159 if (e?.name !== 'AbortError') { 160 logger.error('Failed to mute account', {message: e}) 161 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 162 } 163 } 164 } 165 }, [profile.viewer?.muted, queueUnmute, _, queueMute]) 166 167 const blockAccount = React.useCallback(async () => { 168 if (profile.viewer?.blocking) { 169 try { 170 await queueUnblock() 171 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 172 } catch (e: any) { 173 if (e?.name !== 'AbortError') { 174 logger.error('Failed to unblock account', {message: e}) 175 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 176 } 177 } 178 } else { 179 try { 180 await queueBlock() 181 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 182 } catch (e: any) { 183 if (e?.name !== 'AbortError') { 184 logger.error('Failed to block account', {message: e}) 185 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 186 } 187 } 188 } 189 }, [profile.viewer?.blocking, _, queueUnblock, queueBlock]) 190 191 const onPressFollowAccount = React.useCallback(async () => { 192 try { 193 await queueFollow() 194 Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) 195 } catch (e: any) { 196 if (e?.name !== 'AbortError') { 197 logger.error('Failed to follow account', {message: e}) 198 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 199 } 200 } 201 }, [_, queueFollow]) 202 203 const onPressUnfollowAccount = React.useCallback(async () => { 204 try { 205 await queueUnfollow() 206 Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) 207 } catch (e: any) { 208 if (e?.name !== 'AbortError') { 209 logger.error('Failed to unfollow account', {message: e}) 210 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 211 } 212 } 213 }, [_, queueUnfollow]) 214 215 const onPressReportAccount = React.useCallback(() => { 216 reportDialogControl.open() 217 }, [reportDialogControl]) 218 219 const onPressShareATUri = React.useCallback(() => { 220 shareText(`at://${profile.did}`) 221 }, [profile.did]) 222 223 const onPressShareDID = React.useCallback(() => { 224 shareText(profile.did) 225 }, [profile.did]) 226 227 const onPressSearch = React.useCallback(() => { 228 navigation.navigate('ProfileSearch', {name: profile.handle}) 229 }, [navigation, profile.handle]) 230 231 const verificationCreatePromptControl = Prompt.usePromptControl() 232 const verificationRemovePromptControl = Prompt.usePromptControl() 233 const currentAccountVerifications = 234 profile.verification?.verifications?.filter(v => { 235 return v.issuer === currentAccount?.did 236 }) ?? [] 237 238 const status = useActorStatus(profile) 239 240 const enableSquareButtons = useEnableSquareButtons() 241 242 return ( 243 <EventStopper onKeyDown={false}> 244 <Menu.Root> 245 <Menu.Trigger label={_(msg`More options`)}> 246 {({props}) => { 247 return ( 248 <Button 249 {...props} 250 testID="profileHeaderDropdownBtn" 251 label={_(msg`More options`)} 252 hitSlop={HITSLOP_20} 253 variant="solid" 254 color="secondary" 255 size="small" 256 shape={enableSquareButtons ? 'square' : 'round'}> 257 <ButtonIcon icon={Ellipsis} size="sm" /> 258 </Button> 259 ) 260 }} 261 </Menu.Trigger> 262 263 <Menu.Outer style={{minWidth: 170}}> 264 <Menu.Group> 265 <Menu.Item 266 testID="profileHeaderDropdownShareBtn" 267 label={ 268 isWeb ? _(msg`Copy link to profile`) : _(msg`Share via...`) 269 } 270 onPress={() => { 271 if (showLoggedOutWarning) { 272 loggedOutWarningPromptControl.open() 273 } else { 274 onPressShare() 275 } 276 }}> 277 <Menu.ItemText> 278 {isWeb ? ( 279 <Trans>Copy link to profile</Trans> 280 ) : ( 281 <Trans>Share via...</Trans> 282 )} 283 </Menu.ItemText> 284 <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} /> 285 </Menu.Item> 286 <Menu.Item 287 testID="profileHeaderDropdownShareBtn" 288 label={ 289 isWeb 290 ? _(msg`Copy via bsky.app`) 291 : _(msg`Share via bsky.app...`) 292 } 293 onPress={() => { 294 if (showLoggedOutWarning) { 295 loggedOutWarningPromptControl.open() 296 } else { 297 onPressShareBsky() 298 } 299 }}> 300 <Menu.ItemText> 301 {isWeb ? ( 302 <Trans>Copy via bsky.app</Trans> 303 ) : ( 304 <Trans>Share via bsky.app...</Trans> 305 )} 306 </Menu.ItemText> 307 <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} /> 308 </Menu.Item> 309 <Menu.Item 310 testID="profileHeaderDropdownSearchBtn" 311 label={_(msg`Search skeets`)} 312 onPress={onPressSearch}> 313 <Menu.ItemText> 314 <Trans>Search skeets</Trans> 315 </Menu.ItemText> 316 <Menu.ItemIcon icon={SearchIcon} /> 317 </Menu.Item> 318 </Menu.Group> 319 320 {hasSession && ( 321 <> 322 <Menu.Divider /> 323 <Menu.Group> 324 {!isSelf && ( 325 <> 326 {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && ( 327 <Menu.Item 328 testID="profileHeaderDropdownFollowBtn" 329 label={ 330 isFollowing 331 ? isFollowedBy 332 ? _(msg`Divorce mutual`) 333 : _(msg`Unfollow account`) 334 : _(msg`Follow account`) 335 } 336 onPress={ 337 isFollowing 338 ? onPressUnfollowAccount 339 : onPressFollowAccount 340 }> 341 <Menu.ItemText> 342 {isFollowing ? ( 343 isFollowedBy ? ( 344 <Trans>Divorce mutual</Trans> 345 ) : ( 346 <Trans>Unfollow account</Trans> 347 ) 348 ) : ( 349 <Trans>Follow account</Trans> 350 )} 351 </Menu.ItemText> 352 <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} /> 353 </Menu.Item> 354 )} 355 </> 356 )} 357 <Menu.Item 358 testID="profileHeaderDropdownStarterPackAddRemoveBtn" 359 label={_(msg`Add to starter packs`)} 360 onPress={onPressAddToStarterPacks}> 361 <Menu.ItemText> 362 <Trans>Add to starter packs</Trans> 363 </Menu.ItemText> 364 <Menu.ItemIcon icon={StarterPack} /> 365 </Menu.Item> 366 <Menu.Item 367 testID="profileHeaderDropdownListAddRemoveBtn" 368 label={_(msg`Add to lists`)} 369 onPress={onPressAddRemoveLists}> 370 <Menu.ItemText> 371 <Trans>Add to lists</Trans> 372 </Menu.ItemText> 373 <Menu.ItemIcon icon={List} /> 374 </Menu.Item> 375 {!isSelf && 376 deerVerificationEnabled && 377 (deerVerificationTrusted ? ( 378 <Menu.Item 379 testID="profileHeaderDropdownVerificationTrustRemoveButton" 380 label={_(msg`Remove trust`)} 381 onPress={() => 382 setDeerVerificationTrust.remove(profile.did) 383 }> 384 <Menu.ItemText> 385 <Trans>Remove trust</Trans> 386 </Menu.ItemText> 387 <Menu.ItemIcon icon={CircleXIcon} /> 388 </Menu.Item> 389 ) : ( 390 <Menu.Item 391 testID="profileHeaderDropdownVerificationTrustAddButton" 392 label={_(msg`Trust verifier`)} 393 onPress={() => setDeerVerificationTrust.add(profile.did)}> 394 <Menu.ItemText> 395 <Trans>Trust verifier</Trans> 396 </Menu.ItemText> 397 <Menu.ItemIcon icon={CircleCheckIcon} /> 398 </Menu.Item> 399 ))} 400 {isSelf && canGoLive && ( 401 <Menu.Item 402 testID="profileHeaderDropdownListAddRemoveBtn" 403 label={ 404 status.isActive 405 ? _(msg`Edit live status`) 406 : _(msg`Go live`) 407 } 408 onPress={goLiveDialogControl.open}> 409 <Menu.ItemText> 410 {status.isActive ? ( 411 <Trans>Edit live status</Trans> 412 ) : ( 413 <Trans>Go live</Trans> 414 )} 415 </Menu.ItemText> 416 <Menu.ItemIcon icon={LiveIcon} /> 417 </Menu.Item> 418 )} 419 {verification.viewer.role === 'verifier' && 420 !verification.profile.isViewer && 421 (verification.viewer.hasIssuedVerification ? ( 422 <Menu.Item 423 testID="profileHeaderDropdownVerificationRemoveButton" 424 label={_(msg`Remove verification`)} 425 onPress={() => verificationRemovePromptControl.open()}> 426 <Menu.ItemText> 427 <Trans>Remove verification</Trans> 428 </Menu.ItemText> 429 <Menu.ItemIcon icon={CircleXIcon} /> 430 </Menu.Item> 431 ) : ( 432 <Menu.Item 433 testID="profileHeaderDropdownVerificationCreateButton" 434 label={_(msg`Verify account`)} 435 onPress={() => verificationCreatePromptControl.open()}> 436 <Menu.ItemText> 437 <Trans>Verify account</Trans> 438 </Menu.ItemText> 439 <Menu.ItemIcon icon={CircleCheckIcon} /> 440 </Menu.Item> 441 ))} 442 {!isSelf && ( 443 <> 444 {!profile.viewer?.blocking && 445 !profile.viewer?.mutedByList && ( 446 <Menu.Item 447 testID="profileHeaderDropdownMuteBtn" 448 label={ 449 profile.viewer?.muted 450 ? _(msg`Unmute account`) 451 : _(msg`Mute account`) 452 } 453 onPress={onPressMuteAccount}> 454 <Menu.ItemText> 455 {profile.viewer?.muted ? ( 456 <Trans>Unmute account</Trans> 457 ) : ( 458 <Trans>Mute account</Trans> 459 )} 460 </Menu.ItemText> 461 <Menu.ItemIcon 462 icon={profile.viewer?.muted ? Unmute : Mute} 463 /> 464 </Menu.Item> 465 )} 466 {!profile.viewer?.blockingByList && ( 467 <Menu.Item 468 testID="profileHeaderDropdownBlockBtn" 469 label={ 470 profile.viewer 471 ? _(msg`Unblock account`) 472 : _(msg`Block account`) 473 } 474 onPress={() => blockPromptControl.open()}> 475 <Menu.ItemText> 476 {profile.viewer?.blocking ? ( 477 <Trans>Unblock account</Trans> 478 ) : ( 479 <Trans>Block account</Trans> 480 )} 481 </Menu.ItemText> 482 <Menu.ItemIcon 483 icon={ 484 profile.viewer?.blocking ? PersonCheck : PersonX 485 } 486 /> 487 </Menu.Item> 488 )} 489 <Menu.Item 490 testID="profileHeaderDropdownReportBtn" 491 label={_(msg`Report account`)} 492 onPress={onPressReportAccount}> 493 <Menu.ItemText> 494 <Trans>Report account</Trans> 495 </Menu.ItemText> 496 <Menu.ItemIcon icon={Flag} /> 497 </Menu.Item> 498 </> 499 )} 500 </Menu.Group> 501 </> 502 )} 503 {devModeEnabled ? ( 504 <> 505 <Menu.Divider /> 506 <Menu.Group> 507 <Menu.Item 508 testID="profileHeaderDropdownShareATURIBtn" 509 label={_(msg`Copy at:// URI`)} 510 onPress={onPressShareATUri}> 511 <Menu.ItemText> 512 <Trans>Copy at:// URI</Trans> 513 </Menu.ItemText> 514 <Menu.ItemIcon icon={ClipboardIcon} /> 515 </Menu.Item> 516 <Menu.Item 517 testID="profileHeaderDropdownShareDIDBtn" 518 label={_(msg`Copy DID`)} 519 onPress={onPressShareDID}> 520 <Menu.ItemText> 521 <Trans>Copy DID</Trans> 522 </Menu.ItemText> 523 <Menu.ItemIcon icon={ClipboardIcon} /> 524 </Menu.Item> 525 </Menu.Group> 526 </> 527 ) : null} 528 </Menu.Outer> 529 </Menu.Root> 530 531 <StarterPackDialog 532 control={addToStarterPacksDialogControl} 533 targetDid={profile.did} 534 /> 535 536 <ReportDialog 537 control={reportDialogControl} 538 subject={{ 539 ...profile, 540 $type: 'app.bsky.actor.defs#profileViewDetailed', 541 }} 542 /> 543 544 <Prompt.Basic 545 control={blockPromptControl} 546 title={ 547 profile.viewer?.blocking 548 ? _(msg`Unblock Account?`) 549 : _(msg`Block Account?`) 550 } 551 description={ 552 profile.viewer?.blocking 553 ? _( 554 msg`The account will be able to interact with you after unblocking.`, 555 ) 556 : profile.associated?.labeler 557 ? _( 558 msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`, 559 ) 560 : _( 561 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 562 ) 563 } 564 onConfirm={blockAccount} 565 confirmButtonCta={ 566 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 567 } 568 confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'} 569 /> 570 571 <Prompt.Basic 572 control={loggedOutWarningPromptControl} 573 title={_(msg`Note about sharing`)} 574 description={_( 575 msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 576 )} 577 onConfirm={onPressShare} 578 confirmButtonCta={_(msg`Share anyway`)} 579 /> 580 581 <VerificationCreatePrompt 582 control={verificationCreatePromptControl} 583 profile={profile} 584 /> 585 <VerificationRemovePrompt 586 control={verificationRemovePromptControl} 587 profile={profile} 588 verifications={currentAccountVerifications} 589 /> 590 591 {status.isActive ? ( 592 <EditLiveDialog 593 control={goLiveDialogControl} 594 status={status} 595 embed={status.embed} 596 /> 597 ) : ( 598 <GoLiveDialog control={goLiveDialogControl} profile={profile} /> 599 )} 600 </EventStopper> 601 ) 602} 603 604ProfileMenu = memo(ProfileMenu) 605export {ProfileMenu}