import {useCallback, useMemo, useState} from 'react' import {StyleSheet, View} from 'react-native' import {type AppBskyActorDefs} from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation, useNavigationState} from '@react-navigation/native' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {getCurrentRoute, isTab} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles' import {emitSoftReset} from '#/state/events' import {useFetchHandle} from '#/state/queries/handle' import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfilesQuery} from '#/state/queries/profile' import {type SessionAccount, useSession, useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {PressableWithHover} from '#/view/com/util/PressableWithHover' import {UserAvatar} from '#/view/com/util/UserAvatar' import {NavSignupCard} from '#/view/shell/NavSignupCard' import { atoms as a, tokens, useBreakpoints, useLayoutBreakpoints, useTheme, web, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {type DialogControlProps} from '#/components/Dialog' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft' import { Bell_Filled_Corner0_Rounded as BellFilledIcon, Bell_Stroke2_Corner0_Rounded as BellIcon, } from '#/components/icons/Bell' import { Bookmark as BookmarkIcon, BookmarkFilled as BookmarkFilledIcon, } from '#/components/icons/Bookmark' import { BulletList_Filled_Corner0_Rounded as ListFilledIcon, BulletList_Stroke2_Corner0_Rounded as ListIcon, } from '#/components/icons/BulletList' import {type Props as SVGIconProps} from '#/components/icons/common' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig' import { Hashtag_Filled_Corner0_Rounded as HashtagFilledIcon, Hashtag_Stroke2_Corner0_Rounded as HashtagIcon, } from '#/components/icons/Hashtag' import { HomeOpen_Filled_Corner0_Rounded as HomeFilledIcon, HomeOpen_Stoke2_Corner0_Rounded as HomeIcon, } from '#/components/icons/HomeOpen' import { MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilledIcon, MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon, } from '#/components/icons/MagnifyingGlass' import { Message_Stroke2_Corner0_Rounded as MessageIcon, Message_Stroke2_Corner0_Rounded_Filled as MessageFilledIcon, } from '#/components/icons/Message' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import { SettingsGear2_Filled_Corner0_Rounded as SettingsFilledIcon, SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon, } from '#/components/icons/SettingsGear2' import { UserCircle_Filled_Corner0_Rounded as UserCircleFilledIcon, UserCircle_Stroke2_Corner0_Rounded as UserCircleIcon, } from '#/components/icons/UserCircle' import {CENTER_COLUMN_OFFSET, CENTER_COLUMN_WIDTH} from '#/components/Layout' import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' import {useActorStatus} from '#/features/liveNow' import {router} from '#/routes' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' const LARGE_ELEMENT_SIZE = 48 const NAV_ICON_WIDTH = 28 export const LEFT_NAV_STANDARD_WIDTH = 240 export const LEFT_NAV_MINIMAL_WIDTH = 80 const LEFT_NAV_PWI_WIDTH = 245 function ProfileCard({minimal}: {minimal: boolean}) { const {currentAccount, accounts} = useSession() const {logoutEveryAccount} = useSessionApi() const {isLoading, data} = useProfilesQuery({ handles: accounts.map(acc => acc.did), }) const profiles = data?.profiles const signOutPromptControl = Prompt.usePromptControl() const {t: l} = useLingui() const t = useTheme() const profile = profiles?.find(p => p.did === currentAccount!.did) const otherAccounts = accounts .filter(acc => acc.did !== currentAccount!.did) .map(account => ({ account, profile: profiles?.find(p => p.did === account.did), })) const {isActive: live} = useActorStatus(profile) return ( {!isLoading && profile ? ( {({props, state, control}) => { const active = state.hovered || state.focused || control.isOpen return ( ) }} ) : ( )} logoutEveryAccount('Settings')} confirmButtonCta={l`Sign out`} cancelButtonCta={l`Cancel`} confirmButtonColor="negative" /> ) } function SwitchMenuItems({ accounts, signOutPromptControl, }: { accounts: | { account: SessionAccount profile?: AppBskyActorDefs.ProfileViewDetailed }[] | undefined signOutPromptControl: DialogControlProps }) { const {t: l} = useLingui() const {setShowLoggedOut} = useLoggedOutViewControls() const closeEverything = useCloseAllActiveElements() const onAddAnotherAccount = () => { setShowLoggedOut(true) closeEverything() } return ( {accounts && accounts.length > 0 && ( <> Switch account {accounts.map(other => ( ))} )} Add another account Sign out ) } function SwitcherMenuProfileLink() { const {t: l} = useLingui() const {currentAccount} = useSession() const navigation = useNavigation() const context = Menu.useMenuContext() const profileLink = currentAccount ? makeProfileLink(currentAccount) : '/' const [pathName] = useMemo(() => router.matchPath(profileLink), [profileLink]) const currentRouteInfo = useNavigationState(state => { if (!state) { return {name: 'Home'} } return getCurrentRoute(state) }) const isCurrent = useMemo(() => { if (currentRouteInfo.name === 'Profile') { return ( isTab(currentRouteInfo.name, pathName) && (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === currentAccount?.handle ) } else { return isTab(currentRouteInfo.name, pathName) } }, [currentAccount?.handle, currentRouteInfo, pathName]) const onProfilePress = useCallback( (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) { return } e.preventDefault() context.control.close() if (isCurrent) { emitSoftReset() } else { const [screen, params] = router.matchPath(profileLink) // @ts-expect-error TODO: type matchPath well enough that it can be plugged into navigation.navigate directly navigation.navigate(screen, params, {pop: true}) } }, [navigation, profileLink, isCurrent, context], ) return ( Go to profile ) } function SwitchMenuItem({ account, profile, }: { account: SessionAccount profile: AppBskyActorDefs.ProfileViewDetailed | undefined }) { const {t: l} = useLingui() const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {isActive: live} = useActorStatus(profile) return ( void onPressSwitchAccount(account, 'SwitchAccount')}> {sanitizeHandle(profile?.handle ?? account.handle, '@')} ) } interface NavItemProps { count?: string hasNew?: boolean href: string icons: { inactive: React.ComponentType active: React.ComponentType } label: string minimal: boolean } function NavItem({count, hasNew, href, icons, label, minimal}: NavItemProps) { const t = useTheme() const {t: l} = useLingui() const {currentAccount} = useSession() const [pathName] = useMemo(() => router.matchPath(href), [href]) const currentRouteInfo = useNavigationState(state => { if (!state) { return {name: 'Home'} } return getCurrentRoute(state) }) let isCurrent = currentRouteInfo.name === 'Profile' ? isTab(currentRouteInfo.name, pathName) && (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === currentAccount?.handle : isTab(currentRouteInfo.name, pathName) const isRelated = currentRouteInfo.name.startsWith(pathName) const navigation = useNavigation() const onPressWrapped = useCallback( (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) { return } e.preventDefault() if (isCurrent) { emitSoftReset() } else { const [screen, params] = router.matchPath(href) // @ts-expect-error TODO: type matchPath well enough that it can be plugged into navigation.navigate directly navigation.navigate(screen, params, {pop: true}) } }, [navigation, href, isCurrent], ) const Icon = isCurrent || isRelated ? icons.active : icons.inactive return ( {typeof count === 'string' && count ? ( {count} ) : hasNew ? ( ) : null} {!minimal && ( {label} )} ) } function ComposeBtn({minimal}: {minimal: boolean}) { const {currentAccount} = useSession() const {getState} = useNavigation() const {openComposer} = useOpenComposer() const {t: l} = useLingui() const [isFetchingHandle, setIsFetchingHandle] = useState(false) const fetchHandle = useFetchHandle() const getProfileHandle = async () => { const routes = getState()?.routes const currentRoute = routes?.[routes?.length - 1] if (currentRoute?.name === 'Profile') { let handle: string | undefined = ( currentRoute.params as CommonNavigatorParams['Profile'] ).name if (handle.startsWith('did:')) { try { setIsFetchingHandle(true) handle = await fetchHandle(handle) } catch (e) { handle = undefined } finally { setIsFetchingHandle(false) } } if ( !handle || handle === currentAccount?.handle || isInvalidHandle(handle) ) return undefined return handle } return undefined } const onPressCompose = async () => openComposer({mention: await getProfileHandle(), logContext: 'Fab'}) return ( ) } export function DesktopLeftNav({routeName}: {routeName: string}) { const {hasSession, currentAccount} = useSession() const {t: l} = useLingui() const {gtMobile} = useBreakpoints() const aa = useAgeAssurance() // splitview uses the minimal variant of the leftnav. unfortunately there's no easy // way to thread this data through because of the view hierarchy, so just check the route name const isMessagesRelatedScreen = routeName.startsWith('Messages') && aa.state.access === aa.Access.Full const {leftNavMinimal: leftNavMinimalBreakpoint, centerColumnOffset} = useLayoutBreakpoints() const numUnreadNotifications = useUnreadNotifications() const numUnreadMessages = useUnreadMessageCount() const leftNavMinimal = isMessagesRelatedScreen || leftNavMinimalBreakpoint if (!hasSession && !gtMobile) { return null } return ( {hasSession ? ( ) : !leftNavMinimal ? ( ) : null} {hasSession && ( <> )} ) } const styles = StyleSheet.create({ leftNav: { left: '50%', width: LEFT_NAV_STANDARD_WIDTH, // @ts-expect-error web only maxHeight: '100vh', overflowY: 'auto', scrollbarWidth: 'thin', }, })