MM-39712 - Edit Profile Screen without image picker (#5849)

* feature edit profile screen

* minor refactoring

* Apply suggestions from code review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* ts fixes

* revert floatingTextInput label

This reverts commit a778e1f761.

* code clean up

* Apply suggestions from code review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* code fix

* code fix

* Adding preventDoubleTap

* rename id to fieldKey

* Update edit_profile.tsx

* wip

* navigating through fields; partly done

* navigating through fields - partly done

* navigating through fields; partly done

* completed field navigation

* added theme into dependency array

* code clean up

* revert conditions for disabling fields

* Added colorFilters prop to Loading component

* Completed loading feature on Edit Profile screen

* code clean up

* Add profile_error

* renamed valid_mime_types to  valid_image_mime_types

* added props isDisabled to email field

* refactored next field logic

* fix

* fix

* code clean up

* code clean up

* Updated ESLINT hook rules to warning instead of disabled

* code fix

* code fix

* new line within your_profile component

* added memo for Field component

* added canSave to dependency array

* update loading component - color filter

* Update app/screens/edit_profile/edit_profile.tsx

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* dependency fix

* fix to fetch my latest status

* fix to remove unnecessary user id for local action updateLocalUser

* prevents bouncing for iOS

* code revert

* Adding  textInputStyle and animatedTextStyle to FloatingTextInput component

* correction after dev session

* adding changes as per new ux

* Update edit_profile.tsx

* corrections after ux review

* ux review

* ux review

* code clean up

* Adding userProfileFields into useMemo

* Add enableSaveButton to dependency of submitUser

* Revert fetching status on userProfile

* EditProfile enable extraScrollHeight on iOS only

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Avinash Lingaloo
2021-12-23 15:16:55 +04:00
committed by GitHub
parent b49b4050ac
commit 47e4306361
25 changed files with 983 additions and 49 deletions

View File

@@ -34,8 +34,7 @@ export const autoUpdateTimezone = async (serverUrl: string, {deviceTimezone, use
if (currentTimezone.useAutomaticTimezone && newTimezoneExists) {
const timezone = {useAutomaticTimezone: 'true', automaticTimezone: deviceTimezone, manualTimezone: currentTimezone.manualTimezone};
const updatedUser = {...currentUser, timezone} as UserModel;
await updateMe(serverUrl, updatedUser);
await updateMe(serverUrl, {timezone});
}
return null;
};

View File

@@ -5,7 +5,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database';
import General from '@constants/general';
import DatabaseManager from '@database/manager';
import {queryRecentCustomStatuses} from '@queries/servers/system';
import {queryCurrentUser, queryUserById} from '@queries/servers/user';
import {queryCurrentUser} from '@queries/servers/user';
import {addRecentReaction} from './reactions';
@@ -105,25 +105,40 @@ export const updateRecentCustomStatuses = async (serverUrl: string, customStatus
});
};
export const updateUserPresence = async (serverUrl: string, userStatus: UserStatus) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
export const updateLocalUser = async (
serverUrl: string,
userDetails: Partial<UserProfile> & { status?: string},
) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const user = await queryUserById(operator.database, userStatus.user_id);
if (user) {
user.prepareUpdate((record) => {
record.status = userStatus.status;
});
try {
await operator.batchRecords([user]);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANGES FOR UPDATE USER PRESENCE');
try {
const user = await queryCurrentUser(database);
if (user) {
await database.write(async () => {
await user.update((userRecord: UserModel) => {
userRecord.authService = userDetails.auth_service ?? user.authService;
userRecord.email = userDetails.email ?? user.email;
userRecord.firstName = userDetails.first_name ?? user.firstName;
userRecord.lastName = userDetails.last_name ?? user.lastName;
userRecord.lastPictureUpdate = userDetails.last_picture_update ?? user.lastPictureUpdate;
userRecord.locale = userDetails.locale ?? user.locale;
userRecord.nickname = userDetails.nickname ?? user.nickname;
userRecord.notifyProps = userDetails.notify_props ?? user.notifyProps;
userRecord.position = userDetails?.position ?? user.position;
userRecord.props = userDetails.props ?? user.props;
userRecord.roles = userDetails.roles ?? user.roles;
userRecord.status = userDetails?.status ?? user.status;
userRecord.timezone = userDetails.timezone ?? user.timezone;
userRecord.username = userDetails.username ?? user.username;
});
});
}
} catch (error) {
return {error};
}
return {};
};

View File

@@ -3,7 +3,7 @@
import {Model, Q} from '@nozbe/watermelondb';
import {updateRecentCustomStatuses, updateUserPresence} from '@actions/local/user';
import {updateRecentCustomStatuses, updateLocalUser} from '@actions/local/user';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {Database, General} from '@constants';
import DatabaseManager from '@database/manager';
@@ -143,7 +143,7 @@ export const fetchProfilesPerChannels = async (serverUrl: string, channelIds: st
}
};
export const updateMe = async (serverUrl: string, user: UserModel) => {
export const updateMe = async (serverUrl: string, user: Partial<UserProfile>) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!database) {
@@ -159,7 +159,7 @@ export const updateMe = async (serverUrl: string, user: UserModel) => {
let data: UserProfile;
try {
data = await client.patchMe(user._raw);
data = await client.patchMe(user);
} catch (e) {
forceLogoutIfNecessary(serverUrl, e as ClientError);
return {error: e};
@@ -396,6 +396,7 @@ export const updateUsersNoLongerVisible = async (serverUrl: string): Promise<{er
return {};
};
export const setStatus = async (serverUrl: string, status: UserStatus) => {
let client: Client;
try {
@@ -406,13 +407,13 @@ export const setStatus = async (serverUrl: string, status: UserStatus) => {
try {
const data = await client.updateStatus(status);
updateUserPresence(serverUrl, status);
await updateLocalUser(serverUrl, {status: status.status});
return {
data,
};
} catch (error: any) {
forceLogoutIfNecessary(serverUrl, error);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -8,7 +8,7 @@ import FormattedText from '@components/formatted_text';
import {makeStyleSheetFromTheme} from '@utils/theme';
type ErrorProps = {
error: Partial<ClientErrorProps> | string;
error: ErrorText;
testID?: string;
textStyle?: StyleProp<ViewStyle> | StyleProp<TextStyle>;
theme: Theme;

View File

@@ -5,7 +5,7 @@
import {debounce} from 'lodash';
import React, {useState, useEffect, useRef, useImperativeHandle, forwardRef} from 'react';
import {View, TextInput, TouchableWithoutFeedback, Text, Platform, TextStyle, NativeSyntheticEvent, TextInputFocusEventData, TextInputProps, GestureResponderEvent, TargetedEvent} from 'react-native';
import {GestureResponderEvent, NativeSyntheticEvent, Platform, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native';
import Animated, {useCode, interpolateNode, EasingNode, Value, set, Clock} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
@@ -93,7 +93,9 @@ export type FloatingTextInputRef = {
}
type FloatingTextInputProps = TextInputProps & {
containerStyle?: TextStyle;
containerStyle?: ViewStyle;
textInputStyle?: TextStyle;
labelTextStyle?: TextStyle;
editable?: boolean;
error?: string;
errorIcon?: string;
@@ -120,6 +122,8 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
showErrorIcon = true,
theme,
value = '',
textInputStyle,
labelTextStyle,
...props
}: FloatingTextInputProps, ref) => {
const [focusedLabel, setIsFocusLabel] = useState<boolean | undefined>();
@@ -218,8 +222,8 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH,
height: DEFAULT_INPUT_HEIGHT + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2),
};
const combinedTextInputStyle = [styles.textInput, textInputBorder, textInputColorStyles];
const textAnimatedTextStyle = [styles.label, focusStyle, labelColorStyles];
const combinedTextInputStyle = [styles.textInput, textInputBorder, textInputColorStyles, textInputStyle];
const textAnimatedTextStyle = [styles.label, focusStyle, labelColorStyles, labelTextStyle];
if (error && !focused) {
textAnimatedTextStyle.push({color: theme.errorTextColor});

View File

@@ -58,7 +58,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ProfilePicture = ({author, iconSize, showStatus = true, size = 64, statusSize = 14, statusStyle, testID}: ProfilePictureProps) => {
const ProfilePicture = ({
author,
iconSize,
showStatus = true,
size = 64,
statusSize = 14,
statusStyle,
testID,
}: ProfilePictureProps) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const style = getStyleSheet(theme);

View File

@@ -10,6 +10,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
action?: string;
enabled?: boolean;
onPress: () => void;
title: string;
testID: string;
@@ -19,10 +20,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
actionContainer: {
alignItems: 'flex-end',
justifyContent: 'center',
marginRight: 20,
right: 20,
bottom: 7,
position: 'absolute',
},
action: {
color: theme.buttonBg,
color: changeOpacity(theme.centerChannelColor, 0.7),
fontFamily: 'OpenSans-Semibold',
fontSize: 16,
lineHeight: 24,
@@ -34,6 +37,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flexDirection: 'row',
height: 34,
width: '100%',
alignItems: 'center',
paddingBottom: 5,
},
enabled: {
color: theme.buttonBg,
},
titleContainer: {
alignItems: 'center',
@@ -48,9 +56,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const TabletTitle = ({action, onPress, testID, title}: Props) => {
const TabletTitle = ({action, enabled = true, onPress, testID, title}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const textStyle = [styles.action, enabled && styles.enabled];
return (
<>
@@ -61,12 +70,13 @@ const TabletTitle = ({action, onPress, testID, title}: Props) => {
{Boolean(action) &&
<View style={styles.actionContainer}>
<TouchableWithFeedback
disabled={!enabled}
onPress={onPress}
type={Platform.select({android: 'native', ios: 'opacity'})}
testID={testID}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
>
<Text style={styles.action}>{action}</Text>
<Text style={textStyle}>{action}</Text>
</TouchableWithFeedback>
</View>
}

View File

@@ -1,6 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const VALID_IMAGE_MIME_TYPES = [
'image/jpeg',
'image/jpeg',
'image/jpg',
'image/jp_',
'application/jpg',
'application/x-jpg',
'image/pjpeg',
'image/pipeg',
'image/vnd.swiftview-jpeg',
'image/x-xbitmap',
'image/png',
'application/png',
'application/x-png',
'image/bmp',
'image/x-bmp',
'image/x-bitmap',
'image/x-xbitmap',
'image/x-win-bitmap',
'image/x-windows-bmp',
'image/ms-bmp',
'image/x-ms-bmp',
'application/bmp',
'application/x-bmp',
'application/x-win-bitmap',
] as const;
const Files: Record<string, string[]> = {
AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'],
CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'],

View File

@@ -11,6 +11,7 @@ export const CHANNEL_DETAILS = 'ChannelDetails';
export const CHANNEL_EDIT = 'ChannelEdit';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const CUSTOM_STATUS = 'CustomStatus';
export const EDIT_PROFILE = 'EditProfile';
export const FORGOT_PASSWORD = 'ForgotPassword';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
@@ -38,6 +39,7 @@ export default {
CHANNEL_DETAILS,
CUSTOM_STATUS_CLEAR_AFTER,
CUSTOM_STATUS,
EDIT_PROFILE,
FORGOT_PASSWORD,
HOME,
INTEGRATION_SELECTOR,

View File

@@ -9,7 +9,7 @@ import en from '@assets/i18n/en.json';
import availableLanguages from './languages';
const deviceLocale = getLocales()[0].languageTag;
export const DEFAULT_LOCALE = deviceLocale;
export const DEFAULT_LOCALE = getLocaleFromLanguage(deviceLocale);
function loadTranslation(locale?: string) {
try {

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {RefObject} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {FloatingTextInputRef} from '@components/floating_text_input_label';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import Field from './field';
const services: Record<string, string> = {
gitlab: 'GitLab',
google: 'Google Apps',
office365: 'Office 365',
ldap: 'AD/LDAP',
saml: 'SAML',
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginTop: 2,
},
text: {
...typography('Body', 75),
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
type EmailSettingsProps = {
authService: string;
email: string;
fieldRef: RefObject<FloatingTextInputRef>;
onChange: (fieldKey: string, value: string) => void;
onFocusNextField: (fieldKey: string) => void;
isDisabled: boolean;
label: string;
theme: Theme;
isTablet: boolean;
}
const EmailField = ({
authService,
email,
fieldRef,
onChange,
onFocusNextField,
isDisabled,
label,
theme,
isTablet,
}: EmailSettingsProps) => {
const intl = useIntl();
const service = services[authService];
const style = getStyleSheet(theme);
let fieldDescription: string;
if (service) {
fieldDescription = intl.formatMessage({
id: 'user.edit_profile.email.auth_service',
defaultMessage: 'Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.'}, {email, service});
} else {
fieldDescription = intl.formatMessage({
id: 'user.edit_profile.email.web_client',
defaultMessage: 'Email must be updated using a web client or desktop application.'}, {email, service});
}
const descContainer = [style.container, {paddingHorizontal: isTablet ? 42 : 20}];
return (
<>
<Field
blurOnSubmit={false}
enablesReturnKeyAutomatically={true}
fieldKey='email'
fieldRef={fieldRef}
isDisabled={isDisabled}
keyboardType='email-address'
label={label}
onFocusNextField={onFocusNextField}
onTextChange={onChange}
returnKeyType='next'
testID='edit_profile.text_setting.email'
value={email}
/>
<View
style={descContainer}
>
<Text style={style.text}>{fieldDescription}</Text>
</View>
</>
);
};
export default EmailField;

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, RefObject, useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Platform, StyleSheet, TextInputProps, View} from 'react-native';
import FloatingTextInput, {FloatingTextInputRef} from '@components/floating_text_input_label';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
export type FieldProps = TextInputProps & {
isDisabled?: boolean;
fieldKey: string;
label: string;
maxLength?: number;
onTextChange: (fieldKey: string, value: string) => void;
isOptional?: boolean;
testID: string;
value: string;
fieldRef: RefObject<FloatingTextInputRef>;
onFocusNextField: (fieldKey: string) => void;
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
viewContainer: {
marginVertical: 8,
alignItems: 'center',
width: '100%',
},
disabledStyle: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
});
});
const Field = ({
autoCapitalize = 'none',
autoCorrect = false,
fieldKey,
isDisabled = false,
isOptional = false,
keyboardType = 'default',
label,
maxLength,
onTextChange,
testID,
value,
fieldRef,
onFocusNextField,
...props
}: FieldProps) => {
const theme = useTheme();
const intl = useIntl();
const isTablet = useIsTablet();
const onChangeText = useCallback((text: string) => onTextChange(fieldKey, text), [fieldKey, onTextChange]);
const onSubmitEditing = useCallback(() => {
onFocusNextField(fieldKey);
}, [fieldKey, onFocusNextField]);
const style = getStyleSheet(theme);
const keyboard = (Platform.OS === 'android' && keyboardType === 'url') ? 'default' : keyboardType;
const optionalText = intl.formatMessage({id: 'channel_modal.optional', defaultMessage: '(optional)'});
const formattedLabel = isOptional ? `${label} ${optionalText}` : label;
const textInputStyle = isDisabled ? style.disabledStyle : undefined;
const subContainer = [style.viewContainer, {paddingHorizontal: isTablet ? 42 : 20}];
return (
<View
testID={testID}
style={subContainer}
>
<FloatingTextInput
autoCapitalize={autoCapitalize}
autoCorrect={autoCorrect}
disableFullscreenUI={true}
editable={!isDisabled}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
keyboardType={keyboard}
label={formattedLabel}
maxLength={maxLength}
onChangeText={onChangeText}
testID={`${testID}.input`}
theme={theme}
value={value}
ref={fieldRef}
onSubmitEditing={onSubmitEditing}
textInputStyle={textInputStyle}
{...props}
/>
</View>
);
};
export default memo(Field);

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ErrorText from '@components/error_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type DisplayErrorProps = {
error: Partial<ClientErrorProps> | string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
errorContainer: {
backgroundColor: changeOpacity(theme.errorTextColor, 0.08),
width: '100%',
maxHeight: 48,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
text: {
...typography('Heading', 100),
color: theme.centerChannelColor,
},
icon: {
color: changeOpacity(theme.dndIndicator, 0.64),
...typography('Heading', 300),
marginRight: 9,
},
};
});
const ProfileError = ({error}: DisplayErrorProps) => {
const theme = useTheme();
const style = getStyleSheet(theme);
return (
<View style={style.errorContainer}>
<CompassIcon
style={style.icon}
size={18}
name='alert-outline'
/>
<ErrorText
theme={theme}
testID='edit_profile.error.text'
error={error}
textStyle={style.text}
/>
</View>
);
};
export default ProfileError;

View File

@@ -0,0 +1,485 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {BackHandler, DeviceEventEmitter, Keyboard, Platform, Text, View} from 'react-native';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import {Navigation} from 'react-native-navigation';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {updateMe} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import {FloatingTextInputRef} from '@components/floating_text_input_label';
import Loading from '@components/loading';
import ProfilePicture from '@components/profile_picture';
import TabletTitle from '@components/tablet_title';
import {Events} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {dismissModal, popTopScreen, setButtons} from '@screens/navigation';
import {EditProfileProps, FieldConfig, FieldSequence, UserInfo} from '@typings/screens/edit_profile';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import EmailField from './components/email_field';
import Field from './components/field';
import ProfileError from './components/profile_error';
import type {MessageDescriptor} from '@formatjs/intl/src/types';
const edges: Edge[] = ['bottom', 'left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
flex: {
flex: 1,
},
top: {
marginVertical: 32,
alignItems: 'center',
justifyContent: 'center',
},
separator: {
height: 15,
},
footer: {
height: 40,
width: '100%',
},
spinner: {
position: 'absolute',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
},
description: {
alignSelf: 'center',
marginBottom: 24,
},
text: {
...typography('Body', 75),
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
const FIELDS: { [id: string]: MessageDescriptor } = {
firstName: {
id: t('user.settings.general.firstName'),
defaultMessage: 'First Name',
},
lastName: {
id: t('user.settings.general.lastName'),
defaultMessage: 'Last Name',
},
username: {
id: t('user.settings.general.username'),
defaultMessage: 'Username',
},
nickname: {
id: t('user.settings.general.nickname'),
defaultMessage: 'Nickname',
},
position: {
id: t('user.settings.general.position'),
defaultMessage: 'Position',
},
email: {
id: t('user.settings.general.email'),
defaultMessage: 'Email',
},
};
const CLOSE_BUTTON_ID = 'close-edit-profile';
const UPDATE_BUTTON_ID = 'update-profile';
const includesSsoService = (sso: string) => ['gitlab', 'google', 'office365'].includes(sso);
const isSAMLOrLDAP = (protocol: string) => ['ldap', 'saml'].includes(protocol);
const EditProfile = ({
componentId,
currentUser,
isModal,
isTablet,
lockedFirstName,
lockedLastName,
lockedNickname,
lockedPosition,
}: EditProfileProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const keyboardAwareRef = useRef<KeyboardAwareScrollView>();
const firstNameRef = useRef<FloatingTextInputRef>(null);
const lastNameRef = useRef<FloatingTextInputRef>(null);
const usernameRef = useRef<FloatingTextInputRef>(null);
const emailRef = useRef<FloatingTextInputRef>(null);
const nicknameRef = useRef<FloatingTextInputRef>(null);
const positionRef = useRef<FloatingTextInputRef>(null);
const styles = getStyleSheet(theme);
const [userInfo, setUserInfo] = useState<UserInfo>({
email: currentUser.email,
firstName: currentUser.firstName,
lastName: currentUser.lastName,
nickname: currentUser.nickname,
position: currentUser.position,
username: currentUser.username,
});
const [canSave, setCanSave] = useState(false);
const [error, setError] = useState<ErrorText | undefined>();
const [updating, setUpdating] = useState(false);
const scrollViewRef = useRef<KeyboardAwareScrollView>();
const buttonText = intl.formatMessage({id: 'mobile.account.settings.save', defaultMessage: 'Save'});
const rightButton = useMemo(() => {
return isTablet ? null : {
id: 'update-profile',
enabled: false,
showAsAction: 'always' as const,
testID: 'edit_profile.save.button',
color: theme.sidebarHeaderTextColor,
text: buttonText,
};
}, [isTablet, theme.sidebarHeaderTextColor]);
const service = currentUser.authService;
const leftButton = useMemo(() => {
return isTablet ? null : {
id: CLOSE_BUTTON_ID,
icon: CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor),
testID: CLOSE_BUTTON_ID,
};
}, [isTablet, theme.sidebarHeaderTextColor]);
useEffect(() => {
const unsubscribe = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: { buttonId: string }) => {
switch (buttonId) {
case UPDATE_BUTTON_ID:
submitUser();
break;
case CLOSE_BUTTON_ID:
close();
break;
}
},
}, componentId);
return () => {
unsubscribe.remove();
};
}, [userInfo]);
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', close);
return () => {
backHandler.remove();
};
}, []);
useEffect(() => {
if (!isTablet) {
setButtons(componentId, {
rightButtons: [rightButton!],
leftButtons: [leftButton!],
});
}
}, []);
const close = useCallback(() => {
if (isModal) {
dismissModal({componentId});
} else if (isTablet) {
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, '');
} else {
popTopScreen(componentId);
}
return true;
}, []);
const enableSaveButton = useCallback((value: boolean) => {
if (!isTablet) {
const buttons = {
rightButtons: [{
...rightButton!,
enabled: value,
}],
};
setButtons(componentId, buttons);
}
setCanSave(value);
}, [componentId, rightButton]);
const submitUser = useCallback(preventDoubleTap(async () => {
enableSaveButton(false);
setError(undefined);
setUpdating(true);
try {
const partialUser: Partial<UserProfile> = {
email: userInfo.email,
first_name: userInfo.firstName,
last_name: userInfo.lastName,
nickname: userInfo.nickname,
position: userInfo.position,
username: userInfo.username,
};
const {error: reqError} = await updateMe(serverUrl, partialUser);
if (reqError) {
resetScreen(reqError as Error);
return;
}
close();
} catch (e) {
resetScreen(e as Error);
}
}), [userInfo, enableSaveButton]);
const resetScreen = useCallback((resetError: Error) => {
setError(resetError?.message);
Keyboard.dismiss();
setUpdating(false);
enableSaveButton(true);
scrollViewRef.current?.scrollToPosition(0, 0, true);
}, [enableSaveButton]);
const updateField = useCallback((fieldKey: string, name: string) => {
const update = {...userInfo};
update[fieldKey] = name;
setUserInfo(update);
// @ts-expect-error access object property by string key
const currentValue = currentUser[fieldKey];
const didChange = currentValue !== name;
enableSaveButton(didChange);
}, [userInfo, currentUser]);
const userProfileFields: FieldSequence = useMemo(() => {
return {
firstName: {
ref: firstNameRef,
isDisabled: (isSAMLOrLDAP(service) && lockedFirstName) || includesSsoService(service),
},
lastName: {
ref: lastNameRef,
isDisabled: (isSAMLOrLDAP(service) && lockedLastName) || includesSsoService(service),
},
username: {
ref: usernameRef,
isDisabled: service !== '',
},
email: {
ref: emailRef,
isDisabled: true,
},
nickname: {
ref: nicknameRef,
isDisabled: isSAMLOrLDAP(service) && lockedNickname,
},
position: {
ref: positionRef,
isDisabled: isSAMLOrLDAP(service) && lockedPosition,
},
};
}, [lockedFirstName, lockedLastName, lockedNickname, lockedPosition, currentUser.authService]);
const hasDisabledFields = Object.values(userProfileFields).filter((field) => field.isDisabled).length > 0;
const onFocusNextField = useCallback(((fieldKey: string) => {
const findNextField = () => {
const fields = Object.keys(userProfileFields);
const curIndex = fields.indexOf(fieldKey);
const searchIndex = curIndex + 1;
if (curIndex === -1 || searchIndex > fields.length) {
return undefined;
}
const remainingFields = fields.slice(searchIndex);
const nextFieldIndex = remainingFields.findIndex((f: string) => {
const field = userProfileFields[f];
return !field.isDisabled;
});
if (nextFieldIndex === -1) {
return {isLastEnabledField: true, nextField: undefined};
}
const fieldName = remainingFields[nextFieldIndex];
return {isLastEnabledField: false, nextField: userProfileFields[fieldName]};
};
const next = findNextField();
if (next?.isLastEnabledField && canSave) {
// performs form submission
Keyboard.dismiss();
submitUser();
} else if (next?.nextField) {
next?.nextField?.ref?.current?.focus();
} else {
Keyboard.dismiss();
}
}), [canSave, userProfileFields]);
const fieldConfig: FieldConfig = {
blurOnSubmit: false,
enablesReturnKeyAutomatically: true,
onFocusNextField,
onTextChange: updateField,
returnKeyType: 'next',
};
return (
<>
{isTablet &&
<TabletTitle
action={buttonText}
enabled={canSave}
onPress={submitUser}
testID='custom_status.done.button'
title={intl.formatMessage({id: 'mobile.screen.your_profile', defaultMessage: 'Your Profile'})}
/>
}
<SafeAreaView
edges={edges}
style={styles.flex}
testID='edit_profile.screen'
>
<KeyboardAwareScrollView
bounces={false}
enableAutomaticScroll={true}
enableOnAndroid={true}
enableResetScrollToCoords={true}
extraScrollHeight={Platform.select({ios: 45})}
keyboardOpeningTime={0}
keyboardDismissMode='on-drag'
keyboardShouldPersistTaps='handled'
// @ts-expect-error legacy ref
ref={keyboardAwareRef}
scrollToOverflowEnabled={true}
testID='edit_profile.scroll_view'
style={styles.flex}
>
{updating && (
<View
style={styles.spinner}
>
<Loading
color={theme.buttonBg}
/>
</View>
)}
{Boolean(error) && <ProfileError error={error!}/>}
<View style={styles.top}>
<ProfilePicture
author={currentUser}
size={153}
showStatus={false}
/>
</View>
{hasDisabledFields && (
<View
style={{
paddingHorizontal: isTablet ? 42 : 20,
marginBottom: 16,
}}
>
<Text style={styles.text}>
{intl.formatMessage({
id: 'user.settings.general.field_handled_externally',
defaultMessage: 'Some fields below are handled through your login provider. If you want to change them, youll need to do so through your login provider.',
})}
</Text>
</View>
)}
<Field
fieldKey='firstName'
fieldRef={firstNameRef}
isDisabled={userProfileFields.firstName.isDisabled}
label={intl.formatMessage(FIELDS.firstName)}
testID='edit_profile.text_setting.firstName'
value={userInfo.firstName}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='lastName'
fieldRef={lastNameRef}
isDisabled={userProfileFields.lastName.isDisabled}
label={intl.formatMessage(FIELDS.lastName)}
testID='edit_profile.text_setting.lastName'
value={userInfo.lastName}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='username'
fieldRef={usernameRef}
isDisabled={userProfileFields.username.isDisabled}
label={intl.formatMessage(FIELDS.username)}
maxLength={22}
testID='edit_profile.text_setting.username'
value={userInfo.username}
{...fieldConfig}
/>
<View style={styles.separator}/>
{userInfo.email && (
<EmailField
authService={currentUser.authService}
isDisabled={userProfileFields.email.isDisabled}
email={userInfo.email}
label={intl.formatMessage(FIELDS.email)}
fieldRef={emailRef}
onChange={updateField}
onFocusNextField={onFocusNextField}
theme={theme}
isTablet={Boolean(isTablet)}
/>
)}
<View style={styles.separator}/>
<Field
fieldKey='nickname'
fieldRef={nicknameRef}
isDisabled={userProfileFields.nickname.isDisabled}
label={intl.formatMessage(FIELDS.nickname)}
maxLength={22}
testID='edit_profile.text_setting.nickname'
value={userInfo.nickname}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='position'
fieldRef={positionRef}
isDisabled={userProfileFields.position.isDisabled}
isOptional={true}
label={intl.formatMessage(FIELDS.position)}
maxLength={128}
{...fieldConfig}
returnKeyType='done'
testID='edit_profile.text_setting.position'
value={userInfo.position}
/>
<View style={styles.footer}/>
</KeyboardAwareScrollView>
</SafeAreaView>
</>
);
};
export default EditProfile;

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {WithDatabaseArgs} from '@typings/database/database';
import SystemModel from '@typings/database/models/servers/system';
import UserModel from '@typings/database/models/servers/user';
import EditProfile from './edit_profile';
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
return {
currentUser: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(
(id) => database.get<UserModel>(USER).findAndObserve(id.value),
),
),
lockedFirstName: config.pipe(
switchMap(
({value}: {value: ClientConfig}) => of$(value.LdapFirstNameAttributeSet === 'true' || value.SamlFirstNameAttributeSet === 'true'),
),
),
lockedLastName: config.pipe(
switchMap(
({value}: {value: ClientConfig}) => of$(value.LdapLastNameAttributeSet === 'true' || value.SamlLastNameAttributeSet === 'true'),
),
),
lockedNickname: config.pipe(
switchMap(
({value}: {value: ClientConfig}) => of$(value.LdapNicknameAttributeSet === 'true' || value.SamlNicknameAttributeSet === 'true'),
),
),
lockedPosition: config.pipe(
switchMap(
({value}: {value: ClientConfig}) => of$(value.LdapPositionAttributeSet === 'true' || value.SamlPositionAttributeSet === 'true'),
),
),
lockedPicture: config.pipe(
switchMap(
({value}: {value: ClientConfig}) => of$(value.LdapPictureAttributeSet === 'true'),
),
),
};
});
export default withDatabase(enhanced(EditProfile));

View File

@@ -2,10 +2,13 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import {Events, Screens} from '@constants';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {
@@ -15,9 +18,17 @@ type Props = {
}
const YourProfile = ({isTablet, style, theme}: Props) => {
const intl = useIntl();
const openProfile = useCallback(preventDoubleTap(() => {
// TODO: Open Profile screen in either a screen or in line for tablets
}), [isTablet]);
if (isTablet) {
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, Screens.EDIT_PROFILE);
} else {
showModal(
Screens.EDIT_PROFILE,
intl.formatMessage({id: 'mobile.screen.your_profile', defaultMessage: 'Your Profile'}),
);
}
}), [isTablet, theme]);
return (
<DrawerItem

View File

@@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
import {Events, Screens} from '@constants';
import CustomStatus from '@screens/custom_status';
import EditProfile from '@screens/edit_profile';
type SelectedView = {
id: string;
@@ -14,6 +15,7 @@ type SelectedView = {
const TabletView: Record<string, React.ReactNode> = {
[Screens.CUSTOM_STATUS]: CustomStatus,
[Screens.EDIT_PROFILE]: EditProfile,
};
const AccountTabletView = () => {

View File

@@ -34,7 +34,7 @@ type AccountScreenProps = {
};
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const edges: Edge[] = ['bottom', 'left', 'right'];
const edges: Edge[] = ['left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {

View File

@@ -105,9 +105,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
// case 'EditPost':
// screen = require('@screens/edit_post').default;
// break;
// case 'EditProfile':
// screen = require('@screens/edit_profile').default;
// break;
case Screens.EDIT_PROFILE:
screen = withServerDatabase((require('@screens/edit_profile').default));
break;
// case 'ErrorTeamsList':
// screen = require('@screens/error_teams_list').default;
// break;

View File

@@ -12,6 +12,7 @@ import CompassIcon from '@components/compass_icon';
import {Device, Preferences, Screens} from '@constants';
import NavigationConstants from '@constants/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {NavButtons} from '@typings/screens/navigation';
import {changeOpacity, setNavigatorStyles} from '@utils/theme';
import type {LaunchProps} from '@typings/launch';
@@ -464,12 +465,12 @@ export function showSearchModal(initialValue = '') {
showModal(name, title, passProps, options);
}
export async function dismissModal(options = {}) {
export async function dismissModal(options?: Options & { componentId: string}) {
if (!EphemeralStore.hasModalsOpened()) {
return;
}
const componentId = EphemeralStore.getNavigationTopModalId();
const componentId = options?.componentId || EphemeralStore.getNavigationTopModalId();
if (componentId) {
try {
await Navigation.dismissModal(componentId, options);
@@ -481,7 +482,7 @@ export async function dismissModal(options = {}) {
}
}
export async function dismissAllModals(options = {}) {
export async function dismissAllModals(options: Options = {}) {
if (!EphemeralStore.hasModalsOpened()) {
return;
}
@@ -495,7 +496,7 @@ export async function dismissAllModals(options = {}) {
}
}
export function setButtons(componentId: string, buttons = {leftButtons: [], rightButtons: []}) {
export function setButtons(componentId: string, buttons: NavButtons = {leftButtons: [], rightButtons: []}) {
const options = {
topBar: {
...buttons,

View File

@@ -150,6 +150,7 @@
"mobile.about.powered_by": "{site} is powered by Mattermost",
"mobile.about.serverVersion": "Server Version: {version} (Build {number})",
"mobile.about.serverVersionNoBuild": "Server Version: {version}",
"mobile.account.settings.save": "Save",
"mobile.action_menu.select": "Select an option",
"mobile.add_team.create_team": "Create a New Team",
"mobile.add_team.join_team": "Join Another Team",
@@ -239,6 +240,7 @@
"mobile.routes.custom_status": "Set a Status",
"mobile.routes.table": "Table",
"mobile.routes.user_profile": "Profile",
"mobile.screen.your_profile": "Your Profile",
"mobile.server_identifier.exists": "You are already connected to this server.",
"mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.",
"mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.",
@@ -320,5 +322,8 @@
"status_dropdown.set_online": "Online",
"status_dropdown.set_ooo": "Out Of Office",
"team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.",
"team_list.no_other_teams.title": "No additional teams to join"
"team_list.no_other_teams.title": "No additional teams to join",
"user.edit_profile.email.auth_service": "Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.",
"user.edit_profile.email.web_client": "Email must be updated using a web client or desktop application.",
"user.settings.general.field_handled_externally": "Some fields below are handled through your login provider. If you want to change them, youll need to do so through your login provider."
}

View File

@@ -129,6 +129,7 @@ interface ClientConfig {
LdapLoginButtonTextColor: string;
LdapLoginFieldName: string;
LdapNicknameAttributeSet: string;
LdapPictureAttributeSet: string;
LdapPositionAttributeSet: string;
LockTeammateNameDisplay: string;
MaxFileSize: string;

36
types/screens/edit_profile.d.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RefObject} from 'react';
import {FloatingTextInputRef} from '@components/floating_text_input_label';
import {FieldProps} from '@screens/edit_profile/components/field';
import UserModel from '@typings/database/models/servers/user';
interface UserInfo extends Record<string, string | undefined | null| boolean> {
email: string;
firstName: string;
lastName: string;
nickname: string;
position: string;
username: string;
}
type EditProfileProps = {
componentId: string;
currentUser: UserModel;
isModal?: boolean;
isTablet?: boolean;
lockedFirstName: boolean;
lockedLastName: boolean;
lockedNickname: boolean;
lockedPosition: boolean;
lockedPicture: boolean;
};
type FieldSequence = Record<string, {
ref: RefObject<FloatingTextInputRef>;
isDisabled: boolean;
}>
type FieldConfig = Pick<FieldProps, 'blurOnSubmit' | 'enablesReturnKeyAutomatically' | 'onFocusNextField' | 'onTextChange' | 'returnKeyType'>

9
types/screens/navigation.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {OptionsTopBarButton} from 'react-native-navigation/lib/src/interfaces/Options';
export type NavButtons = {
leftButtons?: OptionsTopBarButton[];
rightButtons?: OptionsTopBarButton[];
}

View File

@@ -1,10 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ImageStyle, TextStyle, ViewStyle} from 'react-native';
type Dictionary<T> = {
[key: string]: T;
};
type Styles = ViewStyle | TextStyle | ImageStyle
type ErrorText = Partial<ClientErrorProps> | string;