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

Configure Feed

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

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