1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Search accounts by name (#7261)

Co-Authored-By: Paweł Lula <pavloblack@hotmail.com>
Co-Authored-By: Mark Stacey <markjstacey@gmail.com>
This commit is contained in:
Paweł Lula 2019-12-04 03:21:55 +01:00 committed by Whymarrh Whitby
parent 6e0b8f80ad
commit 638e242ce6
13 changed files with 331 additions and 102 deletions

View File

@ -1199,6 +1199,12 @@
"searchTokens": { "searchTokens": {
"message": "Search Tokens" "message": "Search Tokens"
}, },
"searchAccounts": {
"message": "Search Accounts"
},
"noAccountsFound": {
"message": "No accounts found for the given search query"
},
"selectAnAccount": { "selectAnAccount": {
"message": "Select an Account" "message": "Select an Account"
}, },

View File

@ -1,6 +1,9 @@
import React, { PureComponent } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import debounce from 'lodash.debounce' 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 { Menu, Item, Divider, CloseArea } from '../dropdowns/components/menu'
import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums'
import { getEnvironmentType } from '../../../../../app/scripts/lib/util' import { getEnvironmentType } from '../../../../../app/scripts/lib/util'
@ -17,17 +20,19 @@ import {
CONNECT_HARDWARE_ROUTE, CONNECT_HARDWARE_ROUTE,
DEFAULT_ROUTE, DEFAULT_ROUTE,
} from '../../../helpers/constants/routes' } 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 = { static contextTypes = {
t: PropTypes.func, t: PropTypes.func,
metricsEvent: PropTypes.func, metricsEvent: PropTypes.func,
} }
static propTypes = { static propTypes = {
accounts: PropTypes.object, shouldShowAccountsSearch: PropTypes.bool,
accounts: PropTypes.array,
history: PropTypes.object, history: PropTypes.object,
identities: PropTypes.object,
isAccountMenuOpen: PropTypes.bool, isAccountMenuOpen: PropTypes.bool,
keyrings: PropTypes.array, keyrings: PropTypes.array,
lockMetamask: PropTypes.func, lockMetamask: PropTypes.func,
@ -39,22 +44,76 @@ export default class AccountMenu extends PureComponent {
originOfCurrentTab: PropTypes.string, originOfCurrentTab: PropTypes.string,
} }
accountsRef;
state = { 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 { isAccountMenuOpen: prevIsAccountMenuOpen } = prevProps
const { searchQuery: prevSearchQuery } = prevState
const { isAccountMenuOpen } = this.props const { isAccountMenuOpen } = this.props
const { searchQuery } = this.state
if (!prevIsAccountMenuOpen && isAccountMenuOpen) { 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 = (
<InputAdornment
position="start"
style={{
maxHeight: 'none',
marginRight: 0,
marginLeft: '8px',
}}
>
<SearchIcon />
</InputAdornment>
)
return [
<TextField
key="search-text-field"
id="search-accounts"
placeholder={this.context.t('searchAccounts')}
type="text"
value={this.state.searchQuery}
onChange={e => this.setSearchQuery(e.target.value)}
startAdornment={inputAdornment}
fullWidth
theme="material-white-padded"
/>,
<Divider key="search-divider" />,
]
} }
renderAccounts () { renderAccounts () {
const { const {
identities,
accounts, accounts,
selectedAddress, selectedAddress,
keyrings, keyrings,
@ -62,14 +121,21 @@ export default class AccountMenu extends PureComponent {
addressConnectedDomainMap, addressConnectedDomainMap,
originOfCurrentTab, originOfCurrentTab,
} = this.props } = 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 => { if (filteredIdentities.length === 0) {
const identity = identities[address] return <p className="account-menu__no-accounts">{this.context.t('noAccountsFound')}</p>
}
return filteredIdentities.map(identity => {
const isSelected = identity.address === selectedAddress const isSelected = identity.address === selectedAddress
const balanceValue = accounts[address] ? accounts[address].balance : ''
const simpleAddress = identity.address.substring(2).toLowerCase() const simpleAddress = identity.address.substring(2).toLowerCase()
const keyring = keyrings.find(kr => { const keyring = keyrings.find(kr => {
@ -106,7 +172,7 @@ export default class AccountMenu extends PureComponent {
</div> </div>
<UserPreferencedCurrencyDisplay <UserPreferencedCurrencyDisplay
className="account-menu__balance" className="account-menu__balance"
value={balanceValue} value={identity.balance}
type={PRIMARY} type={PRIMARY}
/> />
</div> </div>
@ -181,28 +247,41 @@ export default class AccountMenu extends PureComponent {
) )
} }
setAtAccountListBottom = () => { resetSearchQuery () {
const target = document.querySelector('.account-menu__accounts') this.setSearchQuery('')
const { scrollTop, offsetHeight, scrollHeight } = target
const atAccountListBottom = scrollTop + offsetHeight >= scrollHeight
this.setState({ atAccountListBottom })
} }
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 => { handleScrollDown = e => {
e.stopPropagation() e.stopPropagation()
const target = document.querySelector('.account-menu__accounts')
const { scrollHeight } = target const { scrollHeight } = this.accountsRef
target.scroll({ left: 0, top: scrollHeight, behavior: 'smooth' }) this.accountsRef.scroll({ left: 0, top: scrollHeight, behavior: 'smooth' })
this.setAtAccountListBottom()
this.setShouldShowScrollButton()
} }
renderScrollButton () { renderScrollButton () {
const { accounts } = this.props const { shouldShowScrollButton } = this.state
const { atAccountListBottom } = this.state
return !atAccountListBottom && Object.keys(accounts).length > 3 && ( return shouldShowScrollButton && (
<div <div
className="account-menu__scroll-button" className="account-menu__scroll-button"
onClick={this.handleScrollDown} onClick={this.handleScrollDown}
@ -211,20 +290,21 @@ export default class AccountMenu extends PureComponent {
src="./images/icons/down-arrow.svg" src="./images/icons/down-arrow.svg"
width={28} width={28}
height={28} height={28}
alt="scroll down"
/> />
</div> </div>
) )
} }
render () { render () {
const { t } = this.context const { t, metricsEvent } = this.context
const { const {
shouldShowAccountsSearch,
isAccountMenuOpen, isAccountMenuOpen,
toggleAccountMenu, toggleAccountMenu,
lockMetamask, lockMetamask,
history, history,
} = this.props } = this.props
const { metricsEvent } = this.context
return ( return (
<Menu <Menu
@ -246,9 +326,13 @@ export default class AccountMenu extends PureComponent {
</Item> </Item>
<Divider /> <Divider />
<div className="account-menu__accounts-container"> <div className="account-menu__accounts-container">
{shouldShowAccountsSearch ? this.renderAccountsSearch() : null}
<div <div
className="account-menu__accounts" className="account-menu__accounts"
onScroll={this.onScroll} onScroll={this.onScroll}
ref={ref => {
this.accountsRef = ref
}}
> >
{ this.renderAccounts() } { this.renderAccounts() }
</div> </div>

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { compose } from 'recompose' import { compose, withProps } from 'recompose'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import { import {
toggleAccountMenu, toggleAccountMenu,
@ -13,23 +13,28 @@ import {
} from '../../../store/actions' } from '../../../store/actions'
import { import {
getAddressConnectedDomainMap, getAddressConnectedDomainMap,
getMetaMaskAccounts, getMetaMaskAccountsOrdered,
getMetaMaskKeyrings,
getOriginOfCurrentTab, getOriginOfCurrentTab,
getSelectedAddress,
} from '../../../selectors/selectors' } from '../../../selectors/selectors'
import AccountMenu from './account-menu.component' 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) { function mapStateToProps (state) {
const { metamask: { selectedAddress, isAccountMenuOpen, keyrings, identities } } = state const { metamask: { isAccountMenuOpen } } = state
return { return {
selectedAddress,
isAccountMenuOpen, isAccountMenuOpen,
keyrings,
identities,
accounts: getMetaMaskAccounts(state),
addressConnectedDomainMap: getAddressConnectedDomainMap(state), addressConnectedDomainMap: getAddressConnectedDomainMap(state),
originOfCurrentTab: getOriginOfCurrentTab(state), originOfCurrentTab: getOriginOfCurrentTab(state),
selectedAddress: getSelectedAddress(state),
keyrings: getMetaMaskKeyrings(state),
accounts: getMetaMaskAccountsOrdered(state),
} }
} }
@ -65,5 +70,6 @@ function mapDispatchToProps (dispatch) {
export default compose( export default compose(
withRouter, withRouter,
connect(mapStateToProps, mapDispatchToProps) connect(mapStateToProps, mapDispatchToProps),
withProps(({ accounts }) => ({ shouldShowAccountsSearch: accounts.length >= SHOW_SEARCH_ACCOUNTS_MIN_COUNT}))
)(AccountMenu) )(AccountMenu)

View File

@ -51,22 +51,26 @@
height: 16px; height: 16px;
} }
&__accounts { &__accounts-container {
display: flex; display: flex;
flex-flow: column nowrap; flex-direction: column;
overflow-y: auto;
max-height: 256px;
position: relative;
z-index: 200; z-index: 200;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { max-height: 256px;
display: none;
}
@media screen and (max-width: 575px) { @media screen and (max-width: 575px) {
max-height: 228px; max-height: 228px;
} }
}
&__accounts {
overflow-y: auto;
position: relative;
&::-webkit-scrollbar {
display: none;
}
.keyring-label { .keyring-label {
margin-top: 5px; margin-top: 5px;
@ -77,6 +81,11 @@
} }
} }
&__no-accounts {
font-size: 0.8em;
padding: 16px 14px;
}
&__account { &__account {
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;

View File

@ -0,0 +1 @@
export { default } from './search-icon.component'

View File

@ -0,0 +1,12 @@
import React from 'react'
export default function SearchIcon () {
return (
<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<g clipRule="evenodd" fillRule="evenodd">
<path d="M9.167 3.333a5.833 5.833 0 100 11.667 5.833 5.833 0 000-11.667zm-7.5 5.834a7.5 7.5 0 1115 0 7.5 7.5 0 01-15 0z" />
<path d="M13.286 13.286a.833.833 0 011.178 0l3.625 3.625a.833.833 0 11-1.178 1.178l-3.625-3.625a.833.833 0 010-1.178z" />
</g>
</svg>
)
}

View File

@ -28,6 +28,24 @@ const styles = {
}, },
}, },
materialError: {}, materialError: {},
materialWhitePaddedRoot: {
color: '#aeaeae',
},
materialWhitePaddedInput: {
padding: '8px',
'&::placeholder': {
color: '#aeaeae',
},
},
materialWhitePaddedFocused: {
color: '#fff',
},
materialWhitePaddedUnderline: {
'&:after': {
borderBottom: '2px solid #fff',
},
},
// Non-material styles // Non-material styles
formLabel: { formLabel: {
'&$formLabelFocused': { '&$formLabelFocused': {
@ -66,35 +84,99 @@ const styles = {
}, },
} }
const TextField = props => { const getMaterialThemeInputProps = ({
const { error, classes, material, startAdornment, largeLabel, dir, ...textFieldProps } = props 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 ( return (
<MaterialTextField <MaterialTextField
error={Boolean(error)} error={Boolean(error)}
helperText={error} helperText={error}
InputLabelProps={{ {...inputProps}
shrink: material ? undefined : true,
className: material ? '' : (largeLabel ? classes.largeInputLabel : classes.inputLabel),
FormLabelClasses: {
root: material ? classes.materialLabel : classes.formLabel,
focused: material ? classes.materialFocused : classes.formLabelFocused,
error: classes.materialError,
},
}}
InputProps={{
startAdornment: startAdornment || undefined,
disableUnderline: !material,
classes: {
root: material ? '' : classes.inputRoot,
input: material ? '' : classes.input,
underline: material ? classes.materialUnderline : '',
focused: material ? '' : classes.inputFocused,
},
inputProps: {
dir,
},
}}
{...textFieldProps} {...textFieldProps}
/> />
) )
@ -103,13 +185,14 @@ const TextField = props => {
TextField.defaultProps = { TextField.defaultProps = {
error: null, error: null,
dir: 'auto', dir: 'auto',
theme: 'bordered',
} }
TextField.propTypes = { TextField.propTypes = {
error: PropTypes.string, error: PropTypes.string,
classes: PropTypes.object, classes: PropTypes.object,
dir: PropTypes.string, dir: PropTypes.string,
material: PropTypes.bool, theme: PropTypes.oneOf(['bordered', 'material', 'material-white-padded']),
startAdornment: PropTypes.element, startAdornment: PropTypes.element,
largeLabel: PropTypes.bool, largeLabel: PropTypes.bool,
} }

View File

@ -33,14 +33,14 @@ storiesOf('TextField', module)
<TextField <TextField
label="Text" label="Text"
type="text" type="text"
material theme="material"
/> />
)) ))
.add('Material password', () => ( .add('Material password', () => (
<TextField <TextField
label="Password" label="Password"
type="password" type="password"
material theme="material"
/> />
)) ))
.add('Material error', () => ( .add('Material error', () => (
@ -48,6 +48,6 @@ storiesOf('TextField', module)
type="text" type="text"
label="Name" label="Name"
error="Invalid value" error="Invalid value"
material theme="material"
/> />
)) ))

View File

@ -36,13 +36,9 @@ export default class TokenSearch extends Component {
error: PropTypes.string, error: PropTypes.string,
} }
constructor (props) { state = {
super(props)
this.state = {
searchQuery: '', searchQuery: '',
} }
}
handleSearch (searchQuery) { handleSearch (searchQuery) {
this.setState({ searchQuery }) this.setState({ searchQuery })

View File

@ -165,7 +165,7 @@ export default class UnlockPage extends Component {
error={error} error={error}
autoFocus autoFocus
autoComplete="current-password" autoComplete="current-password"
material theme="material"
fullWidth fullWidth
/> />
</form> </form>

View File

@ -1,6 +1,7 @@
import { NETWORK_TYPES } from '../helpers/constants/common' import { NETWORK_TYPES } from '../helpers/constants/common'
import { mapObjectValues } from '../../../app/scripts/lib/util' import { mapObjectValues } from '../../../app/scripts/lib/util'
import { stripHexPrefix, addHexPrefix } from 'ethereumjs-util' import { stripHexPrefix, addHexPrefix } from 'ethereumjs-util'
import { createSelector } from 'reselect'
import abi from 'human-standard-token-abi' import abi from 'human-standard-token-abi'
import { multiplyCurrencies } from '../helpers/utils/conversion-util' import { multiplyCurrencies } from '../helpers/utils/conversion-util'
@ -58,6 +59,28 @@ export function getCurrentNetworkId (state) {
return state.metamask.network 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) { export function getSelectedAddress (state) {
const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0] const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0]
@ -80,25 +103,37 @@ export function getNumberOfTokens (state) {
return tokens ? tokens.length : 0 return tokens ? tokens.length : 0
} }
export function getMetaMaskAccounts (state) { export function getMetaMaskKeyrings (state) {
const currentAccounts = state.metamask.accounts return state.metamask.keyrings
const cachedBalances = state.metamask.cachedBalances[state.metamask.network] }
const selectedAccounts = {}
Object.keys(currentAccounts).forEach(accountID => { export function getMetaMaskIdentities (state) {
const account = currentAccounts[accountID] return state.metamask.identities
if (account && account.balance === null || account.balance === undefined) {
selectedAccounts[accountID] = {
...account,
balance: cachedBalances && cachedBalances[accountID],
} }
} else {
selectedAccounts[accountID] = account export function getMetaMaskAccountsRaw (state) {
return state.metamask.accounts
} }
})
return selectedAccounts 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) { export function isBalanceCached (state) {
const selectedAccountBalance = state.metamask.accounts[getSelectedAddress(state)].balance const selectedAccountBalance = state.metamask.accounts[getSelectedAddress(state)].balance
const cachedBalance = getSelectedAccountCachedBalance(state) const cachedBalance = getSelectedAccountCachedBalance(state)

View File

@ -1,8 +1,5 @@
import assert from 'assert' import assert from 'assert'
import * as selectors from '../selectors' import { getAddressBook } from '../selectors.js'
const {
getAddressBook,
} = selectors
import mockState from './selectors-test-data' import mockState from './selectors-test-data'
describe('selectors', () => { describe('selectors', () => {