Show Recent Addresses on View Only (#1514)

* Add recent addresses to reducer, show them on view only decrypt.

* Update test
This commit is contained in:
William O'Beirne 2018-04-14 15:10:21 -04:00 committed by Daniel Ternyak
parent f63bd92edf
commit 7798b9cef1
11 changed files with 139 additions and 34 deletions

View File

@ -43,7 +43,7 @@ export class KeystoreDecrypt extends PureComponent {
const unlockDisabled = !file || (passReq && !password);
return (
<form id="selectedUploadKey" onSubmit={this.unlock}>
<form onSubmit={this.unlock}>
<div className="form-group">
<input
className="hidden"

View File

@ -0,0 +1,26 @@
@import 'common/sass/variables';
@import 'common/sass/mixins';
.ViewOnly {
&-recent {
text-align: left;
&-separator {
display: block;
margin: $space-sm 0;
text-align: center;
color: $gray-light;
}
.Select {
&-option {
@include ellipsis;
}
.Identicon {
display: inline-block;
margin-right: $space-sm;
}
}
}
}

View File

@ -1,63 +1,101 @@
import React, { PureComponent } from 'react';
import translate from 'translations';
import { donationAddressMap } from 'config';
import { connect } from 'react-redux';
import Select, { Option } from 'react-select';
import translate, { translateRaw } from 'translations';
import { isValidETHAddress } from 'libs/validators';
import { AddressOnlyWallet } from 'libs/wallet';
import { TextArea } from 'components/ui';
import { getRecentAddresses } from 'selectors/wallet';
import { AppState } from 'reducers';
import { Input, Identicon } from 'components/ui';
import './ViewOnly.scss';
interface Props {
interface OwnProps {
onUnlock(param: any): void;
}
interface StateProps {
recentAddresses: AppState['wallet']['recentAddresses'];
}
type Props = OwnProps & StateProps;
interface State {
address: string;
}
export class ViewOnlyDecrypt extends PureComponent<Props, State> {
class ViewOnlyDecryptClass extends PureComponent<Props, State> {
public state = {
address: ''
};
public render() {
const { recentAddresses } = this.props;
const { address } = this.state;
const isValid = isValidETHAddress(address);
const recentOptions = (recentAddresses.map(addr => ({
label: (
<React.Fragment>
<Identicon address={addr} />
{addr}
</React.Fragment>
),
value: addr
// I hate this assertion, but React elements definitely work as labels
})) as any) as Option[];
return (
<div id="selectedUploadKey">
<div className="ViewOnly">
<form className="form-group" onSubmit={this.openWallet}>
<TextArea
className={isValid ? 'is-valid' : 'is-invalid'}
{!!recentOptions.length && (
<div className="ViewOnly-recent">
<Select
value={address}
onChange={this.handleSelectAddress}
options={recentOptions}
placeholder={translateRaw('VIEW_ONLY_RECENT')}
/>
<em className="ViewOnly-recent-separator">{translate('OR')}</em>
</div>
)}
<Input
className={`ViewOnly-input ${isValid ? 'is-valid' : 'is-invalid'}`}
value={address}
onChange={this.changeAddress}
onKeyDown={this.handleEnterKey}
placeholder={donationAddressMap.ETH}
rows={3}
placeholder={translateRaw('VIEW_ONLY_ENTER')}
/>
<button className="btn btn-primary btn-block" disabled={!isValid}>
{translate('NAV_VIEWWALLET')}
<button className="ViewOnly-submit btn btn-primary btn-block" disabled={!isValid}>
{translate('VIEW_ADDR')}
</button>
</form>
</div>
);
}
private changeAddress = (ev: React.FormEvent<HTMLTextAreaElement>) => {
private changeAddress = (ev: React.FormEvent<HTMLInputElement>) => {
this.setState({ address: ev.currentTarget.value });
};
private handleEnterKey = (ev: React.KeyboardEvent<HTMLElement>) => {
if (ev.keyCode === 13) {
this.openWallet(ev);
}
private handleSelectAddress = (option: Option) => {
const address = option && option.value ? option.value.toString() : '';
this.setState({ address }, () => this.openWallet());
};
private openWallet = (ev: React.FormEvent<HTMLElement>) => {
private openWallet = (ev?: React.FormEvent<HTMLElement>) => {
if (ev) {
ev.preventDefault();
}
const { address } = this.state;
ev.preventDefault();
if (isValidETHAddress(address)) {
const wallet = new AddressOnlyWallet(address);
this.props.onUnlock(wallet);
}
};
}
export const ViewOnlyDecrypt = connect((state: AppState): StateProps => ({
recentAddresses: getRecentAddresses(state)
}))(ViewOnlyDecryptClass);

View File

@ -78,7 +78,7 @@
padding: 0.5rem 0.75rem;
}
&::placeholder {
color: rgba(0, 0, 0, 0.3);
color: $input-color-placeholder;
}
&:not([disabled]):not([readonly]) {
&.invalid.has-blurred.has-value {

View File

@ -28,6 +28,7 @@ export interface State {
isPasswordPending: boolean;
tokensError: string | null;
hasSavedWalletTokens: boolean;
recentAddresses: string[];
}
export const INITIAL_STATE: State = {
@ -39,16 +40,32 @@ export const INITIAL_STATE: State = {
isPasswordPending: false,
isTokensLoading: false,
tokensError: null,
hasSavedWalletTokens: true
hasSavedWalletTokens: true,
recentAddresses: []
};
export const RECENT_ADDRESS_LIMIT = 10;
function addRecentAddress(addresses: string[], newWallet: IWallet | null) {
if (!newWallet) {
return addresses;
}
// Push new address onto the front
const newAddresses = [newWallet.getAddressString(), ...addresses];
// Dedupe addresses, limit length
return newAddresses
.filter((addr, idx) => newAddresses.indexOf(addr) === idx)
.splice(0, RECENT_ADDRESS_LIMIT);
}
function setWallet(state: State, action: SetWalletAction): State {
return {
...state,
inst: action.payload,
config: INITIAL_STATE.config,
balance: INITIAL_STATE.balance,
tokens: INITIAL_STATE.tokens
tokens: INITIAL_STATE.tokens,
recentAddresses: addRecentAddress(state.recentAddresses, action.payload)
};
}
@ -145,12 +162,19 @@ function setWalletConfig(state: State, action: SetWalletConfigAction): State {
};
}
function resetWallet(state: State): State {
return {
...INITIAL_STATE,
recentAddresses: state.recentAddresses
};
}
export function wallet(state: State = INITIAL_STATE, action: WalletAction): State {
switch (action.type) {
case TypeKeys.WALLET_SET:
return setWallet(state, action);
case TypeKeys.WALLET_RESET:
return INITIAL_STATE;
return resetWallet(state);
case TypeKeys.WALLET_SET_BALANCE_PENDING:
return setBalancePending(state);
case TypeKeys.WALLET_SET_BALANCE_FULFILLED:

View File

@ -24,6 +24,7 @@
}
&-placeholder {
line-height: $line-height-base;
color: $input-color-placeholder;
}
&-input {
position: absolute;

View File

@ -3,7 +3,7 @@ $input-bg-disabled: $gray-lightest;
$input-color: #333333;
$input-border: $gray-lighter;
$input-border-focus: rgba($brand-primary, 0.6);
$input-color-placeholder: darken($gray-lighter, 10%);
$input-color-placeholder: rgba(0, 0, 0, 0.3);
$input-padding-x: 1rem;
$input-padding-y: 0.75rem;
$input-padding: $input-padding-y $input-padding-x;

View File

@ -201,3 +201,7 @@ export function getDisabledWallets(state: AppState): DisabledWallets {
return disabledWallets;
}
export function getRecentAddresses(state: AppState) {
return state.wallet.recentAddresses;
}

View File

@ -8,7 +8,8 @@ import {
INITIAL_STATE as initialTransactionsState,
State as TransactionsState
} from 'reducers/transactions';
import { State as SwapState, INITIAL_STATE as swapInitialState } from 'reducers/swap';
import { State as SwapState, INITIAL_STATE as initialSwapState } from 'reducers/swap';
import { State as WalletState, INITIAL_STATE as initialWalletState } from 'reducers/wallet';
import { applyMiddleware, createStore, Store } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createLogger } from 'redux-logger';
@ -38,17 +39,19 @@ const configureStore = () => {
middleware = applyMiddleware(sagaMiddleware, routerMiddleware(history as any));
}
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
const localSwapState = loadStatePropertyOrEmptyObject<SwapState>('swap');
const swapState =
localSwapState && localSwapState.step === 3
? {
...swapInitialState,
...initialSwapState,
...localSwapState
}
: { ...swapInitialState };
: { ...initialSwapState };
const savedTransactionState = loadStatePropertyOrEmptyObject<TransactionState>('transaction');
const savedTransactionsState = loadStatePropertyOrEmptyObject<TransactionsState>('transactions');
const savedWalletState = loadStatePropertyOrEmptyObject<WalletState>('wallet');
const persistedInitialState: Partial<AppState> = {
transaction: {
@ -64,13 +67,15 @@ const configureStore = () => {
: transactionInitialState.fields.gasPrice
}
},
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
swap: swapState,
transactions: {
...initialTransactionsState,
...savedTransactionsState
},
wallet: {
...initialWalletState,
...savedWalletState
},
...rehydrateConfigAndCustomTokenState()
};
@ -109,6 +114,9 @@ const configureStore = () => {
transactions: {
recent: state.transactions.recent
},
wallet: {
recentAddresses: state.wallet.recentAddresses
},
...getConfigAndCustomTokensStateToSubscribe(state)
});
}, 50)

View File

@ -418,6 +418,8 @@
"MNEMONIC_FINAL_STEP_3": "Select your wallet type",
"MNEMONIC_FINAL_STEP_4": "Enter your phrase",
"MNEMONIC_FINAL_STEP_5": "Provide file & password",
"VIEW_ONLY_RECENT": "Select a recent address",
"VIEW_ONLY_ENTER": "Enter an address (e.g. 0x4bbeEB066eD09...)",
"GO_TO_ACCOUNT": "Go to Account",
"INSECURE_WALLET_TYPE_TITLE": "This is not a recommended way to access your wallet",
"INSECURE_WALLET_TYPE_DESC": "Entering your $wallet_type on a website is dangerous. If our website is compromised, or you accidentally visit a phishing website, you could lose your funds. Before you continue, consider:",

View File

@ -5,13 +5,14 @@ import * as walletActions from 'actions/wallet';
configuredStore.getState();
describe('wallet reducer', () => {
it('should handle WALLET_SET', () => {
describe('WALLET_SET', () => {
const address = '0x123';
const doSomething = new Promise<string>(resolve => {
setTimeout(() => resolve('Success'), 10);
});
const walletInstance = {
getAddressString: () => doSomething,
getAddressString: () => address,
signRawTransaction: () => doSomething,
signMessage: () => doSomething
};
@ -19,7 +20,8 @@ describe('wallet reducer', () => {
//@ts-ignore
expect(wallet(undefined, walletActions.setWallet(walletInstance))).toEqual({
...INITIAL_STATE,
inst: walletInstance
inst: walletInstance,
recentAddresses: [address]
});
});