diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index fff6d4587771..5d610ae2da19 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -9,7 +9,6 @@ import MagicCodeInput from '@components/MagicCodeInput'; import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import {WideRHPContext} from '@components/WideRHPContextProvider'; import useLocalize from '@hooks/useLocalize'; @@ -21,6 +20,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileSafari} from '@libs/Browser'; import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; import {isValidValidateCode} from '@libs/ValidationUtils'; +import ValidateCodeCountdown from '@pages/signin/ValidateCodeCountdown'; +import type {ValidateCodeCountdownHandle} from '@pages/signin/ValidateCodeCountdown/types'; import {clearValidateCodeActionError} from '@userActions/User'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -115,6 +116,8 @@ function BaseValidateCodeForm({ const StyleUtils = useStyleUtils(); const [formError, setFormError] = useState({}); const [validateCode, setValidateCode] = useState(''); + const [isCountdownRunning, setIsCountdownRunning] = useState(true); + const inputValidateCodeRef = useRef(null); const [account = getEmptyObject()] = useOnyx(ONYXKEYS.ACCOUNT, { canBeMissing: true, @@ -123,13 +126,12 @@ function BaseValidateCodeForm({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case const shouldDisableResendValidateCode = !!isOffline || account?.isLoading; const focusTimeoutRef = useRef(null); - const [timeRemaining, setTimeRemaining] = useState(CONST.REQUEST_CODE_DELAY as number); const [canShowError, setCanShowError] = useState(false); const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE, {canBeMissing: true}); const validateCodeSent = useMemo(() => hasMagicCodeBeenSent ?? validateCodeAction?.validateCodeSent, [hasMagicCodeBeenSent, validateCodeAction?.validateCodeSent]); const latestValidateCodeError = getLatestErrorField(validateCodeAction, validateCodeActionErrorField); const defaultValidateCodeError = getLatestErrorField(validateCodeAction, 'actionVerified'); - const timerRef = useRef(undefined); + const countdownRef = useRef(null); const clearDefaultValidationCodeError = useCallback(() => { // Clear "Failed to send magic code" error @@ -184,6 +186,14 @@ function BaseValidateCodeForm({ }, []), ); + useEffect(() => { + if (!isCountdownRunning) { + return; + } + + countdownRef.current?.resetCountdown(); + }, [isCountdownRunning]); + useEffect(() => { if (!validateCodeSent) { return; @@ -199,24 +209,14 @@ function BaseValidateCodeForm({ } }, [validateCodeSent, wideRHPRouteKeys.length]); - useEffect(() => { - if (timeRemaining > 0) { - timerRef.current = setTimeout(() => { - setTimeRemaining(timeRemaining - 1); - }, 1000); - } - return () => { - clearTimeout(timerRef.current); - }; - }, [timeRemaining]); - /** * Request a validate code / magic code be sent to verify this contact method */ const resendValidateCode = () => { sendValidateCode(); inputValidateCodeRef.current?.clear(); - setTimeRemaining(CONST.REQUEST_CODE_DELAY); + countdownRef.current?.resetCountdown(); + setIsCountdownRunning(true); }; /** @@ -274,7 +274,11 @@ function BaseValidateCodeForm({ return getLatestErrorMessage(account ?? {}); }, [canShowError, formError, account, translate]); - const shouldShowTimer = timeRemaining > 0 && !isOffline; + const shouldShowTimer = isCountdownRunning && !isOffline; + + const handleCountdownFinish = useCallback(() => { + setIsCountdownRunning(false); + }, []); // latestValidateCodeError only holds an error related to bad magic code // while validateError holds flow-specific errors @@ -294,10 +298,9 @@ function BaseValidateCodeForm({ /> {shouldShowTimer && ( - )} diff --git a/src/pages/signin/ValidateCodeCountdown/index.tsx b/src/pages/signin/ValidateCodeCountdown/index.tsx new file mode 100644 index 000000000000..5581dffd9ffb --- /dev/null +++ b/src/pages/signin/ValidateCodeCountdown/index.tsx @@ -0,0 +1,41 @@ +import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import CONST from '@src/CONST'; +import type {ValidateCodeCountdownHandle, ValidateCodeCountdownProps} from './types'; + +function ValidateCodeCountdown({onCountdownFinish}: ValidateCodeCountdownProps, ref: React.ForwardedRef) { + const {translate} = useLocalize(); + + const [timeRemaining, setTimeRemaining] = useState(CONST.REQUEST_CODE_DELAY); + const timerRef = useRef(undefined); + + useImperativeHandle(ref, () => ({ + resetCountdown: () => setTimeRemaining(CONST.REQUEST_CODE_DELAY), + })); + + useEffect(() => { + if (timeRemaining > 0) { + timerRef.current = setTimeout(() => { + setTimeRemaining((prev) => prev - 1); + }, 1000); + } else { + onCountdownFinish(); + } + return () => { + clearTimeout(timerRef.current); + }; + }, [onCountdownFinish, timeRemaining]); + + return ( + + ); +} + +ValidateCodeCountdown.displayName = 'ValidateCodeCountdown'; + +export default forwardRef(ValidateCodeCountdown); diff --git a/src/pages/signin/ValidateCodeCountdown/types.ts b/src/pages/signin/ValidateCodeCountdown/types.ts new file mode 100644 index 000000000000..68712b48b0c0 --- /dev/null +++ b/src/pages/signin/ValidateCodeCountdown/types.ts @@ -0,0 +1,9 @@ +type ValidateCodeCountdownProps = { + onCountdownFinish: () => void; +}; + +type ValidateCodeCountdownHandle = { + resetCountdown: () => void; +}; + +export type {ValidateCodeCountdownHandle, ValidateCodeCountdownProps}; diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx index 006215a1779c..d2deeed34ea7 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -7,7 +7,6 @@ import FormHelpMessage from '@components/FormHelpMessage'; import type {MagicCodeInputHandle} from '@components/MagicCodeInput'; import MagicCodeInput from '@components/MagicCodeInput'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import type {WithToggleVisibilityViewProps} from '@components/withToggleVisibilityView'; @@ -24,6 +23,8 @@ import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {isValidRecoveryCode, isValidTwoFactorCode, isValidValidateCode} from '@libs/ValidationUtils'; import ChangeExpensifyLoginLink from '@pages/signin/ChangeExpensifyLoginLink'; import Terms from '@pages/signin/Terms'; +import ValidateCodeCountdown from '@pages/signin/ValidateCodeCountdown'; +import type {ValidateCodeCountdownHandle} from '@pages/signin/ValidateCodeCountdown/types'; import {clearAccountMessages, clearSignInData as sessionActionsClearSignInData, signIn, signInWithValidateCode} from '@userActions/Session'; import {resendValidateCode as userActionsResendValidateCode} from '@userActions/User'; import CONST from '@src/CONST'; @@ -52,23 +53,23 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false}); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE, {canBeMissing: true}); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const isFocused = useIsFocused(); const {isOffline} = useNetwork(); const [formError, setFormError] = useState({}); const [validateCode, setValidateCode] = useState(credentials?.validateCode ?? ''); const [twoFactorAuthCode, setTwoFactorAuthCode] = useState(''); - const [timeRemaining, setTimeRemaining] = useState(CONST.REQUEST_CODE_DELAY as number); const [recoveryCode, setRecoveryCode] = useState(''); const [needToClearError, setNeedToClearError] = useState(!!account?.errors); + const [isCountdownRunning, setIsCountdownRunning] = useState(true); + const StyleUtils = useStyleUtils(); const prevRequiresTwoFactorAuth = usePrevious(account?.requiresTwoFactorAuth); const prevValidateCode = usePrevious(credentials?.validateCode); const inputValidateCodeRef = useRef(undefined); const input2FARef = useRef(undefined); - const timerRef = useRef(undefined); + const countdownRef = useRef(null); const hasError = !!account && !isEmptyObject(account?.errors) && !needToClearError; const isLoadingResendValidationForm = account?.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM; @@ -86,7 +87,8 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus() || !isVisible || !isFocused) { return; } - setTimeRemaining(CONST.REQUEST_CODE_DELAY); + setIsCountdownRunning(true); + countdownRef.current?.resetCountdown(); inputValidateCodeRef.current.focus(); }, [isVisible, isFocused]); @@ -118,17 +120,6 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco input2FARef.current.clear(); }, [twoFactorAuthCode]); - useEffect(() => { - if (timeRemaining > 0) { - timerRef.current = setTimeout(() => { - setTimeRemaining(timeRemaining - 1); - }, 1000); - } - return () => { - clearTimeout(timerRef.current); - }; - }, [timeRemaining]); - /** * Handle text input and clear formError upon text change */ @@ -157,7 +148,8 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco userActionsResendValidateCode(credentials?.login ?? ''); inputValidateCodeRef.current?.clear(); // Give feedback to the user to let them know the email was sent so that they don't spam the button. - setTimeRemaining(CONST.REQUEST_CODE_DELAY); + countdownRef.current?.resetCountdown(); + setIsCountdownRunning(true); }; /** @@ -211,6 +203,14 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco } }; + useEffect(() => { + if (!isCountdownRunning) { + return; + } + + countdownRef.current?.resetCountdown(); + }, [isCountdownRunning]); + useEffect(() => { if (!isLoadingResendValidationForm) { return; @@ -302,6 +302,10 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco preferredLocale, ]); + const handleCountdownFinish = useCallback(() => { + setIsCountdownRunning(false); + }, []); + return ( {/* At this point, show 2FA only after the user has submitted a magic code and account requires 2FA */} @@ -374,12 +378,11 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco /> {hasError && } - {timeRemaining > 0 && !isOffline ? ( + {isCountdownRunning && !isOffline ? ( - ) : (