// 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 {DeviceEventEmitter, Keyboard, Platform, StyleSheet, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import {updateLocalUser} from '@actions/local/user'; import {fetchCustomProfileAttributes, updateCustomProfileAttributes} from '@actions/remote/custom_profile'; import {setDefaultProfileImage, updateMe, uploadUserProfileImage} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; import TabletTitle from '@components/tablet_title'; import {Events} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {usePreventDoubleTap} from '@hooks/utils'; import SecurityManager from '@managers/security_manager'; import {dismissModal, popTopScreen, setButtons} from '@screens/navigation'; import {logError} from '@utils/log'; import {isCustomFieldSamlLinked} from '@utils/user'; import ProfileForm, {CUSTOM_ATTRS_PREFIX} from './components/form'; import ProfileError from './components/profile_error'; import Updating from './components/updating'; import UserProfilePicture from './components/user_profile_picture'; import type {CustomProfileFieldModel} from '@database/models/server'; import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes'; import type {EditProfileProps, NewProfileImage, UserInfo} from '@typings/screens/edit_profile'; const edges: Edge[] = ['bottom', 'left', 'right']; const styles = StyleSheet.create({ flex: { flex: 1, }, top: { marginVertical: 32, alignItems: 'center', justifyContent: 'center', }, }); const CLOSE_BUTTON_ID = 'close-edit-profile'; const UPDATE_BUTTON_ID = 'update-profile'; const CUSTOM_ATTRS_PREFIX_NAME = `${CUSTOM_ATTRS_PREFIX}.`; const EditProfile = ({ componentId, currentUser, isModal, isTablet, lockedFirstName, lockedLastName, lockedNickname, lockedPosition, lockedPicture, enableCustomAttributes, customAttributesSet, customFields, }: EditProfileProps) => { const intl = useIntl(); const serverUrl = useServerUrl(); const theme = useTheme(); const changedProfilePicture = useRef(undefined); const scrollViewRef = useRef(); const hasUpdateUserInfo = useRef(false); const [userInfo, setUserInfo] = useState({ email: currentUser?.email || '', firstName: currentUser?.firstName || '', lastName: currentUser?.lastName || '', nickname: currentUser?.nickname || '', position: currentUser?.position || '', username: currentUser?.username || '', customAttributes: {}, }); const [canSave, setCanSave] = useState(false); const [error, setError] = useState(); const [usernameError, setUsernameError] = useState(); const [updating, setUpdating] = useState(false); const lastRequest = useRef(0); 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, buttonText]); const leftButton = useMemo(() => { return isTablet ? null : { id: CLOSE_BUTTON_ID, icon: CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor), testID: 'close.edit_profile.button', }; }, [isTablet, theme.centerChannelColor]); useEffect(() => { if (!isTablet) { setButtons(componentId, { rightButtons: [rightButton!], leftButtons: [leftButton!], }); } }, [isTablet, componentId, rightButton, leftButton]); const close = useCallback(() => { if (isModal) { dismissModal({componentId}); } else if (isTablet) { DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); } else { popTopScreen(componentId); } }, [componentId, isModal, isTablet]); const enableSaveButton = useCallback((value: boolean) => { if (!isTablet) { const buttons = { rightButtons: [{ ...rightButton!, enabled: value, }], }; setButtons(componentId, buttons); } setCanSave(value); }, [componentId, rightButton, isTablet]); useEffect(() => { const loadCustomAttributes = async () => { if (!currentUser) { return; } if (enableCustomAttributes) { setUserInfo((prev) => ({ ...prev, customAttributes: customAttributesSet || {}, })); } // Then fetch from server for latest data const reqTime = Date.now(); lastRequest.current = reqTime; const {error: fetchError, attributes} = await fetchCustomProfileAttributes(serverUrl, currentUser.id); if (!fetchError && attributes && lastRequest.current === reqTime) { setUserInfo((prev) => ({...prev, customAttributes: attributes})); } }; loadCustomAttributes(); }, [currentUser, serverUrl, enableCustomAttributes, customAttributesSet]); const resetScreenForProfileError = useCallback((resetError: unknown) => { setUsernameError(resetError); Keyboard.dismiss(); setUpdating(false); enableSaveButton(true); }, [enableSaveButton]); const resetScreen = useCallback((resetError: unknown) => { setError(resetError); Keyboard.dismiss(); setUpdating(false); enableSaveButton(true); scrollViewRef.current?.scrollToPosition(0, 0, true); }, [enableSaveButton]); const submitUser = usePreventDoubleTap(useCallback(async () => { if (!currentUser) { return; } enableSaveButton(false); setError(undefined); setUpdating(true); try { // Build update object with only changed and unlocked fields const newUserInfo: Partial = {}; // Only include fields that have changed and are not locked by SAML if (userInfo.email.trim() !== currentUser.email && !currentUser.authService) { newUserInfo.email = userInfo.email.trim(); } if (userInfo.firstName.trim() !== currentUser.firstName && !lockedFirstName) { newUserInfo.first_name = userInfo.firstName.trim(); } if (userInfo.lastName.trim() !== currentUser.lastName && !lockedLastName) { newUserInfo.last_name = userInfo.lastName.trim(); } if (userInfo.nickname.trim() !== currentUser.nickname && !lockedNickname) { newUserInfo.nickname = userInfo.nickname.trim(); } if (userInfo.position.trim() !== currentUser.position && !lockedPosition) { newUserInfo.position = userInfo.position.trim(); } if (userInfo.username.trim() !== currentUser.username && !currentUser.authService) { newUserInfo.username = userInfo.username.trim(); } const localPath = changedProfilePicture.current?.localPath; const profileImageRemoved = changedProfilePicture.current?.isRemoved; if (localPath) { const now = Date.now(); const {error: uploadError} = await uploadUserProfileImage(serverUrl, localPath); if (uploadError) { resetScreen(uploadError); return; } updateLocalUser(serverUrl, {last_picture_update: now}); } else if (profileImageRemoved) { await setDefaultProfileImage(serverUrl, currentUser.id); } // Only update user info if there are actually changes to unlocked fields if (Object.keys(newUserInfo).length > 0) { const {error: reqError} = await updateMe(serverUrl, newUserInfo); if (reqError) { resetScreenForProfileError(reqError); return; } } // Update custom attributes if changed and not SAML-linked if (userInfo.customAttributes && enableCustomAttributes) { // Create a map of custom fields for quick lookup const customFieldsMap = new Map(); customFields?.forEach((field) => { customFieldsMap.set(field.id, field); }); // Only send custom attributes that have actually changed and are not SAML-linked const changedCustomAttributes: CustomAttributeSet = {}; Object.keys(userInfo.customAttributes).forEach((key) => { const currentValue = (customAttributesSet && customAttributesSet[key]?.value) || ''; const newValue = userInfo.customAttributes[key]?.value || ''; const customAttribute = userInfo.customAttributes[key]; const customField = customFieldsMap.get(customAttribute?.id); // Only include if value changed and field is not SAML-linked if (currentValue !== newValue && !isCustomFieldSamlLinked(customField)) { changedCustomAttributes[key] = userInfo.customAttributes[key]; } }); if (Object.keys(changedCustomAttributes).length > 0) { const {error: attrError} = await updateCustomProfileAttributes(serverUrl, currentUser.id, changedCustomAttributes); if (attrError) { logError('Error updating custom attributes', attrError); resetScreenForProfileError(attrError); return; } } } close(); } catch (e) { resetScreen(e); } }, [ currentUser, enableSaveButton, userInfo, lockedFirstName, lockedLastName, lockedNickname, lockedPosition, enableCustomAttributes, close, serverUrl, resetScreen, resetScreenForProfileError, customFields, customAttributesSet, ])); useAndroidHardwareBackHandler(componentId, close); useNavButtonPressed(UPDATE_BUTTON_ID, componentId, submitUser, [userInfo]); useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, []); const onUpdateProfilePicture = useCallback((newProfileImage: NewProfileImage) => { changedProfilePicture.current = newProfileImage; enableSaveButton(true); }, [enableSaveButton]); const onUpdateField = useCallback((fieldKey: string, value: string) => { const update = {...userInfo}; if (fieldKey.startsWith(CUSTOM_ATTRS_PREFIX_NAME)) { const attrKey = fieldKey.slice(CUSTOM_ATTRS_PREFIX_NAME.length); update.customAttributes = {...update.customAttributes, [attrKey]: {id: attrKey, name: userInfo.customAttributes[attrKey].name, value, type: userInfo.customAttributes[attrKey].type, sort_order: userInfo.customAttributes[attrKey].sort_order}}; } else { switch (fieldKey) { // typescript doesn't like to do update[fieldkey] as it might containg a customAttribute case case 'email': update.email = value; break; case 'firstName': update.firstName = value; break; case 'lastName': update.lastName = value; break; case 'nickname': update.nickname = value; break; case 'position': update.position = value; break; case 'username': update.username = value; break; } } setUserInfo(update); let didChange = false; if (fieldKey.startsWith(CUSTOM_ATTRS_PREFIX_NAME)) { const attrKey = fieldKey.slice(CUSTOM_ATTRS_PREFIX_NAME.length); didChange = userInfo.customAttributes?.[attrKey].value !== value; } else { // @ts-expect-error access object property by string key const currentValue = currentUser[fieldKey]; didChange = currentValue !== value; } hasUpdateUserInfo.current = didChange; enableSaveButton(didChange); }, [userInfo, currentUser, enableSaveButton]); const content = currentUser ? ( {updating && } {Boolean(error) && } ) : null; return ( {isTablet && } {content} ); }; export default EditProfile;