about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-01-14 12:53:13 +0000
committerGitHub <noreply@github.com>2025-01-14 12:53:13 +0000
commit479a4a92e10e8d12d3e164a65970bd2de4abfe8a (patch)
treed73c7c5db743d50c7e43528dc37e1f2a0ca0e24d /src
parent1fc889b883ffd666b4c439738f1f5e97496d55e6 (diff)
downloadvoidsky-479a4a92e10e8d12d3e164a65970bd2de4abfe8a.tar.zst
Account quick switch on web (#7190)
* account quick switch on web

* dont show line if one account

* switch account label text

* add chevron hover state

* swagged up hover state

* add icons

* tune scale anim and respect prefers-reduced-motion

* fix reduced motion

* fix placeholder position

* move menu components out to separate component

* Pipe through outer handlers to Button

* Abstract lag in control.isOpen state

* add profile info into empty space

* fix tablet

* Alternative

* Revert "Alternative"

This reverts commit 050ab9595ef3bbc32529ad6588e4690d37539fbe.

* maybe fix flicker issue

* just do 50ms when not active

* delay other animations

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
-rw-r--r--src/alf/atoms.ts3
-rw-r--r--src/components/Button.tsx46
-rw-r--r--src/components/Menu/index.web.tsx3
-rw-r--r--src/view/shell/desktop/LeftNav.tsx263
4 files changed, 259 insertions, 56 deletions
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 1485ca7fc..a7cf6cb3f 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -961,6 +961,9 @@ export const atoms = {
     transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
     transitionDuration: '100ms',
   }),
+  transition_delay_50ms: web({
+    transitionDelay: '50ms',
+  }),
 
   /**
    * {@link Layout.SCROLLBAR_OFFSET}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 871c17ed5..123e6ee42 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -3,10 +3,12 @@ import {
   AccessibilityProps,
   GestureResponderEvent,
   MouseEvent,
+  NativeSyntheticEvent,
   Pressable,
   PressableProps,
   StyleProp,
   StyleSheet,
+  TargetedEvent,
   TextProps,
   TextStyle,
   View,
@@ -76,6 +78,8 @@ export type ButtonProps = Pick<
   | 'onHoverOut'
   | 'onPressIn'
   | 'onPressOut'
+  | 'onFocus'
+  | 'onBlur'
 > &
   AccessibilityProps &
   VariantProps & {
@@ -116,6 +120,12 @@ export const Button = React.forwardRef<View, ButtonProps>(
       style,
       hoverStyle: hoverStyleProp,
       PressableComponent = Pressable,
+      onPressIn: onPressInOuter,
+      onPressOut: onPressOutOuter,
+      onHoverIn: onHoverInOuter,
+      onHoverOut: onHoverOutOuter,
+      onFocus: onFocusOuter,
+      onBlur: onBlurOuter,
       ...rest
     },
     ref,
@@ -127,7 +137,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
       focused: false,
     })
 
-    const onPressInOuter = rest.onPressIn
     const onPressIn = React.useCallback(
       (e: GestureResponderEvent) => {
         setState(s => ({
@@ -138,7 +147,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
       },
       [setState, onPressInOuter],
     )
-    const onPressOutOuter = rest.onPressOut
     const onPressOut = React.useCallback(
       (e: GestureResponderEvent) => {
         setState(s => ({
@@ -149,7 +157,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
       },
       [setState, onPressOutOuter],
     )
-    const onHoverInOuter = rest.onHoverIn
     const onHoverIn = React.useCallback(
       (e: MouseEvent) => {
         setState(s => ({
@@ -160,7 +167,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
       },
       [setState, onHoverInOuter],
     )
-    const onHoverOutOuter = rest.onHoverOut
     const onHoverOut = React.useCallback(
       (e: MouseEvent) => {
         setState(s => ({
@@ -171,18 +177,26 @@ export const Button = React.forwardRef<View, ButtonProps>(
       },
       [setState, onHoverOutOuter],
     )
-    const onFocus = React.useCallback(() => {
-      setState(s => ({
-        ...s,
-        focused: true,
-      }))
-    }, [setState])
-    const onBlur = React.useCallback(() => {
-      setState(s => ({
-        ...s,
-        focused: false,
-      }))
-    }, [setState])
+    const onFocus = React.useCallback(
+      (e: NativeSyntheticEvent<TargetedEvent>) => {
+        setState(s => ({
+          ...s,
+          focused: true,
+        }))
+        onFocusOuter?.(e)
+      },
+      [setState, onFocusOuter],
+    )
+    const onBlur = React.useCallback(
+      (e: NativeSyntheticEvent<TargetedEvent>) => {
+        setState(s => ({
+          ...s,
+          focused: false,
+        }))
+        onBlurOuter?.(e)
+      },
+      [setState, onBlurOuter],
+    )
 
     const {baseStyles, hoverStyles} = React.useMemo(() => {
       const baseStyles: ViewStyle[] = []
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 6bbb8c21c..d1863e478 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -202,7 +202,7 @@ export function Outer({
   )
 }
 
-export function Item({children, label, onPress, ...rest}: ItemProps) {
+export function Item({children, label, onPress, style, ...rest}: ItemProps) {
   const t = useTheme()
   const {control} = useMenuContext()
   const {
@@ -248,6 +248,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
                 ? t.atoms.bg_contrast_25
                 : t.atoms.bg_contrast_50,
             ],
+          style,
         ])}
         {...web({
           onMouseEnter,
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index d367e1b98..e9ba65ed0 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -9,28 +10,33 @@ import {
   useNavigationState,
 } from '@react-navigation/native'
 
+import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
 import {makeProfileLink} from '#/lib/routes/links'
 import {CommonNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
-import {isInvalidHandle} from '#/lib/strings/handles'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles'
 import {emitSoftReset} from '#/state/events'
 import {useHomeBadge} from '#/state/home-badge'
 import {useFetchHandle} from '#/state/queries/handle'
 import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
 import {useUnreadNotifications} from '#/state/queries/notifications/unread'
-import {useProfileQuery} from '#/state/queries/profile'
-import {useSession} from '#/state/session'
+import {useProfilesQuery} from '#/state/queries/profile'
+import {SessionAccount, useSession, useSessionApi} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
-import {Link} from '#/view/com/util/Link'
+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, useBreakpoints, useTheme} from '#/alf'
+import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {DialogControlProps} from '#/components/Dialog'
+import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft'
 import {
   Bell_Filled_Corner0_Rounded as BellFilled,
   Bell_Stroke2_Corner0_Rounded as Bell,
@@ -39,6 +45,7 @@ import {
   BulletList_Filled_Corner0_Rounded as ListFilled,
   BulletList_Stroke2_Corner0_Rounded as List,
 } from '#/components/icons/BulletList'
+import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
 import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig'
 import {
   Hashtag_Filled_Corner0_Rounded as HashtagFilled,
@@ -54,6 +61,7 @@ import {
   Message_Stroke2_Corner0_Rounded as Message,
   Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
 } from '#/components/icons/Message'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
 import {
   SettingsGear2_Filled_Corner0_Rounded as SettingsFilled,
   SettingsGear2_Stroke2_Corner0_Rounded as Settings,
@@ -62,44 +70,231 @@ import {
   UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
   UserCircle_Stroke2_Corner0_Rounded as UserCircle,
 } from '#/components/icons/UserCircle'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
 import {Text} from '#/components/Typography'
+import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
 import {router} from '../../../routes'
 
 const NAV_ICON_WIDTH = 28
 
 function ProfileCard() {
-  const {currentAccount} = useSession()
-  const {isLoading, data: profile} = useProfileQuery({did: currentAccount!.did})
-  const {isDesktop} = useWebMediaQueries()
+  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 {gtTablet} = useBreakpoints()
   const {_} = useLingui()
+  const t = useTheme()
+
   const size = 48
 
-  return !isLoading && profile ? (
-    <Link
-      href={makeProfileLink({
-        did: currentAccount!.did,
-        handle: currentAccount!.handle,
-      })}
-      style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}
-      title={_(msg`My Profile`)}
-      asAnchor>
-      <UserAvatar
-        avatar={profile.avatar}
-        size={size}
-        type={profile?.associated?.labeler ? 'labeler' : 'user'}
-      />
-    </Link>
-  ) : (
-    <View style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}>
-      <LoadingPlaceholder
-        width={size}
-        height={size}
-        style={{borderRadius: size}}
+  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),
+    }))
+
+  return (
+    <View style={[a.my_md, gtTablet && [a.w_full, a.align_start]]}>
+      {!isLoading && profile ? (
+        <Menu.Root>
+          <Menu.Trigger label={_(msg`Switch accounts`)}>
+            {({props, state, control}) => {
+              const active = state.hovered || state.focused || control.isOpen
+              return (
+                <Button
+                  label={props.accessibilityLabel}
+                  {...props}
+                  style={[
+                    a.w_full,
+                    a.transition_color,
+                    active ? t.atoms.bg_contrast_25 : a.transition_delay_50ms,
+                    a.rounded_full,
+                    a.justify_between,
+                    a.align_center,
+                    a.flex_row,
+                    {gap: 6},
+                    gtTablet && [a.pl_lg, a.pr_md],
+                  ]}>
+                  <View
+                    style={[
+                      !PlatformInfo.getIsReducedMotionEnabled() && [
+                        a.transition_transform,
+                        {transitionDuration: '250ms'},
+                        !active && a.transition_delay_50ms,
+                      ],
+                      a.relative,
+                      a.z_10,
+                      active && {
+                        transform: [
+                          {scale: gtTablet ? 2 / 3 : 0.8},
+                          {translateX: gtTablet ? -22 : 0},
+                        ],
+                      },
+                    ]}>
+                    <UserAvatar
+                      avatar={profile.avatar}
+                      size={size}
+                      type={profile?.associated?.labeler ? 'labeler' : 'user'}
+                    />
+                  </View>
+                  {gtTablet && (
+                    <>
+                      <View
+                        style={[
+                          a.flex_1,
+                          a.transition_opacity,
+                          !active && a.transition_delay_50ms,
+                          {
+                            marginLeft: tokens.space.xl * -1,
+                            opacity: active ? 1 : 0,
+                          },
+                        ]}>
+                        <Text
+                          style={[a.font_heavy, a.text_sm, a.leading_snug]}
+                          numberOfLines={1}>
+                          {sanitizeDisplayName(
+                            profile.displayName || profile.handle,
+                          )}
+                        </Text>
+                        <Text
+                          style={[
+                            a.text_xs,
+                            a.leading_snug,
+                            t.atoms.text_contrast_medium,
+                          ]}
+                          numberOfLines={1}>
+                          {sanitizeHandle(profile.handle, '@')}
+                        </Text>
+                      </View>
+                      <EllipsisIcon
+                        aria-hidden={true}
+                        style={[
+                          t.atoms.text_contrast_medium,
+                          a.transition_opacity,
+                          {opacity: active ? 1 : 0},
+                        ]}
+                        size="sm"
+                      />
+                    </>
+                  )}
+                </Button>
+              )
+            }}
+          </Menu.Trigger>
+          <SwitchMenuItems
+            accounts={otherAccounts}
+            signOutPromptControl={signOutPromptControl}
+          />
+        </Menu.Root>
+      ) : (
+        <LoadingPlaceholder
+          width={size}
+          height={size}
+          style={[{borderRadius: size}, gtTablet && a.ml_lg]}
+        />
+      )}
+      <Prompt.Basic
+        control={signOutPromptControl}
+        title={_(msg`Sign out?`)}
+        description={_(msg`You will be signed out of all your accounts.`)}
+        onConfirm={() => logoutEveryAccount('Settings')}
+        confirmButtonCta={_(msg`Sign out`)}
+        cancelButtonCta={_(msg`Cancel`)}
+        confirmButtonColor="negative"
       />
     </View>
   )
 }
 
+function SwitchMenuItems({
+  accounts,
+  signOutPromptControl,
+}: {
+  accounts:
+    | {
+        account: SessionAccount
+        profile?: AppBskyActorDefs.ProfileView
+      }[]
+    | undefined
+  signOutPromptControl: DialogControlProps
+}) {
+  const {_} = useLingui()
+  const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeEverything = useCloseAllActiveElements()
+
+  const onAddAnotherAccount = () => {
+    setShowLoggedOut(true)
+    closeEverything()
+  }
+  return (
+    <Menu.Outer>
+      {accounts && accounts.length > 0 && (
+        <>
+          <Menu.Group>
+            <Menu.LabelText>
+              <Trans>Switch account</Trans>
+            </Menu.LabelText>
+            {accounts.map(other => (
+              <Menu.Item
+                disabled={!!pendingDid}
+                style={[{minWidth: 150}]}
+                key={other.account.did}
+                label={_(
+                  msg`Switch to ${sanitizeHandle(
+                    other.profile?.handle ?? other.account.handle,
+                    '@',
+                  )}`,
+                )}
+                onPress={() =>
+                  onPressSwitchAccount(other.account, 'SwitchAccount')
+                }>
+                <View style={[{marginLeft: tokens.space._2xs * -1}]}>
+                  <UserAvatar
+                    avatar={other.profile?.avatar}
+                    size={20}
+                    type={
+                      other.profile?.associated?.labeler ? 'labeler' : 'user'
+                    }
+                  />
+                </View>
+                <Menu.ItemText>
+                  {sanitizeHandle(
+                    other.profile?.handle ?? other.account.handle,
+                    '@',
+                  )}
+                </Menu.ItemText>
+              </Menu.Item>
+            ))}
+          </Menu.Group>
+          <Menu.Divider />
+        </>
+      )}
+      <Menu.Item
+        label={_(msg`Add another account`)}
+        onPress={onAddAnotherAccount}>
+        <Menu.ItemIcon icon={PlusIcon} />
+        <Menu.ItemText>
+          <Trans>Add another account</Trans>
+        </Menu.ItemText>
+      </Menu.Item>
+      <Menu.Item label={_(msg`Sign out`)} onPress={signOutPromptControl.open}>
+        <Menu.ItemIcon icon={LeaveIcon} />
+        <Menu.ItemText>
+          <Trans>Sign out</Trans>
+        </Menu.ItemText>
+      </Menu.Item>
+    </Menu.Outer>
+  )
+}
+
 interface NavItemProps {
   count?: string
   hasNew?: boolean
@@ -539,16 +734,6 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     transform: [],
   },
-
-  profileCard: {
-    marginVertical: 10,
-    width: 90,
-    paddingLeft: 12,
-  },
-  profileCardTablet: {
-    width: 70,
-  },
-
   backBtn: {
     position: 'absolute',
     top: 12,