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": {
"message": "Search Tokens"
},
"searchAccounts": {
"message": "Search Accounts"
},
"noAccountsFound": {
"message": "No accounts found for the given search query"
},
"selectAnAccount": {
"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 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 = (
<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 () {
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 <p className="account-menu__no-accounts">{this.context.t('noAccountsFound')}</p>
}
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 {
</div>
<UserPreferencedCurrencyDisplay
className="account-menu__balance"
value={balanceValue}
value={identity.balance}
type={PRIMARY}
/>
</div>
@ -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 && (
<div
className="account-menu__scroll-button"
onClick={this.handleScrollDown}
@ -211,20 +290,21 @@ export default class AccountMenu extends PureComponent {
src="./images/icons/down-arrow.svg"
width={28}
height={28}
alt="scroll down"
/>
</div>
)
}
render () {
const { t } = this.context
const { t, metricsEvent } = this.context
const {
shouldShowAccountsSearch,
isAccountMenuOpen,
toggleAccountMenu,
lockMetamask,
history,
} = this.props
const { metricsEvent } = this.context
return (
<Menu
@ -246,9 +326,13 @@ export default class AccountMenu extends PureComponent {
</Item>
<Divider />
<div className="account-menu__accounts-container">
{shouldShowAccountsSearch ? this.renderAccountsSearch() : null}
<div
className="account-menu__accounts"
onScroll={this.onScroll}
ref={ref => {
this.accountsRef = ref
}}
>
{ this.renderAccounts() }
</div>

View File

@ -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)

View File

@ -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;

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: {},
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 (
<MaterialTextField
error={Boolean(error)}
helperText={error}
InputLabelProps={{
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,
},
}}
{...inputProps}
{...textFieldProps}
/>
)
@ -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,
}

View File

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

View File

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

View File

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

View File

@ -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 = {}
export function getMetaMaskKeyrings (state) {
return state.metamask.keyrings
}
Object.keys(currentAccounts).forEach(accountID => {
const account = currentAccounts[accountID]
if (account && account.balance === null || account.balance === undefined) {
selectedAccounts[accountID] = {
...account,
balance: cachedBalances && cachedBalances[accountID],
export function getMetaMaskIdentities (state) {
return state.metamask.identities
}
} 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) {
const selectedAccountBalance = state.metamask.accounts[getSelectedAddress(state)].balance
const cachedBalance = getSelectedAccountCachedBalance(state)

View File

@ -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', () => {