diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b61349894..e9581e5cf 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1199,6 +1199,12 @@ "searchTokens": { "message": "Search Tokens" }, + "searchAccounts": { + "message": "Search Accounts" + }, + "noAccountsFound": { + "message": "No accounts found for the given search query" + }, "selectAnAccount": { "message": "Select an Account" }, diff --git a/ui/app/components/app/account-menu/account-menu.component.js b/ui/app/components/app/account-menu/account-menu.component.js index 0b241fba3..1a7bdf87f 100644 --- a/ui/app/components/app/account-menu/account-menu.component.js +++ b/ui/app/components/app/account-menu/account-menu.component.js @@ -1,6 +1,9 @@ -import React, { PureComponent } from 'react' +import React, { Component } from 'react' import PropTypes from 'prop-types' import debounce from 'lodash.debounce' +import Fuse from 'fuse.js' +import InputAdornment from '@material-ui/core/InputAdornment' + import { Menu, Item, Divider, CloseArea } from '../dropdowns/components/menu' import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' import { getEnvironmentType } from '../../../../../app/scripts/lib/util' @@ -17,17 +20,19 @@ import { CONNECT_HARDWARE_ROUTE, DEFAULT_ROUTE, } from '../../../helpers/constants/routes' +import TextField from '../../ui/text-field' +import SearchIcon from '../../ui/search-icon' -export default class AccountMenu extends PureComponent { +export default class AccountMenu extends Component { static contextTypes = { t: PropTypes.func, metricsEvent: PropTypes.func, } static propTypes = { - accounts: PropTypes.object, + shouldShowAccountsSearch: PropTypes.bool, + accounts: PropTypes.array, history: PropTypes.object, - identities: PropTypes.object, isAccountMenuOpen: PropTypes.bool, keyrings: PropTypes.array, lockMetamask: PropTypes.func, @@ -39,22 +44,76 @@ export default class AccountMenu extends PureComponent { originOfCurrentTab: PropTypes.string, } + accountsRef; + state = { - atAccountListBottom: false, + shouldShowScrollButton: false, + searchQuery: '', } - componentDidUpdate (prevProps) { + addressFuse = new Fuse([], { + shouldSort: false, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'address', weight: 0.5 }, + ], + }) + + componentDidUpdate (prevProps, prevState) { const { isAccountMenuOpen: prevIsAccountMenuOpen } = prevProps + const { searchQuery: prevSearchQuery } = prevState const { isAccountMenuOpen } = this.props + const { searchQuery } = this.state if (!prevIsAccountMenuOpen && isAccountMenuOpen) { - this.setAtAccountListBottom() + this.setShouldShowScrollButton() + this.resetSearchQuery() } + + // recalculate on each search query change + // whether we can show scroll down button + if (isAccountMenuOpen && prevSearchQuery !== searchQuery) { + this.setShouldShowScrollButton() + } + } + + renderAccountsSearch () { + const inputAdornment = ( + + + + ) + + return [ + this.setSearchQuery(e.target.value)} + startAdornment={inputAdornment} + fullWidth + theme="material-white-padded" + />, + , + ] } renderAccounts () { const { - identities, accounts, selectedAddress, keyrings, @@ -62,14 +121,21 @@ export default class AccountMenu extends PureComponent { addressConnectedDomainMap, originOfCurrentTab, } = this.props + const { searchQuery } = this.state - const accountOrder = keyrings.reduce((list, keyring) => list.concat(keyring.accounts), []) + let filteredIdentities = accounts + if (searchQuery) { + this.addressFuse.setCollection(accounts) + filteredIdentities = this.addressFuse.search(searchQuery) + } - return accountOrder.filter(address => !!identities[address]).map(address => { - const identity = identities[address] + if (filteredIdentities.length === 0) { + return

{this.context.t('noAccountsFound')}

+ } + + return filteredIdentities.map(identity => { const isSelected = identity.address === selectedAddress - const balanceValue = accounts[address] ? accounts[address].balance : '' const simpleAddress = identity.address.substring(2).toLowerCase() const keyring = keyrings.find(kr => { @@ -106,7 +172,7 @@ export default class AccountMenu extends PureComponent { @@ -181,28 +247,41 @@ export default class AccountMenu extends PureComponent { ) } - setAtAccountListBottom = () => { - const target = document.querySelector('.account-menu__accounts') - const { scrollTop, offsetHeight, scrollHeight } = target - const atAccountListBottom = scrollTop + offsetHeight >= scrollHeight - this.setState({ atAccountListBottom }) + resetSearchQuery () { + this.setSearchQuery('') } - onScroll = debounce(this.setAtAccountListBottom, 25) + setSearchQuery (searchQuery) { + this.setState({ searchQuery }) + } + + setShouldShowScrollButton = () => { + const { scrollTop, offsetHeight, scrollHeight } = this.accountsRef + + const canScroll = scrollHeight > offsetHeight + + const atAccountListBottom = scrollTop + offsetHeight >= scrollHeight + + const shouldShowScrollButton = canScroll && !atAccountListBottom + + this.setState({ shouldShowScrollButton}) + } + + onScroll = debounce(this.setShouldShowScrollButton, 25) handleScrollDown = e => { e.stopPropagation() - const target = document.querySelector('.account-menu__accounts') - const { scrollHeight } = target - target.scroll({ left: 0, top: scrollHeight, behavior: 'smooth' }) - this.setAtAccountListBottom() + + const { scrollHeight } = this.accountsRef + this.accountsRef.scroll({ left: 0, top: scrollHeight, behavior: 'smooth' }) + + this.setShouldShowScrollButton() } renderScrollButton () { - const { accounts } = this.props - const { atAccountListBottom } = this.state + const { shouldShowScrollButton } = this.state - return !atAccountListBottom && Object.keys(accounts).length > 3 && ( + return shouldShowScrollButton && (
) } render () { - const { t } = this.context + const { t, metricsEvent } = this.context const { + shouldShowAccountsSearch, isAccountMenuOpen, toggleAccountMenu, lockMetamask, history, } = this.props - const { metricsEvent } = this.context return (
+ {shouldShowAccountsSearch ? this.renderAccountsSearch() : null}
{ + this.accountsRef = ref + }} > { this.renderAccounts() }
diff --git a/ui/app/components/app/account-menu/account-menu.container.js b/ui/app/components/app/account-menu/account-menu.container.js index 00a0666ec..2823a474d 100644 --- a/ui/app/components/app/account-menu/account-menu.container.js +++ b/ui/app/components/app/account-menu/account-menu.container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux' -import { compose } from 'recompose' +import { compose, withProps } from 'recompose' import { withRouter } from 'react-router-dom' import { toggleAccountMenu, @@ -13,23 +13,28 @@ import { } from '../../../store/actions' import { getAddressConnectedDomainMap, - getMetaMaskAccounts, + getMetaMaskAccountsOrdered, + getMetaMaskKeyrings, getOriginOfCurrentTab, + getSelectedAddress, } from '../../../selectors/selectors' - import AccountMenu from './account-menu.component' +/** + * The min amount of accounts to show search field + */ +const SHOW_SEARCH_ACCOUNTS_MIN_COUNT = 5 + function mapStateToProps (state) { - const { metamask: { selectedAddress, isAccountMenuOpen, keyrings, identities } } = state + const { metamask: { isAccountMenuOpen } } = state return { - selectedAddress, isAccountMenuOpen, - keyrings, - identities, - accounts: getMetaMaskAccounts(state), addressConnectedDomainMap: getAddressConnectedDomainMap(state), originOfCurrentTab: getOriginOfCurrentTab(state), + selectedAddress: getSelectedAddress(state), + keyrings: getMetaMaskKeyrings(state), + accounts: getMetaMaskAccountsOrdered(state), } } @@ -65,5 +70,6 @@ function mapDispatchToProps (dispatch) { export default compose( withRouter, - connect(mapStateToProps, mapDispatchToProps) + connect(mapStateToProps, mapDispatchToProps), + withProps(({ accounts }) => ({ shouldShowAccountsSearch: accounts.length >= SHOW_SEARCH_ACCOUNTS_MIN_COUNT})) )(AccountMenu) diff --git a/ui/app/components/app/account-menu/index.scss b/ui/app/components/app/account-menu/index.scss index 93b9766d3..c8385b5e7 100644 --- a/ui/app/components/app/account-menu/index.scss +++ b/ui/app/components/app/account-menu/index.scss @@ -51,22 +51,26 @@ height: 16px; } - &__accounts { + &__accounts-container { display: flex; - flex-flow: column nowrap; - overflow-y: auto; - max-height: 256px; - position: relative; + flex-direction: column; z-index: 200; scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } + max-height: 256px; @media screen and (max-width: 575px) { max-height: 228px; } + } + + &__accounts { + overflow-y: auto; + position: relative; + + &::-webkit-scrollbar { + display: none; + } .keyring-label { margin-top: 5px; @@ -77,6 +81,11 @@ } } + &__no-accounts { + font-size: 0.8em; + padding: 16px 14px; + } + &__account { display: flex; flex-flow: row nowrap; diff --git a/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js b/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js index 82f377358..689a60208 100644 --- a/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js +++ b/ui/app/components/app/transaction-breakdown/transaction-breakdown.container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux' import TransactionBreakdown from './transaction-breakdown.component' -import {getIsMainnet, getNativeCurrency, preferencesSelector} from '../../../selectors/selectors' +import { getIsMainnet, getNativeCurrency, preferencesSelector } from '../../../selectors/selectors' import { getHexGasTotal } from '../../../helpers/utils/confirm-tx.util' import { sumHexes } from '../../../helpers/utils/transactions.util' diff --git a/ui/app/components/ui/search-icon/index.js b/ui/app/components/ui/search-icon/index.js new file mode 100644 index 000000000..f6078b7be --- /dev/null +++ b/ui/app/components/ui/search-icon/index.js @@ -0,0 +1 @@ +export { default } from './search-icon.component' diff --git a/ui/app/components/ui/search-icon/search-icon.component.js b/ui/app/components/ui/search-icon/search-icon.component.js new file mode 100644 index 000000000..b793518a4 --- /dev/null +++ b/ui/app/components/ui/search-icon/search-icon.component.js @@ -0,0 +1,12 @@ +import React from 'react' + +export default function SearchIcon () { + return ( + + + + + + + ) +} diff --git a/ui/app/components/ui/text-field/text-field.component.js b/ui/app/components/ui/text-field/text-field.component.js index 12a97ee4d..557e547c5 100644 --- a/ui/app/components/ui/text-field/text-field.component.js +++ b/ui/app/components/ui/text-field/text-field.component.js @@ -28,6 +28,24 @@ const styles = { }, }, materialError: {}, + materialWhitePaddedRoot: { + color: '#aeaeae', + }, + materialWhitePaddedInput: { + padding: '8px', + + '&::placeholder': { + color: '#aeaeae', + }, + }, + materialWhitePaddedFocused: { + color: '#fff', + }, + materialWhitePaddedUnderline: { + '&:after': { + borderBottom: '2px solid #fff', + }, + }, // Non-material styles formLabel: { '&$formLabelFocused': { @@ -66,35 +84,99 @@ const styles = { }, } -const TextField = props => { - const { error, classes, material, startAdornment, largeLabel, dir, ...textFieldProps } = props +const getMaterialThemeInputProps = ({ + dir, + classes: { materialLabel, materialFocused, materialError, materialUnderline }, + startAdornment, +}) => ({ + InputLabelProps: { + FormLabelClasses: { + root: materialLabel, + focused: materialFocused, + error: materialError, + }, + }, + InputProps: { + startAdornment, + classes: { + underline: materialUnderline, + }, + inputProps: { + dir, + }, + }, +}) + +const getMaterialWhitePaddedThemeInputProps = ({ + dir, + classes: { materialWhitePaddedRoot, materialWhitePaddedFocused, materialWhitePaddedInput, materialWhitePaddedUnderline }, + startAdornment, +}) => ({ + InputProps: { + startAdornment, + classes: { + root: materialWhitePaddedRoot, + focused: materialWhitePaddedFocused, + input: materialWhitePaddedInput, + underline: materialWhitePaddedUnderline, + }, + inputProps: { + dir, + }, + }, +}) + +const getBorderedThemeInputProps = ({ + dir, + classes: { formLabel, formLabelFocused, materialError, largeInputLabel, inputLabel, inputRoot, input, inputFocused }, + largeLabel, + startAdornment, +}) => ({ + InputLabelProps: { + shrink: true, + className: largeLabel ? largeInputLabel : inputLabel, + FormLabelClasses: { + root: formLabel, + focused: formLabelFocused, + error: materialError, + }, + }, + InputProps: { + startAdornment, + disableUnderline: true, + classes: { + root: inputRoot, + input: input, + focused: inputFocused, + }, + inputProps: { + dir, + }, + }, +}) + +const themeToInputProps = { + 'material': getMaterialThemeInputProps, + 'bordered': getBorderedThemeInputProps, + 'material-white-padded': getMaterialWhitePaddedThemeInputProps, +} + +const TextField = ({ + error, + classes, + theme, + startAdornment, + largeLabel, + dir, + ...textFieldProps +}) => { + const inputProps = themeToInputProps[theme]({ classes, startAdornment, largeLabel, dir }) return ( ) @@ -103,13 +185,14 @@ const TextField = props => { TextField.defaultProps = { error: null, dir: 'auto', + theme: 'bordered', } TextField.propTypes = { error: PropTypes.string, classes: PropTypes.object, dir: PropTypes.string, - material: PropTypes.bool, + theme: PropTypes.oneOf(['bordered', 'material', 'material-white-padded']), startAdornment: PropTypes.element, largeLabel: PropTypes.bool, } diff --git a/ui/app/components/ui/text-field/text-field.stories.js b/ui/app/components/ui/text-field/text-field.stories.js index d68f24362..9ef76d1d3 100644 --- a/ui/app/components/ui/text-field/text-field.stories.js +++ b/ui/app/components/ui/text-field/text-field.stories.js @@ -33,14 +33,14 @@ storiesOf('TextField', module) )) .add('Material password', () => ( )) .add('Material error', () => ( @@ -48,6 +48,6 @@ storiesOf('TextField', module) type="text" label="Name" error="Invalid value" - material + theme="material" /> )) diff --git a/ui/app/pages/add-token/token-search/token-search.component.js b/ui/app/pages/add-token/token-search/token-search.component.js index 5542a19ff..eee841654 100644 --- a/ui/app/pages/add-token/token-search/token-search.component.js +++ b/ui/app/pages/add-token/token-search/token-search.component.js @@ -36,12 +36,8 @@ export default class TokenSearch extends Component { error: PropTypes.string, } - constructor (props) { - super(props) - - this.state = { - searchQuery: '', - } + state = { + searchQuery: '', } handleSearch (searchQuery) { diff --git a/ui/app/pages/unlock-page/unlock-page.component.js b/ui/app/pages/unlock-page/unlock-page.component.js index 3aeb2a59b..8620b53f9 100644 --- a/ui/app/pages/unlock-page/unlock-page.component.js +++ b/ui/app/pages/unlock-page/unlock-page.component.js @@ -165,7 +165,7 @@ export default class UnlockPage extends Component { error={error} autoFocus autoComplete="current-password" - material + theme="material" fullWidth /> diff --git a/ui/app/selectors/selectors.js b/ui/app/selectors/selectors.js index bcd21d107..3077421ac 100644 --- a/ui/app/selectors/selectors.js +++ b/ui/app/selectors/selectors.js @@ -1,6 +1,7 @@ import { NETWORK_TYPES } from '../helpers/constants/common' import { mapObjectValues } from '../../../app/scripts/lib/util' import { stripHexPrefix, addHexPrefix } from 'ethereumjs-util' +import { createSelector } from 'reselect' import abi from 'human-standard-token-abi' import { multiplyCurrencies } from '../helpers/utils/conversion-util' @@ -58,6 +59,28 @@ export function getCurrentNetworkId (state) { return state.metamask.network } +export const getMetaMaskAccounts = createSelector( + getMetaMaskAccountsRaw, + getMetaMaskCachedBalances, + (currentAccounts, cachedBalances) => Object.entries(currentAccounts).reduce((selectedAccounts, [accountID, account]) => { + if (account.balance === null || account.balance === undefined) { + return { + ...selectedAccounts, + [accountID]: { + ...account, + balance: cachedBalances && cachedBalances[accountID], + }, + + } + } else { + return { + ...selectedAccounts, + [accountID]: account, + } + } + }, {}) +) + export function getSelectedAddress (state) { const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0] @@ -80,25 +103,37 @@ export function getNumberOfTokens (state) { return tokens ? tokens.length : 0 } -export function getMetaMaskAccounts (state) { - const currentAccounts = state.metamask.accounts - const cachedBalances = state.metamask.cachedBalances[state.metamask.network] - const selectedAccounts = {} - - Object.keys(currentAccounts).forEach(accountID => { - const account = currentAccounts[accountID] - if (account && account.balance === null || account.balance === undefined) { - selectedAccounts[accountID] = { - ...account, - balance: cachedBalances && cachedBalances[accountID], - } - } else { - selectedAccounts[accountID] = account - } - }) - return selectedAccounts +export function getMetaMaskKeyrings (state) { + return state.metamask.keyrings } +export function getMetaMaskIdentities (state) { + return state.metamask.identities +} + +export function getMetaMaskAccountsRaw (state) { + return state.metamask.accounts +} + +export function getMetaMaskCachedBalances (state) { + const network = getCurrentNetworkId(state) + + return state.metamask.cachedBalances[network] +} + +/** + * Get ordered (by keyrings) accounts with identity and balance + */ +export const getMetaMaskAccountsOrdered = createSelector( + getMetaMaskKeyrings, + getMetaMaskIdentities, + getMetaMaskAccounts, + (keyrings, identities, accounts) => keyrings + .reduce((list, keyring) => list.concat(keyring.accounts), []) + .filter(address => !!identities[address]) + .map(address => ({ ...identities[address], ...accounts[address]})) +) + export function isBalanceCached (state) { const selectedAccountBalance = state.metamask.accounts[getSelectedAddress(state)].balance const cachedBalance = getSelectedAccountCachedBalance(state) diff --git a/ui/app/selectors/tests/selectors.test.js b/ui/app/selectors/tests/selectors.test.js index 813628968..16170bc6b 100644 --- a/ui/app/selectors/tests/selectors.test.js +++ b/ui/app/selectors/tests/selectors.test.js @@ -1,8 +1,5 @@ import assert from 'assert' -import * as selectors from '../selectors' -const { - getAddressBook, -} = selectors +import { getAddressBook } from '../selectors.js' import mockState from './selectors-test-data' describe('selectors', () => {