diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9d35994875e1..8fcbb106268a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -416,7 +416,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm; - [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm; [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 584b349c508f..93febc4fd3c0 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -430,4 +430,4 @@ function MagicCodeInput( MagicCodeInput.displayName = 'MagicCodeInput'; export default forwardRef(MagicCodeInput); -export type {MagicCodeInputHandle}; +export type {AutoCompleteVariant, MagicCodeInputHandle}; diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx index b717c4890a2d..74ea4596046e 100644 --- a/src/components/Pressable/PressableWithFeedback.tsx +++ b/src/components/Pressable/PressableWithFeedback.tsx @@ -2,6 +2,7 @@ import React, {forwardRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import OpacityView from '@components/OpacityView'; +import type {Color} from '@styles/theme/types'; import variables from '@styles/variables'; import GenericPressable from './GenericPressable'; import type {PressableRef} from './GenericPressable/types'; @@ -27,6 +28,9 @@ type PressableWithFeedbackProps = PressableProps & { /** Whether the view needs to be rendered offscreen (for Android only) */ needsOffscreenAlphaCompositing?: boolean; + + /** The color of the underlay that will show through when the Pressable is active. */ + underlayColor?: Color; }; function PressableWithFeedback( diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 81229f353e52..4fd218d4fd42 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -92,9 +92,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined; - [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { + backTo: Routes; + }; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: { + contactMethod: string; + }; + [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: { + backTo: Routes; + }; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js deleted file mode 100644 index a9acf37ae556..000000000000 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ /dev/null @@ -1,393 +0,0 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {Component} from 'react'; -import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withTheme, {withThemePropTypes} from '@components/withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import {translatableTextPropTypes} from '@libs/Localize'; -import Navigation from '@libs/Navigation/Navigation'; -import * as Session from '@userActions/Session'; -import * as User from '@userActions/User'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import ValidateCodeForm from './ValidateCodeForm'; - -const propTypes = { - /* Onyx Props */ - - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Value of partner name */ - partnerName: PropTypes.string, - - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - - /** Date when login was validated */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), - - /** Field-specific pending states for offline UI status */ - pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), - - /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - /** User's security group IDs by domain */ - myDomainSecurityGroups: PropTypes.objectOf(PropTypes.string), - - /** All of the user's security groups and their settings */ - securityGroups: PropTypes.shape({ - hasRestrictedPrimaryLogin: PropTypes.bool, - }), - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Passed via route /settings/profile/contact-methods/:contactMethod/details */ - contactMethod: PropTypes.string, - }), - }), - - /** Indicated whether the report data is loading */ - isLoadingReportData: PropTypes.bool, - - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, -}; - -const defaultProps = { - loginList: {}, - session: { - email: null, - }, - myDomainSecurityGroups: {}, - securityGroups: {}, - route: { - params: { - contactMethod: '', - }, - }, - isLoadingReportData: true, -}; - -class ContactMethodDetailsPage extends Component { - constructor(props) { - super(props); - - this.deleteContactMethod = this.deleteContactMethod.bind(this); - this.toggleDeleteModal = this.toggleDeleteModal.bind(this); - this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.getContactMethod = this.getContactMethod.bind(this); - this.setAsDefault = this.setAsDefault.bind(this); - - this.state = { - isDeleteModalOpen: false, - }; - - this.validateCodeFormRef = React.createRef(); - } - - componentDidMount() { - const contactMethod = this.getContactMethod(); - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - if (_.isEmpty(loginData)) { - return; - } - User.resetContactMethodValidateCodeSentState(this.getContactMethod()); - } - - componentDidUpdate(prevProps) { - const contactMethod = this.getContactMethod(); - const validatedDate = lodashGet(this.props.loginList, [contactMethod, 'validatedDate']); - const prevValidatedDate = lodashGet(prevProps.loginList, [contactMethod, 'validatedDate']); - - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - // Navigate to methods page on successful magic code verification - // validatedDate property is responsible to decide the status of the magic code verification - if (!prevValidatedDate && validatedDate) { - // If the selected contactMethod is the current session['login'] and the account is unvalidated, - // the current authToken is invalid after the successful magic code verification. - // So we need to sign out the user and redirect to the sign in page. - if (isDefaultContactMethod) { - Session.signOutAndRedirectToSignIn(); - return; - } - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); - } - } - - /** - * Gets the current contact method from the route params - * @returns {string} - */ - getContactMethod() { - const contactMethod = lodashGet(this.props.route, 'params.contactMethod'); - - // We find the number of times the url is encoded based on the last % sign and remove them. - const lastPercentIndex = contactMethod.lastIndexOf('%'); - const encodePercents = contactMethod.substring(lastPercentIndex).match(new RegExp('25', 'g')); - let numberEncodePercents = encodePercents ? encodePercents.length : 0; - const beforeAtSign = contactMethod.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { - if (numberEncodePercents > 0) { - numberEncodePercents--; - return '%'; - } - return match; - }); - const afterAtSign = contactMethod.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); - - return decodeURIComponent(beforeAtSign + afterAtSign); - } - - /** - * Attempt to set this contact method as user's "Default contact method" - */ - setAsDefault() { - User.setContactMethodAsDefault(this.getContactMethod()); - } - - /** - * Checks if the user is allowed to change their default contact method. This should only be allowed if: - * 1. The viewed contact method is not already their default contact method - * 2. The viewed contact method is validated - * 3. If the user is on a private domain, their security group must allow primary login switching - * - * @returns {Boolean} - */ - canChangeDefaultContactMethod() { - const contactMethod = this.getContactMethod(); - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - - // Cannot set this contact method as default if: - // 1. This contact method is already their default - // 2. This contact method is not validated - if (isDefaultContactMethod || !loginData.validatedDate) { - return false; - } - - const domainName = Str.extractEmailDomain(this.props.session.email); - const primaryDomainSecurityGroupID = lodashGet(this.props.myDomainSecurityGroups, domainName); - - // If there's no security group associated with the user for the primary domain, - // default to allowing the user to change their default contact method. - if (!primaryDomainSecurityGroupID) { - return true; - } - - // Allow user to change their default contact method if they don't have a security group OR if their security group - // does NOT restrict primary login switching. - return !lodashGet(this.props.securityGroups, [`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`, 'hasRestrictedPrimaryLogin'], false); - } - - /** - * Deletes the contact method if it has errors. Otherwise, it shows the confirmation alert and deletes it only if the user confirms. - */ - deleteContactMethod() { - if (!_.isEmpty(lodashGet(this.props.loginList, [this.getContactMethod(), 'errorFields'], {}))) { - User.deleteContactMethod(this.getContactMethod(), this.props.loginList); - return; - } - this.toggleDeleteModal(true); - } - - /** - * Toggle delete confirm modal visibility - * @param {Boolean} isOpen - */ - toggleDeleteModal(isOpen) { - if (canUseTouchScreen() && isOpen) { - InteractionManager.runAfterInteractions(() => { - this.setState({isDeleteModalOpen: isOpen}); - }); - Keyboard.dismiss(); - } else { - this.setState({isDeleteModalOpen: isOpen}); - } - } - - /** - * Delete the contact method and hide the modal - */ - confirmDeleteAndHideModal() { - this.toggleDeleteModal(false); - User.deleteContactMethod(this.getContactMethod(), this.props.loginList); - } - - render() { - const contactMethod = this.getContactMethod(); - - // Replacing spaces with "hard spaces" to prevent breaking the number - const formattedContactMethod = Str.isSMSLogin(contactMethod) ? this.props.formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod; - - if (this.props.isLoadingReportData && _.isEmpty(this.props.loginList)) { - return ; - } - - const loginData = this.props.loginList[contactMethod]; - if (!contactMethod || !loginData) { - return ( - - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - /> - - ); - } - - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false); - const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin')); - const isFailedRemovedContactMethod = Boolean(lodashGet(loginData, 'errorFields.deletedLogin')); - - return ( - this.validateCodeFormRef.current && this.validateCodeFormRef.current.focus()} - testID={ContactMethodDetailsPage.displayName} - > - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - /> - - this.toggleDeleteModal(false)} - onModalHide={() => { - InteractionManager.runAfterInteractions(() => { - if (!this.validateCodeFormRef.current) { - return; - } - this.validateCodeFormRef.current.focusLastSelected(); - }); - }} - prompt={this.props.translate('contacts.removeAreYouSure')} - confirmText={this.props.translate('common.yesContinue')} - cancelText={this.props.translate('common.cancel')} - isVisible={this.state.isDeleteModalOpen && !isDefaultContactMethod} - danger - /> - - {isFailedAddContactMethod && ( - - )} - - {!loginData.validatedDate && !isFailedAddContactMethod && ( - - - - - - )} - {this.canChangeDefaultContactMethod() ? ( - User.clearContactMethodErrors(contactMethod, 'defaultLogin')} - > - - - ) : null} - {isDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} - > - {this.props.translate('contacts.yourDefaultContactMethod')} - - ) : ( - User.clearContactMethodErrors(contactMethod, 'deletedLogin')} - > - this.toggleDeleteModal(true)} - /> - - )} - - - ); - } -} - -ContactMethodDetailsPage.propTypes = propTypes; -ContactMethodDetailsPage.defaultProps = defaultProps; -ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage'; - -export default compose( - withLocalize, - withOnyx({ - loginList: { - key: ONYXKEYS.LOGIN_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - myDomainSecurityGroups: { - key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, - }, - securityGroups: { - key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`, - }, - isLoadingReportData: { - key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`, - }, - }), - withThemeStyles, - withTheme, -)(ContactMethodDetailsPage); diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx new file mode 100644 index 000000000000..7de22da728dd --- /dev/null +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -0,0 +1,305 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import Str from 'expensify-common/lib/str'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import ConfirmModal from '@components/ConfirmModal'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as Session from '@userActions/Session'; +import * as User from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {LoginList, SecurityGroup, Session as TSession} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ValidateCodeForm from './ValidateCodeForm'; +import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; + +type ContactMethodDetailsPageOnyxProps = { + /** Login list for the user that is signed in */ + loginList: OnyxEntry; + + /** Current user session */ + session: OnyxEntry; + + /** User's security group IDs by domain */ + myDomainSecurityGroups: OnyxEntry>; + + /** All of the user's security groups and their settings */ + securityGroups: OnyxCollection; + + /** Indicated whether the report data is loading */ + isLoadingReportData: OnyxEntry; +}; + +type ContactMethodDetailsPageProps = ContactMethodDetailsPageOnyxProps & StackScreenProps; + +function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, securityGroups, isLoadingReportData = true, route}: ContactMethodDetailsPageProps) { + const {formatPhoneNumber, translate} = useLocalize(); + const theme = useTheme(); + const themeStyles = useThemeStyles(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const validateCodeFormRef = useRef(null); + + /** + * Gets the current contact method from the route params + */ + const contactMethod: string = useMemo(() => { + const contactMethodParam = route.params.contactMethod; + + // We find the number of times the url is encoded based on the last % sign and remove them. + const lastPercentIndex = contactMethodParam.lastIndexOf('%'); + const encodePercents = contactMethodParam.substring(lastPercentIndex).match(new RegExp('25', 'g')); + let numberEncodePercents = encodePercents?.length ?? 0; + const beforeAtSign = contactMethodParam.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { + if (numberEncodePercents > 0) { + numberEncodePercents--; + return '%'; + } + return match; + }); + const afterAtSign = contactMethodParam.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); + + return decodeURIComponent(beforeAtSign + afterAtSign); + }, [route.params.contactMethod]); + const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); + const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]); + + /** + * Attempt to set this contact method as user's "Default contact method" + */ + const setAsDefault = useCallback(() => { + User.setContactMethodAsDefault(contactMethod); + }, [contactMethod]); + + /** + * Checks if the user is allowed to change their default contact method. This should only be allowed if: + * 1. The viewed contact method is not already their default contact method + * 2. The viewed contact method is validated + * 3. If the user is on a private domain, their security group must allow primary login switching + */ + const canChangeDefaultContactMethod = useMemo(() => { + // Cannot set this contact method as default if: + // 1. This contact method is already their default + // 2. This contact method is not validated + if (isDefaultContactMethod || !loginData?.validatedDate) { + return false; + } + + const domainName = Str.extractEmailDomain(session?.email ?? ''); + const primaryDomainSecurityGroupID = myDomainSecurityGroups?.[domainName]; + + // If there's no security group associated with the user for the primary domain, + // default to allowing the user to change their default contact method. + if (!primaryDomainSecurityGroupID) { + return true; + } + + // Allow user to change their default contact method if they don't have a security group OR if their security group + // does NOT restrict primary login switching. + return !securityGroups?.[`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`]?.hasRestrictedPrimaryLogin; + }, [isDefaultContactMethod, loginData?.validatedDate, session?.email, myDomainSecurityGroups, securityGroups]); + + /** + * Toggle delete confirm modal visibility + */ + const toggleDeleteModal = useCallback((isOpen: boolean) => { + if (canUseTouchScreen() && isOpen) { + InteractionManager.runAfterInteractions(() => { + setIsDeleteModalOpen(isOpen); + }); + Keyboard.dismiss(); + } else { + setIsDeleteModalOpen(isOpen); + } + }, []); + + /** + * Delete the contact method and hide the modal + */ + const confirmDeleteAndHideModal = useCallback(() => { + toggleDeleteModal(false); + User.deleteContactMethod(contactMethod, loginList ?? {}); + }, [contactMethod, loginList, toggleDeleteModal]); + + useEffect(() => { + if (isEmptyObject(loginData)) { + return; + } + User.resetContactMethodValidateCodeSentState(contactMethod); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const prevValidatedDate = usePrevious(loginData?.validatedDate); + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (prevValidatedDate || !loginData?.validatedDate) { + return; + } + + // If the selected contactMethod is the current session['login'] and the account is unvalidated, + // the current authToken is invalid after the successful magic code verification. + // So we need to sign out the user and redirect to the sign in page. + if (isDefaultContactMethod) { + Session.signOutAndRedirectToSignIn(); + return; + } + // Navigate to methods page on successful magic code verification + // validatedDate property is responsible to decide the status of the magic code verification + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); + }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod]); + + if (isLoadingReportData && isEmptyObject(loginList)) { + return ; + } + + if (!contactMethod || !loginData) { + return ( + + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + /> + + ); + } + + // Replacing spaces with "hard spaces" to prevent breaking the number + const formattedContactMethod = Str.isSMSLogin(contactMethod) ? formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod; + const hasMagicCodeBeenSent = !!loginData.validateCodeSent; + const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin; + const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin; + + return ( + validateCodeFormRef.current?.focus?.()} + testID={ContactMethodDetailsPage.displayName} + > + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + /> + + toggleDeleteModal(false)} + onModalHide={() => { + InteractionManager.runAfterInteractions(() => { + validateCodeFormRef.current?.focusLastSelected?.(); + }); + }} + prompt={translate('contacts.removeAreYouSure')} + confirmText={translate('common.yesContinue')} + cancelText={translate('common.cancel')} + isVisible={isDeleteModalOpen && !isDefaultContactMethod} + danger + /> + + {isFailedAddContactMethod && ( + + )} + + {!loginData.validatedDate && !isFailedAddContactMethod && ( + + + + + + )} + {canChangeDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, 'defaultLogin')} + > + + + ) : null} + {isDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} + > + {translate('contacts.yourDefaultContactMethod')} + + ) : ( + User.clearContactMethodErrors(contactMethod, 'deletedLogin')} + > + toggleDeleteModal(true)} + /> + + )} + + + ); +} + +ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage'; + +export default withOnyx({ + loginList: { + key: ONYXKEYS.LOGIN_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + myDomainSecurityGroups: { + key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, + }, + securityGroups: { + key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`, + }, + isLoadingReportData: { + key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`, + }, +})(ContactMethodDetailsPage); diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx similarity index 53% rename from src/pages/settings/Profile/Contacts/ContactMethodsPage.js rename to src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index c85d123ad3fd..5d150e782c44 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -1,10 +1,9 @@ +import type {StackScreenProps} from '@react-navigation/stack'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import Button from '@components/Button'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import FixedFooter from '@components/FixedFooter'; @@ -13,86 +12,64 @@ import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {LoginList, Session} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -const propTypes = { - /* Onyx Props */ - +type ContactMethodsPageOnyxProps = { /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */ - partnerName: PropTypes.string, - - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - - /** The date when the login was validated, used to show the brickroad status */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), - - /** Field-specific pending states for offline UI status */ - pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), + loginList: OnyxEntry; /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - ...withLocalizePropTypes, + session: OnyxEntry; }; -const defaultProps = { - loginList: {}, - session: { - email: null, - }, -}; +type ContactMethodsPageProps = ContactMethodsPageOnyxProps & StackScreenProps; -function ContactMethodsPage(props) { +function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps) { const styles = useThemeStyles(); - const loginNames = _.keys(props.loginList); - const navigateBackTo = lodashGet(props.route, 'params.backTo', ''); + const {formatPhoneNumber, translate} = useLocalize(); + const loginNames = Object.keys(loginList ?? {}); + const navigateBackTo = route?.params?.backTo || ROUTES.SETTINGS_PROFILE; // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods. // The default contact method is determined by checking against the session email (the current login). - const sortedLoginNames = _.sortBy(loginNames, (loginName) => (props.loginList[loginName].partnerUserID === props.session.email ? 0 : 1)); + const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1)); - const loginMenuItems = _.map(sortedLoginNames, (loginName) => { - const login = props.loginList[loginName]; - const pendingAction = lodashGet(login, 'pendingFields.deletedLogin') || lodashGet(login, 'pendingFields.addedLogin'); - if (!login.partnerUserID && _.isEmpty(pendingAction)) { + const loginMenuItems = sortedLoginNames.map((loginName) => { + const login = loginList?.[loginName]; + const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; + if (!login?.partnerUserID && !pendingAction) { return null; } let description = ''; - if (props.session.email === login.partnerUserID) { - description = props.translate('contacts.getInTouch'); - } else if (lodashGet(login, 'errorFields.addedLogin')) { - description = props.translate('contacts.failedNewContact'); - } else if (!login.validatedDate) { - description = props.translate('contacts.pleaseVerify'); + if (session?.email === login?.partnerUserID) { + description = translate('contacts.getInTouch'); + } else if (login?.errorFields?.addedLogin) { + description = translate('contacts.failedNewContact'); + } else if (!login?.validatedDate) { + description = translate('contacts.pleaseVerify'); } - let indicator = null; - if (_.some(lodashGet(login, 'errorFields', {}), (errorField) => !_.isEmpty(errorField))) { + let indicator; + if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } else if (!login.validatedDate) { + } else if (!login?.validatedDate) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } // Default to using login key if we deleted login.partnerUserID optimistically // but still need to show the pending login being deleted while offline. - const partnerUserID = login.partnerUserID || loginName; - const menuItemTitle = Str.isSMSLogin(partnerUserID) ? props.formatPhoneNumber(partnerUserID) : partnerUserID; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const partnerUserID = login?.partnerUserID || loginName; + const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID; return ( ); @@ -126,25 +103,25 @@ function ContactMethodsPage(props) { testID={ContactMethodsPage.displayName} > Navigation.goBack(navigateBackTo)} /> - {props.translate('contacts.helpTextBeforeEmail')} + {translate('contacts.helpTextBeforeEmail')} - {props.translate('contacts.helpTextAfterEmail')} + {translate('contacts.helpTextAfterEmail')} {loginMenuItems}