import { NativeStackScreenProps } from '@react-navigation/native-stack' import { chunk, flatMap, shuffle, times } from 'lodash' import * as React from 'react' import { Trans, WithTranslation } from 'react-i18next' import { ScrollView, StyleSheet, Text, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { connect } from 'react-redux' import { setBackupCompleted } from 'src/account/actions' import { showError } from 'src/alert/actions' import AppAnalytics from 'src/analytics/AppAnalytics' import { OnboardingEvents } from 'src/analytics/Events' import { BackQuizProgress } from 'src/analytics/types' import CancelConfirm from 'src/backup/CancelConfirm' import { QuizzBottom } from 'src/backup/QuizzBottom' import { getStoredMnemonic, onGetMnemonicFail } from 'src/backup/utils' import CancelButton from 'src/components/CancelButton' import DevSkipButton from 'src/components/DevSkipButton' import TextButton from 'src/components/TextButton' import Touchable from 'src/components/Touchable' import i18n, { withTranslation } from 'src/i18n' import Backspace from 'src/icons/Backspace' import { emptyHeader } from 'src/navigator/Headers' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { RootState } from 'src/redux/reducers' import colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import Logger from 'src/utils/Logger' import { currentAccountSelector } from 'src/web3/selectors' const TAG = 'backup/BackupQuiz' const MNEMONIC_BUTTONS_TO_DISPLAY = 6 // miliseconds to wait until showing success or failure const CHECKING_DURATION = 1.8 * 1000 export enum Mode { Entering, Checking, Failed, } interface State { mode: Mode mnemonic: string mnemonicLength: number mnemonicWords: string[] userChosenWords: Array<{ word: string index: number }> } interface StateProps { account: string | null } interface DispatchProps { setBackupCompleted: typeof setBackupCompleted showError: typeof showError } type OwnProps = NativeStackScreenProps type Props = WithTranslation & StateProps & DispatchProps & OwnProps const mapStateToProps = (state: RootState): StateProps => { return { account: currentAccountSelector(state), } } export const navOptionsForQuiz = ({ route }: OwnProps) => { const isAccountRemoval = route.params?.isAccountRemoval return { ...emptyHeader, headerLeft: () => { return isAccountRemoval ? ( navigate(Screens.SecuritySubmenu)} style={styles.cancelButton} /> ) : ( ) }, headerTitle: i18n.t(`headerTitle`), } } export class BackupQuiz extends React.Component { state: State = { mnemonic: '', mnemonicLength: 0, mnemonicWords: [], userChosenWords: [], mode: Mode.Entering, } setBackSpace = () => { const isVisible = this.state.userChosenWords.length > 0 && this.state.mode === Mode.Entering this.props.navigation.setOptions({ headerRight: () => ( ), }) } componentDidUpdate = () => { this.setBackSpace() } componentDidMount = async () => { await this.retrieveMnemonic() AppAnalytics.track(OnboardingEvents.backup_quiz_start) } retrieveMnemonic = async () => { const mnemonic = await getStoredMnemonic(this.props.account) if (mnemonic) { const shuffledMnemonic = getShuffledWordSet(mnemonic) this.setState({ mnemonic, mnemonicWords: shuffledMnemonic, mnemonicLength: shuffledMnemonic.length, }) } else { onGetMnemonicFail(this.props.showError, 'BackupQuiz') } } onPressMnemonicWord = (word: string, index: number) => { const { mnemonicWords, userChosenWords } = this.state const mnemonicWordsUpdated = [...mnemonicWords] mnemonicWordsUpdated.splice(index, 1) const newUserChosenWords = [...userChosenWords, { word, index }] this.setState({ mnemonicWords: mnemonicWordsUpdated, userChosenWords: newUserChosenWords, }) AppAnalytics.track(OnboardingEvents.backup_quiz_progress, { action: BackQuizProgress.word_chosen, }) } onPressBackspace = () => { const { mnemonicWords, userChosenWords } = this.state if (!userChosenWords.length) { return } const userChosenWordsUpdated = [...userChosenWords] const lastWord = userChosenWordsUpdated.pop() const mnemonicWordsUpdated = [...mnemonicWords] mnemonicWordsUpdated.splice(lastWord!.index, 0, lastWord!.word) this.setState({ mnemonicWords: mnemonicWordsUpdated, userChosenWords: userChosenWordsUpdated, }) AppAnalytics.track(OnboardingEvents.backup_quiz_progress, { action: BackQuizProgress.backspace, }) } onPressReset = async () => { const mnemonic = this.state.mnemonic this.setState({ mnemonicWords: getShuffledWordSet(mnemonic), userChosenWords: [], mode: Mode.Entering, }) } afterCheck = async () => { const { userChosenWords, mnemonicLength } = this.state const mnemonic = this.state.mnemonic const lengthsMatch = userChosenWords.length === mnemonicLength if (lengthsMatch && contentMatches(userChosenWords, mnemonic)) { Logger.debug(TAG, 'Backup quiz passed') this.props.setBackupCompleted() const isAccountRemoval = this.props.route.params?.isAccountRemoval ?? false navigate(Screens.BackupComplete, { isAccountRemoval }) AppAnalytics.track(OnboardingEvents.backup_quiz_complete) } else { Logger.debug(TAG, 'Backup quiz failed, reseting words') this.setState({ mode: Mode.Failed }) AppAnalytics.track(OnboardingEvents.backup_quiz_incorrect) } } onPressSubmit = () => { this.setState({ mode: Mode.Checking }) setTimeout(this.afterCheck, CHECKING_DURATION) } onScreenSkip = () => { Logger.debug(TAG, 'Skipping backup quiz') this.props.setBackupCompleted() } render() { const { t } = this.props const { mnemonicWords: mnemonicWordButtons, userChosenWords, mnemonicLength } = this.state const currentWordIndex = userChosenWords.length + 1 const isQuizComplete = userChosenWords.length === mnemonicLength && mnemonicLength !== 0 const mnemonicWordsToDisplay = mnemonicWordButtons.slice(0, MNEMONIC_BUTTONS_TO_DISPLAY) return ( <> {times(mnemonicLength, (i) => ( {(userChosenWords[i] && userChosenWords[i].word) || i + 1} ))} {this.state.mode === Mode.Failed && ( {t('reset')} )} {!isQuizComplete && ( X )} {mnemonicWordsToDisplay.map((word, index) => ( ))} ) } } interface WordProps { word: string index: number onPressWord: (word: string, index: number) => void } const Word = React.memo(function _Word({ word, index, onPressWord }: WordProps) { const onPressMnemonicWord = React.useCallback(() => { onPressWord(word, index) }, [word, index]) return ( {word} ) }) interface Content { word: string index: number } function contentMatches(userChosenWords: Content[], mnemonic: string) { return userChosenWords.map((w) => w.word).join(' ') === mnemonic } function DeleteWord({ onPressBackspace, visible, }: { onPressBackspace: () => void visible: boolean }) { if (!visible) { return null } return ( ) } function getShuffledWordSet(mnemonic: string) { return flatMap( chunk(mnemonic.split(' '), MNEMONIC_BUTTONS_TO_DISPLAY).map((mnemonicChunk) => shuffle(mnemonicChunk) ) ) } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'space-between', paddingHorizontal: 24, paddingBottom: 24, }, scrollContainer: { paddingTop: 24, flexGrow: 1, }, bottomHalf: { flex: 1, justifyContent: 'center' }, bodyText: { marginTop: 20, ...typeScale.bodyMedium, textAlign: 'center', }, bodyTextBold: { ...typeScale.labelMedium, textAlign: 'center', marginTop: 25, }, chosenWordsContainer: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', maxWidth: 288, alignSelf: 'center', }, chosenWordWrapper: { paddingVertical: 4, paddingHorizontal: 8, marginHorizontal: 3, marginVertical: 4, minWidth: 55, borderWidth: 1, borderColor: colors.borderPrimary, borderRadius: 100, }, chosenWordWrapperFilled: { backgroundColor: colors.backgroundTertiary, }, chosenWord: { ...typeScale.bodySmall, textAlign: 'center', lineHeight: undefined, color: colors.contentSecondary, }, chosenWordFilled: { ...typeScale.bodySmall, textAlign: 'center', lineHeight: undefined, color: colors.contentPrimary, }, mnemonicButtonsContainer: { marginTop: 24, flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', }, mnemonicWordButtonOutterRim: { borderRadius: 100, borderWidth: 1.5, borderColor: colors.accent, overflow: 'hidden', marginVertical: 4, marginHorizontal: 4, }, mnemonicWordButton: { borderRadius: 100, minWidth: 65, paddingHorizontal: 16, paddingVertical: 8, }, mnemonicWordButonText: { textAlign: 'center', color: colors.accent, }, backWord: { paddingRight: 24, paddingLeft: 16, paddingVertical: 4, }, resetButton: { alignItems: 'center', padding: 24, marginTop: 8 }, cancelButton: { color: colors.navigationTopSecondary, }, }) export default connect(mapStateToProps, { setBackupCompleted, showError, })(withTranslation()(BackupQuiz))