mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 766 lines 23 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {StyleSheet, View} from 'react-native' 3import {type AppBskyActorDefs} from '@atproto/api' 4import {msg, plural, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6import { 7 useLinkTo, 8 useNavigation, 9 useNavigationState, 10} from '@react-navigation/native' 11 12import {useActorStatus} from '#/lib/actor-status' 13import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 14import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15import {usePalette} from '#/lib/hooks/usePalette' 16import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 17import {getCurrentRoute, isTab} from '#/lib/routes/helpers' 18import {makeProfileLink} from '#/lib/routes/links' 19import {type CommonNavigatorParams} from '#/lib/routes/types' 20import {useGate} from '#/lib/statsig/statsig' 21import {sanitizeDisplayName} from '#/lib/strings/display-names' 22import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles' 23import {emitSoftReset} from '#/state/events' 24import {useHomeBadge} from '#/state/home-badge' 25import {useFetchHandle} from '#/state/queries/handle' 26import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' 27import {useUnreadNotifications} from '#/state/queries/notifications/unread' 28import {useProfilesQuery} from '#/state/queries/profile' 29import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 30import {useLoggedOutViewControls} from '#/state/shell/logged-out' 31import {useCloseAllActiveElements} from '#/state/util' 32import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 33import {PressableWithHover} from '#/view/com/util/PressableWithHover' 34import {UserAvatar} from '#/view/com/util/UserAvatar' 35import {NavSignupCard} from '#/view/shell/NavSignupCard' 36import {atoms as a, tokens, useLayoutBreakpoints, useTheme, web} from '#/alf' 37import {Button, ButtonIcon, ButtonText} from '#/components/Button' 38import {type DialogControlProps} from '#/components/Dialog' 39import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft' 40import { 41 Bell_Filled_Corner0_Rounded as BellFilled, 42 Bell_Stroke2_Corner0_Rounded as Bell, 43} from '#/components/icons/Bell' 44import { 45 BulletList_Filled_Corner0_Rounded as ListFilled, 46 BulletList_Stroke2_Corner0_Rounded as List, 47} from '#/components/icons/BulletList' 48import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 49import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' 50import { 51 Hashtag_Filled_Corner0_Rounded as HashtagFilled, 52 Hashtag_Stroke2_Corner0_Rounded as Hashtag, 53} from '#/components/icons/Hashtag' 54import { 55 HomeOpen_Filled_Corner0_Rounded as HomeFilled, 56 HomeOpen_Stoke2_Corner0_Rounded as Home, 57} from '#/components/icons/HomeOpen' 58import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 59import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' 60import { 61 Message_Stroke2_Corner0_Rounded as Message, 62 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 63} from '#/components/icons/Message' 64import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 65import { 66 SettingsGear2_Filled_Corner0_Rounded as SettingsFilled, 67 SettingsGear2_Stroke2_Corner0_Rounded as Settings, 68} from '#/components/icons/SettingsGear2' 69import { 70 UserCircle_Filled_Corner0_Rounded as UserCircleFilled, 71 UserCircle_Stroke2_Corner0_Rounded as UserCircle, 72} from '#/components/icons/UserCircle' 73import {CENTER_COLUMN_OFFSET} from '#/components/Layout' 74import * as Menu from '#/components/Menu' 75import * as Prompt from '#/components/Prompt' 76import {Text} from '#/components/Typography' 77import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' 78import {router} from '../../../routes' 79 80const NAV_ICON_WIDTH = 28 81 82function ProfileCard() { 83 const {currentAccount, accounts} = useSession() 84 const {logoutEveryAccount} = useSessionApi() 85 const {isLoading, data} = useProfilesQuery({ 86 handles: accounts.map(acc => acc.did), 87 }) 88 const profiles = data?.profiles 89 const signOutPromptControl = Prompt.usePromptControl() 90 const {leftNavMinimal} = useLayoutBreakpoints() 91 const {_} = useLingui() 92 const t = useTheme() 93 94 const size = 48 95 96 const profile = profiles?.find(p => p.did === currentAccount!.did) 97 const otherAccounts = accounts 98 .filter(acc => acc.did !== currentAccount!.did) 99 .map(account => ({ 100 account, 101 profile: profiles?.find(p => p.did === account.did), 102 })) 103 104 const {isActive: live} = useActorStatus(profile) 105 106 return ( 107 <View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}> 108 {!isLoading && profile ? ( 109 <Menu.Root> 110 <Menu.Trigger label={_(msg`Switch accounts`)}> 111 {({props, state, control}) => { 112 const active = state.hovered || state.focused || control.isOpen 113 return ( 114 <Button 115 label={props.accessibilityLabel} 116 {...props} 117 style={[ 118 a.w_full, 119 a.transition_color, 120 active ? t.atoms.bg_contrast_25 : a.transition_delay_50ms, 121 a.rounded_full, 122 a.justify_between, 123 a.align_center, 124 a.flex_row, 125 {gap: 6}, 126 !leftNavMinimal && [a.pl_lg, a.pr_md], 127 ]}> 128 <View 129 style={[ 130 !PlatformInfo.getIsReducedMotionEnabled() && [ 131 a.transition_transform, 132 {transitionDuration: '250ms'}, 133 !active && a.transition_delay_50ms, 134 ], 135 a.relative, 136 a.z_10, 137 active && { 138 transform: [ 139 {scale: !leftNavMinimal ? 2 / 3 : 0.8}, 140 {translateX: !leftNavMinimal ? -22 : 0}, 141 ], 142 }, 143 ]}> 144 <UserAvatar 145 avatar={profile.avatar} 146 size={size} 147 type={profile?.associated?.labeler ? 'labeler' : 'user'} 148 live={live} 149 /> 150 </View> 151 {!leftNavMinimal && ( 152 <> 153 <View 154 style={[ 155 a.flex_1, 156 a.transition_opacity, 157 !active && a.transition_delay_50ms, 158 { 159 marginLeft: tokens.space.xl * -1, 160 opacity: active ? 1 : 0, 161 }, 162 ]}> 163 <Text 164 style={[a.font_heavy, a.text_sm, a.leading_snug]} 165 numberOfLines={1}> 166 {sanitizeDisplayName( 167 profile.displayName || profile.handle, 168 )} 169 </Text> 170 <Text 171 style={[ 172 a.text_xs, 173 a.leading_snug, 174 t.atoms.text_contrast_medium, 175 ]} 176 numberOfLines={1}> 177 {sanitizeHandle(profile.handle, '@')} 178 </Text> 179 </View> 180 <EllipsisIcon 181 aria-hidden={true} 182 style={[ 183 t.atoms.text_contrast_medium, 184 a.transition_opacity, 185 {opacity: active ? 1 : 0}, 186 ]} 187 size="sm" 188 /> 189 </> 190 )} 191 </Button> 192 ) 193 }} 194 </Menu.Trigger> 195 <SwitchMenuItems 196 accounts={otherAccounts} 197 signOutPromptControl={signOutPromptControl} 198 /> 199 </Menu.Root> 200 ) : ( 201 <LoadingPlaceholder 202 width={size} 203 height={size} 204 style={[{borderRadius: size}, !leftNavMinimal && a.ml_lg]} 205 /> 206 )} 207 <Prompt.Basic 208 control={signOutPromptControl} 209 title={_(msg`Sign out?`)} 210 description={_(msg`You will be signed out of all your accounts.`)} 211 onConfirm={() => logoutEveryAccount('Settings')} 212 confirmButtonCta={_(msg`Sign out`)} 213 cancelButtonCta={_(msg`Cancel`)} 214 confirmButtonColor="negative" 215 /> 216 </View> 217 ) 218} 219 220function SwitchMenuItems({ 221 accounts, 222 signOutPromptControl, 223}: { 224 accounts: 225 | { 226 account: SessionAccount 227 profile?: AppBskyActorDefs.ProfileViewDetailed 228 }[] 229 | undefined 230 signOutPromptControl: DialogControlProps 231}) { 232 const {_} = useLingui() 233 const {setShowLoggedOut} = useLoggedOutViewControls() 234 const closeEverything = useCloseAllActiveElements() 235 236 const onAddAnotherAccount = () => { 237 setShowLoggedOut(true) 238 closeEverything() 239 } 240 return ( 241 <Menu.Outer> 242 {accounts && accounts.length > 0 && ( 243 <> 244 <Menu.Group> 245 <Menu.LabelText> 246 <Trans>Switch account</Trans> 247 </Menu.LabelText> 248 {accounts.map(other => ( 249 <SwitchMenuItem 250 key={other.account.did} 251 account={other.account} 252 profile={other.profile} 253 /> 254 ))} 255 </Menu.Group> 256 <Menu.Divider /> 257 </> 258 )} 259 <Menu.Item 260 label={_(msg`Add another account`)} 261 onPress={onAddAnotherAccount}> 262 <Menu.ItemIcon icon={PlusIcon} /> 263 <Menu.ItemText> 264 <Trans>Add another account</Trans> 265 </Menu.ItemText> 266 </Menu.Item> 267 <Menu.Item label={_(msg`Sign out`)} onPress={signOutPromptControl.open}> 268 <Menu.ItemIcon icon={LeaveIcon} /> 269 <Menu.ItemText> 270 <Trans>Sign out</Trans> 271 </Menu.ItemText> 272 </Menu.Item> 273 </Menu.Outer> 274 ) 275} 276 277function SwitchMenuItem({ 278 account, 279 profile, 280}: { 281 account: SessionAccount 282 profile: AppBskyActorDefs.ProfileViewDetailed | undefined 283}) { 284 const {_} = useLingui() 285 const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() 286 const {isActive: live} = useActorStatus(profile) 287 288 return ( 289 <Menu.Item 290 disabled={!!pendingDid} 291 style={[a.gap_sm, {minWidth: 150}]} 292 key={account.did} 293 label={_( 294 msg`Switch to ${sanitizeHandle( 295 profile?.handle ?? account.handle, 296 '@', 297 )}`, 298 )} 299 onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}> 300 <View> 301 <UserAvatar 302 avatar={profile?.avatar} 303 size={20} 304 type={profile?.associated?.labeler ? 'labeler' : 'user'} 305 live={live} 306 hideLiveBadge 307 /> 308 </View> 309 <Menu.ItemText> 310 {sanitizeHandle(profile?.handle ?? account.handle, '@')} 311 </Menu.ItemText> 312 </Menu.Item> 313 ) 314} 315 316interface NavItemProps { 317 count?: string 318 hasNew?: boolean 319 href: string 320 icon: JSX.Element 321 iconFilled: JSX.Element 322 label: string 323} 324function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) { 325 const t = useTheme() 326 const {_} = useLingui() 327 const {currentAccount} = useSession() 328 const {leftNavMinimal} = useLayoutBreakpoints() 329 const [pathName] = useMemo(() => router.matchPath(href), [href]) 330 const currentRouteInfo = useNavigationState(state => { 331 if (!state) { 332 return {name: 'Home'} 333 } 334 return getCurrentRoute(state) 335 }) 336 let isCurrent = 337 currentRouteInfo.name === 'Profile' 338 ? isTab(currentRouteInfo.name, pathName) && 339 (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === 340 currentAccount?.handle 341 : isTab(currentRouteInfo.name, pathName) 342 const linkTo = useLinkTo() 343 const onPressWrapped = useCallback( 344 (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { 345 if (e.ctrlKey || e.metaKey || e.altKey) { 346 return 347 } 348 e.preventDefault() 349 if (isCurrent) { 350 emitSoftReset() 351 } else { 352 linkTo(href) 353 } 354 }, 355 [linkTo, href, isCurrent], 356 ) 357 358 return ( 359 <PressableWithHover 360 style={[ 361 a.flex_row, 362 a.align_center, 363 a.p_md, 364 a.rounded_sm, 365 a.gap_sm, 366 a.outline_inset_1, 367 a.transition_color, 368 ]} 369 hoverStyle={t.atoms.bg_contrast_25} 370 // @ts-expect-error the function signature differs on web -prf 371 onPress={onPressWrapped} 372 href={href} 373 dataSet={{noUnderline: 1}} 374 role="link" 375 accessibilityLabel={label} 376 accessibilityHint=""> 377 <View 378 style={[ 379 a.align_center, 380 a.justify_center, 381 a.z_10, 382 { 383 width: 24, 384 height: 24, 385 }, 386 leftNavMinimal && { 387 width: 40, 388 height: 40, 389 }, 390 ]}> 391 {isCurrent ? iconFilled : icon} 392 {typeof count === 'string' && count ? ( 393 <View 394 style={[ 395 a.absolute, 396 a.inset_0, 397 {right: -20}, // more breathing room 398 ]}> 399 <Text 400 accessibilityLabel={_( 401 msg`${plural(count, { 402 one: '# unread item', 403 other: '# unread items', 404 })}`, 405 )} 406 accessibilityHint="" 407 accessible={true} 408 numberOfLines={1} 409 style={[ 410 a.absolute, 411 a.text_xs, 412 a.font_bold, 413 a.rounded_full, 414 a.text_center, 415 a.leading_tight, 416 { 417 top: '-10%', 418 left: count.length === 1 ? 12 : 8, 419 backgroundColor: t.palette.primary_500, 420 color: t.palette.white, 421 lineHeight: a.text_sm.fontSize, 422 paddingHorizontal: 4, 423 paddingVertical: 1, 424 minWidth: 16, 425 }, 426 leftNavMinimal && [ 427 { 428 top: '10%', 429 left: count.length === 1 ? 20 : 16, 430 }, 431 ], 432 ]}> 433 {count} 434 </Text> 435 </View> 436 ) : hasNew ? ( 437 <View 438 style={[ 439 a.absolute, 440 a.rounded_full, 441 { 442 backgroundColor: t.palette.primary_500, 443 width: 8, 444 height: 8, 445 right: -2, 446 top: -4, 447 }, 448 leftNavMinimal && { 449 right: 4, 450 top: 2, 451 }, 452 ]} 453 /> 454 ) : null} 455 </View> 456 {!leftNavMinimal && ( 457 <Text style={[a.text_xl, isCurrent ? a.font_heavy : a.font_normal]}> 458 {label} 459 </Text> 460 )} 461 </PressableWithHover> 462 ) 463} 464 465function ComposeBtn() { 466 const {currentAccount} = useSession() 467 const {getState} = useNavigation() 468 const {openComposer} = useOpenComposer() 469 const {_} = useLingui() 470 const {leftNavMinimal} = useLayoutBreakpoints() 471 const [isFetchingHandle, setIsFetchingHandle] = useState(false) 472 const fetchHandle = useFetchHandle() 473 474 const getProfileHandle = async () => { 475 const routes = getState()?.routes 476 const currentRoute = routes?.[routes?.length - 1] 477 478 if (currentRoute?.name === 'Profile') { 479 let handle: string | undefined = ( 480 currentRoute.params as CommonNavigatorParams['Profile'] 481 ).name 482 483 if (handle.startsWith('did:')) { 484 try { 485 setIsFetchingHandle(true) 486 handle = await fetchHandle(handle) 487 } catch (e) { 488 handle = undefined 489 } finally { 490 setIsFetchingHandle(false) 491 } 492 } 493 494 if ( 495 !handle || 496 handle === currentAccount?.handle || 497 isInvalidHandle(handle) 498 ) 499 return undefined 500 501 return handle 502 } 503 504 return undefined 505 } 506 507 const onPressCompose = async () => 508 openComposer({mention: await getProfileHandle()}) 509 510 if (leftNavMinimal) { 511 return null 512 } 513 514 return ( 515 <View style={[a.flex_row, a.pl_md, a.pt_xl]}> 516 <Button 517 disabled={isFetchingHandle} 518 label={_(msg`Compose new post`)} 519 onPress={onPressCompose} 520 size="large" 521 variant="solid" 522 color="primary" 523 style={[a.rounded_full]}> 524 <ButtonIcon icon={EditBig} position="left" /> 525 <ButtonText> 526 <Trans context="action">New Post</Trans> 527 </ButtonText> 528 </Button> 529 </View> 530 ) 531} 532 533function ChatNavItem() { 534 const pal = usePalette('default') 535 const {_} = useLingui() 536 const numUnreadMessages = useUnreadMessageCount() 537 538 return ( 539 <NavItem 540 href="/messages" 541 count={numUnreadMessages.numUnread} 542 hasNew={numUnreadMessages.hasNew} 543 icon={ 544 <Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} /> 545 } 546 iconFilled={ 547 <MessageFilled 548 style={pal.text} 549 aria-hidden={true} 550 width={NAV_ICON_WIDTH} 551 /> 552 } 553 label={_(msg`Chat`)} 554 /> 555 ) 556} 557 558export function DesktopLeftNav() { 559 const {hasSession, currentAccount} = useSession() 560 const pal = usePalette('default') 561 const {_} = useLingui() 562 const {isDesktop} = useWebMediaQueries() 563 const {leftNavMinimal, centerColumnOffset} = useLayoutBreakpoints() 564 const numUnreadNotifications = useUnreadNotifications() 565 const hasHomeBadge = useHomeBadge() 566 const gate = useGate() 567 568 if (!hasSession && !isDesktop) { 569 return null 570 } 571 572 return ( 573 <View 574 role="navigation" 575 style={[ 576 a.px_xl, 577 styles.leftNav, 578 leftNavMinimal && styles.leftNavMinimal, 579 { 580 transform: [ 581 { 582 translateX: 583 -300 + (centerColumnOffset ? CENTER_COLUMN_OFFSET : 0), 584 }, 585 {translateX: '-100%'}, 586 ...a.scrollbar_offset.transform, 587 ], 588 }, 589 ]}> 590 {hasSession ? ( 591 <ProfileCard /> 592 ) : !leftNavMinimal ? ( 593 <View style={[a.pt_xl]}> 594 <NavSignupCard /> 595 </View> 596 ) : null} 597 598 {hasSession && ( 599 <> 600 <NavItem 601 href="/" 602 hasNew={hasHomeBadge && gate('remove_show_latest_button')} 603 icon={ 604 <Home 605 aria-hidden={true} 606 width={NAV_ICON_WIDTH} 607 style={pal.text} 608 /> 609 } 610 iconFilled={ 611 <HomeFilled 612 aria-hidden={true} 613 width={NAV_ICON_WIDTH} 614 style={pal.text} 615 /> 616 } 617 label={_(msg`Home`)} 618 /> 619 <NavItem 620 href="/search" 621 icon={ 622 <MagnifyingGlass 623 style={pal.text} 624 aria-hidden={true} 625 width={NAV_ICON_WIDTH} 626 /> 627 } 628 iconFilled={ 629 <MagnifyingGlassFilled 630 style={pal.text} 631 aria-hidden={true} 632 width={NAV_ICON_WIDTH} 633 /> 634 } 635 label={_(msg`Explore`)} 636 /> 637 <NavItem 638 href="/notifications" 639 count={numUnreadNotifications} 640 icon={ 641 <Bell 642 aria-hidden={true} 643 width={NAV_ICON_WIDTH} 644 style={pal.text} 645 /> 646 } 647 iconFilled={ 648 <BellFilled 649 aria-hidden={true} 650 width={NAV_ICON_WIDTH} 651 style={pal.text} 652 /> 653 } 654 label={_(msg`Notifications`)} 655 /> 656 <ChatNavItem /> 657 <NavItem 658 href="/feeds" 659 icon={ 660 <Hashtag 661 style={pal.text} 662 aria-hidden={true} 663 width={NAV_ICON_WIDTH} 664 /> 665 } 666 iconFilled={ 667 <HashtagFilled 668 style={pal.text} 669 aria-hidden={true} 670 width={NAV_ICON_WIDTH} 671 /> 672 } 673 label={_(msg`Feeds`)} 674 /> 675 <NavItem 676 href="/lists" 677 icon={ 678 <List 679 style={pal.text} 680 aria-hidden={true} 681 width={NAV_ICON_WIDTH} 682 /> 683 } 684 iconFilled={ 685 <ListFilled 686 style={pal.text} 687 aria-hidden={true} 688 width={NAV_ICON_WIDTH} 689 /> 690 } 691 label={_(msg`Lists`)} 692 /> 693 <NavItem 694 href={currentAccount ? makeProfileLink(currentAccount) : '/'} 695 icon={ 696 <UserCircle 697 aria-hidden={true} 698 width={NAV_ICON_WIDTH} 699 style={pal.text} 700 /> 701 } 702 iconFilled={ 703 <UserCircleFilled 704 aria-hidden={true} 705 width={NAV_ICON_WIDTH} 706 style={pal.text} 707 /> 708 } 709 label={_(msg`Profile`)} 710 /> 711 <NavItem 712 href="/settings" 713 icon={ 714 <Settings 715 aria-hidden={true} 716 width={NAV_ICON_WIDTH} 717 style={pal.text} 718 /> 719 } 720 iconFilled={ 721 <SettingsFilled 722 aria-hidden={true} 723 width={NAV_ICON_WIDTH} 724 style={pal.text} 725 /> 726 } 727 label={_(msg`Settings`)} 728 /> 729 730 <ComposeBtn /> 731 </> 732 )} 733 </View> 734 ) 735} 736 737const styles = StyleSheet.create({ 738 leftNav: { 739 ...a.fixed, 740 top: 0, 741 paddingTop: 10, 742 paddingBottom: 10, 743 left: '50%', 744 width: 240, 745 // @ts-expect-error web only 746 maxHeight: '100vh', 747 overflowY: 'auto', 748 }, 749 leftNavMinimal: { 750 paddingTop: 0, 751 paddingBottom: 0, 752 paddingLeft: 0, 753 paddingRight: 0, 754 height: '100%', 755 width: 86, 756 alignItems: 'center', 757 ...web({overflowX: 'hidden'}), 758 }, 759 backBtn: { 760 position: 'absolute', 761 top: 12, 762 right: 12, 763 width: 30, 764 height: 30, 765 }, 766})