feat: added quit modal

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

@ -360,6 +360,8 @@
"onboardingAccountCreate": "Create account",
"onboardingAccountCreated": "Account Created",
"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",
"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.",
@ -408,6 +410,7 @@
"pruneSettingTitle": "Trim Communities",
"publicChatExists": "You are already connected to this community",
"qrView": "View QR",
"quitButton": "Quit",
"quoteThumbnailAlt": "Thumbnail of image from quoted message",
"rateLimitReactMessage": "Slow down! You've sent too many emoji reacts. Try again soon",
"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')};
min-height: ${props => (props.buttonType === SessionButtonType.Ghost ? undefined : '34px')};
padding: ${props =>
props.buttonType === SessionButtonType.Ghost ? '16px 24px 24px' : '0px 18px'};
props.buttonType === SessionButtonType.Ghost ? '18px 24px 22px' : '0px 18px'};
background-color: ${props =>
props.buttonType === SessionButtonType.Solid && props.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 { getTermsOfServicePrivacyModalState } from '../../state/onboarding/selectors/modals';
import {
getQuitModalState,
getTermsOfServicePrivacyModalState,
} from '../../state/onboarding/selectors/modals';
import { QuitModal } from '../dialog/QuitModal';
import { TermsOfServicePrivacyDialog } from '../dialog/TermsOfServicePrivacyDialog';
export const ModalContainer = () => {
const quitModalState = useSelector(getQuitModalState);
const termsOfServicePrivacyModalState = useSelector(getTermsOfServicePrivacyModalState);
return (
<>
{quitModalState && <QuitModal {...quitModalState} />}
{termsOfServicePrivacyModalState && (
<TermsOfServicePrivacyDialog {...termsOfServicePrivacyModalState} />
)}

@ -1,5 +1,7 @@
import { ReactNode } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { updateQuitModal } from '../../../state/onboarding/ducks/modals';
import {
AccountRestoration,
Onboarding,
@ -11,29 +13,55 @@ import {
useOnboardAccountRestorationStep,
useOnboardStep,
} from '../../../state/onboarding/selectors/registration';
import { deleteDbLocally } from '../../../util/accountManager';
import { Flex } from '../../basic/Flex';
import { SessionButtonColor } from '../../basic/SessionButton';
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 = ({
children,
margin,
callback,
shouldQuit,
quitMessage,
}: {
children: ReactNode;
margin?: string;
callback?: () => void;
shouldQuit?: boolean;
quitMessage?: string;
}) => {
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 }}>
<BackButton callback={callback} />
<BackButton callback={callback} shouldQuit={shouldQuit} quitMessage={quitMessage} />
</div>
{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 restorationStep = useOnboardAccountRestorationStep();
@ -48,6 +76,36 @@ export const BackButton = ({ callback }: { callback?: () => void }) => {
iconRotation={90}
padding={'0'}
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'));
if (step === Onboarding.CreateAccount) {
dispatch(setOnboardingStep(Onboarding.Start));

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

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

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

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

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

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

Loading…
Cancel
Save