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

Configure Feed

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

at 8f82ad66df8a21c9a0905dcbf882dd87e892ac8f 592 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 {logger} from '#/logger' 15import {isWeb} from '#/platform/detection' 16import {type Shadow} from '#/state/cache/types' 17import {useModalControls} from '#/state/modals' 18import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 19import { 20 RQKEY as profileQueryKey, 21 useProfileBlockMutationQueue, 22 useProfileFollowMutationQueue, 23 useProfileMuteMutationQueue, 24} from '#/state/queries/profile' 25import {useCanGoLive} from '#/state/service-config' 26import {useSession} from '#/state/session' 27import {EventStopper} from '#/view/com/util/EventStopper' 28import * as Toast from '#/view/com/util/Toast' 29import {atoms as a, useTheme} from '#/alf' 30import {Button, ButtonIcon} from '#/components/Button' 31import {useDialogControl} from '#/components/Dialog' 32import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' 33import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 34import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 35import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' 36import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 37import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 38import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 39import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 40import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 41import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 42import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 43import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 44import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' 45import { 46 PersonCheck_Stroke2_Corner0_Rounded as PersonCheck, 47 PersonX_Stroke2_Corner0_Rounded as PersonX, 48} from '#/components/icons/Person' 49import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 50import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 51import {StarterPack} from '#/components/icons/StarterPack' 52import {EditLiveDialog} from '#/components/live/EditLiveDialog' 53import {GoLiveDialog} from '#/components/live/GoLiveDialog' 54import {GoLiveDisabledDialog} from '#/components/live/GoLiveDisabledDialog' 55import * as Menu from '#/components/Menu' 56import { 57 ReportDialog, 58 useReportDialogControl, 59} from '#/components/moderation/ReportDialog' 60import * as Prompt from '#/components/Prompt' 61import {useFullVerificationState} from '#/components/verification' 62import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 63import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 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 {_} = 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 isFollowing = profile.viewer?.following 82 const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy 83 const isFollowingBlockedAccount = isFollowing && isBlocked 84 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 85 const [devModeEnabled] = useDevMode() 86 const verification = useFullVerificationState({profile}) 87 const canGoLive = useCanGoLive() 88 const status = useActorStatus(profile) 89 const statusNudge = useNux(Nux.LiveNowBetaNudge) 90 const statusNudgeActive = 91 isSelf && 92 canGoLive && 93 statusNudge.status === 'ready' && 94 !statusNudge.nux?.completed 95 const {mutate: saveNux} = useSaveNux() 96 97 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 98 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 99 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 100 profile, 101 'ProfileMenu', 102 ) 103 104 const blockPromptControl = Prompt.usePromptControl() 105 const loggedOutWarningPromptControl = Prompt.usePromptControl() 106 const goLiveDialogControl = useDialogControl() 107 const goLiveDisabledDialogControl = useDialogControl() 108 const addToStarterPacksDialogControl = useDialogControl() 109 110 const showLoggedOutWarning = React.useMemo(() => { 111 return ( 112 profile.did !== currentAccount?.did && 113 !!profile.labels?.find(label => label.val === '!no-unauthenticated') 114 ) 115 }, [currentAccount, profile]) 116 117 const invalidateProfileQuery = React.useCallback(() => { 118 queryClient.invalidateQueries({ 119 queryKey: profileQueryKey(profile.did), 120 }) 121 }, [queryClient, profile.did]) 122 123 const onPressAddToStarterPacks = React.useCallback(() => { 124 logger.metric('profile:addToStarterPack', {}) 125 addToStarterPacksDialogControl.open() 126 }, [addToStarterPacksDialogControl]) 127 128 const onPressShare = React.useCallback(() => { 129 shareUrl(toShareUrl(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 return ( 239 <EventStopper onKeyDown={false}> 240 <Menu.Root> 241 <Menu.Trigger label={_(msg`More options`)}> 242 {({props}) => { 243 return ( 244 <> 245 <Button 246 {...props} 247 testID="profileHeaderDropdownBtn" 248 label={_(msg`More options`)} 249 hitSlop={HITSLOP_20} 250 variant="solid" 251 color="secondary" 252 size="small" 253 shape="round"> 254 {statusNudgeActive && <Gradient style={[a.rounded_full]} />} 255 <ButtonIcon icon={Ellipsis} size="sm" /> 256 </Button> 257 258 {statusNudgeActive && <Dot top={1} right={1} />} 259 </> 260 ) 261 }} 262 </Menu.Trigger> 263 264 <Menu.Outer style={{minWidth: 170}}> 265 <Menu.Group> 266 <Menu.Item 267 testID="profileHeaderDropdownShareBtn" 268 label={ 269 isWeb ? _(msg`Copy link to profile`) : _(msg`Share via...`) 270 } 271 onPress={() => { 272 if (showLoggedOutWarning) { 273 loggedOutWarningPromptControl.open() 274 } else { 275 onPressShare() 276 } 277 }}> 278 <Menu.ItemText> 279 {isWeb ? ( 280 <Trans>Copy link to profile</Trans> 281 ) : ( 282 <Trans>Share via...</Trans> 283 )} 284 </Menu.ItemText> 285 <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} /> 286 </Menu.Item> 287 <Menu.Item 288 testID="profileHeaderDropdownSearchBtn" 289 label={_(msg`Search posts`)} 290 onPress={onPressSearch}> 291 <Menu.ItemText> 292 <Trans>Search posts</Trans> 293 </Menu.ItemText> 294 <Menu.ItemIcon icon={SearchIcon} /> 295 </Menu.Item> 296 </Menu.Group> 297 298 {hasSession && ( 299 <> 300 <Menu.Divider /> 301 <Menu.Group> 302 {!isSelf && ( 303 <> 304 {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && ( 305 <Menu.Item 306 testID="profileHeaderDropdownFollowBtn" 307 label={ 308 isFollowing 309 ? _(msg`Unfollow account`) 310 : _(msg`Follow account`) 311 } 312 onPress={ 313 isFollowing 314 ? onPressUnfollowAccount 315 : onPressFollowAccount 316 }> 317 <Menu.ItemText> 318 {isFollowing ? ( 319 <Trans>Unfollow account</Trans> 320 ) : ( 321 <Trans>Follow account</Trans> 322 )} 323 </Menu.ItemText> 324 <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} /> 325 </Menu.Item> 326 )} 327 </> 328 )} 329 <Menu.Item 330 testID="profileHeaderDropdownStarterPackAddRemoveBtn" 331 label={_(msg`Add to starter packs`)} 332 onPress={onPressAddToStarterPacks}> 333 <Menu.ItemText> 334 <Trans>Add to starter packs</Trans> 335 </Menu.ItemText> 336 <Menu.ItemIcon icon={StarterPack} /> 337 </Menu.Item> 338 <Menu.Item 339 testID="profileHeaderDropdownListAddRemoveBtn" 340 label={_(msg`Add to lists`)} 341 onPress={onPressAddRemoveLists}> 342 <Menu.ItemText> 343 <Trans>Add to lists</Trans> 344 </Menu.ItemText> 345 <Menu.ItemIcon icon={List} /> 346 </Menu.Item> 347 {isSelf && canGoLive && ( 348 <Menu.Item 349 testID="profileHeaderDropdownListAddRemoveBtn" 350 label={ 351 status.isDisabled 352 ? _(msg`Go live (disabled)`) 353 : status.isActive 354 ? _(msg`Edit live status`) 355 : _(msg`Go live`) 356 } 357 onPress={() => { 358 if (status.isDisabled) { 359 goLiveDisabledDialogControl.open() 360 } else { 361 goLiveDialogControl.open() 362 } 363 saveNux({ 364 id: Nux.LiveNowBetaNudge, 365 data: undefined, 366 completed: true, 367 }) 368 }}> 369 {statusNudgeActive && <Gradient />} 370 <Menu.ItemText> 371 {status.isDisabled ? ( 372 <Trans>Go live (disabled)</Trans> 373 ) : status.isActive ? ( 374 <Trans>Edit live status</Trans> 375 ) : ( 376 <Trans>Go live</Trans> 377 )} 378 </Menu.ItemText> 379 {statusNudgeActive && ( 380 <Menu.ItemText 381 style={[ 382 a.flex_0, 383 { 384 color: t.palette.primary_500, 385 right: isWeb ? -8 : -4, 386 }, 387 ]}> 388 <Trans>New</Trans> 389 </Menu.ItemText> 390 )} 391 <Menu.ItemIcon 392 icon={LiveIcon} 393 fill={ 394 statusNudgeActive 395 ? () => t.palette.primary_500 396 : undefined 397 } 398 /> 399 </Menu.Item> 400 )} 401 {verification.viewer.role === 'verifier' && 402 !verification.profile.isViewer && 403 (verification.viewer.hasIssuedVerification ? ( 404 <Menu.Item 405 testID="profileHeaderDropdownVerificationRemoveButton" 406 label={_(msg`Remove verification`)} 407 onPress={() => verificationRemovePromptControl.open()}> 408 <Menu.ItemText> 409 <Trans>Remove verification</Trans> 410 </Menu.ItemText> 411 <Menu.ItemIcon icon={CircleXIcon} /> 412 </Menu.Item> 413 ) : ( 414 <Menu.Item 415 testID="profileHeaderDropdownVerificationCreateButton" 416 label={_(msg`Verify account`)} 417 onPress={() => verificationCreatePromptControl.open()}> 418 <Menu.ItemText> 419 <Trans>Verify account</Trans> 420 </Menu.ItemText> 421 <Menu.ItemIcon icon={CircleCheckIcon} /> 422 </Menu.Item> 423 ))} 424 {!isSelf && ( 425 <> 426 {!profile.viewer?.blocking && 427 !profile.viewer?.mutedByList && ( 428 <Menu.Item 429 testID="profileHeaderDropdownMuteBtn" 430 label={ 431 profile.viewer?.muted 432 ? _(msg`Unmute account`) 433 : _(msg`Mute account`) 434 } 435 onPress={onPressMuteAccount}> 436 <Menu.ItemText> 437 {profile.viewer?.muted ? ( 438 <Trans>Unmute account</Trans> 439 ) : ( 440 <Trans>Mute account</Trans> 441 )} 442 </Menu.ItemText> 443 <Menu.ItemIcon 444 icon={profile.viewer?.muted ? Unmute : Mute} 445 /> 446 </Menu.Item> 447 )} 448 {!profile.viewer?.blockingByList && ( 449 <Menu.Item 450 testID="profileHeaderDropdownBlockBtn" 451 label={ 452 profile.viewer 453 ? _(msg`Unblock account`) 454 : _(msg`Block account`) 455 } 456 onPress={() => blockPromptControl.open()}> 457 <Menu.ItemText> 458 {profile.viewer?.blocking ? ( 459 <Trans>Unblock account</Trans> 460 ) : ( 461 <Trans>Block account</Trans> 462 )} 463 </Menu.ItemText> 464 <Menu.ItemIcon 465 icon={ 466 profile.viewer?.blocking ? PersonCheck : PersonX 467 } 468 /> 469 </Menu.Item> 470 )} 471 <Menu.Item 472 testID="profileHeaderDropdownReportBtn" 473 label={_(msg`Report account`)} 474 onPress={onPressReportAccount}> 475 <Menu.ItemText> 476 <Trans>Report account</Trans> 477 </Menu.ItemText> 478 <Menu.ItemIcon icon={Flag} /> 479 </Menu.Item> 480 </> 481 )} 482 </Menu.Group> 483 </> 484 )} 485 {devModeEnabled ? ( 486 <> 487 <Menu.Divider /> 488 <Menu.Group> 489 <Menu.Item 490 testID="profileHeaderDropdownShareATURIBtn" 491 label={_(msg`Copy at:// URI`)} 492 onPress={onPressShareATUri}> 493 <Menu.ItemText> 494 <Trans>Copy at:// URI</Trans> 495 </Menu.ItemText> 496 <Menu.ItemIcon icon={ClipboardIcon} /> 497 </Menu.Item> 498 <Menu.Item 499 testID="profileHeaderDropdownShareDIDBtn" 500 label={_(msg`Copy DID`)} 501 onPress={onPressShareDID}> 502 <Menu.ItemText> 503 <Trans>Copy DID</Trans> 504 </Menu.ItemText> 505 <Menu.ItemIcon icon={ClipboardIcon} /> 506 </Menu.Item> 507 </Menu.Group> 508 </> 509 ) : null} 510 </Menu.Outer> 511 </Menu.Root> 512 513 <StarterPackDialog 514 control={addToStarterPacksDialogControl} 515 targetDid={profile.did} 516 /> 517 518 <ReportDialog 519 control={reportDialogControl} 520 subject={{ 521 ...profile, 522 $type: 'app.bsky.actor.defs#profileViewDetailed', 523 }} 524 /> 525 526 <Prompt.Basic 527 control={blockPromptControl} 528 title={ 529 profile.viewer?.blocking 530 ? _(msg`Unblock Account?`) 531 : _(msg`Block Account?`) 532 } 533 description={ 534 profile.viewer?.blocking 535 ? _( 536 msg`The account will be able to interact with you after unblocking.`, 537 ) 538 : profile.associated?.labeler 539 ? _( 540 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.`, 541 ) 542 : _( 543 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 544 ) 545 } 546 onConfirm={blockAccount} 547 confirmButtonCta={ 548 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 549 } 550 confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'} 551 /> 552 553 <Prompt.Basic 554 control={loggedOutWarningPromptControl} 555 title={_(msg`Note about sharing`)} 556 description={_( 557 msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 558 )} 559 onConfirm={onPressShare} 560 confirmButtonCta={_(msg`Share anyway`)} 561 /> 562 563 <VerificationCreatePrompt 564 control={verificationCreatePromptControl} 565 profile={profile} 566 /> 567 <VerificationRemovePrompt 568 control={verificationRemovePromptControl} 569 profile={profile} 570 verifications={currentAccountVerifications} 571 /> 572 573 {status.isDisabled ? ( 574 <GoLiveDisabledDialog 575 control={goLiveDisabledDialogControl} 576 status={status} 577 /> 578 ) : status.isActive ? ( 579 <EditLiveDialog 580 control={goLiveDialogControl} 581 status={status} 582 embed={status.embed} 583 /> 584 ) : ( 585 <GoLiveDialog control={goLiveDialogControl} profile={profile} /> 586 )} 587 </EventStopper> 588 ) 589} 590 591ProfileMenu = memo(ProfileMenu) 592export {ProfileMenu}