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

Configure Feed

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

at 82f42e734c50b34de31e8aff1e7ced248ab6e96f 596 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 {HITSLOP_20} from '#/lib/constants' 9import {makeProfileLink} from '#/lib/routes/links' 10import {type NavigationProp} from '#/lib/routes/types' 11import {shareText, shareUrl} from '#/lib/sharing' 12import {toShareUrl} from '#/lib/strings/url-helpers' 13import {type Shadow} from '#/state/cache/types' 14import {useModalControls} from '#/state/modals' 15import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 16import { 17 RQKEY as profileQueryKey, 18 useProfileBlockMutationQueue, 19 useProfileFollowMutationQueue, 20 useProfileMuteMutationQueue, 21} from '#/state/queries/profile' 22import {useSession} from '#/state/session' 23import {EventStopper} from '#/view/com/util/EventStopper' 24import * as Toast from '#/view/com/util/Toast' 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 {DotGrid_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 {useFullVerificationState} from '#/components/verification' 55import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 56import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 57import {useAnalytics} from '#/analytics' 58import {IS_WEB} from '#/env' 59import {useActorStatus, useLiveNowConfig} from '#/features/liveNow' 60import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog' 61import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog' 62import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog' 63import {Dot} from '#/features/nuxs/components/Dot' 64import {Gradient} from '#/features/nuxs/components/Gradient' 65import {useDevMode} from '#/storage/hooks/dev-mode' 66 67let ProfileMenu = ({ 68 profile, 69}: { 70 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 71}): React.ReactNode => { 72 const t = useTheme() 73 const ax = useAnalytics() 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} = useLiveNowConfig() 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 ax.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 ax.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 ax.logger.error('Failed to mute account', {message: e}) 161 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 162 } 163 } 164 } 165 }, [ax, 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 ax.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 ax.logger.error('Failed to block account', {message: e}) 185 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 186 } 187 } 188 } 189 }, [ax, 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 ax.logger.error('Failed to follow account', {message: e}) 198 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 199 } 200 } 201 }, [_, ax, 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 ax.logger.error('Failed to unfollow account', {message: e}) 210 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 211 } 212 } 213 }, [_, ax, 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 IS_WEB ? _(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 {IS_WEB ? ( 280 <Trans>Copy link to profile</Trans> 281 ) : ( 282 <Trans>Share via...</Trans> 283 )} 284 </Menu.ItemText> 285 <Menu.ItemIcon 286 icon={IS_WEB ? ChainLinkIcon : ArrowOutOfBoxIcon} 287 /> 288 </Menu.Item> 289 <Menu.Item 290 testID="profileHeaderDropdownSearchBtn" 291 label={_(msg`Search posts`)} 292 onPress={onPressSearch}> 293 <Menu.ItemText> 294 <Trans>Search posts</Trans> 295 </Menu.ItemText> 296 <Menu.ItemIcon icon={SearchIcon} /> 297 </Menu.Item> 298 </Menu.Group> 299 300 {hasSession && ( 301 <> 302 <Menu.Divider /> 303 <Menu.Group> 304 {!isSelf && ( 305 <> 306 {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && ( 307 <Menu.Item 308 testID="profileHeaderDropdownFollowBtn" 309 label={ 310 isFollowing 311 ? _(msg`Unfollow account`) 312 : _(msg`Follow account`) 313 } 314 onPress={ 315 isFollowing 316 ? onPressUnfollowAccount 317 : onPressFollowAccount 318 }> 319 <Menu.ItemText> 320 {isFollowing ? ( 321 <Trans>Unfollow account</Trans> 322 ) : ( 323 <Trans>Follow account</Trans> 324 )} 325 </Menu.ItemText> 326 <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} /> 327 </Menu.Item> 328 )} 329 </> 330 )} 331 {!isSelf && ( 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 )} 342 <Menu.Item 343 testID="profileHeaderDropdownListAddRemoveBtn" 344 label={_(msg`Add to lists`)} 345 onPress={onPressAddRemoveLists}> 346 <Menu.ItemText> 347 <Trans>Add to lists</Trans> 348 </Menu.ItemText> 349 <Menu.ItemIcon icon={List} /> 350 </Menu.Item> 351 {isSelf && canGoLive && ( 352 <Menu.Item 353 testID="profileHeaderDropdownListAddRemoveBtn" 354 label={ 355 status.isDisabled 356 ? _(msg`Go live (disabled)`) 357 : status.isActive 358 ? _(msg`Edit live status`) 359 : _(msg`Go live`) 360 } 361 onPress={() => { 362 if (status.isDisabled) { 363 goLiveDisabledDialogControl.open() 364 } else { 365 goLiveDialogControl.open() 366 } 367 saveNux({ 368 id: Nux.LiveNowBetaNudge, 369 data: undefined, 370 completed: true, 371 }) 372 }}> 373 {statusNudgeActive && <Gradient />} 374 <Menu.ItemText> 375 {status.isDisabled ? ( 376 <Trans>Go live (disabled)</Trans> 377 ) : status.isActive ? ( 378 <Trans>Edit live status</Trans> 379 ) : ( 380 <Trans>Go live</Trans> 381 )} 382 </Menu.ItemText> 383 {statusNudgeActive && ( 384 <Menu.ItemText 385 style={[ 386 a.flex_0, 387 { 388 color: t.palette.primary_500, 389 right: IS_WEB ? -8 : -4, 390 }, 391 ]}> 392 <Trans>New</Trans> 393 </Menu.ItemText> 394 )} 395 <Menu.ItemIcon 396 icon={LiveIcon} 397 fill={ 398 statusNudgeActive 399 ? () => t.palette.primary_500 400 : undefined 401 } 402 /> 403 </Menu.Item> 404 )} 405 {verification.viewer.role === 'verifier' && 406 !verification.profile.isViewer && 407 (verification.viewer.hasIssuedVerification ? ( 408 <Menu.Item 409 testID="profileHeaderDropdownVerificationRemoveButton" 410 label={_(msg`Remove verification`)} 411 onPress={() => verificationRemovePromptControl.open()}> 412 <Menu.ItemText> 413 <Trans>Remove verification</Trans> 414 </Menu.ItemText> 415 <Menu.ItemIcon icon={CircleXIcon} /> 416 </Menu.Item> 417 ) : ( 418 <Menu.Item 419 testID="profileHeaderDropdownVerificationCreateButton" 420 label={_(msg`Verify account`)} 421 onPress={() => verificationCreatePromptControl.open()}> 422 <Menu.ItemText> 423 <Trans>Verify account</Trans> 424 </Menu.ItemText> 425 <Menu.ItemIcon icon={CircleCheckIcon} /> 426 </Menu.Item> 427 ))} 428 {!isSelf && ( 429 <> 430 {!profile.viewer?.blocking && 431 !profile.viewer?.mutedByList && ( 432 <Menu.Item 433 testID="profileHeaderDropdownMuteBtn" 434 label={ 435 profile.viewer?.muted 436 ? _(msg`Unmute account`) 437 : _(msg`Mute account`) 438 } 439 onPress={onPressMuteAccount}> 440 <Menu.ItemText> 441 {profile.viewer?.muted ? ( 442 <Trans>Unmute account</Trans> 443 ) : ( 444 <Trans>Mute account</Trans> 445 )} 446 </Menu.ItemText> 447 <Menu.ItemIcon 448 icon={profile.viewer?.muted ? Unmute : Mute} 449 /> 450 </Menu.Item> 451 )} 452 {!profile.viewer?.blockingByList && ( 453 <Menu.Item 454 testID="profileHeaderDropdownBlockBtn" 455 label={ 456 profile.viewer 457 ? _(msg`Unblock account`) 458 : _(msg`Block account`) 459 } 460 onPress={() => blockPromptControl.open()}> 461 <Menu.ItemText> 462 {profile.viewer?.blocking ? ( 463 <Trans>Unblock account</Trans> 464 ) : ( 465 <Trans>Block account</Trans> 466 )} 467 </Menu.ItemText> 468 <Menu.ItemIcon 469 icon={ 470 profile.viewer?.blocking ? PersonCheck : PersonX 471 } 472 /> 473 </Menu.Item> 474 )} 475 <Menu.Item 476 testID="profileHeaderDropdownReportBtn" 477 label={_(msg`Report account`)} 478 onPress={onPressReportAccount}> 479 <Menu.ItemText> 480 <Trans>Report account</Trans> 481 </Menu.ItemText> 482 <Menu.ItemIcon icon={Flag} /> 483 </Menu.Item> 484 </> 485 )} 486 </Menu.Group> 487 </> 488 )} 489 {devModeEnabled ? ( 490 <> 491 <Menu.Divider /> 492 <Menu.Group> 493 <Menu.Item 494 testID="profileHeaderDropdownShareATURIBtn" 495 label={_(msg`Copy at:// URI`)} 496 onPress={onPressShareATUri}> 497 <Menu.ItemText> 498 <Trans>Copy at:// URI</Trans> 499 </Menu.ItemText> 500 <Menu.ItemIcon icon={ClipboardIcon} /> 501 </Menu.Item> 502 <Menu.Item 503 testID="profileHeaderDropdownShareDIDBtn" 504 label={_(msg`Copy DID`)} 505 onPress={onPressShareDID}> 506 <Menu.ItemText> 507 <Trans>Copy DID</Trans> 508 </Menu.ItemText> 509 <Menu.ItemIcon icon={ClipboardIcon} /> 510 </Menu.Item> 511 </Menu.Group> 512 </> 513 ) : null} 514 </Menu.Outer> 515 </Menu.Root> 516 517 <StarterPackDialog 518 control={addToStarterPacksDialogControl} 519 targetDid={profile.did} 520 /> 521 522 <ReportDialog 523 control={reportDialogControl} 524 subject={{ 525 ...profile, 526 $type: 'app.bsky.actor.defs#profileViewDetailed', 527 }} 528 /> 529 530 <Prompt.Basic 531 control={blockPromptControl} 532 title={ 533 profile.viewer?.blocking 534 ? _(msg`Unblock Account?`) 535 : _(msg`Block Account?`) 536 } 537 description={ 538 profile.viewer?.blocking 539 ? _( 540 msg`The account will be able to interact with you after unblocking.`, 541 ) 542 : profile.associated?.labeler 543 ? _( 544 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.`, 545 ) 546 : _( 547 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 548 ) 549 } 550 onConfirm={blockAccount} 551 confirmButtonCta={ 552 profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 553 } 554 confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'} 555 /> 556 557 <Prompt.Basic 558 control={loggedOutWarningPromptControl} 559 title={_(msg`Note about sharing`)} 560 description={_( 561 msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 562 )} 563 onConfirm={onPressShare} 564 confirmButtonCta={_(msg`Share anyway`)} 565 /> 566 567 <VerificationCreatePrompt 568 control={verificationCreatePromptControl} 569 profile={profile} 570 /> 571 <VerificationRemovePrompt 572 control={verificationRemovePromptControl} 573 profile={profile} 574 verifications={currentAccountVerifications} 575 /> 576 577 {status.isDisabled ? ( 578 <GoLiveDisabledDialog 579 control={goLiveDisabledDialogControl} 580 status={status} 581 /> 582 ) : status.isActive ? ( 583 <EditLiveDialog 584 control={goLiveDialogControl} 585 status={status} 586 embed={status.embed} 587 /> 588 ) : ( 589 <GoLiveDialog control={goLiveDialogControl} profile={profile} /> 590 )} 591 </EventStopper> 592 ) 593} 594 595ProfileMenu = memo(ProfileMenu) 596export {ProfileMenu}