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