this repo has no description
0
fork

Configure Feed

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

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