Merge pull request #2055 from MyCryptoHQ/enhancement/address-book-to-view-address

Enhance `View Address` screen with a dropdown that draws from address book and recent addresses
This commit is contained in:
Connor Bryan 2018-08-16 11:33:29 -05:00 committed by GitHub
commit 283a97c145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 392 additions and 145 deletions

View File

@ -13,6 +13,12 @@ interface OwnProps {
isSelfAddress?: boolean;
isCheckSummed?: boolean;
showLabelMatch?: boolean;
showIdenticon?: boolean;
showInputLabel?: boolean;
placeholder?: string;
value?: string;
dropdownThreshold?: number;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement>): void;
}
interface StateProps {
@ -26,26 +32,42 @@ const AddressField: React.SFC<Props> = ({
isSelfAddress,
isCheckSummed,
showLabelMatch,
toChecksumAddress
toChecksumAddress,
showIdenticon,
placeholder = donationAddressMap.ETH,
showInputLabel = true,
onChangeOverride,
value,
dropdownThreshold
}) => (
<AddressFieldFactory
isSelfAddress={isSelfAddress}
showLabelMatch={showLabelMatch}
showIdenticon={showIdenticon}
onChangeOverride={onChangeOverride}
value={value}
dropdownThreshold={dropdownThreshold}
withProps={({ currentTo, isValid, isLabelEntry, onChange, onFocus, onBlur, readOnly }) => (
<div className="input-group-wrapper">
<label className="input-group">
<div className="input-group-header">
{translate(isSelfAddress ? 'X_ADDRESS' : 'SEND_ADDR')}
</div>
{showInputLabel && (
<div className="input-group-header">
{translate(isSelfAddress ? 'X_ADDRESS' : 'SEND_ADDR')}
</div>
)}
<Input
className={`input-group-input ${!isValid && !isLabelEntry ? 'invalid' : ''}`}
isValid={isValid}
type="text"
value={isCheckSummed ? toChecksumAddress(currentTo.raw) : currentTo.raw}
placeholder={donationAddressMap.ETH}
value={
value != null
? value
: isCheckSummed ? toChecksumAddress(currentTo.raw) : currentTo.raw
}
placeholder={placeholder}
readOnly={!!(isReadOnly || readOnly)}
spellCheck={false}
onChange={onChange}
onChange={onChangeOverride || onChange}
onFocus={onFocus}
onBlur={onBlur}
/>

View File

@ -50,6 +50,8 @@
&-label {
flex: 1 0 0;
padding-left: 0.2rem;
text-align: left;
}
&-address {

View File

@ -5,89 +5,138 @@ import translate, { translateRaw } from 'translations';
import { AppState } from 'features/reducers';
import { transactionActions, transactionSelectors } from 'features/transaction';
import { addressBookSelectors } from 'features/addressBook';
import { walletSelectors } from 'features/wallet';
import { Address, Identicon } from 'components/ui';
import './AddressFieldDropdown.scss';
interface StateProps {
interface Props {
addressInput: string;
dropdownThreshold?: number;
labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
currentTo: ReturnType<typeof transactionSelectors.getToRaw>;
recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
onEntryClick(address: string): void;
}
interface DispatchProps {
setCurrentTo: transactionActions.TSetCurrentTo;
}
type Props = StateProps & DispatchProps;
interface State {
activeIndex: number | null;
}
class AddressFieldDropdown extends React.Component<Props> {
public state: State = {
class AddressFieldDropdownClass extends React.Component<Props, State> {
public state = {
activeIndex: null
};
private exists: boolean = true;
public componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown);
}
public componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
this.exists = false;
}
public render() {
const { currentTo } = this.props;
const noMatchContent = currentTo.startsWith('0x') ? null : (
<li className="AddressFieldDropdown-dropdown-item AddressFieldDropdown-dropdown-item-no-match">
<i className="fa fa-warning" /> {translate('NO_LABEL_FOUND_CONTAINING')} "{currentTo}".
</li>
);
const { addressInput } = this.props;
return this.props.currentTo.length > 1 ? (
<ul className="AddressFieldDropdown" role="listbox">
{this.getFilteredLabels().length > 0 ? this.renderDropdownItems() : noMatchContent}
</ul>
) : null;
return (
this.getIsVisible() && (
<ul className="AddressFieldDropdown" role="listbox">
{this.getFilteredLabels().length > 0 ? (
this.renderDropdownItems()
) : (
<li className="AddressFieldDropdown-dropdown-item AddressFieldDropdown-dropdown-item-no-match">
<i className="fa fa-warning" /> {translate('NO_LABEL_FOUND_CONTAINING')} "{
addressInput
}".
</li>
)}
</ul>
)
);
}
private renderDropdownItems = () =>
this.getFilteredLabels().map((filteredLabel, index: number) => {
const { activeIndex } = this.state;
const { address, label } = filteredLabel;
const isActive = activeIndex === index;
const className = `AddressFieldDropdown-dropdown-item ${
isActive ? 'AddressFieldDropdown-dropdown-item--active' : ''
}`;
private renderDropdownItems = () => {
const { onEntryClick } = this.props;
const { activeIndex } = this.state;
return (
<li
key={address}
className={className}
onClick={() => this.props.setCurrentTo(address)}
role="option"
title={`${translateRaw('SEND_TO')}${label}`}
>
<div className="AddressFieldDropdown-dropdown-item-identicon">
<Identicon address={address} />
</div>
<strong className="AddressFieldDropdown-dropdown-item-label">{label}</strong>
<em className="AddressFieldDropdown-dropdown-item-address">
<Address address={address} />
</em>
</li>
);
});
return this.getFilteredLabels().map(
({ address, label }: { address: string; label: string }, index: number) => {
const isActive = activeIndex === index;
const className = `AddressFieldDropdown-dropdown-item ${
isActive ? 'AddressFieldDropdown-dropdown-item--active' : ''
}`;
private getFilteredLabels = () =>
Object.keys(this.props.labelAddresses)
.filter(label => label.toLowerCase().includes(this.props.currentTo.toLowerCase()))
.map(label => ({ address: this.props.labelAddresses[label], label }))
return (
<li
key={address}
role="option"
className={className}
onClick={() => onEntryClick(address)}
title={`${translateRaw('SEND_TO')}${label}`}
>
<div className="AddressFieldDropdown-dropdown-item-identicon">
<Identicon address={address} />
</div>
<strong className="AddressFieldDropdown-dropdown-item-label">{label}</strong>
<em className="AddressFieldDropdown-dropdown-item-address">
<Address address={address} />
</em>
</li>
);
}
);
};
private getFormattedRecentAddresses = (): { [label: string]: string } => {
const { labelAddresses, recentAddresses } = this.props;
// Hash existing entries by address for performance.
const addressesInBook: { [address: string]: boolean } = Object.values(labelAddresses).reduce(
(prev: { [address: string]: boolean }, next: string) => {
prev[next] = true;
return prev;
},
{}
);
// Make recent addresses sequential.
let recentAddressCount: number = 0;
const addresses = recentAddresses.reduce((prev: { [label: string]: string }, next: string) => {
// Prevent duplication.
if (addressesInBook[next]) {
return prev;
}
prev[
translateRaw('RECENT_ADDRESS_NUMBER', { $number: (++recentAddressCount).toString() })
] = next;
return prev;
}, {});
return addresses;
};
private getFilteredLabels = () => {
const { addressInput, labelAddresses } = this.props;
const formattedRecentAddresses = this.getFormattedRecentAddresses();
return Object.keys({ ...labelAddresses, ...formattedRecentAddresses })
.filter(label => label.toLowerCase().includes(addressInput))
.map(label => ({ address: labelAddresses[label] || formattedRecentAddresses[label], label }))
.slice(0, 5);
};
private getIsVisible = () =>
this.props.currentTo.length > 1 && this.getFilteredLabels().length > 0;
private getIsVisible = () => {
const { addressInput, dropdownThreshold = 3 } = this.props;
return addressInput.length >= dropdownThreshold && this.getFilteredLabels().length > 0;
};
private setActiveIndex = (activeIndex: number | null) => this.setState({ activeIndex });
private clearActiveIndex = () => this.setActiveIndex(null);
//#region Keyboard Controls
private handleKeyDown = (e: KeyboardEvent) => {
if (this.getIsVisible()) {
switch (e.key) {
@ -107,6 +156,7 @@ class AddressFieldDropdown extends React.Component<Props> {
};
private handleEnterKeyDown = () => {
const { onEntryClick } = this.props;
const { activeIndex } = this.state;
if (activeIndex !== null) {
@ -114,11 +164,13 @@ class AddressFieldDropdown extends React.Component<Props> {
filteredLabels.forEach(({ address }, index) => {
if (activeIndex === index) {
this.props.setCurrentTo(address);
onEntryClick(address);
}
});
this.clearActiveIndex();
if (this.exists) {
this.clearActiveIndex();
}
}
};
@ -150,16 +202,116 @@ class AddressFieldDropdown extends React.Component<Props> {
this.setState({ activeIndex });
};
private setActiveIndex = (activeIndex: number | null) => this.setState({ activeIndex });
private clearActiveIndex = () => this.setActiveIndex(null);
//#endregion Keyboard Controls
}
export default connect(
//#region Uncontrolled
/**
* @desc The `onChangeOverride` prop needs to work
* with actual events, but also needs a value to be directly passed in
* occasionally. This interface allows us to skip all of the other FormEvent
* properties and methods.
*/
interface FakeFormEvent {
currentTarget: {
value: string;
};
}
interface UncontrolledAddressFieldDropdownProps {
value: string;
labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
dropdownThreshold?: number;
onChangeOverride(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
}
/**
* @desc The uncontrolled dropdown changes the address input onClick,
* as well as calls the onChange override, but does not update the `currentTo`
* property in the Redux store.
*/
function RawUncontrolledAddressFieldDropdown({
value,
onChangeOverride,
labelAddresses,
recentAddresses,
dropdownThreshold
}: UncontrolledAddressFieldDropdownProps) {
const onEntryClick = (address: string) => onChangeOverride({ currentTarget: { value: address } });
return (
<AddressFieldDropdownClass
addressInput={value}
onEntryClick={onEntryClick}
labelAddresses={labelAddresses}
recentAddresses={recentAddresses}
dropdownThreshold={dropdownThreshold}
/>
);
}
const UncontrolledAddressFieldDropdown = connect((state: AppState) => ({
labelAddresses: addressBookSelectors.getLabelAddresses(state),
recentAddresses: walletSelectors.getRecentAddresses(state)
}))(RawUncontrolledAddressFieldDropdown);
//#endregion Uncontrolled
//#region Controlled
interface ControlledAddressFieldDropdownProps {
currentTo: ReturnType<typeof transactionSelectors.getToRaw>;
labelAddresses: ReturnType<typeof addressBookSelectors.getLabelAddresses>;
recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
setCurrentTo: transactionActions.TSetCurrentTo;
dropdownThreshold?: number;
}
/**
* @desc The controlled dropdown connects directly to the Redux store,
* modifying the `currentTo` property onChange.
*/
function RawControlledAddressFieldDropdown({
currentTo,
labelAddresses,
recentAddresses,
setCurrentTo,
dropdownThreshold
}: ControlledAddressFieldDropdownProps) {
return (
<AddressFieldDropdownClass
addressInput={currentTo}
onEntryClick={setCurrentTo}
labelAddresses={labelAddresses}
recentAddresses={recentAddresses}
dropdownThreshold={dropdownThreshold}
/>
);
}
const ControlledAddressFieldDropdown = connect(
(state: AppState) => ({
currentTo: transactionSelectors.getToRaw(state),
labelAddresses: addressBookSelectors.getLabelAddresses(state),
currentTo: transactionSelectors.getToRaw(state)
recentAddresses: walletSelectors.getRecentAddresses(state)
}),
{ setCurrentTo: transactionActions.setCurrentTo }
)(AddressFieldDropdown);
{
setCurrentTo: transactionActions.setCurrentTo
}
)(RawControlledAddressFieldDropdown);
//#endregion Controlled
interface AddressFieldDropdownProps {
controlled: boolean;
value?: string;
dropdownThreshold?: number;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement> | FakeFormEvent): void;
}
export default function AddressFieldDropdown({
controlled = true,
...props
}: AddressFieldDropdownProps) {
const Dropdown = controlled ? ControlledAddressFieldDropdown : UncontrolledAddressFieldDropdown;
return <Dropdown {...props} />;
}

View File

@ -15,7 +15,11 @@ interface OwnProps {
to: string | null;
isSelfAddress?: boolean;
showLabelMatch?: boolean;
showIdenticon?: boolean;
value?: string;
dropdownThreshold?: number;
withProps(props: CallbackProps): React.ReactElement<any> | null;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement>): void;
}
interface State {
@ -56,16 +60,30 @@ class AddressFieldFactoryClass extends React.Component<Props> {
}
public render() {
const {
isSelfAddress,
showLabelMatch,
withProps,
showIdenticon,
onChangeOverride,
value,
dropdownThreshold
} = this.props;
return (
<div className="AddressField">
<AddressInputFactory
isSelfAddress={this.props.isSelfAddress}
showLabelMatch={this.props.showLabelMatch}
isSelfAddress={isSelfAddress}
showLabelMatch={showLabelMatch}
withProps={withProps}
showIdenticon={showIdenticon}
onChangeOverride={onChangeOverride}
value={value}
dropdownThreshold={dropdownThreshold}
isFocused={this.state.isFocused}
onChange={this.setAddress}
onFocus={this.focus}
onBlur={this.setBlurTimeout}
withProps={this.props.withProps}
/>
</div>
);
@ -76,8 +94,10 @@ class AddressFieldFactoryClass extends React.Component<Props> {
private blur = () => this.setState({ isFocused: false });
private setAddress = (ev: React.FormEvent<HTMLInputElement>) => {
const { onChangeOverride, setCurrentTo } = this.props;
const { value } = ev.currentTarget;
this.props.setCurrentTo(value);
onChangeOverride ? onChangeOverride(ev) : setCurrentTo(value);
};
private setBlurTimeout = () => (this.goingToBlur = window.setTimeout(this.blur, 150));
@ -90,13 +110,21 @@ const AddressFieldFactory = connect(null, { setCurrentTo: transactionActions.set
interface DefaultAddressFieldProps {
isSelfAddress?: boolean;
showLabelMatch?: boolean;
showIdenticon?: boolean;
value?: string;
dropdownThreshold?: number;
withProps(props: CallbackProps): React.ReactElement<any> | null;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement>): void;
}
const DefaultAddressField: React.SFC<DefaultAddressFieldProps> = ({
isSelfAddress,
showLabelMatch,
withProps
showIdenticon,
value,
withProps,
onChangeOverride,
dropdownThreshold
}) => (
<Query
params={['to']}
@ -106,6 +134,10 @@ const DefaultAddressField: React.SFC<DefaultAddressFieldProps> = ({
isSelfAddress={isSelfAddress}
showLabelMatch={showLabelMatch}
withProps={withProps}
showIdenticon={showIdenticon}
onChangeOverride={onChangeOverride}
value={value}
dropdownThreshold={dropdownThreshold}
/>
)}
/>

View File

@ -27,8 +27,13 @@ interface StateProps {
interface OwnProps {
isSelfAddress?: boolean;
showLabelMatch?: boolean;
showIdenticon?: boolean;
isFocused?: boolean;
className?: string;
value?: string;
dropdownThreshold?: number;
onChange(ev: React.FormEvent<HTMLInputElement>): void;
onChangeOverride?(ev: React.FormEvent<HTMLInputElement>): void;
onFocus(ev: React.FormEvent<HTMLInputElement>): void;
onBlur(ev: React.FormEvent<HTMLInputElement>): void;
withProps(props: CallbackProps): React.ReactElement<any> | null;
@ -69,14 +74,32 @@ class AddressInputFactoryClass extends Component<Props> {
showLabelMatch,
isSelfAddress,
isResolving,
isFocused
isFocused,
showIdenticon = true,
onChangeOverride,
value,
dropdownThreshold
} = this.props;
const { value } = currentTo;
const addr = addHexPrefix(value ? value.toString('hex') : '0');
const inputClassName = `AddressInput-input ${label ? 'AddressInput-input-with-label' : ''}`;
const sendingTo = `${translateRaw('SENDING_TO')} ${label}`;
const isENSAddress = currentTo.raw.includes('.eth');
/**
* @desc Initially set the address to the passed value.
* If there wasn't a value passed, use the value from the redux store.
*/
let addr = value;
if (addr == null) {
addr = addHexPrefix(currentTo.value ? currentTo.value.toString('hex') : '0');
}
/**
* @desc If passed a value and an onChangeOverride function,
* infer that the dropdown should be uncontrolled.
*/
const controlled = value == null && !onChangeOverride;
return (
<div className="AddressInput form-group">
<div className={inputClassName}>
@ -95,7 +118,15 @@ class AddressInputFactoryClass extends Component<Props> {
}
/>
<ENSStatus ensAddress={currentTo.raw} isLoading={isResolving} rawAddress={addr} />
{isFocused && !isENSAddress && <AddressFieldDropdown />}
{isFocused &&
!isENSAddress && (
<AddressFieldDropdown
controlled={controlled}
value={value}
onChangeOverride={onChangeOverride}
dropdownThreshold={dropdownThreshold}
/>
)}
{showLabelMatch &&
label && (
<div title={sendingTo} className="AddressInput-input-label">
@ -103,9 +134,11 @@ class AddressInputFactoryClass extends Component<Props> {
</div>
)}
</div>
<div className="AddressInput-identicon">
<Identicon address={addr} />
</div>
{showIdenticon && (
<div className="AddressInput-identicon">
<Identicon address={addr} />
</div>
)}
</div>
);
}

View File

@ -100,7 +100,7 @@ $speed: 500ms;
}
&-form {
max-width: 360px;
max-width: 480px;
margin: 0 auto;
}
&-label {

View File

@ -2,24 +2,24 @@
@import 'common/sass/mixins';
.ViewOnly {
&-recent {
text-align: left;
&-fields {
display: flex;
flex-direction: column;
&-separator {
display: block;
margin: $space-sm 0;
text-align: center;
color: shade-dark(0.3);
}
&-field {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
.Select {
&-option {
@include ellipsis;
&:nth-of-type(2) {
margin-bottom: calc(1rem + 15px);
font-size: 1.2rem;
}
.Identicon {
display: inline-block;
margin-right: $space-sm;
& .AddressField {
width: 100%;
}
}
}

View File

@ -1,13 +1,12 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import Select, { Option } from 'react-select';
import translate, { translateRaw } from 'translations';
import { AddressOnlyWallet } from 'libs/wallet';
import { AppState } from 'features/reducers';
import { getIsValidAddressFn } from 'features/config';
import { walletSelectors } from 'features/wallet';
import { Input, Identicon } from 'components/ui';
import { Input } from 'components/ui';
import { AddressField } from 'components';
import './ViewOnly.scss';
interface OwnProps {
@ -15,7 +14,6 @@ interface OwnProps {
}
interface StateProps {
recentAddresses: ReturnType<typeof walletSelectors.getRecentAddresses>;
isValidAddress: ReturnType<typeof getIsValidAddressFn>;
}
@ -23,55 +21,50 @@ type Props = OwnProps & StateProps;
interface State {
address: string;
addressFromBook: string;
}
class ViewOnlyDecryptClass extends PureComponent<Props, State> {
public state = {
address: ''
address: '',
addressFromBook: ''
};
public render() {
const { recentAddresses, isValidAddress } = this.props;
const { address } = this.state;
const { isValidAddress } = this.props;
const { address, addressFromBook } = this.state;
const isValid = isValidAddress(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 className="ViewOnly">
<form className="form-group" onSubmit={this.openWallet}>
{!!recentOptions.length && (
<div className="ViewOnly-recent">
<Select
value={address}
onChange={this.handleSelectAddress}
options={recentOptions}
placeholder={translateRaw('VIEW_ONLY_RECENT')}
<form className="form-group" onSubmit={this.openWalletWithAddress}>
<section className="ViewOnly-fields">
<section className="ViewOnly-fields-field">
<AddressField
value={addressFromBook}
showInputLabel={false}
showIdenticon={false}
placeholder={translateRaw('SELECT_FROM_ADDRESS_BOOK')}
onChangeOverride={this.handleSelectAddressFromBook}
dropdownThreshold={0}
/>
<em className="ViewOnly-recent-separator">{translate('OR')}</em>
</div>
)}
<Input
isValid={isValid}
className="ViewOnly-input"
value={address}
onChange={this.changeAddress}
placeholder={translateRaw('VIEW_ONLY_ENTER')}
/>
<button className="ViewOnly-submit btn btn-primary btn-block" disabled={!isValid}>
{translate('VIEW_ADDR')}
</button>
</section>
<section className="ViewOnly-fields-field">
<em>{translate('OR')}</em>
</section>
<section className="ViewOnly-fields-field">
<Input
isValid={isValid}
className="ViewOnly-input"
value={address}
onChange={this.changeAddress}
placeholder={translateRaw('VIEW_ONLY_ENTER')}
/>
<button className="ViewOnly-submit btn btn-primary btn-block" disabled={!isValid}>
{translate('VIEW_ADDR')}
</button>
</section>
</section>
</form>
</div>
);
@ -81,18 +74,30 @@ class ViewOnlyDecryptClass extends PureComponent<Props, State> {
this.setState({ address: ev.currentTarget.value });
};
private handleSelectAddress = (option: Option) => {
const address = option && option.value ? option.value.toString() : '';
this.setState({ address }, () => this.openWallet());
private handleSelectAddressFromBook = (ev: React.FormEvent<HTMLInputElement>) => {
const { currentTarget: { value: addressFromBook } } = ev;
this.setState({ addressFromBook }, this.openWalletWithAddressBook);
};
private openWallet = (ev?: React.FormEvent<HTMLElement>) => {
private openWalletWithAddress = (ev?: React.FormEvent<HTMLElement>) => {
const { address } = this.state;
if (ev) {
ev.preventDefault();
}
this.openWallet(address);
};
private openWalletWithAddressBook = () => {
const { addressFromBook } = this.state;
this.openWallet(addressFromBook);
};
private openWallet = (address: string) => {
const { isValidAddress } = this.props;
const { address } = this.state;
if (isValidAddress(address)) {
const wallet = new AddressOnlyWallet(address);
this.props.onUnlock(wallet);
@ -101,6 +106,5 @@ class ViewOnlyDecryptClass extends PureComponent<Props, State> {
}
export const ViewOnlyDecrypt = connect((state: AppState): StateProps => ({
recentAddresses: walletSelectors.getRecentAddresses(state),
isValidAddress: getIsValidAddressFn(state)
}))(ViewOnlyDecryptClass);

View File

@ -669,6 +669,8 @@
"WHAT_IS_PAYMENT_ID": "what's a payment ID?",
"ANNOUNCEMENT_MESSAGE": "MyCrypto.com no longer allows the use of private keys, mnemonics, or keystore files in the browser. To continue using them, please download the [MyCrypto Desktop App](https://download.mycrypto.com).",
"U2F_NOT_SUPPORTED": "The U2F standard that hardware wallets use does not seem to be supported by your browser. Please try again using Google Chrome.",
"SIMILAR_TRANSACTION_ERROR": "This transaction is very similar to a recent transaction. Please wait a few moments and try again, or click 'Advanced' and manually set the nonce to a new value."
"SIMILAR_TRANSACTION_ERROR": "This transaction is very similar to a recent transaction. Please wait a few moments and try again, or click 'Advanced' and manually set the nonce to a new value.",
"SELECT_FROM_ADDRESS_BOOK": "Select from your address book",
"RECENT_ADDRESS_NUMBER": "Recent Address #$number"
}
}