mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 15:09:33 +01:00
enhance settings UI
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
Appearance,
|
Appearance,
|
||||||
|
Pressable,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as Animatable from 'react-native-animatable';
|
import * as Animatable from 'react-native-animatable';
|
||||||
|
|
||||||
@@ -259,7 +260,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 0,
|
||||||
}}>
|
}}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
@@ -272,6 +273,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
borderBottomColor: colors.nav,
|
borderBottomColor: colors.nav,
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
paddingBottom: 3,
|
paddingBottom: 3,
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
Account Settings
|
Account Settings
|
||||||
</Text>
|
</Text>
|
||||||
@@ -366,11 +368,11 @@ export const Settings = ({route, navigation}) => {
|
|||||||
onPress={item.func}
|
onPress={item.func}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
height: 50,
|
||||||
paddingVertical: pv + 5,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -386,6 +388,10 @@ export const Settings = ({route, navigation}) => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
}}>
|
||||||
<PressableButton
|
<PressableButton
|
||||||
color={colors.shade}
|
color={colors.shade}
|
||||||
selectedColor={colors.accent}
|
selectedColor={colors.accent}
|
||||||
@@ -457,6 +463,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</PressableButton>
|
</PressableButton>
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
@@ -465,7 +472,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
fontFamily: WEIGHT.bold,
|
fontFamily: WEIGHT.bold,
|
||||||
textAlignVertical: 'center',
|
textAlignVertical: 'center',
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
|
paddingHorizontal: 12,
|
||||||
borderBottomColor: colors.nav,
|
borderBottomColor: colors.nav,
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
paddingBottom: 3,
|
paddingBottom: 3,
|
||||||
@@ -480,6 +487,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
textAlignVertical: 'center',
|
textAlignVertical: 'center',
|
||||||
color: colors.pri,
|
color: colors.pri,
|
||||||
marginTop: pv + 5,
|
marginTop: pv + 5,
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
Accent Color{'\n'}
|
Accent Color{'\n'}
|
||||||
<Text
|
<Text
|
||||||
@@ -505,6 +513,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
{[
|
{[
|
||||||
'#e6194b',
|
'#e6194b',
|
||||||
@@ -555,7 +564,10 @@ export const Settings = ({route, navigation}) => {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<PressableButton
|
||||||
|
color={colors.bg}
|
||||||
|
selectedColor={colors.nav}
|
||||||
|
alpha={!colors.night ? -0.02 : 0.02}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await setSetting(
|
await setSetting(
|
||||||
settings,
|
settings,
|
||||||
@@ -575,14 +587,14 @@ export const Settings = ({route, navigation}) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
activeOpacity={opacity}
|
customStyle={{
|
||||||
style={{
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
paddingVertical: pv + 5,
|
height: 50,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -609,9 +621,12 @@ export const Settings = ({route, navigation}) => {
|
|||||||
settings.useSystemTheme ? 'toggle-switch' : 'toggle-switch-off'
|
settings.useSystemTheme ? 'toggle-switch' : 'toggle-switch-off'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</PressableButton>
|
||||||
|
|
||||||
<TouchableOpacity
|
<PressableButton
|
||||||
|
color={colors.bg}
|
||||||
|
selectedColor={colors.nav}
|
||||||
|
alpha={!colors.night ? -0.02 : 0.02}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!colors.night) {
|
if (!colors.night) {
|
||||||
MMKV.setStringAsync('theme', JSON.stringify({night: true}));
|
MMKV.setStringAsync('theme', JSON.stringify({night: true}));
|
||||||
@@ -623,13 +638,15 @@ export const Settings = ({route, navigation}) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
activeOpacity={opacity}
|
activeOpacity={opacity}
|
||||||
style={{
|
customStyle={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
paddingVertical: pv + 5,
|
height: 50,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
borderRadius: 0,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -652,9 +669,9 @@ export const Settings = ({route, navigation}) => {
|
|||||||
color={colors.night ? colors.accent : colors.icon}
|
color={colors.night ? colors.accent : colors.icon}
|
||||||
name={colors.night ? 'toggle-switch' : 'toggle-switch-off'}
|
name={colors.night ? 'toggle-switch' : 'toggle-switch-off'}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</PressableButton>
|
||||||
|
|
||||||
<TouchableOpacity
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
@@ -662,6 +679,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -735,10 +753,13 @@ export const Settings = ({route, navigation}) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
|
||||||
{DDS.isTab ? (
|
{DDS.isTab ? (
|
||||||
<TouchableOpacity
|
<PressableButton
|
||||||
|
color={colors.bg}
|
||||||
|
selectedColor={colors.nav}
|
||||||
|
alpha={!colors.night ? -0.02 : 0.02}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await setSetting(
|
await setSetting(
|
||||||
settings,
|
settings,
|
||||||
@@ -746,7 +767,6 @@ export const Settings = ({route, navigation}) => {
|
|||||||
!settings.forcePortraitOnTablet,
|
!settings.forcePortraitOnTablet,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
activeOpacity={opacity}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
@@ -754,6 +774,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -775,7 +796,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
: 'toggle-switch-off'
|
: 'toggle-switch-off'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</PressableButton>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -787,31 +808,62 @@ export const Settings = ({route, navigation}) => {
|
|||||||
borderBottomColor: colors.nav,
|
borderBottomColor: colors.nav,
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
paddingBottom: 3,
|
paddingBottom: 3,
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
Backup & Restore
|
Backup & Restore
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
{[
|
||||||
onPress={async () => {
|
{
|
||||||
await setSetting(
|
name: 'Backup data',
|
||||||
settings,
|
func: () => {
|
||||||
'useSystemTheme',
|
Linking.openURL('https://www.notesnook.com/privacy.html');
|
||||||
!settings.useSystemTheme,
|
},
|
||||||
);
|
desc: 'Backup all your data to phone storage',
|
||||||
|
},
|
||||||
if (!settings.useSystemTheme) {
|
{
|
||||||
MMKV.setStringAsync(
|
name: 'Restore data',
|
||||||
'theme',
|
func: () => {
|
||||||
JSON.stringify({night: Appearance.getColorScheme() === 'dark'}),
|
Linking.openURL('https://www.notesnook.com');
|
||||||
);
|
},
|
||||||
changeColorScheme(
|
desc: 'Restore backup from your phone.',
|
||||||
Appearance.getColorScheme() === 'dark'
|
},
|
||||||
? COLOR_SCHEME_DARK
|
].map((item) => (
|
||||||
: COLOR_SCHEME_LIGHT,
|
<PressableButton
|
||||||
);
|
color={colors.bg}
|
||||||
}
|
selectedColor={colors.nav}
|
||||||
|
alpha={!colors.night ? -0.02 : 0.02}
|
||||||
|
key={item.name}
|
||||||
|
customStyle={{
|
||||||
|
height: 50,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
activeOpacity={opacity}
|
onPress={item.func}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: SIZE.sm,
|
||||||
|
fontFamily: WEIGHT.regular,
|
||||||
|
textAlignVertical: 'center',
|
||||||
|
color: colors.pri,
|
||||||
|
width: '100%',
|
||||||
|
}}>
|
||||||
|
{item.name}
|
||||||
|
{'\n'}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: SIZE.xs,
|
||||||
|
color: colors.icon,
|
||||||
|
}}>
|
||||||
|
{item.desc}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
{item.customComponent ? item.customComponent : null}
|
||||||
|
</PressableButton>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
@@ -819,6 +871,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
height: 50,
|
height: 50,
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -856,13 +909,16 @@ export const Settings = ({route, navigation}) => {
|
|||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await setSetting(settings, 'reminder', item.value);
|
await setSetting(settings, 'reminder', item.value);
|
||||||
}}
|
}}
|
||||||
key={item.value}
|
key={item.value}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
settings.reminder === 'daily' ? colors.accent : colors.nav,
|
settings.reminder === item.value
|
||||||
|
? colors.accent
|
||||||
|
: colors.nav,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: 60,
|
width: 60,
|
||||||
@@ -871,7 +927,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
settings.reminder === 'daily' ? 'white' : colors.icon,
|
settings.reminder === item.value ? 'white' : colors.icon,
|
||||||
fontSize: SIZE.xs,
|
fontSize: SIZE.xs,
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -879,24 +935,42 @@ export const Settings = ({route, navigation}) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<PressableButton
|
||||||
|
color={colors.bg}
|
||||||
|
selectedColor={colors.nav}
|
||||||
|
alpha={!colors.night ? -0.02 : 0.02}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
|
if (!user) {
|
||||||
|
ToastEvent.show(
|
||||||
|
'You must login to enable encryption',
|
||||||
|
'error',
|
||||||
|
'global',
|
||||||
|
6000,
|
||||||
|
() => {
|
||||||
|
NavigationService.navigate('Login', {
|
||||||
|
root: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'Login',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await setSetting(
|
await setSetting(
|
||||||
settings,
|
settings,
|
||||||
'encryptedBackup',
|
'encryptedBackup',
|
||||||
!settings.encryptedBackup,
|
!settings.encryptedBackup,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
activeOpacity={opacity}
|
customStyle={{
|
||||||
style={{
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
height: 50,
|
height: 50,
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -916,55 +990,12 @@ export const Settings = ({route, navigation}) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Icon
|
<Icon
|
||||||
size={SIZE.xl}
|
size={SIZE.xl}
|
||||||
color={settings.useSystemTheme ? colors.accent : colors.icon}
|
color={settings.encryptedBackup ? colors.accent : colors.icon}
|
||||||
name={
|
name={
|
||||||
settings.useSystemTheme ? 'toggle-switch' : 'toggle-switch-off'
|
settings.encryptedBackup ? 'toggle-switch' : 'toggle-switch-off'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</PressableButton>
|
||||||
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
name: 'Backup data',
|
|
||||||
func: () => {
|
|
||||||
Linking.openURL('https://www.notesnook.com/privacy.html');
|
|
||||||
},
|
|
||||||
desc: 'Backup all your data to phone storage',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Restore data',
|
|
||||||
func: () => {
|
|
||||||
Linking.openURL('https://www.notesnook.com');
|
|
||||||
},
|
|
||||||
desc: 'Restore backup from your phone.',
|
|
||||||
},
|
|
||||||
].map((item) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.name}
|
|
||||||
activeOpacity={opacity}
|
|
||||||
onPress={item.func}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: SIZE.sm,
|
|
||||||
fontFamily: WEIGHT.regular,
|
|
||||||
textAlignVertical: 'center',
|
|
||||||
color: colors.pri,
|
|
||||||
height: 50,
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{item.name}
|
|
||||||
{'\n'}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: SIZE.xs,
|
|
||||||
color: colors.icon,
|
|
||||||
}}>
|
|
||||||
{item.desc}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
{item.customComponent ? item.customComponent : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -975,6 +1006,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
borderBottomColor: colors.nav,
|
borderBottomColor: colors.nav,
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
paddingBottom: 3,
|
paddingBottom: 3,
|
||||||
|
paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
Other
|
Other
|
||||||
</Text>
|
</Text>
|
||||||
@@ -987,21 +1019,32 @@ export const Settings = ({route, navigation}) => {
|
|||||||
},
|
},
|
||||||
desc: 'Read our privacy policy',
|
desc: 'Read our privacy policy',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Check for updates',
|
||||||
|
func: () => {
|
||||||
|
Linking.openURL('https://www.notesnook.com/privacy.html');
|
||||||
|
},
|
||||||
|
desc: 'Check for a newer version of app',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'About',
|
||||||
func: () => {
|
func: () => {
|
||||||
Linking.openURL('https://www.notesnook.com');
|
Linking.openURL('https://www.notesnook.com');
|
||||||
},
|
},
|
||||||
desc: null,
|
desc: 'You are using the latest version of our app.',
|
||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<TouchableOpacity
|
<PressableButton
|
||||||
|
color={colors.bg}
|
||||||
|
selectedColor={colors.nav}
|
||||||
|
alpha={!colors.night ? -0.02 : 0.02}
|
||||||
key={item.name}
|
key={item.name}
|
||||||
activeOpacity={opacity}
|
|
||||||
onPress={item.func}
|
onPress={item.func}
|
||||||
style={{
|
customStyle={{
|
||||||
height: 50,
|
height: 50,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
width: '100%',
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -1009,6 +1052,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
fontFamily: WEIGHT.regular,
|
fontFamily: WEIGHT.regular,
|
||||||
textAlignVertical: 'center',
|
textAlignVertical: 'center',
|
||||||
color: colors.pri,
|
color: colors.pri,
|
||||||
|
width: '100%',
|
||||||
}}>
|
}}>
|
||||||
{item.name}
|
{item.name}
|
||||||
{'\n'}
|
{'\n'}
|
||||||
@@ -1021,7 +1065,7 @@ export const Settings = ({route, navigation}) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
{item.customComponent ? item.customComponent : null}
|
{item.customComponent ? item.customComponent : null}
|
||||||
</TouchableOpacity>
|
</PressableButton>
|
||||||
))}
|
))}
|
||||||
<Seperator />
|
<Seperator />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
Reference in New Issue
Block a user