Mnemonic Redesign (#1501)

* Added a shuffle to swap out the numbers and made them read-only.

* Add a border and an index when selecting a confirmation word

* Add error flashing

* Transform the inputs into buttons when confirming

* Change naming of "peek" to "revealing"

* If statement needs brackets

* Linter complaints

* Code cleanup

* Formatting and removing unused styles.

* Move out shuffle to a util

* Additional test cases

* Function call, forgot the parens

* Adjust sizing and position of confirmation index in Mnemonic button

* Implement requested style changes: selected buttons, margins on inputs

* Use lodahs shuffle

* No need to spread this array
This commit is contained in:
Connor Bryan 2018-04-12 21:50:36 -07:00 committed by Daniel Ternyak
parent a40b2cc499
commit 7320413dab
5 changed files with 226 additions and 123 deletions

5
.gitignore vendored
View File

@ -58,4 +58,7 @@ v8-compile-cache-0/
package-lock.json package-lock.json
# Favicon cache # Favicon cache
.wwp-cache .wwp-cache
# MacOSX
.DS_Store

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { generateMnemonic } from 'bip39'; import { generateMnemonic } from 'bip39';
import translate from 'translations'; import translate from 'translations';
import shuffle from 'lodash/shuffle';
import Word from './Word'; import Word from './Word';
import FinalSteps from '../FinalSteps'; import FinalSteps from '../FinalSteps';
import Template from '../Template'; import Template from '../Template';
@ -10,8 +11,10 @@ import './Mnemonic.scss';
interface State { interface State {
words: string[]; words: string[];
confirmValues: string[]; confirmValues: string[];
confirmWords: WordTuple[][];
isConfirming: boolean; isConfirming: boolean;
isConfirmed: boolean; isConfirmed: boolean;
isRevealingNextWord: boolean;
} }
interface WordTuple { interface WordTuple {
@ -23,8 +26,10 @@ export default class GenerateMnemonic extends React.Component<{}, State> {
public state: State = { public state: State = {
words: [], words: [],
confirmValues: [], confirmValues: [],
confirmWords: [],
isConfirming: false, isConfirming: false,
isConfirmed: false isConfirmed: false,
isRevealingNextWord: false
}; };
public componentDidMount() { public componentDidMount() {
@ -32,63 +37,57 @@ export default class GenerateMnemonic extends React.Component<{}, State> {
} }
public render() { public render() {
const { words, isConfirming, isConfirmed } = this.state; const { words, confirmWords, isConfirming, isConfirmed } = this.state;
let content; const defaultBtnClassName = 'GenerateMnemonic-buttons-btn btn btn-default';
const canContinue = this.checkCanContinue();
const [firstHalf, lastHalf] =
confirmWords.length === 0 ? this.splitWordsIntoHalves(words) : confirmWords;
if (isConfirmed) { const content = isConfirmed ? (
content = <FinalSteps walletType={WalletType.Mnemonic} />; <FinalSteps walletType={WalletType.Mnemonic} />
} else { ) : (
const canContinue = this.checkCanContinue(); <div className="GenerateMnemonic">
const firstHalf: WordTuple[] = []; <h1 className="GenerateMnemonic-title">{translate('GENERATE_MNEMONIC_TITLE')}</h1>
const lastHalf: WordTuple[] = [];
words.forEach((word, index) => {
if (index < words.length / 2) {
firstHalf.push({ word, index });
} else {
lastHalf.push({ word, index });
}
});
content = ( <p className="GenerateMnemonic-help">
<div className="GenerateMnemonic"> {isConfirming ? translate('MNEMONIC_DESCRIPTION_1') : translate('MNEMONIC_DESCRIPTION_2')}
<h1 className="GenerateMnemonic-title">{translate('GENERATE_MNEMONIC_TITLE')}</h1> </p>
<p className="GenerateMnemonic-help"> <div className="GenerateMnemonic-words">
{isConfirming {[firstHalf, lastHalf].map((ws, i) => (
? translate('MNEMONIC_DESCRIPTION_1') <div key={i} className="GenerateMnemonic-words-column">
: translate('MNEMONIC_DESCRIPTION_2')} {ws.map(this.makeWord)}
</p> </div>
))}
<div className="GenerateMnemonic-words">
{[firstHalf, lastHalf].map((ws, i) => (
<div key={i} className="GenerateMnemonic-words-column">
{ws.map(this.makeWord)}
</div>
))}
</div>
<div className="GenerateMnemonic-buttons">
{!isConfirming && (
<button
className="GenerateMnemonic-buttons-btn btn btn-default"
onClick={this.regenerateWordArray}
>
<i className="fa fa-refresh" /> {translate('REGENERATE_MNEMONIC')}
</button>
)}
<button
className="GenerateMnemonic-buttons-btn btn btn-primary"
disabled={!canContinue}
onClick={this.goToNextStep}
>
{translate('CONFIRM_MNEMONIC')}
</button>
</div>
<button className="GenerateMnemonic-skip" onClick={this.skip} />
</div> </div>
);
} <div className="GenerateMnemonic-buttons">
{!isConfirming && (
<button className={defaultBtnClassName} onClick={this.regenerateWordArray}>
<i className="fa fa-refresh" /> {translate('REGENERATE_MNEMONIC')}
</button>
)}
{isConfirming && (
<button
className={defaultBtnClassName}
disabled={canContinue}
onClick={this.revealNextWord}
>
<i className="fa fa-eye" /> {translate('REVEAL_NEXT_MNEMONIC')}
</button>
)}
<button
className="GenerateMnemonic-buttons-btn btn btn-primary"
disabled={!canContinue}
onClick={this.goToNextStep}
>
{translate('CONFIRM_MNEMONIC')}
</button>
</div>
<button className="GenerateMnemonic-skip" onClick={this.skip} />
</div>
);
return <Template>{content}</Template>; return <Template>{content}</Template>;
} }
@ -97,14 +96,6 @@ export default class GenerateMnemonic extends React.Component<{}, State> {
this.setState({ words: generateMnemonic().split(' ') }); this.setState({ words: generateMnemonic().split(' ') });
}; };
private handleConfirmChange = (index: number, value: string) => {
this.setState((state: State) => {
const confirmValues = [...state.confirmValues];
confirmValues[index] = value;
this.setState({ confirmValues });
});
};
private goToNextStep = () => { private goToNextStep = () => {
if (!this.checkCanContinue()) { if (!this.checkCanContinue()) {
return; return;
@ -113,7 +104,13 @@ export default class GenerateMnemonic extends React.Component<{}, State> {
if (this.state.isConfirming) { if (this.state.isConfirming) {
this.setState({ isConfirmed: true }); this.setState({ isConfirmed: true });
} else { } else {
this.setState({ isConfirming: true }); const shuffledWords = shuffle(this.state.words);
const confirmWords = this.splitWordsIntoHalves(shuffledWords);
this.setState({
isConfirming: true,
confirmWords
});
} }
}; };
@ -129,18 +126,79 @@ export default class GenerateMnemonic extends React.Component<{}, State> {
} }
}; };
private makeWord = (word: WordTuple) => ( private makeWord = (word: WordTuple) => {
<Word const { words, confirmValues, isRevealingNextWord, isConfirming } = this.state;
key={`${word.word}${word.index}`} const confirmIndex = words.indexOf(word.word);
index={word.index} const nextIndex = confirmValues.length;
word={word.word} const isNext = confirmIndex === nextIndex;
value={this.state.confirmValues[word.index] || ''} const isRevealed = isRevealingNextWord && isNext;
isReadOnly={!this.state.isConfirming} const hasBeenConfirmed = this.getWordConfirmed(word.word);
onChange={this.handleConfirmChange}
/> return (
); <Word
key={`${word.word}${word.index}`}
index={word.index}
confirmIndex={confirmIndex}
word={word.word}
value={confirmValues[word.index] || ''}
showIndex={!isConfirming}
isNext={isNext}
isBeingRevealed={isRevealed}
isConfirming={isConfirming}
hasBeenConfirmed={hasBeenConfirmed}
onClick={this.handleWordClick}
/>
);
};
private handleWordClick = (_: number, value: string) => {
const { confirmValues: previousConfirmValues, words, isConfirming } = this.state;
const wordAlreadyConfirmed = previousConfirmValues.includes(value);
const activeIndex = previousConfirmValues.length;
const isCorrectChoice = words[activeIndex] === value;
if (isConfirming && !wordAlreadyConfirmed && isCorrectChoice) {
const confirmValues = previousConfirmValues.concat(value);
this.setState({ confirmValues });
}
};
private getWordConfirmed = (word: string) => this.state.confirmValues.includes(word);
private skip = () => { private skip = () => {
this.setState({ isConfirmed: true }); this.setState({ isConfirmed: true });
}; };
private revealNextWord = () => {
const revealDuration = 400;
this.setState(
{
isRevealingNextWord: true
},
() =>
setTimeout(
() =>
this.setState({
isRevealingNextWord: false
}),
revealDuration
)
);
};
private splitWordsIntoHalves = (words: string[]) => {
const firstHalf: WordTuple[] = [];
const lastHalf: WordTuple[] = [];
words.forEach((word: string, index: number) => {
const inFirstColumn = index < words.length / 2;
const half = inFirstColumn ? firstHalf : lastHalf;
half.push({ word, index });
});
return [firstHalf, lastHalf];
};
} }

View File

@ -16,21 +16,14 @@ $number-margin: 6px;
.MnemonicWord { .MnemonicWord {
display: flex; display: flex;
width: $width; width: $width;
margin-bottom: $space-md; margin-bottom: $space-xs;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
&-number { & .input-group-addon {
display: inline-block; margin-bottom: $space-md;
width: $number-width;
margin-right: $number-margin;
text-align: right;
font-size: 26px;
font-weight: 100;
line-height: 40px;
vertical-align: bottom;
} }
&-word { &-word {
@ -39,14 +32,31 @@ $number-margin: 6px;
&-input { &-input {
animation: word-fade 400ms ease 1; animation: word-fade 400ms ease 1;
animation-fill-mode: both; animation-fill-mode: both;
transition: border 0.2s ease-in;
margin-bottom: 0;
} }
}
&-toggle { &-button {
color: $gray-light; position: relative;
width: 300px;
margin-bottom: $space-sm;
&:hover { &-index {
color: $gray; position: absolute;
} top: -4px;
left: -7px;
z-index: 1;
color: #fff;
width: 26px;
height: 26px;
border-radius: 100%;
background: linear-gradient(
to top,
lighten($brand-success, 10%),
lighten($brand-success, 5%)
);
line-height: 24px;
} }
} }

View File

@ -1,67 +1,98 @@
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { translateRaw } from 'translations';
import './Word.scss'; import './Word.scss';
import { Input } from 'components/ui'; import { Input } from 'components/ui';
interface Props { interface Props {
index: number; index: number;
confirmIndex: number;
word: string; word: string;
value: string; value: string;
isReadOnly: boolean; showIndex: boolean;
onChange(index: number, value: string): void; isNext: boolean;
isBeingRevealed: boolean;
isConfirming: boolean;
hasBeenConfirmed: boolean;
onClick(index: number, value: string): void;
} }
interface State { interface State {
isShowingWord: boolean; flashingError: boolean;
} }
export default class MnemonicWord extends React.Component<Props, State> { export default class MnemonicWord extends React.Component<Props, State> {
public state = { public state = {
isShowingWord: false flashingError: false
}; };
public render() { public render() {
const { index, word, value, isReadOnly } = this.props; const {
const { isShowingWord } = this.state; hasBeenConfirmed,
const readOnly = isReadOnly || isShowingWord; isBeingRevealed,
showIndex,
index,
isConfirming,
confirmIndex,
word
} = this.props;
const { flashingError } = this.state;
const btnClassName = classnames({
btn: true,
'btn-default': !(isBeingRevealed || flashingError),
'btn-success': isBeingRevealed,
'btn-danger': flashingError
});
const indexClassName = 'input-group-addon input-group-addon--transparent';
return ( return (
<div className="input-group-wrapper MnemonicWord"> <div className="input-group-wrapper MnemonicWord">
<label className="input-group input-group-inline ENSInput-name"> <label className="input-group input-group-inline ENSInput-name">
<span className="input-group-addon input-group-addon--transparent">{index + 1}.</span> {showIndex && <span className={indexClassName}>{index + 1}.</span>}
<Input {hasBeenConfirmed && (
className={`MnemonicWord-word-input ${!isReadOnly && 'border-rad-right-0'}`} <span className="MnemonicWord-button-index">{confirmIndex + 1}</span>
value={readOnly ? word : value} )}
onChange={this.handleChange} {isConfirming ? (
readOnly={readOnly} <button
/> className={`MnemonicWord-button ${btnClassName} ${
{!isReadOnly && ( hasBeenConfirmed ? 'disabled' : ''
<span }`}
onClick={this.toggleShow} onClick={() => this.handleClick(word)}
aria-label={translateRaw('GEN_ARIA_2')}
role="button"
className="MnemonicWord-word-toggle input-group-addon"
> >
<i {word}
className={classnames( </button>
'fa', ) : (
isShowingWord && 'fa-eye-slash', <Input className="MnemonicWord-word-input" value={word} readOnly={true} />
!isShowingWord && 'fa-eye'
)}
/>
</span>
)} )}
</label> </label>
</div> </div>
); );
} }
private handleChange = (ev: React.FormEvent<HTMLInputElement>) => { private handleClick = (value: string) => {
this.props.onChange(this.props.index, ev.currentTarget.value); const { isNext, index, onClick } = this.props;
if (!isNext) {
this.flashError();
}
onClick(index, value);
}; };
private toggleShow = () => { private flashError = () => {
this.setState({ isShowingWord: !this.state.isShowingWord }); const errorDuration = 200;
this.setState(
{
flashingError: true
},
() =>
setTimeout(
() =>
this.setState({
flashingError: false
}),
errorDuration
)
);
}; };
} }

View File

@ -379,8 +379,9 @@
"GENERATE_THING": "Generate a $thing", "GENERATE_THING": "Generate a $thing",
"REGENERATE_MNEMONIC": "Regenerate Phrase", "REGENERATE_MNEMONIC": "Regenerate Phrase",
"CONFIRM_MNEMONIC": "Confirm Phrase", "CONFIRM_MNEMONIC": "Confirm Phrase",
"REVEAL_NEXT_MNEMONIC": "Reveal Next Word",
"MNEMONIC_CHOOSE_ADDR": "Choose address", "MNEMONIC_CHOOSE_ADDR": "Choose address",
"MNEMONIC_DESCRIPTION_1": "Re-enter your phrase to confirm you copied it correctly. If you forgot one of your words, just click the button beside the input to reveal it.", "MNEMONIC_DESCRIPTION_1": "Click the words of your phrase in order. If you've forgotten the next word, click the 'Reveal Next Word' button below.",
"MNEMONIC_DESCRIPTION_2": "Write these words down. Do not copy them to your clipboard, or save them anywhere online.", "MNEMONIC_DESCRIPTION_2": "Write these words down. Do not copy them to your clipboard, or save them anywhere online.",
"MODAL_BACK": "Back", "MODAL_BACK": "Back",
"WALLET_UNLOCKING": "Unlocking...", "WALLET_UNLOCKING": "Unlocking...",