mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-01-12 22:51:18 +08:00
feat: basic web implementation for native stack
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { HeaderOptions } from '../types';
|
||||
|
||||
export default function getHeaderTitle(
|
||||
options: HeaderOptions & { title?: string },
|
||||
options: { title?: string; headerTitle?: HeaderOptions['headerTitle'] },
|
||||
fallback: string
|
||||
): string {
|
||||
return typeof options.headerTitle === 'string'
|
||||
|
||||
@@ -9,7 +9,12 @@ import type {
|
||||
StackNavigationState,
|
||||
StackRouterOptions,
|
||||
} from '@react-navigation/native';
|
||||
import type { ImageSourcePropType, StyleProp, ViewStyle } from 'react-native';
|
||||
import type {
|
||||
ImageSourcePropType,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import type {
|
||||
ScreenProps,
|
||||
ScreenStackHeaderConfigProps,
|
||||
@@ -65,6 +70,10 @@ export type NativeStackNavigationOptions = {
|
||||
* You can use it to show a back button alongside `headerLeft` if you have specified it.
|
||||
*
|
||||
* This will have no effect on the first screen in the stack.
|
||||
*
|
||||
* Only supported on iOS.
|
||||
*
|
||||
* @platform ios
|
||||
*/
|
||||
headerBackVisible?: boolean;
|
||||
/**
|
||||
@@ -133,6 +142,10 @@ export type NativeStackNavigationOptions = {
|
||||
headerLargeTitle?: boolean;
|
||||
/**
|
||||
* Whether drop shadow of header is visible when a large title is shown.
|
||||
*
|
||||
* Only supported on iOS.
|
||||
*
|
||||
* @platform ios
|
||||
*/
|
||||
headerLargeTitleShadowVisible?: boolean;
|
||||
/**
|
||||
@@ -223,12 +236,11 @@ export type NativeStackNavigationOptions = {
|
||||
* - fontWeight
|
||||
* - color
|
||||
*/
|
||||
headerTitleStyle?: StyleProp<{
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
headerTitleStyle?: StyleProp<
|
||||
Pick<TextStyle, 'fontFamily' | 'fontSize' | 'fontWeight'> & {
|
||||
color?: string;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
* Options to render a native search bar on iOS.
|
||||
*
|
||||
@@ -279,6 +291,8 @@ export type NativeStackNavigationOptions = {
|
||||
* Supported values:
|
||||
* - "push": the new screen will perform push animation.
|
||||
* - "pop": the new screen will perform pop animation.
|
||||
*
|
||||
* Only supported on iOS and Android.
|
||||
*/
|
||||
animationTypeForReplace?: ScreenProps['replaceAnimation'];
|
||||
/**
|
||||
@@ -291,6 +305,8 @@ export type NativeStackNavigationOptions = {
|
||||
* - "slide_from_right": slide in the new screen from right (Android only, uses default animation on iOS)
|
||||
* - "slide_from_left": slide in the new screen from left (Android only, uses default animation on iOS)
|
||||
* - "none": don't animate the screen
|
||||
*
|
||||
* Only supported on iOS and Android.
|
||||
*/
|
||||
animation?: ScreenProps['stackAnimation'];
|
||||
/**
|
||||
@@ -304,6 +320,8 @@ export type NativeStackNavigationOptions = {
|
||||
* - "containedTransparentModal": will use "UIModalPresentationOverCurrentContext" modal style on iOS and will fallback to "transparentModal" on Android.
|
||||
* - "fullScreenModal": will use "UIModalPresentationFullScreen" modal style on iOS and will fallback to "modal" on Android.
|
||||
* - "formSheet": will use "UIModalPresentationFormSheet" modal style on iOS and will fallback to "modal" on Android.
|
||||
*
|
||||
* Only supported on iOS and Android.
|
||||
*/
|
||||
presentation?: Exclude<ScreenProps['stackPresentation'], 'push'> | 'card';
|
||||
/**
|
||||
@@ -318,6 +336,8 @@ export type NativeStackNavigationOptions = {
|
||||
* - "landscape": landscape orientations are permitted.
|
||||
* - "landscape_left": landscape-left orientation is permitted.
|
||||
* - "landscape_right": landscape-right orientation is permitted.
|
||||
*
|
||||
* Only supported on iOS and Android.
|
||||
*/
|
||||
orientation?: ScreenProps['screenOrientation'];
|
||||
};
|
||||
|
||||
229
packages/native-stack/src/views/NativeStackView.native.tsx
Normal file
229
packages/native-stack/src/views/NativeStackView.native.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { SafeAreaProviderCompat } from '@react-navigation/elements';
|
||||
import {
|
||||
ParamListBase,
|
||||
Route,
|
||||
StackActions,
|
||||
StackNavigationState,
|
||||
useTheme,
|
||||
} from '@react-navigation/native';
|
||||
import * as React from 'react';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Screen,
|
||||
ScreenStack,
|
||||
StackPresentationTypes,
|
||||
} from 'react-native-screens';
|
||||
import warnOnce from 'warn-once';
|
||||
|
||||
import type {
|
||||
NativeStackDescriptorMap,
|
||||
NativeStackNavigationHelpers,
|
||||
NativeStackNavigationOptions,
|
||||
} from '../types';
|
||||
import DebugContainer from './DebugContainer';
|
||||
import HeaderConfig from './HeaderConfig';
|
||||
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
|
||||
const MaybeNestedStack = ({
|
||||
options,
|
||||
route,
|
||||
presentation,
|
||||
children,
|
||||
}: {
|
||||
options: NativeStackNavigationOptions;
|
||||
route: Route<string>;
|
||||
presentation: Exclude<StackPresentationTypes, 'push'> | 'card';
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { headerShown = true, contentStyle } = options;
|
||||
|
||||
const isHeaderInModal = isAndroid
|
||||
? false
|
||||
: presentation !== 'card' && headerShown === true;
|
||||
|
||||
const headerShownPreviousRef = React.useRef(headerShown);
|
||||
|
||||
React.useEffect(() => {
|
||||
warnOnce(
|
||||
!isAndroid &&
|
||||
presentation !== 'card' &&
|
||||
headerShownPreviousRef.current !== headerShown,
|
||||
`Dynamically changing 'headerShown' in modals will result in remounting the screen and losing all local state. See options for the screen '${route.name}'.`
|
||||
);
|
||||
|
||||
headerShownPreviousRef.current = headerShown;
|
||||
}, [headerShown, presentation, route.name]);
|
||||
|
||||
const content = (
|
||||
<DebugContainer
|
||||
style={[
|
||||
styles.container,
|
||||
presentation !== 'transparentModal' &&
|
||||
presentation !== 'containedTransparentModal' && {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
contentStyle,
|
||||
]}
|
||||
stackPresentation={presentation === 'card' ? 'push' : presentation}
|
||||
>
|
||||
{children}
|
||||
</DebugContainer>
|
||||
);
|
||||
|
||||
if (isHeaderInModal) {
|
||||
return (
|
||||
<ScreenStack style={styles.container}>
|
||||
<Screen enabled style={StyleSheet.absoluteFill}>
|
||||
<HeaderConfig {...options} route={route} />
|
||||
{content}
|
||||
</Screen>
|
||||
</ScreenStack>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
state: StackNavigationState<ParamListBase>;
|
||||
navigation: NativeStackNavigationHelpers;
|
||||
descriptors: NativeStackDescriptorMap;
|
||||
};
|
||||
|
||||
function NativeStackViewInner({ state, navigation, descriptors }: Props) {
|
||||
const [nextDismissedKey, setNextDismissedKey] =
|
||||
React.useState<string | null>(null);
|
||||
|
||||
const dismissedRouteName = nextDismissedKey
|
||||
? state.routes.find((route) => route.key === nextDismissedKey)?.name
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (dismissedRouteName) {
|
||||
const message =
|
||||
`The screen '${dismissedRouteName}' was removed natively but didn't get removed from JS state. ` +
|
||||
`This can happen if the action was prevented in a 'beforeRemove' listener, which is not fully supported in native-stack.\n\n` +
|
||||
`Consider using 'gestureEnabled: false' to prevent back gesture and use a custom back button with 'headerLeft' option to override the native behavior.`;
|
||||
|
||||
console.error(message);
|
||||
}
|
||||
}, [dismissedRouteName]);
|
||||
|
||||
return (
|
||||
<ScreenStack style={styles.container}>
|
||||
{state.routes.map((route, index) => {
|
||||
const { options, render: renderScene } = descriptors[route.key];
|
||||
const {
|
||||
gestureEnabled,
|
||||
headerShown,
|
||||
animationTypeForReplace = 'pop',
|
||||
animation,
|
||||
orientation,
|
||||
statusBarAnimation,
|
||||
statusBarHidden,
|
||||
statusBarStyle,
|
||||
} = options;
|
||||
|
||||
let { presentation = 'card' } = options;
|
||||
|
||||
if (index === 0) {
|
||||
// first screen should always be treated as `card`, it resolves problems with no header animation
|
||||
// for navigator with first screen as `modal` and the next as `card`
|
||||
presentation = 'card';
|
||||
}
|
||||
|
||||
const isHeaderInPush = isAndroid
|
||||
? headerShown
|
||||
: presentation === 'card' && headerShown !== false;
|
||||
|
||||
return (
|
||||
<Screen
|
||||
key={route.key}
|
||||
enabled
|
||||
style={StyleSheet.absoluteFill}
|
||||
gestureEnabled={
|
||||
isAndroid
|
||||
? // This prop enables handling of system back gestures on Android
|
||||
// Since we handle them in JS side, we disable this
|
||||
false
|
||||
: gestureEnabled
|
||||
}
|
||||
replaceAnimation={animationTypeForReplace}
|
||||
stackPresentation={presentation === 'card' ? 'push' : presentation}
|
||||
stackAnimation={animation}
|
||||
screenOrientation={orientation}
|
||||
statusBarAnimation={statusBarAnimation}
|
||||
statusBarHidden={statusBarHidden}
|
||||
statusBarStyle={statusBarStyle}
|
||||
onWillAppear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionStart',
|
||||
data: { closing: false },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onWillDisappear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionStart',
|
||||
data: { closing: true },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onAppear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionEnd',
|
||||
data: { closing: false },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onDisappear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionEnd',
|
||||
data: { closing: true },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onDismissed={() => {
|
||||
navigation.dispatch({
|
||||
...StackActions.pop(),
|
||||
source: route.key,
|
||||
target: state.key,
|
||||
});
|
||||
|
||||
setNextDismissedKey(route.key);
|
||||
}}
|
||||
>
|
||||
<HeaderConfig
|
||||
{...options}
|
||||
route={route}
|
||||
headerShown={isHeaderInPush}
|
||||
/>
|
||||
<MaybeNestedStack
|
||||
options={options}
|
||||
route={route}
|
||||
presentation={presentation}
|
||||
>
|
||||
{renderScene()}
|
||||
</MaybeNestedStack>
|
||||
</Screen>
|
||||
);
|
||||
})}
|
||||
</ScreenStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NativeStackView(props: Props) {
|
||||
return (
|
||||
<SafeAreaProviderCompat>
|
||||
<NativeStackViewInner {...props} />
|
||||
</SafeAreaProviderCompat>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,223 +1,123 @@
|
||||
import { SafeAreaProviderCompat } from '@react-navigation/elements';
|
||||
import {
|
||||
getHeaderTitle,
|
||||
Header,
|
||||
HeaderBackButton,
|
||||
SafeAreaProviderCompat,
|
||||
Screen,
|
||||
} from '@react-navigation/elements';
|
||||
import type {
|
||||
ParamListBase,
|
||||
Route,
|
||||
StackActions,
|
||||
StackNavigationState,
|
||||
useTheme,
|
||||
} from '@react-navigation/native';
|
||||
import * as React from 'react';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Screen,
|
||||
ScreenStack,
|
||||
StackPresentationTypes,
|
||||
} from 'react-native-screens';
|
||||
import warnOnce from 'warn-once';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
|
||||
import type {
|
||||
NativeStackDescriptorMap,
|
||||
NativeStackNavigationHelpers,
|
||||
NativeStackNavigationOptions,
|
||||
} from '../types';
|
||||
import DebugContainer from './DebugContainer';
|
||||
import HeaderConfig from './HeaderConfig';
|
||||
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
|
||||
const MaybeNestedStack = ({
|
||||
options,
|
||||
route,
|
||||
presentation,
|
||||
children,
|
||||
}: {
|
||||
options: NativeStackNavigationOptions;
|
||||
route: Route<string>;
|
||||
presentation: Exclude<StackPresentationTypes, 'push'> | 'card';
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { headerShown = true, contentStyle } = options;
|
||||
|
||||
const isHeaderInModal = isAndroid
|
||||
? false
|
||||
: presentation !== 'card' && headerShown === true;
|
||||
|
||||
const headerShownPreviousRef = React.useRef(headerShown);
|
||||
|
||||
React.useEffect(() => {
|
||||
warnOnce(
|
||||
!isAndroid &&
|
||||
presentation !== 'card' &&
|
||||
headerShownPreviousRef.current !== headerShown,
|
||||
`Dynamically changing 'headerShown' in modals will result in remounting the screen and losing all local state. See options for the screen '${route.name}'.`
|
||||
);
|
||||
|
||||
headerShownPreviousRef.current = headerShown;
|
||||
}, [headerShown, presentation, route.name]);
|
||||
|
||||
const content = (
|
||||
<DebugContainer
|
||||
style={[
|
||||
styles.container,
|
||||
presentation !== 'transparentModal' &&
|
||||
presentation !== 'containedTransparentModal' && {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
contentStyle,
|
||||
]}
|
||||
stackPresentation={presentation === 'card' ? 'push' : presentation}
|
||||
>
|
||||
{children}
|
||||
</DebugContainer>
|
||||
);
|
||||
|
||||
if (isHeaderInModal) {
|
||||
return (
|
||||
<ScreenStack style={styles.container}>
|
||||
<Screen enabled style={StyleSheet.absoluteFill}>
|
||||
<HeaderConfig {...options} route={route} />
|
||||
{content}
|
||||
</Screen>
|
||||
</ScreenStack>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
state: StackNavigationState<ParamListBase>;
|
||||
// This is used for the native implementation of the stack.
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
navigation: NativeStackNavigationHelpers;
|
||||
descriptors: NativeStackDescriptorMap;
|
||||
};
|
||||
|
||||
function NativeStackViewInner({ state, navigation, descriptors }: Props) {
|
||||
const [nextDismissedKey, setNextDismissedKey] =
|
||||
React.useState<string | null>(null);
|
||||
|
||||
const dismissedRouteName = nextDismissedKey
|
||||
? state.routes.find((route) => route.key === nextDismissedKey)?.name
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (dismissedRouteName) {
|
||||
const message =
|
||||
`The screen '${dismissedRouteName}' was removed natively but didn't get removed from JS state. ` +
|
||||
`This can happen if the action was prevented in a 'beforeRemove' listener, which is not fully supported in native-stack.\n\n` +
|
||||
`Consider using 'gestureEnabled: false' to prevent back gesture and use a custom back button with 'headerLeft' option to override the native behavior.`;
|
||||
|
||||
console.error(message);
|
||||
}
|
||||
}, [dismissedRouteName]);
|
||||
|
||||
return (
|
||||
<ScreenStack style={styles.container}>
|
||||
{state.routes.map((route, index) => {
|
||||
const { options, render: renderScene } = descriptors[route.key];
|
||||
const {
|
||||
gestureEnabled,
|
||||
headerShown,
|
||||
animationTypeForReplace = 'pop',
|
||||
animation,
|
||||
orientation,
|
||||
statusBarAnimation,
|
||||
statusBarHidden,
|
||||
statusBarStyle,
|
||||
} = options;
|
||||
|
||||
let { presentation = 'card' } = options;
|
||||
|
||||
if (index === 0) {
|
||||
// first screen should always be treated as `card`, it resolves problems with no header animation
|
||||
// for navigator with first screen as `modal` and the next as `card`
|
||||
presentation = 'card';
|
||||
}
|
||||
|
||||
const isHeaderInPush = isAndroid
|
||||
? headerShown
|
||||
: presentation === 'card' && headerShown !== false;
|
||||
|
||||
return (
|
||||
<Screen
|
||||
key={route.key}
|
||||
enabled
|
||||
style={StyleSheet.absoluteFill}
|
||||
gestureEnabled={
|
||||
isAndroid
|
||||
? // This prop enables handling of system back gestures on Android
|
||||
// Since we handle them in JS side, we disable this
|
||||
false
|
||||
: gestureEnabled
|
||||
}
|
||||
replaceAnimation={animationTypeForReplace}
|
||||
stackPresentation={presentation === 'card' ? 'push' : presentation}
|
||||
stackAnimation={animation}
|
||||
screenOrientation={orientation}
|
||||
statusBarAnimation={statusBarAnimation}
|
||||
statusBarHidden={statusBarHidden}
|
||||
statusBarStyle={statusBarStyle}
|
||||
onWillAppear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionStart',
|
||||
data: { closing: false },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onWillDisappear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionStart',
|
||||
data: { closing: true },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onAppear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionEnd',
|
||||
data: { closing: false },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onDisappear={() => {
|
||||
navigation.emit({
|
||||
type: 'transitionEnd',
|
||||
data: { closing: true },
|
||||
target: route.key,
|
||||
});
|
||||
}}
|
||||
onDismissed={() => {
|
||||
navigation.dispatch({
|
||||
...StackActions.pop(),
|
||||
source: route.key,
|
||||
target: state.key,
|
||||
});
|
||||
|
||||
setNextDismissedKey(route.key);
|
||||
}}
|
||||
>
|
||||
<HeaderConfig
|
||||
{...options}
|
||||
route={route}
|
||||
headerShown={isHeaderInPush}
|
||||
/>
|
||||
<MaybeNestedStack
|
||||
options={options}
|
||||
route={route}
|
||||
presentation={presentation}
|
||||
>
|
||||
{renderScene()}
|
||||
</MaybeNestedStack>
|
||||
</Screen>
|
||||
);
|
||||
})}
|
||||
</ScreenStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NativeStackView(props: Props) {
|
||||
export default function NativeStackView({ state, descriptors }: Props) {
|
||||
return (
|
||||
<SafeAreaProviderCompat>
|
||||
<NativeStackViewInner {...props} />
|
||||
<View style={styles.container}>
|
||||
{state.routes.map((route, i) => {
|
||||
const isFocused = state.index === i;
|
||||
const canGoBack = i !== 0;
|
||||
const { options, navigation, render } = descriptors[route.key];
|
||||
|
||||
const {
|
||||
headerShown,
|
||||
headerTintColor,
|
||||
headerBackImageSource,
|
||||
headerLeft,
|
||||
headerRight,
|
||||
headerTitle,
|
||||
headerTitleStyle,
|
||||
headerStyle,
|
||||
headerShadowVisible,
|
||||
headerTranslucent,
|
||||
contentStyle,
|
||||
} = options;
|
||||
|
||||
return (
|
||||
<Screen
|
||||
key={route.key}
|
||||
focused={isFocused}
|
||||
route={route}
|
||||
navigation={navigation}
|
||||
headerShown={headerShown}
|
||||
header={
|
||||
<Header
|
||||
title={getHeaderTitle(options, route.name)}
|
||||
headerTintColor={headerTintColor}
|
||||
headerLeft={
|
||||
typeof headerLeft === 'function'
|
||||
? ({ tintColor }) => headerLeft({ tintColor })
|
||||
: headerLeft === undefined && canGoBack
|
||||
? ({ tintColor }) => (
|
||||
<HeaderBackButton
|
||||
tintColor={tintColor}
|
||||
backImage={
|
||||
headerBackImageSource !== undefined
|
||||
? () => (
|
||||
<Image
|
||||
source={headerBackImageSource}
|
||||
style={[styles.backImage, { tintColor }]}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onPress={navigation.goBack}
|
||||
canGoBack={canGoBack}
|
||||
/>
|
||||
)
|
||||
: headerLeft
|
||||
}
|
||||
headerRight={
|
||||
typeof headerRight === 'function'
|
||||
? ({ tintColor }) => headerRight({ tintColor })
|
||||
: headerRight
|
||||
}
|
||||
headerTitle={
|
||||
typeof headerTitle === 'function'
|
||||
? ({ children, tintColor }) =>
|
||||
headerTitle({ children, tintColor })
|
||||
: headerTitle
|
||||
}
|
||||
headerTitleStyle={headerTitleStyle}
|
||||
headerStyle={[
|
||||
headerTranslucent
|
||||
? {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
: null,
|
||||
headerStyle,
|
||||
headerShadowVisible === false
|
||||
? { shadowOpacity: 0, borderBottomWidth: 0 }
|
||||
: null,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ display: isFocused ? 'flex' : 'none' },
|
||||
]}
|
||||
>
|
||||
<View style={contentStyle}>{render()}</View>
|
||||
</Screen>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</SafeAreaProviderCompat>
|
||||
);
|
||||
}
|
||||
@@ -226,4 +126,10 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backImage: {
|
||||
height: 24,
|
||||
width: 24,
|
||||
margin: 3,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user