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

Configure Feed

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

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