Browse Source

Merge branch 'develop' into wording

master
meriadec 7 years ago
parent
commit
02e04950f1
No known key found for this signature in database GPG Key ID: 1D2FC2305E2CB399
  1. 4
      src/components/ConfettiParty/Confetti.js
  2. 20
      src/components/ConfettiParty/index.js
  3. 22
      src/components/GenuineCheck.js
  4. 136
      src/components/base/AccountsList/AccountRow.js
  5. 120
      src/components/base/AccountsList/index.js
  6. 15
      src/components/base/Input/index.js
  7. 51
      src/components/base/Spoiler/index.js
  8. 14
      src/components/modals/AccountSettingRenderBody.js
  9. 41
      src/components/modals/AddAccounts/index.js
  10. 131
      src/components/modals/AddAccounts/steps/03-step-import.js
  11. 11
      src/components/modals/AddAccounts/steps/04-step-finish.js
  12. 26
      src/config/constants.js
  13. 15
      src/helpers/accountName.js
  14. 2
      src/reducers/onboarding.js
  15. 16
      static/i18n/en/app.yml

4
src/components/ConfettiParty/Confetti.js

@ -1,6 +1,9 @@
// @flow // @flow
import React, { PureComponent } from 'react' import React, { PureComponent } from 'react'
import Animated from 'animated/lib/targets/react-dom' import Animated from 'animated/lib/targets/react-dom'
import Easing from 'animated/lib/Easing'
const easing = Easing.bezier(0.0, 0.3, 1, 1)
class Confetti extends PureComponent< class Confetti extends PureComponent<
{ {
@ -25,6 +28,7 @@ class Confetti extends PureComponent<
Animated.timing(this.state.progress, { Animated.timing(this.state.progress, {
toValue: 1, toValue: 1,
duration, duration,
easing,
}).start() }).start()
} }
render() { render() {

20
src/components/ConfettiParty/index.js

@ -31,24 +31,32 @@ const nextConfetti = (mode: ?string) =>
id: id++, id: id++,
shape: shapes[Math.floor(shapes.length * Math.random())], shape: shapes[Math.floor(shapes.length * Math.random())],
initialRotation: 360 * Math.random(), initialRotation: 360 * Math.random(),
initialYPercent: -0.15 * Math.random(), initialYPercent: -0.04 + -0.25 * Math.random(),
initialXPercent: 0.2 + 0.6 * Math.random(), initialXPercent: 0.2 + 0.6 * Math.random(),
initialScale: 1, initialScale: 1,
rotations: 8 * Math.random() - 4, rotations: 8 * Math.random() - 4,
delta: [(Math.random() - 0.5) * 600, 300 + 300 * Math.random()], delta: [(Math.random() - 0.5) * 1500, 500 + 500 * Math.random()],
duration: 6000 + 5000 * Math.random(), duration: 12000 + 8000 * Math.random(),
} }
class ConfettiParty extends PureComponent<{ emit: boolean }, { confettis: Array<Object> }> { class ConfettiParty extends PureComponent<{ emit: boolean }, { confettis: Array<Object> }> {
state = { state = {
// $FlowFixMe // $FlowFixMe
confettis: Array(64) confettis: Array(100)
.fill(null) .fill(null)
.map(nextConfetti), .map(nextConfetti),
} }
componentDidMount() { componentDidMount() {
this.setEmit(this.props.emit) this.setEmit(this.props.emit)
this.initialTimeout = setTimeout(() => {
clearInterval(this.initialInterval)
}, 10000)
this.initialInterval = setInterval(() => {
this.setState(({ confettis }) => ({
confettis: confettis.slice(confettis.length > 200 ? 1 : 0).concat(nextConfetti()),
}))
}, 100)
} }
componentDidUpdate(prevProps: *) { componentDidUpdate(prevProps: *) {
@ -59,6 +67,8 @@ class ConfettiParty extends PureComponent<{ emit: boolean }, { confettis: Array<
componentWillUnmount() { componentWillUnmount() {
this.setEmit(false) this.setEmit(false)
clearInterval(this.initialInterval)
clearTimeout(this.initialTimeout)
} }
setEmit(on: boolean) { setEmit(on: boolean) {
@ -73,6 +83,8 @@ class ConfettiParty extends PureComponent<{ emit: boolean }, { confettis: Array<
} }
} }
interval: * interval: *
initialInterval: *
initialTimeout: *
render() { render() {
const { confettis } = this.state const { confettis } = this.state

22
src/components/GenuineCheck.js

@ -5,13 +5,14 @@ import { timeout } from 'rxjs/operators/timeout'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { compose } from 'redux' import { compose } from 'redux'
import { translate, Trans } from 'react-i18next' import { translate, Trans } from 'react-i18next'
import { delay, createCancelablePolling } from 'helpers/promise'
import logger from 'logger'
import type { T, Device } from 'types/common' import type { T, Device } from 'types/common'
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' import type { DeviceInfo } from 'helpers/devices/getDeviceInfo'
import { GENUINE_TIMEOUT, DEVICE_INFOS_TIMEOUT } from 'config/constants' import { GENUINE_TIMEOUT, DEVICE_INFOS_TIMEOUT, GENUINE_CACHE_DELAY } from 'config/constants'
import { createCancelablePolling } from 'helpers/promise'
import { getCurrentDevice } from 'reducers/devices' import { getCurrentDevice } from 'reducers/devices'
import { createCustomErrorClass } from 'helpers/errors' import { createCustomErrorClass } from 'helpers/errors'
@ -46,12 +47,7 @@ const mapStateToProps = state => ({
const Bold = props => <Text ff="Open Sans|Bold" {...props} /> const Bold = props => <Text ff="Open Sans|Bold" {...props} />
// to speed up genuine check, cache result by device id // to speed up genuine check, cache result by device id
const GENUINITY_CACHE = {} const genuineDevices = new WeakSet()
const getDeviceId = (device: Device) => device.path
const setDeviceGenuinity = (device: Device, isGenuine: boolean) =>
(GENUINITY_CACHE[getDeviceId(device)] = isGenuine)
const getDeviceGenuinity = (device: Device): ?boolean =>
GENUINITY_CACHE[getDeviceId(device)] || null
class GenuineCheck extends PureComponent<Props> { class GenuineCheck extends PureComponent<Props> {
connectInteractionHandler = () => connectInteractionHandler = () =>
@ -76,7 +72,9 @@ class GenuineCheck extends PureComponent<Props> {
device: Device, device: Device,
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,
}) => { }) => {
if (getDeviceGenuinity(device) === true) { if (genuineDevices.has(device)) {
logger.log("genuine was already checked. don't check again")
await delay(GENUINE_CACHE_DELAY)
return true return true
} }
const res = await getIsGenuine const res = await getIsGenuine
@ -85,10 +83,10 @@ class GenuineCheck extends PureComponent<Props> {
.toPromise() .toPromise()
const isGenuine = res === '0000' const isGenuine = res === '0000'
if (!isGenuine) { if (!isGenuine) {
return Promise.reject(new DeviceNotGenuineError()) throw new DeviceNotGenuineError()
} }
setDeviceGenuinity(device, true) genuineDevices.add(device)
return Promise.resolve(true) return true
} }
handleFail = (err: Error) => { handleFail = (err: Error) => {

136
src/components/base/AccountsList/AccountRow.js

@ -11,100 +11,79 @@ import Radio from 'components/base/Radio'
import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon' import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon'
import FormattedVal from 'components/base/FormattedVal' import FormattedVal from 'components/base/FormattedVal'
import Input from 'components/base/Input' import Input from 'components/base/Input'
import IconEdit from 'icons/Edit' import { MAX_ACCOUNT_NAME_SIZE } from 'config/constants'
import IconCheck from 'icons/Check'
import type { T } from 'types/common'
type Props = { type Props = {
account: Account, account: Account,
isChecked: boolean, isChecked: boolean,
isDisabled?: boolean, isDisabled?: boolean,
onClick: Account => void, autoFocusInput?: boolean,
onAccountUpdate: Account => void, accountName: string,
t: T, onToggleAccount?: (Account, boolean) => void,
onEditName?: (Account, string) => void,
} }
type State = { export default class AccountRow extends PureComponent<Props> {
isEditing: boolean, handlePreventSubmit = (e: SyntheticEvent<*>) => {
accountNameCopy: string, e.preventDefault()
e.stopPropagation()
} }
export default class AccountRow extends PureComponent<Props, State> { onToggleAccount = () => {
state = { const { onToggleAccount, account, isChecked } = this.props
isEditing: false, if (onToggleAccount) onToggleAccount(account, !isChecked)
accountNameCopy: '',
} }
componentDidUpdate(prevProps: Props, prevState: State) { handleChangeName = (name: string) => {
const startedEditing = !prevState.isEditing && this.state.isEditing const { onEditName, account } = this.props
if (startedEditing) { if (onEditName) onEditName(account, name)
this._input && this._input.handleSelectEverything()
}
} }
handleEditClick = (e: SyntheticEvent<any>) => { onClickInput = (e: SyntheticEvent<*>) => {
this.handlePreventSubmit(e) e.preventDefault()
const { account } = this.props e.stopPropagation()
this.setState({ isEditing: true, accountNameCopy: account.name })
} }
handleSubmitName = (e: SyntheticEvent<any>) => { onFocus = (e: *) => {
this.handlePreventSubmit(e) e.target.select()
const { account, onAccountUpdate, isChecked, onClick } = this.props
const { accountNameCopy } = this.state
const updatedAccount = { ...account, name: accountNameCopy }
this.setState({ isEditing: false, accountNameCopy: '' })
onAccountUpdate(updatedAccount)
if (!isChecked) {
onClick(updatedAccount)
} }
onBlur = (e: *) => {
const { onEditName, account } = this.props
const { value } = e.target
if (!value && onEditName) {
// don't leave an empty input on blur
onEditName(account, account.name)
} }
handlePreventSubmit = (e: SyntheticEvent<any>) => {
// prevent account row to be submitted
e.preventDefault()
e.stopPropagation()
} }
handleChangeName = (accountNameCopy: string) => this.setState({ accountNameCopy })
handleReset = () => this.setState({ isEditing: false, accountNameCopy: '' })
_input = null _input = null
render() { render() {
const { account, isChecked, onClick, isDisabled, t } = this.props const { account, isChecked, onEditName, accountName, isDisabled, autoFocusInput } = this.props
const { isEditing, accountNameCopy } = this.state
return ( return (
<AccountRowContainer onClick={() => onClick(account)} isDisabled={isDisabled}> <AccountRowContainer
isDisabled={isDisabled}
onClick={isDisabled ? null : this.onToggleAccount}
>
<CryptoCurrencyIcon currency={account.currency} size={16} color={account.currency.color} /> <CryptoCurrencyIcon currency={account.currency} size={16} color={account.currency.color} />
<Box shrink grow ff="Open Sans|SemiBold" color="dark" fontSize={4}> <Box shrink grow ff="Open Sans|SemiBold" color="dark" fontSize={4}>
{isEditing ? ( {onEditName ? (
<Input <Input
containerProps={{ style: { width: 260 } }} containerProps={{ style: { width: 200 } }}
value={accountNameCopy} value={accountName}
onChange={this.handleChangeName} onChange={this.handleChangeName}
onClick={this.handlePreventSubmit} onClick={this.onClickInput}
onEnter={this.handleSubmitName} onEnter={this.handlePreventSubmit}
onEsc={this.handleReset} onFocus={this.onFocus}
renderRight={ onBlur={this.onBlur}
<InputRight onClick={this.handleSubmitName}> maxLength={MAX_ACCOUNT_NAME_SIZE}
<IconCheck size={16} /> editInPlace
</InputRight> autoFocus={autoFocusInput}
}
ref={input => (this._input = input)}
/> />
) : ( ) : (
<div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{account.name}</div> <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{accountName}</div>
)} )}
</Box> </Box>
{!isEditing && (
<Edit onClick={this.handleEditClick}>
<IconEdit size={13} />
<span>{t('app:addAccounts.editName')}</span>
</Edit>
)}
<FormattedVal <FormattedVal
val={account.balance} val={account.balance}
unit={account.unit} unit={account.unit}
@ -112,7 +91,11 @@ export default class AccountRow extends PureComponent<Props, State> {
fontSize={4} fontSize={4}
color="grey" color="grey"
/> />
{!isDisabled ? (
<Radio disabled isChecked={isChecked || !!isDisabled} /> <Radio disabled isChecked={isChecked || !!isDisabled} />
) : (
<div style={{ width: 20 }} />
)}
</AccountRowContainer> </AccountRowContainer>
) )
} }
@ -140,30 +123,3 @@ const AccountRowContainer = styled(Tabbable).attrs({
background-color: ${p => darken(p.theme.colors.lightGrey, 0.03)}; background-color: ${p => darken(p.theme.colors.lightGrey, 0.03)};
} }
` `
const Edit = styled(Box).attrs({
color: 'wallet',
fontSize: 3,
horizontal: true,
align: 'center',
flow: 1,
py: 1,
})`
display: none;
${AccountRowContainer}:hover & {
display: flex;
}
&:hover {
text-decoration: underline;
}
`
const InputRight = styled(Box).attrs({
bg: 'wallet',
color: 'white',
align: 'center',
justify: 'center',
shrink: 0,
})`
width: 40px;
`

120
src/components/base/AccountsList/index.js

@ -1,82 +1,120 @@
// @flow // @flow
import React from 'react' import React, { Component } from 'react'
import styled from 'styled-components'
import { translate } from 'react-i18next' import { translate } from 'react-i18next'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import Box from 'components/base/Box' import Box from 'components/base/Box'
import FakeLink from 'components/base/FakeLink' import FakeLink from 'components/base/FakeLink'
import Spinner from 'components/base/Spinner'
import type { T } from 'types/common' import type { T } from 'types/common'
import { SpoilerIcon } from '../Spoiler'
import AccountRow from './AccountRow' import AccountRow from './AccountRow'
const AccountsList = ({ class AccountsList extends Component<
{
accounts: Account[],
checkedIds?: string[],
editedNames: { [accountId: string]: string },
setAccountName?: (Account, string) => void,
onToggleAccount?: Account => void,
onSelectAll?: (Account[]) => void,
onUnselectAll?: (Account[]) => void,
title?: string,
emptyText?: string,
autoFocusFirstInput?: boolean,
collapsible?: boolean,
t: T,
},
{
collapsed: boolean,
},
> {
state = {
collapsed: false,
}
toggleCollapse = () => {
this.setState(({ collapsed }) => ({ collapsed: !collapsed }))
}
onSelectAll = () => {
const { accounts, onSelectAll } = this.props
if (onSelectAll) onSelectAll(accounts)
}
onUnselectAll = () => {
const { accounts, onUnselectAll } = this.props
if (onUnselectAll) onUnselectAll(accounts)
}
render() {
const {
accounts, accounts,
checkedIds, checkedIds,
onToggleAccount, onToggleAccount,
onUpdateAccount, editedNames,
setAccountName,
onSelectAll, onSelectAll,
onUnselectAll, onUnselectAll,
isLoading,
title, title,
emptyText, emptyText,
autoFocusFirstInput,
collapsible,
t, t,
}: { } = this.props
accounts: Account[], const { collapsed } = this.state
checkedIds: string[],
onToggleAccount: Account => void,
onUpdateAccount: Account => void,
onSelectAll: () => void,
onUnselectAll: () => void,
isLoading?: boolean,
title?: string,
emptyText?: string,
t: T,
}) => {
const withToggleAll = !!onSelectAll && !!onUnselectAll && accounts.length > 1 const withToggleAll = !!onSelectAll && !!onUnselectAll && accounts.length > 1
const isAllSelected = accounts.every(acc => !!checkedIds.find(id => acc.id === id)) const isAllSelected =
!checkedIds || accounts.every(acc => !!checkedIds.find(id => acc.id === id))
return ( return (
<Box flow={3}> <Box flow={3} mt={4}>
{(title || withToggleAll) && ( {(title || withToggleAll) && (
<Box horizontal align="center"> <Box horizontal align="center">
{title && ( {title && (
<Box ff="Open Sans|Bold" color="dark" fontSize={2} textTransform="uppercase"> <Box
horizontal
ff="Open Sans|Bold"
color="dark"
fontSize={2}
textTransform="uppercase"
cursor={collapsible ? 'pointer' : undefined}
onClick={collapsible ? this.toggleCollapse : undefined}
>
{collapsible ? <SpoilerIcon isOpened={!collapsed} mr={1} /> : null}
{title} {title}
</Box> </Box>
)} )}
{withToggleAll && ( {withToggleAll && (
<FakeLink <FakeLink
ml="auto" ml="auto"
onClick={isAllSelected ? onUnselectAll : onSelectAll} onClick={isAllSelected ? this.onUnselectAll : this.onSelectAll}
fontSize={3} fontSize={3}
style={{ lineHeight: '10px' }} style={{ lineHeight: '10px' }}
> >
{isAllSelected ? t('app:addAccounts.unselectAll') : t('app:addAccounts.selectAll')} {isAllSelected
? t('app:addAccounts.unselectAll', { count: accounts.length })
: t('app:addAccounts.selectAll', { count: accounts.length })}
</FakeLink> </FakeLink>
)} )}
</Box> </Box>
)} )}
{accounts.length || isLoading ? ( {collapsed ? null : accounts.length ? (
<Box flow={2}> <Box flow={2}>
{accounts.map(account => ( {accounts.map((account, i) => (
<AccountRow <AccountRow
key={account.id} key={account.id}
account={account} account={account}
isChecked={checkedIds.find(id => id === account.id) !== undefined} autoFocusInput={i === 0 && autoFocusFirstInput}
onClick={onToggleAccount} isDisabled={!onToggleAccount || !checkedIds}
onAccountUpdate={onUpdateAccount} isChecked={!checkedIds || checkedIds.find(id => id === account.id) !== undefined}
t={t} onToggleAccount={onToggleAccount}
onEditName={setAccountName}
accountName={
typeof editedNames[account.id] === 'string'
? editedNames[account.id]
: account.name
}
/> />
))} ))}
{isLoading && (
<LoadingRow>
<Spinner color="grey" size={16} />
</LoadingRow>
)}
</Box> </Box>
) : emptyText && !isLoading ? ( ) : emptyText ? (
<Box ff="Open Sans|Regular" fontSize={3}> <Box ff="Open Sans|Regular" fontSize={3}>
{emptyText} {emptyText}
</Box> </Box>
@ -84,16 +122,6 @@ const AccountsList = ({
</Box> </Box>
) )
} }
}
const LoadingRow = styled(Box).attrs({
horizontal: true,
borderRadius: 1,
px: 3,
align: 'center',
justify: 'center',
})`
height: 48px;
border: 1px dashed ${p => p.theme.colors.grey};
`
export default translate()(AccountsList) export default translate()(AccountsList)

15
src/components/base/Input/index.js

@ -15,12 +15,18 @@ const Container = styled(Box).attrs({
})` })`
background: ${p => p.theme.colors.white}; background: ${p => p.theme.colors.white};
border-radius: ${p => p.theme.radii[1]}px; border-radius: ${p => p.theme.radii[1]}px;
border: 1px solid border-width: 1px;
${p => border-style: solid;
border-color: ${p =>
p.error ? p.theme.colors.pearl : p.isFocus ? p.theme.colors.wallet : p.theme.colors.fog}; p.error ? p.theme.colors.pearl : p.isFocus ? p.theme.colors.wallet : p.theme.colors.fog};
box-shadow: ${p => (p.isFocus ? `rgba(0, 0, 0, 0.05) 0 2px 2px` : 'none')}; box-shadow: ${p => (p.isFocus ? `rgba(0, 0, 0, 0.05) 0 2px 2px` : 'none')};
height: ${p => (p.small ? '34' : '40')}px; height: ${p => (p.small ? '34' : '40')}px;
position: relative; position: relative;
&:not(:hover) {
background: ${p => (!p.isFocus && p.editInPlace ? 'transparent' : undefined)};
border-color: ${p => (!p.isFocus && p.editInPlace ? 'transparent' : undefined)};
}
` `
const ErrorDisplay = styled(Box)` const ErrorDisplay = styled(Box)`
@ -44,6 +50,7 @@ const Base = styled.input.attrs({
outline: none; outline: none;
padding: 0; padding: 0;
width: 100%; width: 100%;
background: none;
&::placeholder { &::placeholder {
color: ${p => p.theme.colors.fog}; color: ${p => p.theme.colors.fog};
@ -82,6 +89,7 @@ type Props = {
containerProps?: Object, containerProps?: Object,
error?: string | boolean, error?: string | boolean,
small?: boolean, small?: boolean,
editInPlace?: boolean,
} }
type State = { type State = {
@ -152,7 +160,7 @@ class Input extends PureComponent<Props, State> {
render() { render() {
const { isFocus } = this.state const { isFocus } = this.state
const { renderLeft, renderRight, containerProps, small, error } = this.props const { renderLeft, renderRight, containerProps, editInPlace, small, error } = this.props
return ( return (
<Container <Container
@ -162,6 +170,7 @@ class Input extends PureComponent<Props, State> {
{...containerProps} {...containerProps}
small={small} small={small}
error={error} error={error}
editInPlace={editInPlace}
> >
{renderLeft} {renderLeft}
<Box px={3} grow shrink> <Box px={3} grow shrink>

51
src/components/base/Spoiler/index.js

@ -1,6 +1,7 @@
// @flow // @flow
import React, { PureComponent, Fragment } from 'react' import React, { PureComponent, Fragment } from 'react'
import uncontrollable from 'uncontrollable'
import styled from 'styled-components' import styled from 'styled-components'
import Box from 'components/base/Box' import Box from 'components/base/Box'
@ -10,6 +11,8 @@ import IconChevronRight from 'icons/ChevronRight'
type Props = { type Props = {
children: any, children: any,
title: string, title: string,
opened: boolean,
onOpen: boolean => void,
} }
type State = { type State = {
@ -24,7 +27,6 @@ const Title = styled(Text).attrs({
})` })`
text-transform: ${p => (!p.textTransform ? 'auto' : 'uppercase')}; text-transform: ${p => (!p.textTransform ? 'auto' : 'uppercase')};
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer;
outline: none; outline: none;
` `
@ -33,30 +35,47 @@ const IconContainer = styled(Box)`
transition: 150ms linear transform; transition: 150ms linear transform;
` `
class Spoiler extends PureComponent<Props, State> { export class SpoilerIcon extends PureComponent<{ isOpened: boolean }> {
state = { render() {
isOpened: false, const { isOpened, ...rest } = this.props
return (
<IconContainer isOpened={isOpened} {...rest}>
<IconChevronRight size={12} />
</IconContainer>
)
} }
}
/* eslint-disable react/no-multi-comp */
toggle = () => this.setState({ isOpened: !this.state.isOpened }) class Spoiler extends PureComponent<Props, State> {
toggle = () => {
const { opened, onOpen } = this.props
onOpen(!opened)
}
render() { render() {
const { title, children, ...p } = this.props const { title, opened, onOpen, children, ...p } = this.props
const { isOpened } = this.state
return ( return (
<Fragment> <Fragment>
<Box horizontal flow={1} color="dark" align="center" {...p}> <Box
<IconContainer isOpened={isOpened}> onClick={this.toggle}
<IconChevronRight size={12} /> horizontal
</IconContainer> flow={1}
<Title {...p} onClick={this.toggle}> color="dark"
{title} cursor="pointer"
</Title> align="center"
{...p}
>
<SpoilerIcon isOpened={opened} />
<Title {...p}>{title}</Title>
</Box> </Box>
{isOpened && children} {opened && children}
</Fragment> </Fragment>
) )
} }
} }
export default Spoiler export default uncontrollable(Spoiler, {
opened: 'onOpen',
})

14
src/components/modals/AccountSettingRenderBody.js

@ -9,7 +9,8 @@ import { translate } from 'react-i18next'
import type { Account, Unit, Currency } from '@ledgerhq/live-common/lib/types' import type { Account, Unit, Currency } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common' import type { T } from 'types/common'
import { MODAL_SETTINGS_ACCOUNT } from 'config/constants' import { MODAL_SETTINGS_ACCOUNT, MAX_ACCOUNT_NAME_SIZE } from 'config/constants'
import { validateNameEdition } from 'helpers/accountName'
import { updateAccount, removeAccount } from 'actions/accounts' import { updateAccount, removeAccount } from 'actions/accounts'
import { setDataModal } from 'reducers/modals' import { setDataModal } from 'reducers/modals'
@ -131,13 +132,11 @@ class HelperComp extends PureComponent<Props, State> {
const { updateAccount, setDataModal } = this.props const { updateAccount, setDataModal } = this.props
const { accountName, accountUnit, endpointConfig, endpointConfigError } = this.state const { accountName, accountUnit, endpointConfig, endpointConfigError } = this.state
const sanitizedAccountName = accountName ? accountName.replace(/\s+/g, ' ').trim() : null const name = validateNameEdition(account, accountName)
if (account.name || sanitizedAccountName) {
account = { account = {
...account, ...account,
unit: accountUnit || account.unit, unit: accountUnit || account.unit,
name: sanitizedAccountName || account.name, name,
} }
if (endpointConfig && !endpointConfigError) { if (endpointConfig && !endpointConfigError) {
account.endpointConfig = endpointConfig account.endpointConfig = endpointConfig
@ -145,9 +144,6 @@ class HelperComp extends PureComponent<Props, State> {
updateAccount(account) updateAccount(account)
setDataModal(MODAL_SETTINGS_ACCOUNT, { account }) setDataModal(MODAL_SETTINGS_ACCOUNT, { account })
onClose() onClose()
} else {
this.setState({ accountNameError: true })
}
} }
handleFocus = (e: any, name: string) => { handleFocus = (e: any, name: string) => {
@ -211,7 +207,7 @@ class HelperComp extends PureComponent<Props, State> {
<Box> <Box>
<Input <Input
value={account.name} value={account.name}
maxLength={30} maxLength={MAX_ACCOUNT_NAME_SIZE}
onChange={this.handleChangeName} onChange={this.handleChangeName}
renderLeft={<InputLeft currency={account.currency} />} renderLeft={<InputLeft currency={account.currency} />}
onFocus={e => this.handleFocus(e, 'accountName')} onFocus={e => this.handleFocus(e, 'accountName')}

41
src/components/modals/AddAccounts/index.js

@ -23,6 +23,7 @@ import { closeModal } from 'reducers/modals'
import Modal from 'components/base/Modal' import Modal from 'components/base/Modal'
import Stepper from 'components/base/Stepper' import Stepper from 'components/base/Stepper'
import { validateNameEdition } from 'helpers/accountName'
import StepChooseCurrency, { StepChooseCurrencyFooter } from './steps/01-step-choose-currency' import StepChooseCurrency, { StepChooseCurrencyFooter } from './steps/01-step-choose-currency'
import StepConnectDevice, { StepConnectDeviceFooter } from './steps/02-step-connect-device' import StepConnectDevice, { StepConnectDeviceFooter } from './steps/02-step-connect-device'
@ -91,7 +92,9 @@ type State = {
currency: ?Currency, currency: ?Currency,
scannedAccounts: Account[], scannedAccounts: Account[],
checkedAccountsIds: string[], checkedAccountsIds: string[],
editedNames: { [_: string]: string },
err: ?Error, err: ?Error,
reset: number,
} }
export type StepProps = DefaultStepProps & { export type StepProps = DefaultStepProps & {
@ -104,12 +107,15 @@ export type StepProps = DefaultStepProps & {
checkedAccountsIds: string[], checkedAccountsIds: string[],
scanStatus: ScanStatus, scanStatus: ScanStatus,
err: ?Error, err: ?Error,
onClickAdd: void => Promise<void>, onClickAdd: () => Promise<void>,
onCloseModal: void => void, onGoStep1: () => void,
resetScanState: void => void, onCloseModal: () => void,
resetScanState: () => void,
setCurrency: (?Currency) => void, setCurrency: (?Currency) => void,
setAppOpened: boolean => void, setAppOpened: boolean => void,
setScanStatus: (ScanStatus, ?Error) => string, setScanStatus: (ScanStatus, ?Error) => string,
setAccountName: (Account, string) => void,
editedNames: { [_: string]: string },
setScannedAccounts: ({ scannedAccounts?: Account[], checkedAccountsIds?: string[] }) => void, setScannedAccounts: ({ scannedAccounts?: Account[], checkedAccountsIds?: string[] }) => void,
} }
@ -129,8 +135,10 @@ const INITIAL_STATE = {
currency: null, currency: null,
scannedAccounts: [], scannedAccounts: [],
checkedAccountsIds: [], checkedAccountsIds: [],
editedNames: {},
err: null, err: null,
scanStatus: 'idle', scanStatus: 'idle',
reset: 0,
} }
class AddAccounts extends PureComponent<Props, State> { class AddAccounts extends PureComponent<Props, State> {
@ -139,15 +147,16 @@ class AddAccounts extends PureComponent<Props, State> {
handleClickAdd = async () => { handleClickAdd = async () => {
const { addAccount } = this.props const { addAccount } = this.props
const { scannedAccounts, checkedAccountsIds } = this.state const { scannedAccounts, checkedAccountsIds, editedNames } = this.state
const accountsIdsMap = checkedAccountsIds.reduce((acc, cur) => { const accountsIdsMap = checkedAccountsIds.reduce((acc, cur) => {
acc[cur] = true acc[cur] = true
return acc return acc
}, {}) }, {})
const accountsToAdd = scannedAccounts.filter(account => accountsIdsMap[account.id] === true) const accountsToAdd = scannedAccounts.filter(account => accountsIdsMap[account.id] === true)
for (let i = 0; i < accountsToAdd.length; i++) { for (const account of accountsToAdd) {
await idleCallback() await idleCallback()
addAccount(accountsToAdd[i]) const name = validateNameEdition(account, editedNames[account.id])
addAccount({ ...account, name })
} }
} }
@ -160,6 +169,12 @@ class AddAccounts extends PureComponent<Props, State> {
this.setState({ scanStatus, err }) this.setState({ scanStatus, err })
} }
handleSetAccountName = (account: Account, name: string) => {
this.setState(({ editedNames }) => ({
editedNames: { ...editedNames, [account.id]: name },
}))
}
handleSetScannedAccounts = ({ handleSetScannedAccounts = ({
checkedAccountsIds, checkedAccountsIds,
scannedAccounts, scannedAccounts,
@ -184,6 +199,10 @@ class AddAccounts extends PureComponent<Props, State> {
handleSetAppOpened = (isAppOpened: boolean) => this.setState({ isAppOpened }) handleSetAppOpened = (isAppOpened: boolean) => this.setState({ isAppOpened })
onGoStep1 = () => {
this.setState(({ reset }) => ({ ...INITIAL_STATE, reset: reset + 1 }))
}
render() { render() {
const { t, device, existingAccounts } = this.props const { t, device, existingAccounts } = this.props
const { const {
@ -194,9 +213,11 @@ class AddAccounts extends PureComponent<Props, State> {
checkedAccountsIds, checkedAccountsIds,
scanStatus, scanStatus,
err, err,
editedNames,
reset,
} = this.state } = this.state
const addtionnalProps = { const stepperProps = {
currency, currency,
device, device,
existingAccounts, existingAccounts,
@ -212,6 +233,9 @@ class AddAccounts extends PureComponent<Props, State> {
setScannedAccounts: this.handleSetScannedAccounts, setScannedAccounts: this.handleSetScannedAccounts,
resetScanState: this.handleResetScanState, resetScanState: this.handleResetScanState,
setAppOpened: this.handleSetAppOpened, setAppOpened: this.handleSetAppOpened,
setAccountName: this.handleSetAccountName,
onGoStep1: this.onGoStep1,
editedNames,
} }
return ( return (
@ -221,12 +245,13 @@ class AddAccounts extends PureComponent<Props, State> {
onHide={() => this.setState({ ...INITIAL_STATE })} onHide={() => this.setState({ ...INITIAL_STATE })}
render={({ onClose }) => ( render={({ onClose }) => (
<Stepper <Stepper
key={reset} // THIS IS A HACK because stepper is not controllable. FIXME
title={t('app:addAccounts.title')} title={t('app:addAccounts.title')}
initialStepId="chooseCurrency" initialStepId="chooseCurrency"
onStepChange={this.handleStepChange} onStepChange={this.handleStepChange}
onClose={onClose} onClose={onClose}
steps={this.STEPS} steps={this.STEPS}
{...addtionnalProps} {...stepperProps}
> >
<Track onUnmount event="CloseModalAddAccounts" /> <Track onUnmount event="CloseModalAddAccounts" />
<SyncSkipUnderPriority priority={100} /> <SyncSkipUnderPriority priority={100} />

131
src/components/modals/AddAccounts/steps/03-step-import.js

@ -1,6 +1,8 @@
// @flow // @flow
import invariant from 'invariant' import invariant from 'invariant'
import styled from 'styled-components'
import { Trans } from 'react-i18next'
import React, { PureComponent, Fragment } from 'react' import React, { PureComponent, Fragment } from 'react'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import uniq from 'lodash/uniq' import uniq from 'lodash/uniq'
@ -14,9 +16,23 @@ import Button from 'components/base/Button'
import AccountsList from 'components/base/AccountsList' import AccountsList from 'components/base/AccountsList'
import IconExclamationCircleThin from 'icons/ExclamationCircleThin' import IconExclamationCircleThin from 'icons/ExclamationCircleThin'
import TranslatedError from '../../../TranslatedError' import TranslatedError from '../../../TranslatedError'
import Spinner from '../../../base/Spinner'
import Text from '../../../base/Text'
import type { StepProps } from '../index' import type { StepProps } from '../index'
const LoadingRow = styled(Box).attrs({
horizontal: true,
borderRadius: 1,
px: 3,
align: 'center',
justify: 'center',
mt: 1,
})`
height: 48px;
border: 1px dashed ${p => p.theme.colors.grey};
`
class StepImport extends PureComponent<StepProps> { class StepImport extends PureComponent<StepProps> {
componentDidMount() { componentDidMount() {
this.props.setScanStatus('scanning') this.props.setScanStatus('scanning')
@ -118,27 +134,20 @@ class StepImport extends PureComponent<StepProps> {
} }
} }
handleUpdateAccount = (updatedAccount: Account) => { handleSelectAll = (accountsToSelect: Account[]) => {
const { scannedAccounts, setScannedAccounts } = this.props const { setScannedAccounts, checkedAccountsIds } = this.props
setScannedAccounts({ setScannedAccounts({
scannedAccounts: scannedAccounts.map(account => { checkedAccountsIds: uniq(checkedAccountsIds.concat(accountsToSelect.map(a => a.id))),
if (account.id !== updatedAccount.id) {
return account
}
return updatedAccount
}),
}) })
} }
handleSelectAll = () => { handleUnselectAll = (accountsToRemove: Account[]) => {
const { scannedAccounts, setScannedAccounts } = this.props const { setScannedAccounts, checkedAccountsIds } = this.props
setScannedAccounts({ setScannedAccounts({
checkedAccountsIds: scannedAccounts.filter(a => a.operations.length > 0).map(a => a.id), checkedAccountsIds: checkedAccountsIds.filter(id => !accountsToRemove.some(a => id === a.id)),
}) })
} }
handleUnselectAll = () => this.props.setScannedAccounts({ checkedAccountsIds: [] })
renderError() { renderError() {
const { err, t } = this.props const { err, t } = this.props
invariant(err, 'Trying to render inexisting error') invariant(err, 'Trying to render inexisting error')
@ -168,66 +177,111 @@ class StepImport extends PureComponent<StepProps> {
scannedAccounts, scannedAccounts,
checkedAccountsIds, checkedAccountsIds,
existingAccounts, existingAccounts,
setAccountName,
editedNames,
t, t,
} = this.props } = this.props
if (err) { if (err) {
// TODO prefer rendering a component
return this.renderError() return this.renderError()
} }
const currencyName = currency ? currency.name : '' const currencyName = currency ? currency.name : ''
const importableAccounts = scannedAccounts.filter(acc => { const importedAccounts = []
if (acc.operations.length <= 0) { const importableAccounts = []
return false const creatableAccounts = []
let alreadyEmptyAccount
scannedAccounts.forEach(acc => {
const existingAccount = existingAccounts.find(a => a.id === acc.id)
const empty = acc.operations.length === 0
if (existingAccount) {
importedAccounts.push(existingAccount)
if (empty) {
alreadyEmptyAccount = existingAccount
} }
return existingAccounts.find(a => a.id === acc.id) === undefined } else if (empty) {
}) creatableAccounts.push(acc)
} else {
const creatableAccounts = scannedAccounts.filter(acc => { importableAccounts.push(acc)
if (acc.operations.length > 0) {
return false
} }
return existingAccounts.find(a => a.id === acc.id) === undefined
}) })
const importableAccountsListTitle = t('app:addAccounts.accountToImportSubtitle', { const importableAccountsListTitle = t('app:addAccounts.accountToImportSubtitle', {
count: importableAccounts.length, count: importableAccounts.length,
}) })
const importedAccountsListTitle = t('app:addAccounts.accountAlreadyImportedSubtitle', {
count: importableAccounts.length,
})
const importableAccountsEmpty = t('app:addAccounts.noAccountToImport', { currencyName }) const importableAccountsEmpty = t('app:addAccounts.noAccountToImport', { currencyName })
const alreadyEmptyAccount = scannedAccounts.find(a => a.operations.length === 0)
const shouldShowNew = scanStatus !== 'scanning'
return ( return (
<Fragment> <Fragment>
<TrackPage category="AddAccounts" name="Step3" /> <TrackPage category="AddAccounts" name="Step3" />
<Box flow={5}> <Box mt={-4}>
{importedAccounts.length === 0 ? null : (
<AccountsList
title={importedAccountsListTitle}
accounts={importedAccounts}
editedNames={editedNames}
collapsible
/>
)}
{importableAccounts.length === 0 ? null : (
<AccountsList <AccountsList
title={importableAccountsListTitle} title={importableAccountsListTitle}
emptyText={importableAccountsEmpty} emptyText={importableAccountsEmpty}
accounts={importableAccounts} accounts={importableAccounts}
checkedIds={checkedAccountsIds} checkedIds={checkedAccountsIds}
onToggleAccount={this.handleToggleAccount} onToggleAccount={this.handleToggleAccount}
onUpdateAccount={this.handleUpdateAccount} setAccountName={setAccountName}
editedNames={editedNames}
onSelectAll={this.handleSelectAll} onSelectAll={this.handleSelectAll}
onUnselectAll={this.handleUnselectAll} onUnselectAll={this.handleUnselectAll}
isLoading={scanStatus === 'scanning'} autoFocusFirstInput
/> />
)}
{!shouldShowNew ? null : (
<AccountsList <AccountsList
autoFocusFirstInput={importableAccounts.length === 0}
title={t('app:addAccounts.createNewAccount.title')} title={t('app:addAccounts.createNewAccount.title')}
emptyText={ emptyText={
alreadyEmptyAccount alreadyEmptyAccount ? (
? t('app:addAccounts.createNewAccount.noOperationOnLastAccount', { <Trans
accountName: alreadyEmptyAccount.name, i18nKey="app:addAccounts.createNewAccount.noOperationOnLastAccount"
}) parent="div"
: t('app:addAccounts.createNewAccount.noAccountToCreate', { currencyName }) >
{' '}
<Text ff="Open Sans|SemiBold" color="dark">
{alreadyEmptyAccount.name}
</Text>{' '}
</Trans>
) : (
<Trans i18nKey="app:addAccounts.createNewAccount.noAccountToCreate" parent="div">
{' '}
<Text ff="Open Sans|SemiBold" color="dark">
{currencyName}
</Text>{' '}
</Trans>
)
} }
accounts={creatableAccounts} accounts={creatableAccounts}
checkedIds={checkedAccountsIds} checkedIds={checkedAccountsIds}
onToggleAccount={this.handleToggleAccount} onToggleAccount={this.handleToggleAccount}
onUpdateAccount={this.handleUpdateAccount} setAccountName={setAccountName}
isLoading={scanStatus === 'scanning'} editedNames={editedNames}
/> />
)}
{scanStatus === 'scanning' ? (
<LoadingRow>
<Spinner color="grey" size={16} />
</LoadingRow>
) : null}
</Box> </Box>
{err && <Box shrink>{err.message}</Box>} {err && <Box shrink>{err.message}</Box>}
@ -264,9 +318,7 @@ export const StepImportFooter = ({
const ctaWording = const ctaWording =
scanStatus === 'scanning' scanStatus === 'scanning'
? t('app:common.sync.syncing') ? t('app:common.sync.syncing')
: willCreateAccount || willAddAccounts : t('app:addAccounts.cta.add', { count })
? t('app:addAccounts.cta.add', { count })
: t('app:common.close')
const willClose = !willCreateAccount && !willAddAccounts const willClose = !willCreateAccount && !willAddAccounts
const onClick = willClose const onClick = willClose
@ -291,7 +343,10 @@ export const StepImportFooter = ({
)} )}
<Button <Button
primary primary
disabled={scanStatus !== 'finished' && scanStatus !== 'error'} disabled={
(scanStatus !== 'finished' && scanStatus !== 'error') ||
!(willCreateAccount || willAddAccounts)
}
onClick={onClick} onClick={onClick}
> >
{ctaWording} {ctaWording}

11
src/components/modals/AddAccounts/steps/04-step-finish.js

@ -9,18 +9,23 @@ import IconCheckCircle from 'icons/CheckCircle'
import type { StepProps } from '../index' import type { StepProps } from '../index'
function StepFinish({ onCloseModal, t }: StepProps) { function StepFinish({ onCloseModal, onGoStep1, t }: StepProps) {
return ( return (
<Box align="center" py={6}> <Box align="center" py={6}>
<TrackPage category="AddAccounts" name="Step4" /> <TrackPage category="AddAccounts" name="Step4" />
<Box color="positiveGreen" mb={4}> <Box color="positiveGreen">
<IconCheckCircle size={40} /> <IconCheckCircle size={40} />
</Box> </Box>
<Box mb={4}>{t('app:addAccounts.success')}</Box> <Box p={4}>{t('app:addAccounts.success')}</Box>
<Box horizontal>
<Button mr={2} outline onClick={onGoStep1}>
{t('app:addAccounts.cta.addMore')}
</Button>
<Button primary onClick={onCloseModal}> <Button primary onClick={onCloseModal}>
{t('app:common.close')} {t('app:common.close')}
</Button> </Button>
</Box> </Box>
</Box>
) )
} }

26
src/config/constants.js

@ -19,23 +19,21 @@ export const MIN_WIDTH = intFromEnv('LEDGER_MIN_WIDTH', 1024)
// time and delays... // time and delays...
export const GET_CALLS_TIMEOUT = intFromEnv('GET_CALLS_TIMEOUT', 30 * 1000) export const CHECK_APP_INTERVAL_WHEN_INVALID = 600
export const CHECK_APP_INTERVAL_WHEN_VALID = 1200
export const CHECK_UPDATE_DELAY = 5000
export const DEVICE_DISCONNECT_DEBOUNCE = intFromEnv('LEDGER_DEVICE_DISCONNECT_DEBOUNCE', 1000)
export const DEVICE_INFOS_TIMEOUT = intFromEnv('DEVICE_INFOS_TIMEOUT', 5 * 1000)
export const GENUINE_CACHE_DELAY = intFromEnv('GENUINE_CACHE_DELAY', 1000)
export const GENUINE_TIMEOUT = intFromEnv('GENUINE_TIMEOUT', 120 * 1000)
export const GET_CALLS_RETRY = intFromEnv('GET_CALLS_RETRY', 2) export const GET_CALLS_RETRY = intFromEnv('GET_CALLS_RETRY', 2)
export const GET_CALLS_TIMEOUT = intFromEnv('GET_CALLS_TIMEOUT', 30 * 1000)
export const LISTEN_DEVICES_POLLING_INTERVAL = intFromEnv('LISTEN_DEVICES_POLLING_INTERVAL', 100) export const LISTEN_DEVICES_POLLING_INTERVAL = intFromEnv('LISTEN_DEVICES_POLLING_INTERVAL', 100)
export const OUTDATED_CONSIDERED_DELAY = intFromEnv('OUTDATED_CONSIDERED_DELAY', 5 * 60 * 1000)
export const SYNC_MAX_CONCURRENT = intFromEnv('LEDGER_SYNC_MAX_CONCURRENT', 1)
export const SYNC_BOOT_DELAY = 2 * 1000
export const SYNC_ALL_INTERVAL = 120 * 1000 export const SYNC_ALL_INTERVAL = 120 * 1000
export const DEVICE_INFOS_TIMEOUT = intFromEnv('DEVICE_INFOS_TIMEOUT', 5 * 1000) export const SYNC_BOOT_DELAY = 2 * 1000
export const GENUINE_TIMEOUT = intFromEnv('GENUINE_TIMEOUT', 120 * 1000) export const SYNC_MAX_CONCURRENT = intFromEnv('LEDGER_SYNC_MAX_CONCURRENT', 1)
export const SYNC_TIMEOUT = intFromEnv('SYNC_TIMEOUT', 30 * 1000) export const SYNC_TIMEOUT = intFromEnv('SYNC_TIMEOUT', 30 * 1000)
export const OUTDATED_CONSIDERED_DELAY = intFromEnv('OUTDATED_CONSIDERED_DELAY', 5 * 60 * 1000)
export const CHECK_APP_INTERVAL_WHEN_INVALID = 600
export const CHECK_APP_INTERVAL_WHEN_VALID = 1200
export const CHECK_UPDATE_DELAY = 5e3
export const DEVICE_DISCONNECT_DEBOUNCE = intFromEnv('LEDGER_DEVICE_DISCONNECT_DEBOUNCE', 1000)
// Endpoints... // Endpoints...
@ -82,6 +80,8 @@ export const EXPERIMENTAL_HTTP_ON_RENDERER = boolFromEnv('EXPERIMENTAL_HTTP_ON_R
// Other constants // Other constants
export const MAX_ACCOUNT_NAME_SIZE = 30
export const MODAL_ADD_ACCOUNTS = 'MODAL_ADD_ACCOUNTS' export const MODAL_ADD_ACCOUNTS = 'MODAL_ADD_ACCOUNTS'
export const MODAL_OPERATION_DETAILS = 'MODAL_OPERATION_DETAILS' export const MODAL_OPERATION_DETAILS = 'MODAL_OPERATION_DETAILS'
export const MODAL_RECEIVE = 'MODAL_RECEIVE' export const MODAL_RECEIVE = 'MODAL_RECEIVE'

15
src/helpers/accountName.js

@ -1,10 +1,19 @@
// @flow // @flow
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import { MAX_ACCOUNT_NAME_SIZE } from 'config/constants'
export const getAccountPlaceholderName = ( export const getAccountPlaceholderName = (
c: CryptoCurrency, c: CryptoCurrency,
index: number, index: number,
isLegacy: boolean = false, isLegacy: boolean = false,
) => `${c.name} ${index}${isLegacy ? ' (legacy)' : ''}` ) => `${c.name} ${index + 1}${isLegacy ? ' (legacy)' : ''}`
export const getNewAccountPlaceholderName = (_c: CryptoCurrency, _index: number) => `New Account` export const getNewAccountPlaceholderName = getAccountPlaceholderName // same naming
// export const getNewAccountPlaceholderName = (_c: CryptoCurrency, _index: number) => `New Account`
export const validateNameEdition = (account: Account, name: ?string): string =>
(
(name || account.name || '').replace(/\s+/g, ' ').trim() ||
account.name ||
getAccountPlaceholderName(account.currency, account.index)
).slice(0, MAX_ACCOUNT_NAME_SIZE)

2
src/reducers/onboarding.js

@ -32,7 +32,7 @@ export type OnboardingState = {
const state: OnboardingState = { const state: OnboardingState = {
stepIndex: 0, // FIXME is this used at all? dup with stepName? stepIndex: 0, // FIXME is this used at all? dup with stepName?
stepName: SKIP_ONBOARDING ? 'finish' : 'start', stepName: SKIP_ONBOARDING ? 'analytics' : 'start',
genuine: { genuine: {
pinStepPass: false, pinStepPass: false,
recoveryStepPass: false, recoveryStepPass: false,

16
static/i18n/en/app.yml

@ -146,10 +146,11 @@ addAccounts:
connectDevice: Connect device connectDevice: Connect device
import: Select accounts import: Select accounts
finish: Confirmation finish: Confirmation
accountToImportSubtitle: Select existing accounts accountAlreadyImportedSubtitle: Already in portfolio
accountToImportSubtitle_plural: 'Select ({{count}}) existing accounts' accountToImportSubtitle: 'Select account'
selectAll: Select all accountToImportSubtitle_plural: 'Select accounts'
unselectAll: Deselect all selectAll: Select all ({{count}})
unselectAll: Deselect all ({{count}})
editName: Edit name editName: Edit name
newAccount: New account newAccount: New account
legacyAccount: '{{accountName}} (legacy)' legacyAccount: '{{accountName}} (legacy)'
@ -157,11 +158,12 @@ addAccounts:
success: Account added to your portfolio success: Account added to your portfolio
# success_plural: Accounts successfully added to your portfolio. # success_plural: Accounts successfully added to your portfolio.
createNewAccount: createNewAccount:
title: Create new account title: Create a new account
noOperationOnLastAccount: 'You have to receive crypto assets on {{accountName}} before you can create a new account.' noOperationOnLastAccount: 'You have to receive crypto assets on <1><0>{{accountName}}</0></1> before you can create a new account.'
noAccountToCreate: No {{currencyName}} account was found to create noAccountToCreate: No <1><0>{{currencyName}}</0></1> account was found to create
somethingWentWrong: Something went wrong during synchronization, please try again. somethingWentWrong: Something went wrong during synchronization, please try again.
cta: cta:
addMore: 'Add more'
add: 'Add account' add: 'Add account'
add_plural: 'Add accounts' add_plural: 'Add accounts'
operationDetails: operationDetails:

Loading…
Cancel
Save