feat: added quit modal

when going back during critical onboarding steps it's better to restart the app entirely
pull/3083/head
William Grant 10 months ago
parent 150ea61b03
commit 55f5cf5c9b

@ -360,6 +360,8 @@
"onboardingAccountCreate": "Create account", "onboardingAccountCreate": "Create account",
"onboardingAccountCreated": "Account Created", "onboardingAccountCreated": "Account Created",
"onboardingAccountExists": "I have an account", "onboardingAccountExists": "I have an account",
"onboardingBackAccountCreation": "You cannot go back further. In order to cancel your account creation, Session needs to quit.",
"onboardingBackLoadAccount": "You cannot go back further. In order to stop loading your account, Session needs to quit.",
"onboardingBubbleWelcomeToSession": "Welcome to Session", "onboardingBubbleWelcomeToSession": "Welcome to Session",
"onboardingHitThePlusButton": "Hit the plus button to start a chat, create a group, or join an official community!", "onboardingHitThePlusButton": "Hit the plus button to start a chat, create a group, or join an official community!",
"onboardingRecoveryPassword": "Enter your recovery password to load your account. If you haven't saved it, you can find it in your app settings.", "onboardingRecoveryPassword": "Enter your recovery password to load your account. If you haven't saved it, you can find it in your app settings.",
@ -408,6 +410,7 @@
"pruneSettingTitle": "Trim Communities", "pruneSettingTitle": "Trim Communities",
"publicChatExists": "You are already connected to this community", "publicChatExists": "You are already connected to this community",
"qrView": "View QR", "qrView": "View QR",
"quitButton": "Quit",
"quoteThumbnailAlt": "Thumbnail of image from quoted message", "quoteThumbnailAlt": "Thumbnail of image from quoted message",
"rateLimitReactMessage": "Slow down! You've sent too many emoji reacts. Try again soon", "rateLimitReactMessage": "Slow down! You've sent too many emoji reacts. Try again soon",
"reactionListCountPlural": "And $otherPlural$ have reacted <span>$emoji$</span> to this message", "reactionListCountPlural": "And $otherPlural$ have reacted <span>$emoji$</span> to this message",

@ -50,7 +50,7 @@ const StyledButton = styled.button<{
height: ${props => (props.buttonType === SessionButtonType.Ghost ? undefined : '34px')}; height: ${props => (props.buttonType === SessionButtonType.Ghost ? undefined : '34px')};
min-height: ${props => (props.buttonType === SessionButtonType.Ghost ? undefined : '34px')}; min-height: ${props => (props.buttonType === SessionButtonType.Ghost ? undefined : '34px')};
padding: ${props => padding: ${props =>
props.buttonType === SessionButtonType.Ghost ? '16px 24px 24px' : '0px 18px'}; props.buttonType === SessionButtonType.Ghost ? '18px 24px 22px' : '0px 18px'};
background-color: ${props => background-color: ${props =>
props.buttonType === SessionButtonType.Solid && props.color props.buttonType === SessionButtonType.Solid && props.color
? `var(--${props.color}-color)` ? `var(--${props.color}-color)`

@ -0,0 +1,129 @@
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import useKey from 'react-use/lib/useKey';
import styled from 'styled-components';
import { updateQuitModal } from '../../state/onboarding/ducks/modals';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { Flex } from '../basic/Flex';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SpacerLG, SpacerSM } from '../basic/Text';
const StyledMessage = styled.span`
max-width: 300px;
width: 100%;
line-height: 1.4;
`;
type QuitModalProps = {
message?: string;
title?: string;
onOk?: any;
onClose?: any;
closeAfterInput?: boolean;
/**
* function to run on ok click. Closes modal after execution by default
* sometimes the callback might need arguments when using radioOptions
*/
onClickOk?: (...args: Array<any>) => Promise<void> | void;
onClickClose?: () => any;
/**
* function to run on close click. Closes modal after execution by default
*/
onClickCancel?: () => any;
okText?: string;
cancelText?: string;
okTheme?: SessionButtonColor;
closeTheme?: SessionButtonColor;
};
export const QuitModal = (props: QuitModalProps) => {
const dispatch = useDispatch();
const {
title = '',
message = '',
okTheme,
closeTheme = SessionButtonColor.Danger,
onClickOk,
onClickClose,
onClickCancel,
closeAfterInput = true,
} = props;
const [isLoading, setIsLoading] = useState(false);
const okText = props.okText || window.i18n('ok');
const cancelText = props.cancelText || window.i18n('cancel');
const onClickOkHandler = async () => {
if (onClickOk) {
setIsLoading(true);
try {
await onClickOk();
} catch (e) {
window.log.warn(e);
} finally {
setIsLoading(false);
}
}
if (closeAfterInput) {
dispatch(updateQuitModal(null));
}
};
/**
* Performs specified on close action then removes the modal.
*/
const onClickCancelHandler = () => {
onClickCancel?.();
onClickClose?.();
dispatch(updateQuitModal(null));
};
useKey('Enter', () => {
void onClickOkHandler();
});
useKey('Escape', () => {
onClickCancelHandler();
});
return (
<SessionWrapperModal
title={title}
onClose={onClickClose}
showExitIcon={false}
showHeader={true}
additionalClassName={'no-body-padding'}
>
<Flex container={true} width={'100%'} justifyContent="center" alignItems="center">
<SpacerLG />
<StyledMessage>{message}</StyledMessage>
<SpacerLG />
</Flex>
<SpacerSM />
<Flex container={true} width={'100%'} justifyContent="center" alignItems="center">
<SessionButton
text={okText}
buttonColor={okTheme}
buttonType={SessionButtonType.Ghost}
onClick={onClickOkHandler}
disabled={isLoading}
dataTestId="session-confirm-ok-button"
/>
<SessionButton
text={cancelText}
buttonColor={!okTheme ? closeTheme : undefined}
buttonType={SessionButtonType.Ghost}
onClick={onClickCancelHandler}
disabled={isLoading}
dataTestId="session-confirm-cancel-button"
/>
</Flex>
</SessionWrapperModal>
);
};

@ -1,12 +1,18 @@
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getTermsOfServicePrivacyModalState } from '../../state/onboarding/selectors/modals'; import {
getQuitModalState,
getTermsOfServicePrivacyModalState,
} from '../../state/onboarding/selectors/modals';
import { QuitModal } from '../dialog/QuitModal';
import { TermsOfServicePrivacyDialog } from '../dialog/TermsOfServicePrivacyDialog'; import { TermsOfServicePrivacyDialog } from '../dialog/TermsOfServicePrivacyDialog';
export const ModalContainer = () => { export const ModalContainer = () => {
const quitModalState = useSelector(getQuitModalState);
const termsOfServicePrivacyModalState = useSelector(getTermsOfServicePrivacyModalState); const termsOfServicePrivacyModalState = useSelector(getTermsOfServicePrivacyModalState);
return ( return (
<> <>
{quitModalState && <QuitModal {...quitModalState} />}
{termsOfServicePrivacyModalState && ( {termsOfServicePrivacyModalState && (
<TermsOfServicePrivacyDialog {...termsOfServicePrivacyModalState} /> <TermsOfServicePrivacyDialog {...termsOfServicePrivacyModalState} />
)} )}

@ -1,5 +1,7 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { updateQuitModal } from '../../../state/onboarding/ducks/modals';
import { import {
AccountRestoration, AccountRestoration,
Onboarding, Onboarding,
@ -11,29 +13,55 @@ import {
useOnboardAccountRestorationStep, useOnboardAccountRestorationStep,
useOnboardStep, useOnboardStep,
} from '../../../state/onboarding/selectors/registration'; } from '../../../state/onboarding/selectors/registration';
import { deleteDbLocally } from '../../../util/accountManager';
import { Flex } from '../../basic/Flex'; import { Flex } from '../../basic/Flex';
import { SessionButtonColor } from '../../basic/SessionButton';
import { SessionIconButton } from '../../icon'; import { SessionIconButton } from '../../icon';
/** Min height should match the onboarding step with the largest height this prevents the loading spinner from jumping around while still keeping things centered */
const StyledBackButtonContainer = styled(Flex)`
min-height: 276px;
height: 100%;
`;
export const BackButtonWithinContainer = ({ export const BackButtonWithinContainer = ({
children, children,
margin, margin,
callback, callback,
shouldQuit,
quitMessage,
}: { }: {
children: ReactNode; children: ReactNode;
margin?: string; margin?: string;
callback?: () => void; callback?: () => void;
shouldQuit?: boolean;
quitMessage?: string;
}) => { }) => {
return ( return (
<Flex container={true} width={'100%'} flexDirection="row" alignItems="flex-start"> <StyledBackButtonContainer
container={true}
width={'100%'}
flexDirection="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<div style={{ margin }}> <div style={{ margin }}>
<BackButton callback={callback} /> <BackButton callback={callback} shouldQuit={shouldQuit} quitMessage={quitMessage} />
</div> </div>
{children} {children}
</Flex> </StyledBackButtonContainer>
); );
}; };
export const BackButton = ({ callback }: { callback?: () => void }) => { export const BackButton = ({
callback,
shouldQuit,
quitMessage,
}: {
callback?: () => void;
shouldQuit?: boolean;
quitMessage?: string;
}) => {
const step = useOnboardStep(); const step = useOnboardStep();
const restorationStep = useOnboardAccountRestorationStep(); const restorationStep = useOnboardAccountRestorationStep();
@ -48,6 +76,36 @@ export const BackButton = ({ callback }: { callback?: () => void }) => {
iconRotation={90} iconRotation={90}
padding={'0'} padding={'0'}
onClick={() => { onClick={() => {
if (shouldQuit && quitMessage) {
dispatch(
updateQuitModal({
title: window.i18n('warning'),
message: quitMessage,
okTheme: SessionButtonColor.Danger,
okText: window.i18n('quitButton'),
onClickOk: async () => {
try {
window.log.warn(
'[onboarding] Deleting everything on device but keeping network data'
);
await deleteDbLocally();
} catch (error) {
window.log.warn(
'[onboarding] Something went wrong when deleting all local data:',
error && error.stack ? error.stack : error
);
} finally {
window.restart();
}
},
onClickCancel: () => {
window.inboxStore?.dispatch(updateQuitModal(null));
},
})
);
return;
}
dispatch(setDirection('backward')); dispatch(setDirection('backward'));
if (step === Onboarding.CreateAccount) { if (step === Onboarding.CreateAccount) {
dispatch(setOnboardingStep(Onboarding.Start)); dispatch(setOnboardingStep(Onboarding.Start));

@ -109,6 +109,8 @@ export const CreateAccount = () => {
return ( return (
<BackButtonWithinContainer <BackButtonWithinContainer
margin={'2px 0 0 -36px'} margin={'2px 0 0 -36px'}
shouldQuit={true}
quitMessage={window.i18n('onboardingBackAccountCreation')}
callback={() => { callback={() => {
dispatch(setDisplayName('')); dispatch(setDisplayName(''));
dispatch(setRecoveryPassword('')); dispatch(setRecoveryPassword(''));

@ -190,121 +190,113 @@ export const RestoreAccount = () => {
}; };
return ( return (
<> <BackButtonWithinContainer
{step === AccountRestoration.RecoveryPassword || step === AccountRestoration.DisplayName ? ( margin={'2px 0 0 -36px'}
<BackButtonWithinContainer shouldQuit={step !== AccountRestoration.RecoveryPassword}
margin={'2px 0 0 -36px'} quitMessage={window.i18n('onboardingBackLoadAccount')}
callback={() => { callback={() => {
dispatch(setRecoveryPassword('')); dispatch(setRecoveryPassword(''));
dispatch(setDisplayName('')); dispatch(setDisplayName(''));
dispatch(setProgress(0)); dispatch(setProgress(0));
dispatch(setRecoveryPasswordError(undefined)); dispatch(setRecoveryPasswordError(undefined));
dispatch(setDisplayNameError(undefined)); dispatch(setDisplayNameError(undefined));
}} }}
> >
<Flex <Flex
container={true} container={true}
width="100%" width="100%"
flexDirection="column" flexDirection="column"
justifyContent="flex-start" justifyContent="flex-start"
alignItems="flex-start" alignItems="flex-start"
margin={'0 0 0 8px'} margin={
> step === AccountRestoration.RecoveryPassword || step === AccountRestoration.DisplayName
{step === AccountRestoration.RecoveryPassword ? ( ? '0 0 0 8px'
<> : '0px'
<Flex container={true} width={'100%'} alignItems="center"> }
<OnboardHeading>{window.i18n('sessionRecoveryPassword')}</OnboardHeading> >
<SessionIcon {step === AccountRestoration.RecoveryPassword ? (
iconType="recoveryPasswordOutline" <>
iconSize="huge" <Flex container={true} width={'100%'} alignItems="center">
iconColor="var(--text-primary-color)" <OnboardHeading>{window.i18n('sessionRecoveryPassword')}</OnboardHeading>
style={{ margin: '-4px 0 0 8px' }} <SessionIcon
/> iconType="recoveryPasswordOutline"
</Flex> iconSize="huge"
<SpacerSM /> iconColor="var(--text-primary-color)"
<OnboardDescription>{window.i18n('onboardingRecoveryPassword')}</OnboardDescription> style={{ margin: '-4px 0 0 8px' }}
<SpacerLG /> />
<SessionInput </Flex>
ariaLabel="Recovery password input" <SpacerSM />
autoFocus={true} <OnboardDescription>{window.i18n('onboardingRecoveryPassword')}</OnboardDescription>
disableOnBlurEvent={true} <SpacerLG />
type="password" <SessionInput
placeholder={window.i18n('recoveryPasswordEnter')} ariaLabel="Recovery password input"
value={recoveryPassword} autoFocus={true}
onValueChanged={(seed: string) => { disableOnBlurEvent={true}
dispatch(setRecoveryPassword(seed)); type="password"
dispatch( placeholder={window.i18n('recoveryPasswordEnter')}
setRecoveryPasswordError( value={recoveryPassword}
!seed ? window.i18n('recoveryPasswordEnter') : undefined onValueChanged={(seed: string) => {
) dispatch(setRecoveryPassword(seed));
); dispatch(
}} setRecoveryPasswordError(!seed ? window.i18n('recoveryPasswordEnter') : undefined)
onEnterPressed={recoverAndFetchDisplayName} );
error={recoveryPasswordError} }}
enableShowHideButton={true} onEnterPressed={recoverAndFetchDisplayName}
showHideButtonAriaLabels={{ error={recoveryPasswordError}
hide: 'Hide recovery password toggle', enableShowHideButton={true}
show: 'Reveal recovery password toggle', showHideButtonAriaLabels={{
}} hide: 'Hide recovery password toggle',
showHideButtonDataTestIds={{ show: 'Reveal recovery password toggle',
hide: 'hide-recovery-phrase-toggle', }}
show: 'reveal-recovery-phrase-toggle', showHideButtonDataTestIds={{
}} hide: 'hide-recovery-phrase-toggle',
inputDataTestId="recovery-phrase-input" show: 'reveal-recovery-phrase-toggle',
/> }}
<SpacerLG /> inputDataTestId="recovery-phrase-input"
<ContinueButton />
onClick={recoverAndFetchDisplayName} <SpacerLG />
disabled={!(!!recoveryPassword && !recoveryPasswordError)} <ContinueButton
/> onClick={recoverAndFetchDisplayName}
</> disabled={!(!!recoveryPassword && !recoveryPasswordError)}
) : ( />
<Flex container={true} width="100%" flexDirection="column" alignItems="flex-start"> </>
<OnboardHeading>{window.i18n('displayNameNew')}</OnboardHeading> ) : step === AccountRestoration.DisplayName ? (
<SpacerSM /> <Flex container={true} width="100%" flexDirection="column" alignItems="flex-start">
<OnboardDescription>{window.i18n('displayNameErrorNew')}</OnboardDescription> <OnboardHeading>{window.i18n('displayNameNew')}</OnboardHeading>
<SpacerLG /> <SpacerSM />
<SessionInput <OnboardDescription>{window.i18n('displayNameErrorNew')}</OnboardDescription>
ariaLabel={window.i18n('enterDisplayName')} <SpacerLG />
autoFocus={true} <SessionInput
disableOnBlurEvent={true} ariaLabel={window.i18n('enterDisplayName')}
type="text" autoFocus={true}
placeholder={window.i18n('enterDisplayName')} disableOnBlurEvent={true}
value={displayName} type="text"
onValueChanged={(name: string) => { placeholder={window.i18n('enterDisplayName')}
const sanitizedName = sanitizeDisplayNameOrToast( value={displayName}
name, onValueChanged={(name: string) => {
setDisplayNameError, const sanitizedName = sanitizeDisplayNameOrToast(
dispatch name,
); setDisplayNameError,
dispatch(setDisplayName(sanitizedName)); dispatch
}} );
onEnterPressed={recoverAndEnterDisplayName} dispatch(setDisplayName(sanitizedName));
error={displayNameError} }}
inputDataTestId="display-name-input" onEnterPressed={recoverAndEnterDisplayName}
/> error={displayNameError}
<SpacerLG /> inputDataTestId="display-name-input"
<ContinueButton />
onClick={recoverAndEnterDisplayName} <SpacerLG />
disabled={ <ContinueButton
isEmpty(recoveryPassword) || onClick={recoverAndEnterDisplayName}
!isEmpty(recoveryPasswordError) || disabled={
isEmpty(displayName) || isEmpty(recoveryPassword) ||
!isEmpty(displayNameError) !isEmpty(recoveryPasswordError) ||
} isEmpty(displayName) ||
/> !isEmpty(displayNameError)
</Flex> }
)} />
</Flex> </Flex>
</BackButtonWithinContainer> ) : (
) : (
<Flex
container={true}
width="100%"
flexDirection="column"
justifyContent="flex-start"
alignItems="flex-start"
>
<SessionProgressBar <SessionProgressBar
initialValue={ initialValue={
step !== AccountRestoration.Finished && step !== AccountRestoration.Complete step !== AccountRestoration.Finished && step !== AccountRestoration.Complete
@ -317,8 +309,8 @@ export const RestoreAccount = () => {
subtitle={window.i18n('loadAccountProgressMessage')} subtitle={window.i18n('loadAccountProgressMessage')}
showPercentage={true} showPercentage={true}
/> />
</Flex> )}
)} </Flex>
</> </BackButtonWithinContainer>
); );
}; };

@ -1,13 +1,16 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { TermsOfServicePrivacyDialogProps } from '../../../components/dialog/TermsOfServicePrivacyDialog'; import { TermsOfServicePrivacyDialogProps } from '../../../components/dialog/TermsOfServicePrivacyDialog';
import { ConfirmModalState } from '../../ducks/modalDialog';
export type TermsOfServicePrivacyModalState = TermsOfServicePrivacyDialogProps | null; export type TermsOfServicePrivacyModalState = TermsOfServicePrivacyDialogProps | null;
export type ModalsState = { export type ModalsState = {
quitModalState: ConfirmModalState | null;
termsOfServicePrivacyModalState: TermsOfServicePrivacyModalState | null; termsOfServicePrivacyModalState: TermsOfServicePrivacyModalState | null;
}; };
const initialState: ModalsState = { const initialState: ModalsState = {
quitModalState: null,
termsOfServicePrivacyModalState: null, termsOfServicePrivacyModalState: null,
}; };
@ -15,6 +18,9 @@ export const modalsSlice = createSlice({
name: 'modals', name: 'modals',
initialState, initialState,
reducers: { reducers: {
updateQuitModal(state, action: PayloadAction<ConfirmModalState>) {
return { ...state, quitModalState: action.payload };
},
updateTermsOfServicePrivacyModal( updateTermsOfServicePrivacyModal(
state, state,
action: PayloadAction<TermsOfServicePrivacyModalState> action: PayloadAction<TermsOfServicePrivacyModalState>
@ -24,5 +30,5 @@ export const modalsSlice = createSlice({
}, },
}); });
export const { updateTermsOfServicePrivacyModal } = modalsSlice.actions; export const { updateQuitModal, updateTermsOfServicePrivacyModal } = modalsSlice.actions;
export default modalsSlice.reducer; export default modalsSlice.reducer;

@ -1,4 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { ConfirmModalState } from '../../ducks/modalDialog';
import { ModalsState, TermsOfServicePrivacyModalState } from '../ducks/modals'; import { ModalsState, TermsOfServicePrivacyModalState } from '../ducks/modals';
import { OnboardingStoreState } from '../store'; import { OnboardingStoreState } from '../store';
@ -6,6 +7,11 @@ const getModals = (state: OnboardingStoreState): ModalsState => {
return state.modals; return state.modals;
}; };
export const getQuitModalState = createSelector(
getModals,
(state: ModalsState): ConfirmModalState => state.quitModalState
);
export const getTermsOfServicePrivacyModalState = createSelector( export const getTermsOfServicePrivacyModalState = createSelector(
getModals, getModals,
(state: ModalsState): TermsOfServicePrivacyModalState => state.termsOfServicePrivacyModalState (state: ModalsState): TermsOfServicePrivacyModalState => state.termsOfServicePrivacyModalState

@ -360,6 +360,8 @@ export type LocalizerKeys =
| 'onboardingAccountCreate' | 'onboardingAccountCreate'
| 'onboardingAccountCreated' | 'onboardingAccountCreated'
| 'onboardingAccountExists' | 'onboardingAccountExists'
| 'onboardingBackAccountCreation'
| 'onboardingBackLoadAccount'
| 'onboardingBubbleWelcomeToSession' | 'onboardingBubbleWelcomeToSession'
| 'onboardingHitThePlusButton' | 'onboardingHitThePlusButton'
| 'onboardingRecoveryPassword' | 'onboardingRecoveryPassword'
@ -408,6 +410,7 @@ export type LocalizerKeys =
| 'pruneSettingTitle' | 'pruneSettingTitle'
| 'publicChatExists' | 'publicChatExists'
| 'qrView' | 'qrView'
| 'quitButton'
| 'quoteThumbnailAlt' | 'quoteThumbnailAlt'
| 'rateLimitReactMessage' | 'rateLimitReactMessage'
| 'reactionListCountPlural' | 'reactionListCountPlural'

@ -245,7 +245,7 @@ export async function registrationDone(ourPubkey: string, displayName: string) {
trigger('registration_done'); trigger('registration_done');
} }
const deleteDbLocally = async () => { export const deleteDbLocally = async () => {
window?.log?.info('last message sent successfully. Deleting everything'); window?.log?.info('last message sent successfully. Deleting everything');
await window.persistStore?.purge(); await window.persistStore?.purge();
window?.log?.info('store purged'); window?.log?.info('store purged');

Loading…
Cancel
Save