import React from 'react'; import { Keyboard, ScrollView, TextInput, View } from 'react-native'; import { connect } from 'react-redux'; import { sha256 } from 'js-sha256'; import RNPickerSelect from 'react-native-picker-select'; import { dequal } from 'dequal'; import omit from 'lodash/omit'; import { StackNavigationOptions } from '@react-navigation/stack'; import Touch from '../../containers/Touch'; import KeyboardView from '../../containers/KeyboardView'; import sharedStyles from '../Styles'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers'; import { LISTENER } from '../../containers/Toast'; import EventEmitter from '../../lib/methods/helpers/events'; import { FormTextInput } from '../../containers/TextInput'; import log, { events, logEvent } from '../../lib/methods/helpers/log'; import I18n from '../../i18n'; import Button from '../../containers/Button'; import Avatar from '../../containers/Avatar'; import { setUser } from '../../actions/login'; import { CustomIcon } from '../../containers/CustomIcon'; import * as HeaderButton from '../../containers/HeaderButton'; import StatusBar from '../../containers/StatusBar'; import { themes } from '../../lib/constants'; import { TSupportedThemes, withTheme } from '../../theme'; import { getUserSelector } from '../../selectors/login'; import SafeAreaView from '../../containers/SafeAreaView'; import styles from './styles'; import { ProfileStackParamList } from '../../stacks/types'; import { Services } from '../../lib/services'; import { IApplicationState, IAvatar, IAvatarButton, IAvatarSuggestion, IBaseScreen, IProfileParams, IUser } from '../../definitions'; import { twoFactor } from '../../lib/services/twoFactor'; import { TwoFactorMethods } from '../../definitions/ITotp'; import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSheet'; import { DeleteAccountActionSheetContent } from './components/DeleteAccountActionSheetContent'; import ActionSheetContentWithInputAndSubmit from '../../containers/ActionSheet/ActionSheetContentWithInputAndSubmit'; import { pickImageFromLibrary } from '../../lib/methods/mediaPicker'; interface IProfileViewProps extends IActionSheetProvider, IBaseScreen { user: IUser; baseUrl: string; Accounts_AllowEmailChange: boolean; Accounts_AllowPasswordChange: boolean; Accounts_AllowRealNameChange: boolean; Accounts_AllowUserAvatarChange: boolean; Accounts_AllowUsernameChange: boolean; Accounts_CustomFields: string; theme: TSupportedThemes; Accounts_AllowDeleteOwnAccount: boolean; isMasterDetail: boolean; } interface IProfileViewState { saving: boolean; name: string; username: string; email: string | null; newPassword: string | null; currentPassword: string | null; avatarUrl: string | null; avatar: IAvatar; avatarSuggestions: IAvatarSuggestion; customFields: { [key: string | number]: string; }; twoFactorCode: null | { twoFactorCode: string; twoFactorMethod: string; }; } class ProfileView extends React.Component { private name?: TextInput | null; private username?: TextInput | null; private email?: TextInput; private avatarUrl?: TextInput; private newPassword?: TextInput; setHeader = () => { const { navigation, isMasterDetail } = this.props; const options: StackNavigationOptions = { title: I18n.t('Profile') }; if (!isMasterDetail) { options.headerLeft = () => ; } options.headerRight = () => ( navigation?.navigate('UserPreferencesView')} testID='preferences-view-open' /> ); navigation.setOptions(options); }; constructor(props: IProfileViewProps) { super(props); this.setHeader(); } state: IProfileViewState = { saving: false, name: '', username: '', email: '', newPassword: '', currentPassword: '', avatarUrl: '', avatar: { data: {}, url: '' }, avatarSuggestions: {}, customFields: {}, twoFactorCode: null }; async componentDidMount() { this.init(); try { const result = await Services.getAvatarSuggestion(); this.setState({ avatarSuggestions: result }); } catch (e) { log(e); } } UNSAFE_componentWillReceiveProps(nextProps: IProfileViewProps) { const { user } = this.props; /* * We need to ignore status because on Android ImagePicker * changes the activity, so, the user status changes and * it's resetting the avatar right after * select some image from gallery. */ if (!dequal(omit(user, ['status']), omit(nextProps.user, ['status']))) { this.init(nextProps.user); } } setAvatar = (avatar: IAvatar) => { const { Accounts_AllowUserAvatarChange } = this.props; if (!Accounts_AllowUserAvatarChange) { return; } this.setState({ avatar }); }; init = (user?: IUser) => { const { user: userProps } = this.props; const { name, username, emails, customFields } = user || userProps; this.setState({ name: name as string, username, email: emails ? emails[0].address : null, newPassword: null, currentPassword: null, avatarUrl: null, avatar: { data: {}, url: '' }, customFields: customFields || {} }); }; formIsChanged = () => { const { name, username, email, newPassword, avatar, customFields } = this.state; const { user } = this.props; let customFieldsChanged = false; const customFieldsKeys = Object.keys(customFields); if (customFieldsKeys.length) { customFieldsKeys.forEach(key => { if (!user.customFields || user.customFields[key] !== customFields[key]) { customFieldsChanged = true; } }); } return !( user.name === name && user.username === username && !newPassword && user.emails && user.emails[0].address === email && !avatar.data && !customFieldsChanged ); }; handleError = (e: any, _func: string, action: string) => { if (e.data && e.data.error.includes('[error-too-many-requests]')) { return showErrorAlert(e.data.error); } if (I18n.isTranslated(e.error)) { return showErrorAlert(I18n.t(e.error)); } showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t(action) })); }; submit = async (): Promise => { Keyboard.dismiss(); if (!this.formIsChanged()) { return; } this.setState({ saving: true }); const { name, username, email, newPassword, currentPassword, avatar, customFields, twoFactorCode } = this.state; const { user, dispatch } = this.props; const params = {} as IProfileParams; // Name if (user.name !== name) { params.name = name; } // Username if (user.username !== username) { params.username = username; } // Email if (user.emails && user.emails[0].address !== email) { params.email = email; } // newPassword if (newPassword) { params.newPassword = newPassword; } // currentPassword if (currentPassword) { params.currentPassword = sha256(currentPassword); } const requirePassword = !!params.email || newPassword; if (requirePassword && !params.currentPassword) { this.setState({ saving: false }); this.props.showActionSheet({ children: ( { this.props.hideActionSheet(); this.setState({ currentPassword: p }, () => this.submit()); }} onCancel={this.props.hideActionSheet} /> ), headerHeight: 225 }); return; } try { if (avatar.url) { try { logEvent(events.PROFILE_SAVE_AVATAR); await Services.setAvatarFromService(avatar); } catch (e) { logEvent(events.PROFILE_SAVE_AVATAR_F); this.setState({ saving: false, currentPassword: null }); return this.handleError(e, 'setAvatarFromService', 'changing_avatar'); } } const twoFactorOptions = params.currentPassword ? { twoFactorCode: params.currentPassword, twoFactorMethod: TwoFactorMethods.PASSWORD } : null; const result = await Services.saveUserProfileMethod(params, customFields, twoFactorCode || twoFactorOptions); if (result) { logEvent(events.PROFILE_SAVE_CHANGES); if (customFields) { dispatch(setUser({ customFields, ...params })); } else { dispatch(setUser({ ...params })); } EventEmitter.emit(LISTENER, { message: I18n.t('Profile_saved_successfully') }); this.init(); } this.setState({ saving: false, currentPassword: null, twoFactorCode: null }); } catch (e: any) { if (e?.error === 'totp-invalid' && e?.details.method !== TwoFactorMethods.PASSWORD) { try { const code = await twoFactor({ method: e?.details.method, invalid: e?.error === 'totp-invalid' && !!twoFactorCode }); return this.setState({ twoFactorCode: code }, () => this.submit()); } catch { // cancelled twoFactor modal } } logEvent(events.PROFILE_SAVE_CHANGES_F); this.setState({ saving: false, currentPassword: null, twoFactorCode: null }); this.handleError(e, 'saveUserProfile', 'saving_profile'); } }; resetAvatar = async () => { const { Accounts_AllowUserAvatarChange } = this.props; if (!Accounts_AllowUserAvatarChange) { return; } try { const { user } = this.props; await Services.resetAvatar(user.id); EventEmitter.emit(LISTENER, { message: I18n.t('Avatar_changed_successfully') }); this.init(); } catch (e) { this.handleError(e, 'resetAvatar', 'changing_avatar'); } }; pickImage = async () => { const { Accounts_AllowUserAvatarChange } = this.props; if (!Accounts_AllowUserAvatarChange) { return; } try { logEvent(events.PROFILE_PICK_AVATAR); const response = await pickImageFromLibrary(true); if (response) { this.setAvatar({ url: response.path, data: `data:image/jpeg;base64,${response.data}`, service: 'upload' }); } } catch (error) { logEvent(events.PROFILE_PICK_AVATAR_F); console.warn(error); } }; pickImageWithURL = (avatarUrl: string) => { logEvent(events.PROFILE_PICK_AVATAR_WITH_URL); this.setAvatar({ url: avatarUrl, data: avatarUrl, service: 'url' }); }; renderAvatarButton = ({ key, child, onPress, disabled = false }: IAvatarButton) => { const { theme } = this.props; return ( {child} ); }; renderAvatarButtons = () => { const { avatarUrl, avatarSuggestions } = this.state; const { user, theme, Accounts_AllowUserAvatarChange } = this.props; return ( {this.renderAvatarButton({ child: , onPress: () => this.resetAvatar(), disabled: !Accounts_AllowUserAvatarChange, key: 'profile-view-reset-avatar' })} {this.renderAvatarButton({ child: , onPress: () => this.pickImage(), disabled: !Accounts_AllowUserAvatarChange, key: 'profile-view-upload-avatar' })} {this.renderAvatarButton({ child: , onPress: () => (avatarUrl ? this.pickImageWithURL(avatarUrl) : null), disabled: !avatarUrl, key: 'profile-view-avatar-url-button' })} {Object.keys(avatarSuggestions).map(service => { const { url, blob, contentType } = avatarSuggestions[service]; return this.renderAvatarButton({ disabled: !Accounts_AllowUserAvatarChange, key: `profile-view-avatar-${service}`, child: , onPress: () => this.setAvatar({ url, data: blob, service, contentType }) }); })} ); }; renderCustomFields = () => { const { customFields } = this.state; const { Accounts_CustomFields } = this.props; if (!Accounts_CustomFields) { return null; } try { const parsedCustomFields = JSON.parse(Accounts_CustomFields); return Object.keys(parsedCustomFields).map((key, index, array) => { if (parsedCustomFields[key].type === 'select') { const options = parsedCustomFields[key].options.map((option: string) => ({ label: option, value: option })); return ( { const newValue: { [key: string]: string } = {}; newValue[key] = value; this.setState({ customFields: { ...customFields, ...newValue } }); }} value={customFields[key]} > { // @ts-ignore this[key] = e; }} label={key} placeholder={key} value={customFields[key]} testID='settings-view-language' /> ); } return ( { // @ts-ignore this[key] = e; }} key={key} label={key} placeholder={key} value={customFields[key]} onChangeText={value => { const newValue: { [key: string]: string } = {}; newValue[key] = value; this.setState({ customFields: { ...customFields, ...newValue } }); }} onSubmitEditing={() => { if (array.length - 1 > index) { // @ts-ignore return this[array[index + 1]].focus(); } this.avatarUrl?.focus(); }} /> ); }); } catch (error) { return null; } }; logoutOtherLocations = () => { logEvent(events.PL_OTHER_LOCATIONS); showConfirmationAlert({ message: I18n.t('You_will_be_logged_out_from_other_locations'), confirmationText: I18n.t('Logout'), onPress: async () => { try { await Services.logoutOtherLocations(); EventEmitter.emit(LISTENER, { message: I18n.t('Logged_out_of_other_clients_successfully') }); } catch { logEvent(events.PL_OTHER_LOCATIONS_F); EventEmitter.emit(LISTENER, { message: I18n.t('Logout_failed') }); } } }); }; deleteOwnAccount = () => { logEvent(events.DELETE_OWN_ACCOUNT); this.props.showActionSheet({ children: , headerHeight: 225 }); }; render() { const { name, username, email, newPassword, avatarUrl, customFields, avatar, saving } = this.state; const { user, theme, Accounts_AllowEmailChange, Accounts_AllowPasswordChange, Accounts_AllowRealNameChange, Accounts_AllowUserAvatarChange, Accounts_AllowUsernameChange, Accounts_CustomFields, Accounts_AllowDeleteOwnAccount } = this.props; return ( { this.name = e; }} label={I18n.t('Name')} placeholder={I18n.t('Name')} value={name} onChangeText={(value: string) => this.setState({ name: value })} onSubmitEditing={() => { this.username?.focus(); }} testID='profile-view-name' /> { this.username = e; }} label={I18n.t('Username')} placeholder={I18n.t('Username')} value={username} onChangeText={value => this.setState({ username: value })} onSubmitEditing={() => { this.email?.focus(); }} testID='profile-view-username' /> { if (e) { this.email = e; } }} label={I18n.t('Email')} placeholder={I18n.t('Email')} value={email || undefined} onChangeText={value => this.setState({ email: value })} onSubmitEditing={() => { this.newPassword?.focus(); }} testID='profile-view-email' /> { if (e) { this.newPassword = e; } }} label={I18n.t('New_Password')} placeholder={I18n.t('New_Password')} value={newPassword || undefined} onChangeText={value => this.setState({ newPassword: value })} onSubmitEditing={() => { if (Accounts_CustomFields && Object.keys(customFields).length) { // @ts-ignore return this[Object.keys(customFields)[0]].focus(); } this.avatarUrl?.focus(); }} secureTextEntry testID='profile-view-new-password' /> {this.renderCustomFields()} { if (e) { this.avatarUrl = e; } }} label={I18n.t('Avatar_Url')} placeholder={I18n.t('Avatar_Url')} value={avatarUrl || undefined} onChangeText={value => this.setState({ avatarUrl: value })} onSubmitEditing={this.submit} testID='profile-view-avatar-url' /> {this.renderAvatarButtons()}