From 6f84d5bede07d64249a37ccc79d004a8fc6fc93d Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 13 Mar 2024 18:04:01 +1100 Subject: [PATCH] feat: animated progress loader to spec timeout display name fetch to 15 seconds, added display name inital screen still needs work --- .../loading/bar/SessionProgressBar.tsx | 2 +- .../registration/RegistrationStages.tsx | 42 +-- .../registration/stages/CreateAccount.tsx | 35 +-- .../registration/stages/RestoreAccount.tsx | 246 +++++++++++++----- ts/components/registration/utils/index.tsx | 34 +++ ts/session/constants.ts | 9 + ts/session/utils/errors.ts | 2 +- ts/state/onboarding/ducks/registration.ts | 12 +- ts/util/accountManager.ts | 4 +- 9 files changed, 263 insertions(+), 123 deletions(-) create mode 100644 ts/components/registration/utils/index.tsx diff --git a/ts/components/loading/bar/SessionProgressBar.tsx b/ts/components/loading/bar/SessionProgressBar.tsx index d67fe6b9d..af5b9d0f5 100644 --- a/ts/components/loading/bar/SessionProgressBar.tsx +++ b/ts/components/loading/bar/SessionProgressBar.tsx @@ -65,7 +65,7 @@ export function SessionProgressBar(props: Props) { ) : null} {subtitle ? {subtitle} : null} - {showPercentage ? {progress}% : null} + {showPercentage ? {Math.floor(progress)}% : null} {subtitle || showPercentage ? : null} diff --git a/ts/components/registration/RegistrationStages.tsx b/ts/components/registration/RegistrationStages.tsx index d90a2bb1d..0a0f1ddb5 100644 --- a/ts/components/registration/RegistrationStages.tsx +++ b/ts/components/registration/RegistrationStages.tsx @@ -4,11 +4,12 @@ import { useMount } from 'react-use'; import styled from 'styled-components'; import { Data } from '../../data/data'; import { getSwarmPollingInstance } from '../../session/apis/snode_api'; +import { ONBOARDING_TIMES } from '../../session/constants'; import { getConversationController } from '../../session/conversations'; import { InvalidWordsError, NotEnoughWordsError, mnDecode } from '../../session/crypto/mnemonic'; import { PromiseUtils, StringUtils, ToastUtils } from '../../session/utils'; import { fromHex } from '../../session/utils/String'; -import { trigger } from '../../shims/events'; +import { NotFoundError } from '../../session/utils/errors'; import { Onboarding, setGeneratedRecoveryPhrase, @@ -29,7 +30,7 @@ import { Flex } from '../basic/Flex'; import { SpacerLG, SpacerSM } from '../basic/Text'; import { SessionIcon, SessionIconButton } from '../icon'; import { CreateAccount, RestoreAccount, Start } from './stages'; -import { displayNameIsValid } from './stages/CreateAccount'; +import { displayNameIsValid } from './utils'; const StyledRegistrationContainer = styled(Flex)` width: 348px; @@ -59,22 +60,18 @@ type SignInDetails = { * Ask for a display name, as we will drop incoming ConfigurationMessages if any are saved on the swarm. * We will handle a ConfigurationMessage */ -export async function signInWithRecovery(signInDetails: SignInDetails) { +export async function signInWithNewDisplayName(signInDetails: SignInDetails) { const { displayName, userRecoveryPhrase } = signInDetails; - window?.log?.info('RESTORING FROM SEED'); + window.log.debug(`WIP: [signInWithNewDisplayName] starting sign in with new display name....`); const trimName = displayName ? displayNameIsValid(displayName) : undefined; - // shows toast to user about the error if (!trimName) { return; } try { await resetRegistration(); - await registerSingleDevice(userRecoveryPhrase, 'english', trimName); await setSignWithRecoveryPhrase(true); - - trigger('openInbox'); } catch (e) { await resetRegistration(); ToastUtils.pushToastError('registrationError', `Error: ${e.message || 'Something went wrong'}`); @@ -83,14 +80,15 @@ export async function signInWithRecovery(signInDetails: SignInDetails) { } /** - * This is will try to sign in with the user recovery phrase. - * If no ConfigurationMessage is received in 60seconds, the loading will be canceled. + * This will try to sign in with the user recovery phrase. + * If no ConfigurationMessage is received within ONBOARDING_RECOVERY_TIMEOUT, the user will be asked to enter a display name. */ -export async function signInWithLinking(signInDetails: SignInDetails) { +export async function signInAndFetchDisplayName(signInDetails: SignInDetails) { const { userRecoveryPhrase, errorCallback } = signInDetails; - window?.log?.info('LINKING DEVICE'); + window.log.debug(`WIP: [signInAndFetchDisplayName] starting sign in....`); try { + throw new NotFoundError('Got a config message from network but without a displayName...'); await resetRegistration(); await signInByLinkingDevice(userRecoveryPhrase, 'english'); let displayNameFromNetwork = ''; @@ -102,20 +100,23 @@ export async function signInWithLinking(signInDetails: SignInDetails) { await setSignInByLinking(false); await setSignWithRecoveryPhrase(true); done(displayName); - displayNameFromNetwork = displayName; }); - }, 60000); + }, ONBOARDING_TIMES.RECOVERY_TIMEOUT); if (displayNameFromNetwork.length) { // display name, avatars, groups and contacts should already be handled when this event was triggered. - window?.log?.info(`We got a displayName from network: "${displayNameFromNetwork}"`); + window.log.debug( + `WIP: [signInAndFetchDisplayName] we got a displayName from network: "${displayNameFromNetwork}"` + ); } else { - window?.log?.info('Got a config message from network but without a displayName...'); - throw new Error('Got a config message from network but without a displayName...'); + window.log.debug( + `WIP: [signInAndFetchDisplayName] Got a config message from network but without a displayName...` + ); + throw new NotFoundError('Got a config message from network but without a displayName...'); } // Do not set the lastProfileUpdateTimestamp. // We expect to get a display name from a configuration message while we are loading messages of this user - trigger('openInbox'); + return displayNameFromNetwork; } catch (e) { await resetRegistration(); if (errorCallback) { @@ -127,7 +128,10 @@ export async function signInWithLinking(signInDetails: SignInDetails) { void errorCallback(window.i18n('recoveryPasswordErrorMessageGeneric')); } } - window?.log?.warn('exception during registration:', e); + window.log.debug( + `WIP: [signInAndFetchDisplayName] exception during registration: ${e.message || e}` + ); + return ''; } } diff --git a/ts/components/registration/stages/CreateAccount.tsx b/ts/components/registration/stages/CreateAccount.tsx index 8678345a1..a14d4a56d 100644 --- a/ts/components/registration/stages/CreateAccount.tsx +++ b/ts/components/registration/stages/CreateAccount.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { SettingsKey } from '../../../data/settings-key'; import { ToastUtils } from '../../../session/utils'; -import { sanitizeSessionUsername } from '../../../session/utils/String'; import { trigger } from '../../../shims/events'; import { AccountCreation, @@ -22,45 +21,13 @@ import { SessionInput } from '../../inputs'; import { resetRegistration } from '../RegistrationStages'; import { OnboardContainer, OnboardDescription, OnboardHeading } from '../components'; import { BackButtonWithininContainer } from '../components/BackButton'; - -function sanitizeDisplayNameOrToast( - displayName: string, - setDisplayName: (sanitized: string) => void, - setDisplayNameError: (error: string | undefined) => void -) { - try { - const sanitizedName = sanitizeSessionUsername(displayName); - const trimName = sanitizedName.trim(); - setDisplayName(sanitizedName); - setDisplayNameError(!trimName ? window.i18n('displayNameEmpty') : undefined); - } catch (e) { - setDisplayName(displayName); - setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter')); - } -} - -/** - * Returns undefined if an error happened, or the trim userName. - * - * Be sure to use the trimmed userName for creating the account. - */ -export const displayNameIsValid = (displayName: string): undefined | string => { - const trimName = displayName.trim(); - - if (!trimName) { - window?.log?.warn('invalid trimmed name for registration'); - ToastUtils.pushToastError('invalidDisplayName', window.i18n('displayNameEmpty')); - return undefined; - } - return trimName; -}; +import { displayNameIsValid, sanitizeDisplayNameOrToast } from '../utils'; async function signUp(signUpDetails: { displayName: string; generatedRecoveryPhrase: string }) { const { displayName, generatedRecoveryPhrase } = signUpDetails; window?.log?.info('SIGNING UP'); const trimName = displayNameIsValid(displayName); - // shows toast to user about the error if (!trimName) { return; } diff --git a/ts/components/registration/stages/RestoreAccount.tsx b/ts/components/registration/stages/RestoreAccount.tsx index 00de2efed..7cd80f040 100644 --- a/ts/components/registration/stages/RestoreAccount.tsx +++ b/ts/components/registration/stages/RestoreAccount.tsx @@ -1,5 +1,9 @@ +import { isEmpty } from 'lodash'; import { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; +import { ONBOARDING_TIMES } from '../../../session/constants'; +import { NotFoundError } from '../../../session/utils/errors'; +import { trigger } from '../../../shims/events'; import { AccountRestoration, setAccountRestorationStep, @@ -11,54 +15,219 @@ import { SpacerLG, SpacerSM } from '../../basic/Text'; import { SessionIcon } from '../../icon'; import { SessionInput } from '../../inputs'; import { SessionProgressBar } from '../../loading'; -import { signInWithLinking } from '../RegistrationStages'; +import { signInAndFetchDisplayName, signInWithNewDisplayName } from '../RegistrationStages'; import { OnboardContainer, OnboardDescription, OnboardHeading } from '../components'; import { BackButtonWithininContainer } from '../components/BackButton'; +import { sanitizeDisplayNameOrToast } from '../utils'; export const RestoreAccount = () => { const step = useOnboardAccountRestorationStep(); const [recoveryPhrase, setRecoveryPhrase] = useState(''); const [recoveryPhraseError, setRecoveryPhraseError] = useState(undefined as string | undefined); + + const [displayName, setDisplayName] = useState(''); + const [displayNameError, setDisplayNameError] = useState(''); + const [progress, setProgress] = useState(0); const dispatch = useDispatch(); // Seed is mandatory no matter which mode const seedOK = !!recoveryPhrase && !recoveryPhraseError; + const displayNameOK = !!displayName && !displayNameError; - const activateContinueButton = seedOK && !(step === AccountRestoration.Loading); + const activateContinueButton = + seedOK && + !( + step === + (AccountRestoration.Loading || AccountRestoration.Finishing || AccountRestoration.Finished) + ); - const continueYourSession = async () => { + const recoverWithoutDisplayName = async () => { + setProgress(0); dispatch(setAccountRestorationStep(AccountRestoration.Loading)); - await signInWithLinking({ + try { + const displayNameFromNetwork = await signInAndFetchDisplayName({ + userRecoveryPhrase: recoveryPhrase, + errorCallback: setRecoveryPhraseError, + }); + setDisplayName(displayNameFromNetwork); + dispatch(setAccountRestorationStep(AccountRestoration.Finishing)); + } catch (e) { + if (e instanceof NotFoundError) { + window.log.debug( + `WIP: [continueYourSession] AccountRestoration.DisplayName failed to fetch display name so we need to enter it manually error ${e.message || + e}` + ); + dispatch(setAccountRestorationStep(AccountRestoration.DisplayName)); + } else { + dispatch(setAccountRestorationStep(AccountRestoration.RecoveryPassword)); + } + } + }; + + const recoverWithDisplayName = async () => { + if (!displayNameOK) { + return; + } + + void signInWithNewDisplayName({ + displayName, userRecoveryPhrase: recoveryPhrase, - errorCallback: setRecoveryPhraseError, }); + dispatch(setAccountRestorationStep(AccountRestoration.Complete)); }; useEffect(() => { let interval: NodeJS.Timeout; + if (step === AccountRestoration.Loading) { interval = setInterval(() => { - setProgress(oldProgress => { - if (oldProgress === 100) { - clearInterval(interval); - return 100; - } - // Increment by 100 / 15 = 6.67 each second to complete in 15 seconds - return Math.min(oldProgress + 100 / 15, 100); - }); - }, 1000); + if (progress < 100) { + setProgress(progress + 1); + } + window.log.debug( + `WIP: [continueYourSession] AccountRestoration.Loading Loading progress ${progress}%` + ); + + if (progress >= 100) { + clearInterval(interval); + // if we didn't get the display name in time, we need to enter it manually + window.log.debug( + `WIP: [continueYourSession] AccountRestoration.Loading We didn't get the display name in time, we need to enter it manually` + ); + dispatch(setAccountRestorationStep(AccountRestoration.DisplayName)); + } + }, ONBOARDING_TIMES.RECOVERY_TIMEOUT / 100); + } + + if (step === AccountRestoration.Finishing) { + interval = setInterval(() => { + if (progress < 100) { + setProgress(progress + 1); + } + window.log.debug( + `WIP: [continueYourSession] AccountRestoration. Finishing progress ${progress}%` + ); + + if (progress >= 100) { + clearInterval(interval); + dispatch(setAccountRestorationStep(AccountRestoration.Finished)); + } + }, ONBOARDING_TIMES.RECOVERY_FINISHING / 100); + } + + if (step === AccountRestoration.Finished) { + interval = setInterval(() => { + clearInterval(interval); + if (!isEmpty(displayName)) { + window.log.debug( + `WIP: [continueYourSession] AccountRestoration.Complete Finished progress` + ); + dispatch(setAccountRestorationStep(AccountRestoration.Complete)); + } else { + dispatch(setAccountRestorationStep(AccountRestoration.RecoveryPassword)); + window.log.debug( + `WIP: [continueYourSession] AccountRestoration.DisplayName failed to fetch display name so we need to enter it manually` + ); + } + }, ONBOARDING_TIMES.RECOVERY_FINISHED); + } + + if (step === AccountRestoration.Complete) { + if (!isEmpty(displayName)) { + window.log.debug( + `WIP: [continueYourSession] AccountRestoration.Complete opening inbox for ${displayName}` + ); + trigger('openInbox'); + } } return () => clearInterval(interval); - }, [step]); + }, [dispatch, displayName, progress, step]); return ( - {step === AccountRestoration.Loading ? ( + {step === AccountRestoration.RecoveryPassword || step === AccountRestoration.DisplayName ? ( + + + {step === AccountRestoration.RecoveryPassword ? ( + <> + + {window.i18n('sessionRecoveryPassword')} + + + + {window.i18n('onboardingRecoveryPassword')} + + { + setRecoveryPhrase(seed); + setRecoveryPhraseError(!seed ? window.i18n('recoveryPhraseEmpty') : undefined); + }} + onEnterPressed={recoverWithoutDisplayName} + error={recoveryPhraseError} + enableShowHide={true} + inputDataTestId="recovery-phrase-input" + /> + + + + ) : ( + <> + {/* TODO this doesn't load for some reason */} + + {window.i18n('displayNamePick')} + + {window.i18n('displayNameDescription')} + + { + sanitizeDisplayNameOrToast(name, setDisplayName, setDisplayNameError); + }} + onEnterPressed={recoverWithDisplayName} + error={displayNameError} + inputDataTestId="display-name-input" + /> + + + + + )} + + + ) : ( { showPercentage={true} /> - ) : ( - - - - {window.i18n('sessionRecoveryPassword')} - - - - {window.i18n('onboardingRecoveryPassword')} - - { - setRecoveryPhrase(seed); - setRecoveryPhraseError(!seed ? window.i18n('recoveryPhraseEmpty') : undefined); - }} - onEnterPressed={continueYourSession} - error={recoveryPhraseError} - enableShowHide={true} - inputDataTestId="recovery-phrase-input" - /> - - - - )} ); diff --git a/ts/components/registration/utils/index.tsx b/ts/components/registration/utils/index.tsx new file mode 100644 index 000000000..0bdeb2d20 --- /dev/null +++ b/ts/components/registration/utils/index.tsx @@ -0,0 +1,34 @@ +import { ToastUtils } from '../../../session/utils'; +import { sanitizeSessionUsername } from '../../../session/utils/String'; + +export function sanitizeDisplayNameOrToast( + displayName: string, + setDisplayName: (sanitized: string) => void, + setDisplayNameError: (error: string | undefined) => void +) { + try { + const sanitizedName = sanitizeSessionUsername(displayName); + const trimName = sanitizedName.trim(); + setDisplayName(sanitizedName); + setDisplayNameError(!trimName ? window.i18n('displayNameEmpty') : undefined); + } catch (e) { + setDisplayName(displayName); + setDisplayNameError(window.i18n('displayNameErrorDescriptionShorter')); + } +} + +/** + * Returns undefined if an error happened, or the trim userName. + * + * Be sure to use the trimmed userName for creating the account. + */ +export const displayNameIsValid = (displayName: string): undefined | string => { + const trimName = displayName.trim(); + + if (!trimName) { + window?.log?.warn('invalid trimmed name for registration'); + ToastUtils.pushToastError('invalidDisplayName', window.i18n('displayNameEmpty')); + return undefined; + } + return trimName; +}; diff --git a/ts/session/constants.ts b/ts/session/constants.ts index 0d7524690..dc805d81d 100644 --- a/ts/session/constants.ts +++ b/ts/session/constants.ts @@ -80,3 +80,12 @@ export const FEATURE_RELEASE_TIMESTAMPS = { USER_CONFIG: 1690761600000, // Monday July 31st at 10am Melbourne time }; + +export const ONBOARDING_TIMES = { + /** 15 seconds */ + RECOVERY_TIMEOUT: 15 * DURATION.SECONDS, + /** 0.3 seconds */ + RECOVERY_FINISHING: 0.3 * DURATION.SECONDS, + /** 0.2 seconds */ + RECOVERY_FINISHED: 0.2 * DURATION.SECONDS, +}; diff --git a/ts/session/utils/errors.ts b/ts/session/utils/errors.ts index 0807b10c0..9e5f0029d 100644 --- a/ts/session/utils/errors.ts +++ b/ts/session/utils/errors.ts @@ -25,7 +25,7 @@ export class EmptySwarmError extends Error { export class NotFoundError extends Error { public error: any; - constructor(message: string, error: any) { + constructor(message: string, error?: any) { // 'Error' breaks prototype chain here super(message); this.error = error; diff --git a/ts/state/onboarding/ducks/registration.ts b/ts/state/onboarding/ducks/registration.ts index a12f0db91..d3b5cb96b 100644 --- a/ts/state/onboarding/ducks/registration.ts +++ b/ts/state/onboarding/ducks/registration.ts @@ -19,14 +19,16 @@ export enum AccountCreation { export enum AccountRestoration { /** starting screen */ RecoveryPassword, - /** fetching account details */ + /** fetching account details, so we increment progress to 100% over 15s */ Loading, - /** we failed to fetch a display name in time so we choose a new one */ + /** found account details, so we increment the remaining progress to 100% over 0.3s */ + Finishing, + /** found the account details and the progress is now 100%, so we wait for 0.2s */ + Finished, + /** we failed to fetch account details in time, so we enter it manually */ DisplayName, - /** show conversation screen */ + /** we have restored successfuly, show the conversation screen */ Complete, - /** TODO to be removed */ - LinkDevice, } export type OnboardingState = { diff --git a/ts/util/accountManager.ts b/ts/util/accountManager.ts index 7e5f934b6..d61be9a72 100644 --- a/ts/util/accountManager.ts +++ b/ts/util/accountManager.ts @@ -74,7 +74,7 @@ export async function signInWithRecovery( } /** - * Sign in with a recovery phrase but trying to recover display name and avatar from the first encountered configuration message. + * Sign in with a recovery phrase and try to recover display name and avatar from the first encountered configuration message. * @param mnemonic the mnemonic the user duly saved in a safe place. We will restore his sessionID based on this. * @param mnemonicLanguage 'english' only is supported */ @@ -97,7 +97,7 @@ export async function signInByLinkingDevice(mnemonic: string, mnemonicLanguage: return pubKeyString; } /** - * This is a signup. User has no recovery and does not try to link a device + * This signs up a new user account. User has no recovery and does not try to link a device * @param mnemonic The mnemonic generated on first app loading and to use for this brand new user * @param mnemonicLanguage only 'english' is supported * @param profileName the display name to register, character limit is MAX_NAME_LENGTH_BYTES