Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -115,6 +116,8 @@ function BaseValidateCodeForm({
const StyleUtils = useStyleUtils();
const [formError, setFormError] = useState<ValidateCodeFormError>({});
const [validateCode, setValidateCode] = useState('');
const [isCountdownRunning, setIsCountdownRunning] = useState(true);

const inputValidateCodeRef = useRef<MagicCodeInputHandle>(null);
const [account = getEmptyObject<Account>()] = useOnyx(ONYXKEYS.ACCOUNT, {
canBeMissing: true,
Expand All @@ -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<NodeJS.Timeout | null>(null);
const [timeRemaining, setTimeRemaining] = useState(CONST.REQUEST_CODE_DELAY as number);
const [canShowError, setCanShowError] = useState<boolean>(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<NodeJS.Timeout | undefined>(undefined);
const countdownRef = useRef<ValidateCodeCountdownHandle | null>(null);

const clearDefaultValidationCodeError = useCallback(() => {
// Clear "Failed to send magic code" error
Expand Down Expand Up @@ -184,6 +186,14 @@ function BaseValidateCodeForm({
}, []),
);

useEffect(() => {
if (!isCountdownRunning) {
return;
}

countdownRef.current?.resetCountdown();
}, [isCountdownRunning]);

useEffect(() => {
if (!validateCodeSent) {
return;
Expand All @@ -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);
};

/**
Expand Down Expand Up @@ -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
Expand All @@ -294,10 +298,9 @@ function BaseValidateCodeForm({
/>
{shouldShowTimer && (
<View style={[styles.mt5, styles.flexRow, styles.renderHTML]}>
<RenderHTML
html={translate('validateCodeForm.requestNewCode', {
timeRemaining: `00:${String(timeRemaining).padStart(2, '0')}`,
})}
<ValidateCodeCountdown
ref={countdownRef}
Comment thread
BartekObudzinski marked this conversation as resolved.
onCountdownFinish={handleCountdownFinish}
/>
</View>
)}
Expand Down
41 changes: 41 additions & 0 deletions src/pages/signin/ValidateCodeCountdown/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ValidateCodeCountdownHandle>) {
const {translate} = useLocalize();

const [timeRemaining, setTimeRemaining] = useState<number>(CONST.REQUEST_CODE_DELAY);
const timerRef = useRef<NodeJS.Timeout | undefined>(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);
};
Comment thread
BartekObudzinski marked this conversation as resolved.
}, [onCountdownFinish, timeRemaining]);

return (
<RenderHTML
html={translate('validateCodeForm.requestNewCode', {
timeRemaining: `00:${String(timeRemaining).padStart(2, '0')}`,
})}
/>
);
}

ValidateCodeCountdown.displayName = 'ValidateCodeCountdown';

export default forwardRef(ValidateCodeCountdown);
9 changes: 9 additions & 0 deletions src/pages/signin/ValidateCodeCountdown/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type ValidateCodeCountdownProps = {
onCountdownFinish: () => void;
};

type ValidateCodeCountdownHandle = {
resetCountdown: () => void;
};

export type {ValidateCodeCountdownHandle, ValidateCodeCountdownProps};
47 changes: 25 additions & 22 deletions src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<FormError>({});
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<boolean>(!!account?.errors);
const [isCountdownRunning, setIsCountdownRunning] = useState(true);
const StyleUtils = useStyleUtils();

const prevRequiresTwoFactorAuth = usePrevious(account?.requiresTwoFactorAuth);
const prevValidateCode = usePrevious(credentials?.validateCode);

const inputValidateCodeRef = useRef<MagicCodeInputHandle | undefined>(undefined);
const input2FARef = useRef<MagicCodeInputHandle | undefined>(undefined);
const timerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const countdownRef = useRef<ValidateCodeCountdownHandle | null>(null);

const hasError = !!account && !isEmptyObject(account?.errors) && !needToClearError;
const isLoadingResendValidationForm = account?.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM;
Expand All @@ -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]);

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
};

/**
Expand Down Expand Up @@ -211,6 +203,14 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco
}
};

useEffect(() => {
if (!isCountdownRunning) {
return;
}

countdownRef.current?.resetCountdown();
}, [isCountdownRunning]);

useEffect(() => {
if (!isLoadingResendValidationForm) {
return;
Expand Down Expand Up @@ -302,6 +302,10 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco
preferredLocale,
]);

const handleCountdownFinish = useCallback(() => {
setIsCountdownRunning(false);
}, []);

return (
<SafariFormWrapper>
{/* At this point, show 2FA only after the user has submitted a magic code and account requires 2FA */}
Expand Down Expand Up @@ -374,12 +378,11 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco
/>
{hasError && <FormHelpMessage message={getLatestErrorMessage(account)} />}
<View style={[styles.alignItemsStart]}>
{timeRemaining > 0 && !isOffline ? (
{isCountdownRunning && !isOffline ? (
<View style={[styles.mt2, styles.flexRow, styles.renderHTML]}>
<RenderHTML
html={translate('validateCodeForm.requestNewCode', {
timeRemaining: `00:${String(timeRemaining).padStart(2, '0')}`,
})}
<ValidateCodeCountdown
ref={countdownRef}
Comment thread
BartekObudzinski marked this conversation as resolved.
onCountdownFinish={handleCountdownFinish}
/>
</View>
) : (
Expand Down
Loading