Browse Source

Merge branch 'develop' into ds-wording

master
meriadec 7 years ago
parent
commit
3627b85623
No known key found for this signature in database GPG Key ID: 1D2FC2305E2CB399
  1. 18
      PULL_REQUEST_TEMPLATE.md
  2. 10
      README.md
  3. 2
      package.json
  4. 22
      scripts/reset-files.sh
  5. 125
      src/components/AccountPage/AccountBalanceSummaryHeader.js
  6. 100
      src/components/AccountPage/AccountHeaderActions.js
  7. 160
      src/components/AccountPage/index.js
  8. 1
      src/components/BalanceSummary/index.js
  9. 10
      src/components/CalculateBalance.js
  10. 3
      src/components/ConfettiParty/Confetti.js
  11. 66
      src/components/ConfettiParty/index.js
  12. 203
      src/components/DeviceInteraction/DeviceInteractionStep.js
  13. 112
      src/components/DeviceInteraction/components.js
  14. 116
      src/components/DeviceInteraction/index.js
  15. 81
      src/components/DeviceInteraction/stories.js
  16. 0
      src/components/EnsureDevice.js
  17. 310
      src/components/EnsureDeviceApp.js
  18. 158
      src/components/GenuineCheck.js
  19. 37
      src/components/GenuineCheckModal.js
  20. 90
      src/components/GenuineCheckModal/index.js
  21. 7
      src/components/ManagerPage/Dashboard.js
  22. 2
      src/components/ManagerPage/FirmwareUpdate.js
  23. 46
      src/components/ManagerPage/ManagerGenuineCheck.js
  24. 73
      src/components/ManagerPage/index.js
  25. 2
      src/components/Onboarding/helperComponents.js
  26. 84
      src/components/Onboarding/steps/Finish.js
  27. 6
      src/components/Onboarding/steps/GenuineCheck/index.js
  28. 81
      src/components/Workflow/EnsureDashboard.js
  29. 88
      src/components/Workflow/EnsureGenuine.js
  30. 171
      src/components/Workflow/WorkflowDefault.js
  31. 194
      src/components/Workflow/WorkflowWithIcon.js
  32. 93
      src/components/Workflow/index.js
  33. 5
      src/components/base/SideBar/SideBarListItem.js
  34. 48
      src/components/base/Stepper/index.js
  35. 2
      src/components/base/Stepper/stories.js
  36. 2
      src/components/modals/Debug.js
  37. 25
      src/components/modals/Receive/01-step-account.js
  38. 16
      src/components/modals/Receive/02-step-connect-device.js
  39. 59
      src/components/modals/Receive/03-step-confirm-address.js
  40. 48
      src/components/modals/Receive/04-step-receive-funds.js
  41. 454
      src/components/modals/Receive/index.js
  42. 29
      src/components/modals/Receive/steps/01-step-account.js
  43. 42
      src/components/modals/Receive/steps/02-step-connect-device.js
  44. 112
      src/components/modals/Receive/steps/03-step-confirm-address.js
  45. 68
      src/components/modals/Receive/steps/04-step-receive-funds.js
  46. 43
      src/components/modals/StepConnectDevice.js
  47. 1
      src/config/constants.js
  48. 13
      src/helpers/deviceAccess.js
  49. 2
      src/helpers/getAddressForCurrency/btc.js
  50. 33
      src/helpers/promise.js
  51. 46
      static/i18n/en/errors.yml

18
PULL_REQUEST_TEMPLATE.md

@ -1,17 +1,13 @@
## What is the type of this PR? <!-- Description of what the PR does go here... screenshot might be good if appropriate -->
<!-- e.g. Bug Fix, Feature, Code Quality Improvement, UI Polish... --> ### Type
## Any background context and/or relevant tickets/issues you want to provide with?
<!-- e.g. GitHub issue #45 --> <!-- e.g. Bug Fix, Feature, Code Quality Improvement, UI Polish... -->
## Short description on what this PR suppose to do?
<!-- e.g. Adding genuine check to the onboarding --> ### Context
## Any special conditions required for testing? <!-- e.g. GitHub issue #45 / contextual discussion -->
<!-- e.g. Clear db, add special env variable.. --> ### Parts of the app affected / Test plan
## Screenshots (if appropriate) <!-- Which part of the app is affected? What to do to test it, any special thing to do? -->

10
README.md

@ -120,12 +120,14 @@ yarn flow # launch flow
yarn test # launch unit tests yarn test # launch unit tests
``` ```
### Programmaically reset hard the app ### Programmatically reset app files
Stop the app and to clean accounts, settings, etc, run
```bash ```bash
rm -rf ~/Library/Application\ Support/Electron/ # clear the dev electron user data directory
# it remove sqlite db, accounts, settings
# useful to start from a fresh state
yarn reset-files
``` ```
## File structure ## File structure

2
package.json

@ -20,7 +20,7 @@
"release": "bash ./scripts/release.sh", "release": "bash ./scripts/release.sh",
"start": "bash ./scripts/start.sh", "start": "bash ./scripts/start.sh",
"storybook": "NODE_ENV=development STORYBOOK_ENV=1 start-storybook -s ./static -p 4444", "storybook": "NODE_ENV=development STORYBOOK_ENV=1 start-storybook -s ./static -p 4444",
"trans": "node scripts/trans" "reset-files": "bash ./scripts/reset-files.sh"
}, },
"electronWebpack": { "electronWebpack": {
"title": true, "title": true,

22
scripts/reset-files.sh

@ -0,0 +1,22 @@
#!/bin/bash
set -e
echo "> Getting user data folder..."
TMP_FILE=`mktemp`
cat <<EOF > $TMP_FILE
const { app } = require('electron')
console.log(app.getPath('userData'))
EOF
USER_DATA_FOLDER=`timeout --preserve-status 0.5 electron $TMP_FILE || echo` # echo used to ensure status 0
rm $TMP_FILE
read -p "> Remove folder \"$USER_DATA_FOLDER\"? (y/n) " -n 1 -r
echo
if [[ $REPLY == "y" ]]
then
rm -rf "$USER_DATA_FOLDER"
else
echo "> Nothing done. Bye"
fi

125
src/components/AccountPage/AccountBalanceSummaryHeader.js

@ -0,0 +1,125 @@
// @flow
import React, { PureComponent } from 'react'
import { createStructuredSelector } from 'reselect'
import { compose } from 'redux'
import { connect } from 'react-redux'
import { translate } from 'react-i18next'
import type { Currency, Account } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common'
import { saveSettings } from 'actions/settings'
import { accountSelector } from 'reducers/accounts'
import { counterValueCurrencySelector, selectedTimeRangeSelector } from 'reducers/settings'
import type { TimeRange } from 'reducers/settings'
import {
BalanceTotal,
BalanceSinceDiff,
BalanceSincePercent,
} from 'components/BalanceSummary/BalanceInfos'
import Box from 'components/base/Box'
import FormattedVal from 'components/base/FormattedVal'
import PillsDaysCount from 'components/PillsDaysCount'
type OwnProps = {
isAvailable: boolean,
totalBalance: number,
sinceBalance: number,
refBalance: number,
accountId: string, // eslint-disable-line
}
type Props = OwnProps & {
counterValue: Currency,
t: T,
account: Account,
saveSettings: ({ selectedTimeRange: TimeRange }) => *,
selectedTimeRange: TimeRange,
}
const mapStateToProps = createStructuredSelector({
account: accountSelector,
counterValue: counterValueCurrencySelector,
selectedTimeRange: selectedTimeRangeSelector,
})
const mapDispatchToProps = {
saveSettings,
}
class AccountBalanceSummaryHeader extends PureComponent<Props> {
handleChangeSelectedTime = item => {
this.props.saveSettings({ selectedTimeRange: item.key })
}
render() {
const {
account,
t,
counterValue,
selectedTimeRange,
isAvailable,
totalBalance,
sinceBalance,
refBalance,
} = this.props
return (
<Box flow={4} mb={2}>
<Box horizontal>
<BalanceTotal
showCryptoEvenIfNotAvailable
isAvailable={isAvailable}
totalBalance={account.balance}
unit={account.unit}
>
<FormattedVal
animateTicker
disableRounding
alwaysShowSign={false}
color="warmGrey"
unit={counterValue.units[0]}
fontSize={6}
showCode
val={totalBalance}
/>
</BalanceTotal>
<Box>
<PillsDaysCount selected={selectedTimeRange} onChange={this.handleChangeSelectedTime} />
</Box>
</Box>
<Box horizontal justifyContent="center" flow={7}>
<BalanceSincePercent
isAvailable={isAvailable}
t={t}
alignItems="center"
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
since={selectedTimeRange}
/>
<BalanceSinceDiff
isAvailable={isAvailable}
t={t}
counterValue={counterValue}
alignItems="center"
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
since={selectedTimeRange}
/>
</Box>
</Box>
)
}
}
export default compose(
connect(
mapStateToProps,
mapDispatchToProps,
),
translate(), // FIXME t() is not even needed directly here. should be underlying component responsability to inject it
)(AccountBalanceSummaryHeader)

100
src/components/AccountPage/AccountHeaderActions.js

@ -0,0 +1,100 @@
// @flow
import React, { PureComponent, Fragment } from 'react'
import { compose } from 'redux'
import { connect } from 'react-redux'
import { translate } from 'react-i18next'
import styled from 'styled-components'
import type { Account } from '@ledgerhq/live-common/lib/types'
import Tooltip from 'components/base/Tooltip'
import { MODAL_SEND, MODAL_RECEIVE, MODAL_SETTINGS_ACCOUNT } from 'config/constants'
import type { T } from 'types/common'
import { rgba } from 'styles/helpers'
import { openModal } from 'reducers/modals'
import IconAccountSettings from 'icons/AccountSettings'
import IconReceive from 'icons/Receive'
import IconSend from 'icons/Send'
import Box, { Tabbable } from 'components/base/Box'
import Button from 'components/base/Button'
const ButtonSettings = styled(Tabbable).attrs({
cursor: 'pointer',
align: 'center',
justify: 'center',
borderRadius: 1,
})`
width: 40px;
height: 40px;
&:hover {
color: ${p => (p.disabled ? '' : p.theme.colors.dark)};
background: ${p => (p.disabled ? '' : rgba(p.theme.colors.fog, 0.2))};
}
&:active {
background: ${p => (p.disabled ? '' : rgba(p.theme.colors.fog, 0.3))};
}
`
const mapStateToProps = null
const mapDispatchToProps = {
openModal,
}
type OwnProps = {
account: Account,
}
type Props = OwnProps & {
t: T,
openModal: Function,
}
class AccountHeaderActions extends PureComponent<Props> {
render() {
const { account, openModal, t } = this.props
return (
<Box horizontal alignItems="center" justifyContent="flex-end" flow={2}>
{account.operations.length > 0 && (
<Fragment>
<Button small primary onClick={() => openModal(MODAL_SEND, { account })}>
<Box horizontal flow={1} alignItems="center">
<IconSend size={12} />
<Box>{t('app:send.title')}</Box>
</Box>
</Button>
<Button small primary onClick={() => openModal(MODAL_RECEIVE, { account })}>
<Box horizontal flow={1} alignItems="center">
<IconReceive size={12} />
<Box>{t('app:receive.title')}</Box>
</Box>
</Button>
</Fragment>
)}
<Tooltip render={() => t('app:account.settings.title')}>
<ButtonSettings onClick={() => openModal(MODAL_SETTINGS_ACCOUNT, { account })}>
<Box justifyContent="center">
<IconAccountSettings size={16} />
</Box>
</ButtonSettings>
</Tooltip>
</Box>
)
}
}
export default compose(
connect(
mapStateToProps,
mapDispatchToProps,
),
translate(),
)(AccountHeaderActions)

160
src/components/AccountPage/index.js

@ -5,19 +5,8 @@ import { compose } from 'redux'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { translate } from 'react-i18next' import { translate } from 'react-i18next'
import { Redirect } from 'react-router' import { Redirect } from 'react-router'
import styled from 'styled-components'
import type { Currency, Account } from '@ledgerhq/live-common/lib/types' import type { Currency, Account } from '@ledgerhq/live-common/lib/types'
import SyncOneAccountOnMount from 'components/SyncOneAccountOnMount'
import Tooltip from 'components/base/Tooltip'
import TrackPage from 'analytics/TrackPage'
import { MODAL_SEND, MODAL_RECEIVE, MODAL_SETTINGS_ACCOUNT } from 'config/constants'
import type { T } from 'types/common' import type { T } from 'types/common'
import { rgba } from 'styles/helpers'
import { saveSettings } from 'actions/settings'
import { accountSelector } from 'reducers/accounts' import { accountSelector } from 'reducers/accounts'
import { import {
counterValueCurrencySelector, counterValueCurrencySelector,
@ -26,47 +15,19 @@ import {
timeRangeDaysByKey, timeRangeDaysByKey,
} from 'reducers/settings' } from 'reducers/settings'
import type { TimeRange } from 'reducers/settings' import type { TimeRange } from 'reducers/settings'
import { openModal } from 'reducers/modals'
import IconAccountSettings from 'icons/AccountSettings'
import IconReceive from 'icons/Receive'
import IconSend from 'icons/Send'
import TrackPage from 'analytics/TrackPage'
import SyncOneAccountOnMount from 'components/SyncOneAccountOnMount'
import BalanceSummary from 'components/BalanceSummary' import BalanceSummary from 'components/BalanceSummary'
import { import Box from 'components/base/Box'
BalanceTotal,
BalanceSinceDiff,
BalanceSincePercent,
} from 'components/BalanceSummary/BalanceInfos'
import Box, { Tabbable } from 'components/base/Box'
import Button from 'components/base/Button'
import FormattedVal from 'components/base/FormattedVal'
import PillsDaysCount from 'components/PillsDaysCount'
import OperationsList from 'components/OperationsList' import OperationsList from 'components/OperationsList'
import StickyBackToTop from 'components/StickyBackToTop' import StickyBackToTop from 'components/StickyBackToTop'
import AccountHeader from './AccountHeader' import AccountHeader from './AccountHeader'
import AccountHeaderActions from './AccountHeaderActions'
import AccountBalanceSummaryHeader from './AccountBalanceSummaryHeader'
import EmptyStateAccount from './EmptyStateAccount' import EmptyStateAccount from './EmptyStateAccount'
const ButtonSettings = styled(Tabbable).attrs({
cursor: 'pointer',
align: 'center',
justify: 'center',
borderRadius: 1,
})`
width: 40px;
height: 40px;
&:hover {
color: ${p => (p.disabled ? '' : p.theme.colors.dark)};
background: ${p => (p.disabled ? '' : rgba(p.theme.colors.fog, 0.2))};
}
&:active {
background: ${p => (p.disabled ? '' : rgba(p.theme.colors.fog, 0.3))};
}
`
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
account: accountSelector(state, { accountId: props.match.params.id }), account: accountSelector(state, { accountId: props.match.params.id }),
counterValue: counterValueCurrencySelector(state), counterValue: counterValueCurrencySelector(state),
@ -74,74 +35,54 @@ const mapStateToProps = (state, props) => ({
selectedTimeRange: selectedTimeRangeSelector(state), selectedTimeRange: selectedTimeRangeSelector(state),
}) })
const mapDispatchToProps = { const mapDispatchToProps = null
openModal,
saveSettings,
}
type Props = { type Props = {
counterValue: Currency, counterValue: Currency,
t: T, t: T,
account?: Account, account?: Account,
openModal: Function,
saveSettings: ({ selectedTimeRange: TimeRange }) => *,
selectedTimeRange: TimeRange, selectedTimeRange: TimeRange,
} }
class AccountPage extends PureComponent<Props> { class AccountPage extends PureComponent<Props> {
handleChangeSelectedTime = item => { renderBalanceSummaryHeader = ({ isAvailable, totalBalance, sinceBalance, refBalance }) => {
this.props.saveSettings({ selectedTimeRange: item.key }) const { account } = this.props
if (!account) return null
return (
<AccountBalanceSummaryHeader
accountId={account.id}
isAvailable={isAvailable}
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
/>
)
} }
_cacheBalance = null
render() { render() {
const { account, openModal, t, counterValue, selectedTimeRange } = this.props const { account, t, counterValue, selectedTimeRange } = this.props
const daysCount = timeRangeDaysByKey[selectedTimeRange] const daysCount = timeRangeDaysByKey[selectedTimeRange]
// Don't even throw if we jumped in wrong account route
if (!account) { if (!account) {
return <Redirect to="/" /> return <Redirect to="/" />
} }
return ( return (
// Force re-render account page, for avoid animation // `key` forces re-render account page when going an another account (skip animations)
<Box key={account.id}> <Box key={account.id}>
<TrackPage <TrackPage
category="Account" category="Account"
currency={account.currency.id} currency={account.currency.id}
operationsLength={account.operations.length} operationsLength={account.operations.length}
/> />
<SyncOneAccountOnMount priority={10} accountId={account.id} /> <SyncOneAccountOnMount priority={10} accountId={account.id} />
<Box horizontal mb={5} flow={4}> <Box horizontal mb={5} flow={4}>
<AccountHeader account={account} /> <AccountHeader account={account} />
<Box horizontal alignItems="center" justifyContent="flex-end" flow={2}> <AccountHeaderActions account={account} />
{account.operations.length > 0 && (
<Fragment>
<Button small primary onClick={() => openModal(MODAL_SEND, { account })}>
<Box horizontal flow={1} alignItems="center">
<IconSend size={12} />
<Box>{t('app:send.title')}</Box>
</Box>
</Button>
<Button small primary onClick={() => openModal(MODAL_RECEIVE, { account })}>
<Box horizontal flow={1} alignItems="center">
<IconReceive size={12} />
<Box>{t('app:receive.title')}</Box>
</Box>
</Button>
</Fragment>
)}
<Tooltip render={() => t('app:account.settings.title')}>
<ButtonSettings onClick={() => openModal(MODAL_SETTINGS_ACCOUNT, { account })}>
<Box justifyContent="center">
<IconAccountSettings size={16} />
</Box>
</ButtonSettings>
</Tooltip>
</Box>
</Box> </Box>
{account.operations.length > 0 ? ( {account.operations.length > 0 ? (
<Fragment> <Fragment>
<Box mb={7}> <Box mb={7}>
@ -152,59 +93,12 @@ class AccountPage extends PureComponent<Props> {
counterValue={counterValue} counterValue={counterValue}
daysCount={daysCount} daysCount={daysCount}
selectedTimeRange={selectedTimeRange} selectedTimeRange={selectedTimeRange}
renderHeader={({ isAvailable, totalBalance, sinceBalance, refBalance }) => ( renderHeader={this.renderBalanceSummaryHeader}
<Box flow={4} mb={2}>
<Box horizontal>
<BalanceTotal
showCryptoEvenIfNotAvailable
isAvailable={isAvailable}
totalBalance={account.balance}
unit={account.unit}
>
<FormattedVal
animateTicker
disableRounding
alwaysShowSign={false}
color="warmGrey"
unit={counterValue.units[0]}
fontSize={6}
showCode
val={totalBalance}
/>
</BalanceTotal>
<Box>
<PillsDaysCount
selected={selectedTimeRange}
onChange={this.handleChangeSelectedTime}
/>
</Box>
</Box>
<Box horizontal justifyContent="center" flow={7}>
<BalanceSincePercent
isAvailable={isAvailable}
t={t}
alignItems="center"
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
since={selectedTimeRange}
/>
<BalanceSinceDiff
isAvailable={isAvailable}
t={t}
counterValue={counterValue}
alignItems="center"
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
since={selectedTimeRange}
/>
</Box>
</Box>
)}
/> />
</Box> </Box>
<OperationsList account={account} title={t('app:account.lastOperations')} /> <OperationsList account={account} title={t('app:account.lastOperations')} />
<StickyBackToTop /> <StickyBackToTop />
</Fragment> </Fragment>
) : ( ) : (

1
src/components/BalanceSummary/index.js

@ -35,6 +35,7 @@ const BalanceSummary = ({
selectedTimeRange, selectedTimeRange,
}: Props) => { }: Props) => {
const account = accounts.length === 1 ? accounts[0] : undefined const account = accounts.length === 1 ? accounts[0] : undefined
// FIXME This nesting 😱
return ( return (
<Card p={0} py={5}> <Card p={0} py={5}>
<CalculateBalance accounts={accounts} daysCount={daysCount}> <CalculateBalance accounts={accounts} daysCount={daysCount}>

10
src/components/CalculateBalance.js

@ -32,6 +32,7 @@ type Props = OwnProps & {
balanceStart: number, balanceStart: number,
balanceEnd: number, balanceEnd: number,
isAvailable: boolean, isAvailable: boolean,
hash: string,
} }
const mapStateToProps = (state: State, props: OwnProps) => { const mapStateToProps = (state: State, props: OwnProps) => {
@ -71,19 +72,20 @@ const mapStateToProps = (state: State, props: OwnProps) => {
({ ...item, originalValue: originalValues[i] || 0 }), ({ ...item, originalValue: originalValues[i] || 0 }),
) )
const balanceEnd = balanceHistory[balanceHistory.length - 1].value
return { return {
isAvailable, isAvailable,
balanceHistory, balanceHistory,
balanceStart: balanceHistory[0].value, balanceStart: balanceHistory[0].value,
balanceEnd: balanceHistory[balanceHistory.length - 1].value, balanceEnd,
hash: `${balanceHistory.length}_${balanceEnd}`,
} }
} }
const hash = ({ balanceHistory, balanceEnd }) => `${balanceHistory.length}_${balanceEnd}`
class CalculateBalance extends Component<Props> { class CalculateBalance extends Component<Props> {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return hash(nextProps) !== hash(this.props) return nextProps.hash !== this.props.hash
} }
render() { render() {
const { children } = this.props const { children } = this.props

3
src/components/ConfettiParty/Confetti.js

@ -1,3 +1,4 @@
// @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'
@ -13,7 +14,7 @@ class Confetti extends PureComponent<
delta: [number, number], delta: [number, number],
}, },
{ {
value: *, progress: Animated.Value,
}, },
> { > {
state = { state = {

66
src/components/ConfettiParty/index.js

@ -1,3 +1,5 @@
// @flow
import React, { PureComponent } from 'react' import React, { PureComponent } from 'react'
import { i } from 'helpers/staticPath' import { i } from 'helpers/staticPath'
import Confetti from './Confetti' import Confetti from './Confetti'
@ -9,22 +11,68 @@ const shapes = [
i('confetti-shapes/4.svg'), i('confetti-shapes/4.svg'),
] ]
class ConfettiParty extends PureComponent<{}> { let id = 1
state = {
confettis: Array(64) const nextConfetti = (mode: ?string) =>
.fill(null) mode === 'emit'
.map((_, i) => ({ ? {
id: i, 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.2 + 0.1 * Math.random(), initialYPercent: -0.05,
initialXPercent:
0.5 + 0.5 * Math.cos(Date.now() / 1000) * (0.5 + 0.5 * Math.sin(Date.now() / 6000)),
initialScale: 1,
rotations: 4 * Math.random() - 2,
delta: [(Math.random() - 0.5) * 200, 600 + 200 * Math.random()],
duration: 10000,
}
: {
id: id++,
shape: shapes[Math.floor(shapes.length * Math.random())],
initialRotation: 360 * Math.random(),
initialYPercent: -0.15 * Math.random(),
initialXPercent: 0.2 + 0.6 * Math.random(), initialXPercent: 0.2 + 0.6 * Math.random(),
initialScale: 1, initialScale: 1,
rotations: 4 + 4 * Math.random(), rotations: 8 * Math.random() - 4,
delta: [(Math.random() - 0.5) * 600, 300 + 300 * Math.random()], delta: [(Math.random() - 0.5) * 600, 300 + 300 * Math.random()],
duration: 6000 + 5000 * Math.random(), duration: 6000 + 5000 * Math.random(),
})), }
class ConfettiParty extends PureComponent<{ emit: boolean }, { confettis: Array<Object> }> {
state = {
// $FlowFixMe
confettis: Array(64)
.fill(null)
.map(nextConfetti),
}
componentDidMount() {
this.setEmit(this.props.emit)
}
componentDidUpdate(prevProps: *) {
if (this.props.emit !== prevProps.emit) {
this.setEmit(this.props.emit)
}
}
componentWillUnmount() {
this.setEmit(false)
}
setEmit(on: boolean) {
if (on) {
this.interval = setInterval(() => {
this.setState(({ confettis }) => ({
confettis: confettis.slice(confettis.length > 200 ? 1 : 0).concat(nextConfetti('emit')),
}))
}, 40)
} else {
clearInterval(this.interval)
}
} }
interval: *
render() { render() {
const { confettis } = this.state const { confettis } = this.state

203
src/components/DeviceInteraction/DeviceInteractionStep.js

@ -0,0 +1,203 @@
// @flow
import React, { PureComponent } from 'react'
import Box from 'components/base/Box'
import { delay } from 'helpers/promise'
import {
DeviceInteractionStepContainer,
SpinnerContainer,
IconContainer,
SuccessContainer,
ErrorContainer,
} from './components'
export type Step = {
id: string,
title?: React$Node | (Object => React$Node),
desc?: React$Node,
icon: React$Node,
run?: Object => Promise<any> | { promise: Promise<any>, unsubscribe: void => any },
render?: ({ onSuccess: Object => any, onFail: Error => void }, any) => React$Node,
minMs?: number,
}
type Status = 'idle' | 'running'
type Props = {
isFirst: boolean,
isLast: boolean,
isActive: boolean,
isFinished: boolean,
isPrecedentActive: boolean,
isError: boolean,
isSuccess: boolean,
isPassed: boolean,
step: Step,
onSuccess: (any, Step) => any,
onFail: (Error, Step) => any,
data: any,
}
class DeviceInteractionStep extends PureComponent<
Props,
{
status: Status,
},
> {
static defaultProps = {
data: {},
}
state = {
status: this.props.isFirst ? 'running' : 'idle',
}
componentDidMount() {
if (this.props.isFirst) {
this.run()
}
}
componentDidUpdate(prevProps: Props) {
const { isActive, isError } = this.props
const { status } = this.state
const didActivated = isActive && !prevProps.isActive
const didDeactivated = !isActive && prevProps.isActive
const stillActivated = isActive && prevProps.isActive
const didResetError = !isError && !!prevProps.isError
if (didActivated && status !== 'running') {
this.run()
}
if (didResetError && stillActivated) {
this.run()
}
if (didDeactivated && status === 'running') {
this.cancel()
}
}
componentWillUnmount() {
if (this._unsubscribe) {
this._unsubscribe()
}
this._unmounted = true
}
_unsubscribe = null
_unmounted = false
handleSuccess = (res: any) => {
const { onSuccess, step, isError } = this.props
if (isError) return
this.setState({ status: 'idle' })
onSuccess(res, step)
}
handleFail = (e: Error) => {
const { onFail, step } = this.props
this.setState({ status: 'idle' })
onFail(e, step)
}
run = async () => {
const { step, data } = this.props
const { status } = this.state
if (status !== 'running') {
this.setState({ status: 'running' })
}
if (!step.run) {
return
}
try {
const d1 = Date.now()
// $FlowFixMe JUST TESTED THE `run` 6 LINES BEFORE!!!
const res = (await step.run(data)) || {}
if (this._unmounted) return
if (step.minMs) {
const d2 = Date.now()
// $FlowFixMe SAME THING, JUST TESTED THE MINMS KEY, BUT EH
if (d2 - d1 < step.minMs) {
// $FlowFixMe nice type checking
await delay(step.minMs - (d2 - d1))
if (this._unmounted) return
}
}
if (res.promise) {
this._unsubscribe = res.unsubscribe
const realRes = await res.promise
if (this._unmounted) return
this.handleSuccess(realRes)
} else {
this.handleSuccess(res)
}
} catch (e) {
this.handleFail(e)
}
}
cancel = () => this.setState({ status: 'idle' })
render() {
const {
isFirst,
isLast,
isActive,
isFinished,
isPrecedentActive,
isSuccess,
isError,
isPassed,
step,
data,
} = this.props
const { status } = this.state
const title = typeof step.title === 'function' ? step.title(data) : step.title
const { render: CustomRender } = step
const isRunning = status === 'running'
return (
<DeviceInteractionStepContainer
isFirst={isFirst}
isLast={isLast}
isFinished={isFinished}
isSuccess={isSuccess}
isActive={isActive}
isPrecedentActive={isPrecedentActive}
isError={isError}
>
<IconContainer>{step.icon}</IconContainer>
<Box py={4} justify="center" grow shrink>
{title && (
<Box color={isActive && !isSuccess ? 'dark' : ''} ff="Open Sans|SemiBold">
{title}
</Box>
)}
{step.desc && step.desc}
{CustomRender && (
<CustomRender onSuccess={this.handleSuccess} onFail={this.handleFail} data={data} />
)}
</Box>
<div style={{ width: 70, position: 'relative', overflow: 'hidden', pointerEvents: 'none' }}>
<SpinnerContainer isVisible={isRunning} isPassed={isPassed} isError={isError} />
<ErrorContainer isVisible={isError} />
<SuccessContainer isVisible={isSuccess} />
</div>
</DeviceInteractionStepContainer>
)
}
}
export default DeviceInteractionStep

112
src/components/DeviceInteraction/components.js

@ -0,0 +1,112 @@
// @flow
import React from 'react'
import styled from 'styled-components'
import { radii } from 'styles/theme'
import { rgba } from 'styles/helpers'
import TranslatedError from 'components/TranslatedError'
import Box from 'components/base/Box'
import FakeLink from 'components/base/FakeLink'
import Spinner from 'components/base/Spinner'
import IconCheck from 'icons/Check'
import IconCross from 'icons/Cross'
import IconExclamationCircle from 'icons/ExclamationCircle'
export const DeviceInteractionStepContainer = styled(Box).attrs({
horizontal: true,
ff: 'Open Sans',
fontSize: 3,
bg: 'white',
color: 'graphite',
})`
position: relative;
z-index: ${p => (p.isActive ? 1 : '')};
max-width: 500px;
min-height: 80px;
border: 1px solid ${p => p.theme.colors.fog};
border-color: ${p =>
p.isError ? p.theme.colors.alertRed : p.isActive && !p.isFinished ? p.theme.colors.wallet : ''};
border-top-color: ${p => (p.isFirst || p.isActive ? '' : 'transparent')};
border-bottom-color: ${p => (p.isPrecedentActive ? 'transparent' : '')};
border-bottom-left-radius: ${p => (p.isLast ? `${radii[1]}px` : 0)};
border-bottom-right-radius: ${p => (p.isLast ? `${radii[1]}px` : 0)};
border-top-left-radius: ${p => (p.isFirst ? `${radii[1]}px` : 0)};
border-top-right-radius: ${p => (p.isFirst ? `${radii[1]}px` : 0)};
box-shadow: ${p =>
p.isActive && !p.isSuccess
? `
${rgba(p.isError ? p.theme.colors.alertRed : p.theme.colors.wallet, 0.2)} 0 0 3px 2px
`
: 'none'};
`
export const IconContainer = ({ children }: { children: any }) => (
<Box align="center" justify="center" style={{ width: 70 }}>
{children}
</Box>
)
const SpinnerContainerWrapper = styled.div`
color: ${p => p.theme.colors.grey};
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
transition: 350ms cubic-bezier(0.62, 0.28, 0.39, 0.94);
transition-property: transform opacity;
opacity: ${p => (p.isVisible ? 1 : 0)};
transform: translate3d(0, ${p => (!p.isVisible ? -40 : 0)}px, 0);
`
export const SpinnerContainer = ({ isVisible }: { isVisible: boolean }) => (
<SpinnerContainerWrapper isVisible={isVisible}>
<Spinner size={16} />
</SpinnerContainerWrapper>
)
const SuccessContainerWrapper = styled(SpinnerContainerWrapper)`
color: ${p => p.theme.colors.wallet};
transform: translate3d(0, ${p => (!p.isVisible ? 40 : 0)}px, 0);
`
export const SuccessContainer = ({ isVisible }: { isVisible: boolean }) => (
<SuccessContainerWrapper isVisible={isVisible}>
<IconCheck size={16} />
</SuccessContainerWrapper>
)
const ErrorContainerWrapper = styled(SpinnerContainerWrapper)`
color: ${p => p.theme.colors.alertRed};
transform: translate3d(0, ${p => (!p.isVisible ? 40 : 0)}px, 0);
`
export const ErrorContainer = ({ isVisible }: { isVisible: boolean }) => (
<ErrorContainerWrapper isVisible={isVisible}>
<IconCross size={16} />
</ErrorContainerWrapper>
)
export const ErrorDescContainer = ({
error,
onRetry,
...p
}: {
error: Error,
onRetry: void => void,
}) => (
<Box horizontal fontSize={3} color="alertRed" align="center" cursor="text" {...p}>
<IconExclamationCircle size={16} />
<Box ml={1}>
<TranslatedError error={error} />
</Box>
<FakeLink ml={1} underline color="alertRed" onClick={onRetry}>
{'Retry'}
</FakeLink>
</Box>
)

116
src/components/DeviceInteraction/index.js

@ -0,0 +1,116 @@
// @flow
import React, { PureComponent } from 'react'
import { delay } from 'helpers/promise'
import Box from 'components/base/Box'
import DeviceInteractionStep from './DeviceInteractionStep'
import { ErrorDescContainer } from './components'
import type { Step } from './DeviceInteractionStep'
type Props = {
steps: Step[],
onSuccess?: any => void,
onFail?: any => void,
waitBeforeSuccess?: number,
// when true and there is an error, display the error + retry button
shouldRenderRetry?: boolean,
}
type State = {
stepIndex: number,
isSuccess: boolean,
error: ?Error,
data: Object,
}
const INITIAL_STATE = {
stepIndex: 0,
isSuccess: false,
error: null,
data: {},
}
class DeviceInteraction extends PureComponent<Props, State> {
state = INITIAL_STATE
componentWillUnmount() {
this._unmounted = true
}
_unmounted = false
reset = () => this.setState(INITIAL_STATE)
handleSuccess = async (res: any, step: Step) => {
const { onSuccess, steps, waitBeforeSuccess } = this.props
const { stepIndex, data: prevData } = this.state
const isCurrentStep = step.id === steps[stepIndex].id
if (!isCurrentStep) {
return
}
const data = { ...prevData, [step.id]: res || true }
const isLast = stepIndex === steps.length - 1
if (isLast) {
if (!waitBeforeSuccess) {
onSuccess && onSuccess(data)
}
this.setState({ isSuccess: true, data })
if (waitBeforeSuccess) {
await delay(waitBeforeSuccess)
if (this._unmounted) return
onSuccess && onSuccess(data)
}
} else {
this.setState({ stepIndex: stepIndex + 1, data })
}
}
handleFail = (error: Error, step: Step) => {
const { steps, onFail } = this.props
const { stepIndex } = this.state
const isCurrentStep = step === steps[stepIndex]
if (!isCurrentStep) {
return
}
this.setState({ error })
onFail && onFail(error)
}
render() {
const { steps, shouldRenderRetry, ...props } = this.props
const { stepIndex, error, isSuccess, data } = this.state
return (
<Box {...props}>
{steps.map((step, i) => {
const isError = !!error && i === stepIndex
return (
<DeviceInteractionStep
key={step.id}
step={step}
isError={isError}
isFirst={i === 0}
isLast={i === steps.length - 1}
isPrecedentActive={i === stepIndex - 1}
isActive={i === stepIndex}
isPassed={i < stepIndex}
isSuccess={i < stepIndex || (i === stepIndex && isSuccess)}
isFinished={isSuccess}
onSuccess={this.handleSuccess}
onFail={this.handleFail}
data={data}
/>
)
})}
{error &&
shouldRenderRetry && <ErrorDescContainer error={error} onRetry={this.reset} mt={2} />}
</Box>
)
}
}
export default DeviceInteraction

81
src/components/DeviceInteraction/stories.js

@ -0,0 +1,81 @@
// @flow
import React, { Fragment } from 'react'
import styled from 'styled-components'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import DeviceInteraction from 'components/DeviceInteraction'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import IconUsb from 'icons/Usb'
const stories = storiesOf('Components', module)
stories.add('DeviceInteraction', () => <Wrapper />)
const MockIcon = styled.div`
width: ${p => p.size}px;
height: ${p => p.size}px;
background: ${p => p.theme.colors.lightFog};
border-radius: 50%;
`
const mockIcon = <MockIcon size={36} />
class Wrapper extends React.Component<any> {
_ref = null
handleReset = () => this._ref && this._ref.reset()
render() {
return (
<Fragment>
<button style={{ marginBottom: 40 }} onClick={this.handleReset}>
{'reset'}
</button>
<DeviceInteraction
ref={n => (this._ref = n)}
steps={[
{
id: 'deviceConnect',
title: 'Connect your device',
icon: <IconUsb size={36} />,
desc: 'If you dont connect your device, we wont be able to read on it',
render: ({ onSuccess, onFail }) => (
<Box p={2} bg="lightGrey" mt={2} borderRadius={1}>
<Box horizontal flow={2}>
<Button small primary onClick={() => onSuccess({ name: 'Nano S' })}>
{'Nano S'}
</Button>
<Button small primary onClick={() => onSuccess({ name: 'Blue' })}>
{'Blue'}
</Button>
<Button small danger onClick={onFail}>
{'make it fail'}
</Button>
</Box>
</Box>
),
},
{
id: 'deviceOpen',
title: ({ deviceConnect: device }) =>
`Open the Bitcoin application on your ${device ? `${device.name} ` : ''}device`,
desc: 'To be able to retriev your Bitcoins',
icon: mockIcon,
run: () => new Promise(resolve => setTimeout(resolve, 1 * 1000)),
},
{
id: 'check',
title: 'Checking if all is alright...',
desc: 'This should take only 1 second...',
icon: mockIcon,
run: () => new Promise(resolve => setTimeout(resolve, 1 * 1000)),
},
]}
onSuccess={action('onSuccess')}
/>
</Fragment>
)
}
}

0
src/components/Workflow/EnsureDevice.js → src/components/EnsureDevice.js

310
src/components/EnsureDeviceApp.js

@ -1,153 +1,57 @@
// @flow // @flow
import { PureComponent } from 'react'
import { connect } from 'react-redux'
import logger from 'logger'
import invariant from 'invariant'
import { isSegwitAccount } from 'helpers/bip32'
import React, { Component } from 'react'
import invariant from 'invariant'
import { connect } from 'react-redux'
import { Trans } from 'react-i18next'
import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types' import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import type { Device } from 'types/common' import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react'
import { getDevices } from 'reducers/devices' import logger from 'logger'
import type { State as StoreState } from 'reducers/index'
import getAddress from 'commands/getAddress' import getAddress from 'commands/getAddress'
import { createCancelablePolling } from 'helpers/promise'
import { standardDerivation } from 'helpers/derivations' import { standardDerivation } from 'helpers/derivations'
import isDashboardOpen from 'commands/isDashboardOpen' import { isSegwitAccount } from 'helpers/bip32'
import { createCustomErrorClass } from 'helpers/errors' import { BtcUnmatchedApp } from 'helpers/getAddressForCurrency/btc'
import { CHECK_APP_INTERVAL_WHEN_VALID, CHECK_APP_INTERVAL_WHEN_INVALID } from 'config/constants'
export const WrongAppOpened = createCustomErrorClass('WrongAppOpened')
export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount')
type OwnProps = { import DeviceInteraction from 'components/DeviceInteraction'
currency?: ?CryptoCurrency, import Text from 'components/base/Text'
deviceSelected: ?Device,
withGenuineCheck?: boolean,
account?: ?Account,
onStatusChange?: (DeviceStatus, AppStatus, ?string) => void,
onGenuineCheck?: (isGenuine: boolean) => void,
// TODO prefer children function
render?: ({
appStatus: AppStatus,
genuineCheckStatus: GenuineCheckStatus,
currency: ?CryptoCurrency,
devices: Device[],
deviceSelected: ?Device,
deviceStatus: DeviceStatus,
error: ?Error,
}) => React$Node,
}
type Props = OwnProps & { import IconUsb from 'icons/Usb'
devices: Device[],
}
type DeviceStatus = 'unconnected' | 'connected' import type { Device } from 'types/common'
type AppStatus = 'success' | 'fail' | 'progress' import { createCustomErrorClass } from 'helpers/errors'
import { getCurrentDevice } from 'reducers/devices'
type GenuineCheckStatus = 'success' | 'fail' | 'progress' export const WrongAppOpened = createCustomErrorClass('WrongAppOpened')
export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount')
type State = { const usbIcon = <IconUsb size={36} />
deviceStatus: DeviceStatus, const Bold = props => <Text ff="Open Sans|Bold" {...props} />
appStatus: AppStatus,
error: ?Error,
genuineCheckStatus: GenuineCheckStatus,
}
const mapStateToProps = (state: StoreState) => ({ const mapStateToProps = state => ({
devices: getDevices(state), device: getCurrentDevice(state),
}) })
// TODO we want to split into <EnsureDeviceCurrency/> and <EnsureDeviceAccount/> class EnsureDeviceApp extends Component<{
// and minimize the current codebase AF device: ?Device,
class EnsureDeviceApp extends PureComponent<Props, State> { account?: ?Account,
state = { currency?: ?CryptoCurrency,
appStatus: 'progress', }> {
deviceStatus: this.props.deviceSelected ? 'connected' : 'unconnected', connectInteractionHandler = () =>
error: null, createCancelablePolling(() => {
genuineCheckStatus: 'progress', if (!this.props.device) return Promise.reject()
} return Promise.resolve(this.props.device)
})
componentDidMount() {
if (this.props.deviceSelected !== null) { openAppInteractionHandler = ({ device }) =>
this.checkAppOpened() createCancelablePolling(
} async () => {
} const { account, currency: _currency } = this.props
const currency = account ? account.currency : _currency
componentWillReceiveProps(nextProps) { invariant(currency, 'No currency given')
const { deviceStatus } = this.state const address = await getAddressFromAccountOrCurrency(device, account, currency)
const { deviceSelected, devices } = this.props
const { devices: nextDevices, deviceSelected: nextDeviceSelected } = nextProps
if (deviceStatus === 'unconnected' && !deviceSelected && nextDeviceSelected) {
this.handleStatusChange('connected', 'progress')
}
if (deviceStatus !== 'unconnected' && devices !== nextDevices) {
const isConnected = nextDevices.find(d => d === nextDeviceSelected)
if (!isConnected) {
this.handleStatusChange('unconnected', 'progress')
}
}
}
componentDidUpdate(prevProps) {
const { deviceSelected } = this.props
const { deviceSelected: prevDeviceSelected } = prevProps
if (prevDeviceSelected !== deviceSelected) {
this.handleStatusChange('connected', 'progress')
// TODO: refacto to more generic/global way
clearTimeout(this._timeout)
this._timeout = setTimeout(this.checkAppOpened, 250)
}
}
componentWillUnmount() {
clearTimeout(this._timeout)
this._unmounted = true
}
checkAppOpened = async () => {
const { deviceSelected, account, currency, withGenuineCheck } = this.props
const { appStatus } = this.state
if (!deviceSelected) {
return
}
let isSuccess = true
try {
if (account || currency) {
const cur = account ? account.currency : currency
invariant(cur, 'currency is available')
const { address } = await getAddress
.send({
devicePath: deviceSelected.path,
currencyId: cur.id,
path: account
? account.freshAddressPath
: standardDerivation({ currency: cur, segwit: false, x: 0 }),
segwit: account ? isSegwitAccount(account) : false,
})
.toPromise()
.catch(e => {
if (
e &&
(e.name === 'TransportStatusError' ||
// we don't want these error to appear (caused by usb disconnect..)
e.message === 'could not read from HID device' ||
e.message === 'Cannot write to HID device')
) {
logger.log(e)
throw new WrongAppOpened(`WrongAppOpened ${cur.id}`, { currencyName: cur.name })
}
throw e
})
if (account) { if (account) {
const { freshAddress } = account const { freshAddress } = account
if (account && freshAddress !== address) { if (account && freshAddress !== address) {
@ -157,82 +61,74 @@ class EnsureDeviceApp extends PureComponent<Props, State> {
}) })
} }
} }
} else { return address
logger.warn('EnsureDeviceApp for using dashboard is DEPRECATED !!!') },
// TODO: FIXME REMOVE THIS ! should use EnsureDashboard dedicated component. {
const isDashboard = isDashboardOpen.send({ devicePath: deviceSelected.path }).toPromise() shouldThrow: (err: Error) => {
const isWrongApp = err instanceof BtcUnmatchedApp
if (!isDashboard) { const isWrongDevice = err instanceof WrongDeviceForAccount
throw new Error(`dashboard is not opened`) return isWrongApp || isWrongDevice
} },
} },
)
this.handleStatusChange(this.state.deviceStatus, 'success')
renderOpenAppTitle = () => {
if (withGenuineCheck && appStatus !== 'success') { const { account, currency } = this.props
this.handleGenuineCheck() const cur = account ? account.currency : currency
} invariant(cur, 'No currency given')
} catch (e) { return (
this.handleStatusChange(this.state.deviceStatus, 'fail', e) <Trans i18nKey="deviceConnect:step2.open" parent="div">
isSuccess = false {'Open the '}
} <strong>{cur.name}</strong>
{' app on your device'}
// TODO: refacto to more generic/global way </Trans>
if (!this._unmounted) { )
this._timeout = setTimeout(
this.checkAppOpened,
isSuccess ? CHECK_APP_INTERVAL_WHEN_VALID : CHECK_APP_INTERVAL_WHEN_INVALID,
)
}
}
_timeout: *
_unmounted = false
handleStatusChange = (deviceStatus, appStatus, error = null) => {
const { onStatusChange } = this.props
clearTimeout(this._timeout)
if (!this._unmounted) {
this.setState({ deviceStatus, appStatus, error })
onStatusChange && onStatusChange(deviceStatus, appStatus, error)
}
}
handleGenuineCheck = async () => {
// TODO: do a *real* genuine check
await sleep(1)
if (!this._unmounted) {
this.setState({ genuineCheckStatus: 'success' })
this.props.onGenuineCheck && this.props.onGenuineCheck(true)
}
} }
render() { render() {
const { currency, account, devices, deviceSelected, render } = this.props const { account, currency, ...props } = this.props
const { appStatus, deviceStatus, genuineCheckStatus, error } = this.state const cur = account ? account.currency : currency
const Icon = cur ? getCryptoCurrencyIcon(cur) : null
if (render) { return (
// if cur is not provided, we assume we want to check if user is on <DeviceInteraction
// the dashboard shouldRenderRetry
const cur = account ? account.currency : currency steps={[
{
return render({ id: 'device',
appStatus, title: (
currency: cur, <Trans i18nKey="app:deviceConnect.step1.connect" parent="div">
devices, {'Connect and unlock your '}
deviceSelected: deviceStatus === 'connected' ? deviceSelected : null, <Bold>{'Ledger device'}</Bold>
deviceStatus, </Trans>
genuineCheckStatus, ),
error, icon: usbIcon,
}) run: this.connectInteractionHandler,
} },
{
return null id: 'address',
title: this.renderOpenAppTitle,
icon: Icon ? <Icon size={24} /> : null,
run: this.openAppInteractionHandler,
},
]}
{...props}
/>
)
} }
} }
export default connect(mapStateToProps)(EnsureDeviceApp) async function getAddressFromAccountOrCurrency(device, account, currency) {
const { address } = await getAddress
async function sleep(s) { .send({
return new Promise(resolve => setTimeout(resolve, s * 1e3)) devicePath: device.path,
currencyId: currency.id,
path: account
? account.freshAddressPath
: standardDerivation({ currency, segwit: false, x: 0 }),
segwit: account ? isSegwitAccount(account) : false,
})
.toPromise()
return address
} }
export default connect(mapStateToProps)(EnsureDeviceApp)

158
src/components/GenuineCheck.js

@ -0,0 +1,158 @@
// @flow
import React, { PureComponent } from 'react'
import { timeout } from 'rxjs/operators/timeout'
import { connect } from 'react-redux'
import { compose } from 'redux'
import { translate, Trans } from 'react-i18next'
import type { T, Device } from 'types/common'
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo'
import { GENUINE_TIMEOUT, DEVICE_INFOS_TIMEOUT } from 'config/constants'
import { createCancelablePolling } from 'helpers/promise'
import { getCurrentDevice } from 'reducers/devices'
import { createCustomErrorClass } from 'helpers/errors'
import getDeviceInfo from 'commands/getDeviceInfo'
import getIsGenuine from 'commands/getIsGenuine'
import DeviceInteraction from 'components/DeviceInteraction'
import Text from 'components/base/Text'
import IconUsb from 'icons/Usb'
import IconHome from 'icons/Home'
import IconEye from 'icons/Eye'
const DeviceNotGenuineError = createCustomErrorClass('DeviceNotGenuine')
type Props = {
t: T,
onSuccess: void => void,
onFail: Error => void,
onUnavailable: Error => void,
device: ?Device,
}
const usbIcon = <IconUsb size={36} />
const homeIcon = <IconHome size={24} />
const eyeIcon = <IconEye size={24} />
const mapStateToProps = state => ({
device: getCurrentDevice(state),
})
const Bold = props => <Text ff="Open Sans|Bold" {...props} />
// to speed up genuine check, cache result by device id
const GENUINITY_CACHE = {}
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> {
connectInteractionHandler = () =>
createCancelablePolling(() => {
const { device } = this.props
if (!device) return Promise.reject()
return Promise.resolve(device)
})
checkDashboardInteractionHandler = ({ device }: { device: Device }) =>
createCancelablePolling(() =>
getDeviceInfo
.send({ devicePath: device.path })
.pipe(timeout(DEVICE_INFOS_TIMEOUT))
.toPromise(),
)
checkGenuineInteractionHandler = async ({
device,
deviceInfo,
}: {
device: Device,
deviceInfo: DeviceInfo,
}) => {
if (getDeviceGenuinity(device) === true) {
return true
}
const res = await getIsGenuine
.send({ devicePath: device.path, deviceInfo })
.pipe(timeout(GENUINE_TIMEOUT))
.toPromise()
const isGenuine = res === '0000'
if (!isGenuine) {
return Promise.reject(new Error('Device not genuine')) // TODO: use custom error class
}
setDeviceGenuinity(device, true)
return Promise.resolve(true)
}
handleFail = (err: Error) => {
const { onFail, onUnavailable } = this.props
if (err instanceof DeviceNotGenuineError) {
onFail(err)
} else {
onUnavailable(err)
}
}
render() {
const { onSuccess, ...props } = this.props
const steps = [
{
id: 'device',
title: (
<Trans i18nKey="app:deviceConnect.step1.connect" parent="div">
{'Connect and unlock your '}
<Bold>{'Ledger device'}</Bold>
</Trans>
),
icon: usbIcon,
run: this.connectInteractionHandler,
},
{
id: 'deviceInfo',
title: (
<Trans i18nKey="deviceConnect:dashboard.open" parent="div">
{'Navigate to the '}
<Bold>{'dashboard'}</Bold>
{' on your device'}
</Trans>
),
icon: homeIcon,
run: this.checkDashboardInteractionHandler,
},
{
id: 'isGenuine',
title: (
<Trans i18nKey="deviceConnect:stepGenuine.open" parent="div">
{'Allow '}
<Bold>{'Ledger Manager'}</Bold>
{' on your device'}
</Trans>
),
icon: eyeIcon,
run: this.checkGenuineInteractionHandler,
},
]
return (
<DeviceInteraction
waitBeforeSuccess={500}
steps={steps}
onSuccess={onSuccess}
onFail={this.handleFail}
{...props}
/>
)
}
}
export default compose(
translate(),
connect(mapStateToProps),
)(GenuineCheck)

37
src/components/GenuineCheckModal.js

@ -0,0 +1,37 @@
// @flow
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import Modal, { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal'
import GenuineCheck from 'components/GenuineCheck'
type Props = {
t: T,
onSuccess: void => void,
onFail: void => void,
onUnavailable: Error => void,
}
class GenuineCheckModal extends PureComponent<Props> {
renderBody = ({ onClose }) => {
const { t, onSuccess, onFail, onUnavailable } = this.props
return (
<ModalBody onClose={onClose}>
<ModalTitle>{t('app:genuinecheck.modal.title')}</ModalTitle>
<ModalContent>
<GenuineCheck onSuccess={onSuccess} onFail={onFail} onUnavailable={onUnavailable} />
</ModalContent>
</ModalBody>
)
}
render() {
const { t, ...props } = this.props
return <Modal {...props} render={this.renderBody} />
}
}
export default translate()(GenuineCheckModal)

90
src/components/GenuineCheckModal/index.js

@ -1,90 +0,0 @@
// @flow
import React, { PureComponent, Fragment } from 'react'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import Modal, { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal'
import Workflow from 'components/Workflow'
import WorkflowDefault from 'components/Workflow/WorkflowDefault'
type Props = {
t: T,
onGenuineCheckPass: () => void,
onGenuineCheckFailed: () => void,
onGenuineCheckUnavailable: Error => void,
}
type State = {}
class GenuineCheckStatus extends PureComponent<*> {
componentDidUpdate() {
this.sideEffect()
}
sideEffect() {
const {
isGenuine,
error,
onGenuineCheckPass,
onGenuineCheckFailed,
onGenuineCheckUnavailable,
} = this.props
if (isGenuine !== null) {
if (isGenuine) {
onGenuineCheckPass()
} else {
onGenuineCheckFailed()
}
} else if (error) {
onGenuineCheckUnavailable(error)
}
}
render() {
return null
}
}
/* eslint-disable react/no-multi-comp */
class GenuineCheck extends PureComponent<Props, State> {
renderBody = ({ onClose }) => {
const { t, onGenuineCheckPass, onGenuineCheckFailed, onGenuineCheckUnavailable } = this.props
// TODO: use the real devices list. for now we force choosing only
// the current device because we don't handle multi device in MVP
return (
<ModalBody onClose={onClose}>
<ModalTitle>{t('app:genuinecheck.modal.title')}</ModalTitle>
<ModalContent>
<Workflow
renderDefault={(device, deviceInfo, isGenuine, errors) => (
<Fragment>
<GenuineCheckStatus
isGenuine={isGenuine}
error={errors.genuineError}
onGenuineCheckPass={onGenuineCheckPass}
onGenuineCheckFailed={onGenuineCheckFailed}
onGenuineCheckUnavailable={onGenuineCheckUnavailable}
/>
<WorkflowDefault
device={device}
deviceInfo={deviceInfo}
isGenuine={isGenuine}
errors={errors} // TODO: FIX ERRORS
/>
</Fragment>
)}
/>
</ModalContent>
</ModalBody>
)
}
render() {
const { ...props } = this.props
return <Modal {...props} render={({ onClose }) => this.renderBody({ onClose })} />
}
}
export default translate()(GenuineCheck)

7
src/components/ManagerPage/Dashboard.js

@ -31,12 +31,7 @@ const Dashboard = ({ device, deviceInfo, t }: Props) => (
<FirmwareUpdate deviceInfo={deviceInfo} device={device} /> <FirmwareUpdate deviceInfo={deviceInfo} device={device} />
</Box> </Box>
<Box mt={5}> <Box mt={5}>
<AppsList <AppsList device={device} deviceInfo={deviceInfo} />
device={device}
targetId={deviceInfo.targetId}
provider={deviceInfo.providerId}
fullVersion={deviceInfo.fullVersion}
/>
</Box> </Box>
</Box> </Box>
) )

2
src/components/ManagerPage/FirmwareUpdate.js

@ -25,7 +25,7 @@ import Button from 'components/base/Button'
import NanoS from 'icons/device/NanoS' import NanoS from 'icons/device/NanoS'
import CheckFull from 'icons/CheckFull' import CheckFull from 'icons/CheckFull'
import { PreventDeviceChangeRecheck } from '../Workflow/EnsureDevice' import { PreventDeviceChangeRecheck } from 'components/EnsureDevice'
import UpdateFirmwareButton from './UpdateFirmwareButton' import UpdateFirmwareButton from './UpdateFirmwareButton'
let CACHED_LATEST_FIRMWARE = null let CACHED_LATEST_FIRMWARE = null

46
src/components/ManagerPage/ManagerGenuineCheck.js

@ -0,0 +1,46 @@
// @flow
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import { i } from 'helpers/staticPath'
import GenuineCheck from 'components/GenuineCheck'
import Box from 'components/base/Box'
import Space from 'components/base/Space'
import Text from 'components/base/Text'
type Props = {
t: T,
onSuccess: void => void,
}
class ManagerGenuineCheck extends PureComponent<Props> {
render() {
const { t, onSuccess } = this.props
return (
<Box align="center">
<Space of={60} />
<Box align="center" style={{ maxWidth: 460 }}>
<img
src={i('logos/connectDevice.png')}
alt="connect your device"
style={{ marginBottom: 30, maxWidth: 362, width: '100%' }}
/>
<Text ff="Museo Sans|Regular" fontSize={7} color="black" style={{ marginBottom: 10 }}>
{t('app:manager.device.title')}
</Text>
<Text ff="Museo Sans|Light" fontSize={5} color="grey" align="center">
{t('app:manager.device.desc')}
</Text>
</Box>
<Space of={40} />
<GenuineCheck shouldRenderRetry onSuccess={onSuccess} />
</Box>
)
}
}
export default translate()(ManagerGenuineCheck)

73
src/components/ManagerPage/index.js

@ -1,52 +1,51 @@
// @flow // @flow
/* eslint-disable react/jsx-no-literals */ // FIXME: remove
import React, { PureComponent } from 'react' import React, { PureComponent } from 'react'
import invariant from 'invariant'
import type { Device } from 'types/common' import type { Device } from 'types/common'
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' import type { DeviceInfo } from 'helpers/devices/getDeviceInfo'
import Workflow from 'components/Workflow'
import WorkflowWithIcon from 'components/Workflow/WorkflowWithIcon'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import FlashMcu from './FlashMcu' // import FlashMcu from './FlashMcu'
type Error = { import ManagerGenuineCheck from './ManagerGenuineCheck'
message: string,
stack: string, type Props = {}
type State = {
isGenuine: ?boolean,
device: ?Device,
deviceInfo: ?DeviceInfo,
} }
class ManagerPage extends PureComponent<*, *> { class ManagerPage extends PureComponent<Props, State> {
state = {
isGenuine: null,
device: null,
deviceInfo: null,
}
// prettier-ignore
handleSuccessGenuine = ({ device, deviceInfo }: { device: Device, deviceInfo: DeviceInfo }) => { // eslint-disable-line react/no-unused-prop-types
this.setState({ isGenuine: true, device, deviceInfo })
}
render() { render() {
return ( const { isGenuine, device, deviceInfo } = this.state
<Workflow
renderFinalUpdate={(device: Device, deviceInfo: DeviceInfo) => ( if (!isGenuine) {
<p>UPDATE FINAL FIRMARE (TEMPLATE + ACTION WIP) {deviceInfo.isOSU}</p> return <ManagerGenuineCheck onSuccess={this.handleSuccessGenuine} />
)} }
renderMcuUpdate={(device: Device, deviceInfo: DeviceInfo) => (
<FlashMcu device={device} deviceInfo={deviceInfo} /> invariant(device, 'Inexistant device considered genuine')
)} invariant(deviceInfo, 'Inexistant device infos for genuine device')
renderDashboard={(device: Device, deviceInfo: DeviceInfo) => (
<Dashboard device={device} deviceInfo={deviceInfo} /> // TODO
)} // renderFinalUpdate
renderDefault={( // renderMcuUpdate
device: ?Device,
deviceInfo: ?DeviceInfo, return <Dashboard device={device} deviceInfo={deviceInfo} />
isGenuine: ?boolean,
errors: {
dashboardError: ?Error,
genuineError: ?Error,
},
) => (
<WorkflowWithIcon
device={device}
deviceInfo={deviceInfo}
errors={errors}
isGenuine={isGenuine}
/>
)}
/>
)
} }
} }

2
src/components/Onboarding/helperComponents.js

@ -61,10 +61,10 @@ export const LiveLogoContainer = styled(Box).attrs({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
})` })`
background-color: white;
box-shadow: 0 2px 24px 0 #00000014; box-shadow: 0 2px 24px 0 #00000014;
width: ${p => (p.width ? p.width : 80)} width: ${p => (p.width ? p.width : 80)}
height: ${p => (p.height ? p.height : 80)} height: ${p => (p.height ? p.height : 80)}
` `
// INSTRUCTION LIST // INSTRUCTION LIST

84
src/components/Onboarding/steps/Finish.js

@ -1,6 +1,6 @@
// @flow // @flow
import React from 'react' import React, { Component } from 'react'
import { shell } from 'electron' import { shell } from 'electron'
import styled from 'styled-components' import styled from 'styled-components'
import { i } from 'helpers/staticPath' import { i } from 'helpers/staticPath'
@ -48,39 +48,63 @@ const socialMedia = [
}, },
] ]
export default (props: StepProps) => { export default class Finish extends Component<StepProps, *> {
const { finish, t } = props state = { emit: false }
return ( onMouseUp = () => this.setState({ emit: false })
<Box sticky justifyContent="center"> onMouseDown = () => {
<ConfettiLayer> this.setState({ emit: true })
<ConfettiParty /> }
</ConfettiLayer> onMouseLeave = () => {
<Box alignItems="center"> this.setState({ emit: false })
<Box style={{ position: 'relative' }}> }
<LiveLogo render() {
style={{ width: 64, height: 64 }} const { finish, t } = this.props
icon={<img alt="" src={i('ledgerlive-logo.svg')} width={40} height={40} />} const { emit } = this.state
/> return (
<Box color="positiveGreen" style={{ position: 'absolute', right: 0, bottom: 0 }}> <Box sticky justifyContent="center">
<IconCheckFull size={18} /> <ConfettiLayer>
<ConfettiParty emit={emit} />
</ConfettiLayer>
<Box alignItems="center">
<Box
style={{ position: 'relative' }}
onMouseDown={this.onMouseDown}
onMouseUp={this.onMouseUp}
onMouseLeave={this.onMouseLeave}
>
<LiveLogo
style={{ width: 64, height: 64 }}
icon={
<img
draggable="false"
alt=""
src={i('ledgerlive-logo.svg')}
width={40}
height={40}
/>
}
/>
<Box color="positiveGreen" style={{ position: 'absolute', right: 0, bottom: 0 }}>
<IconCheckFull size={18} />
</Box>
</Box> </Box>
</Box>
<Box pt={5} align="center"> <Box pt={5} align="center">
<Title>{t('onboarding:finish.title')}</Title> <Title>{t('onboarding:finish.title')}</Title>
<Description>{t('onboarding:finish.desc')}</Description> <Description>{t('onboarding:finish.desc')}</Description>
</Box> </Box>
<Box p={5}> <Box p={5}>
<Button primary padded onClick={() => finish()}> <Button primary padded onClick={() => finish()}>
{t('onboarding:finish.openAppButton')} {t('onboarding:finish.openAppButton')}
</Button> </Button>
</Box> </Box>
<Box horizontal mt={3} flow={5} color="grey"> <Box horizontal mt={3} flow={5} color="grey">
{socialMedia.map(socMed => <SocialMediaBox key={socMed.key} socMed={socMed} />)} {socialMedia.map(socMed => <SocialMediaBox key={socMed.key} socMed={socMed} />)}
</Box>
</Box> </Box>
</Box> </Box>
</Box> )
) }
} }
type SocMed = { type SocMed = {

6
src/components/Onboarding/steps/GenuineCheck/index.js

@ -263,9 +263,9 @@ class GenuineCheck extends PureComponent<StepProps, State> {
<GenuineCheckModal <GenuineCheckModal
isOpened={isGenuineCheckModalOpened} isOpened={isGenuineCheckModalOpened}
onClose={this.handleCloseGenuineCheckModal} onClose={this.handleCloseGenuineCheckModal}
onGenuineCheckPass={this.handleGenuineCheckPass} onSuccess={this.handleGenuineCheckPass}
onGenuineCheckFailed={this.handleGenuineCheckFailed} onFail={this.handleGenuineCheckFailed}
onGenuineCheckUnavailable={this.handleGenuineCheckUnavailable} onUnavailable={this.handleGenuineCheckUnavailable}
/> />
</FixedTopContainer> </FixedTopContainer>
) )

81
src/components/Workflow/EnsureDashboard.js

@ -1,81 +0,0 @@
// @flow
import { PureComponent } from 'react'
import isEqual from 'lodash/isEqual'
import type { Node } from 'react'
import type { Device } from 'types/common'
import getDeviceInfo from 'commands/getDeviceInfo'
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo'
type Error = {
message: string,
stack: string,
}
type Props = {
device: ?Device,
children: (deviceInfo: ?DeviceInfo, error: ?Error) => Node,
}
type State = {
deviceInfo: ?DeviceInfo,
error: ?Error,
}
class EnsureDashboard extends PureComponent<Props, State> {
static defaultProps = {
children: null,
device: null,
}
state = {
deviceInfo: null,
error: null,
}
componentDidMount() {
this.checkForDashboard()
}
componentDidUpdate({ device }: Props) {
if (this.props.device !== device && this.props.device) {
this.checkForDashboard()
}
}
componentWillUnmount() {
this._unmounting = true
}
_checking = false
_unmounting = false
checkForDashboard = async () => {
const { device } = this.props
if (device && !this._checking) {
this._checking = true
try {
const deviceInfo = await getDeviceInfo.send({ devicePath: device.path }).toPromise()
if (!isEqual(this.state.deviceInfo, deviceInfo) || this.state.error) {
!this._unmounting && this.setState({ deviceInfo, error: null })
}
} catch (err) {
if (!isEqual(err, this.state.error)) {
!this._unmounting && this.setState({ error: err, deviceInfo: null })
}
}
this._checking = false
}
}
render() {
const { deviceInfo, error } = this.state
const { children } = this.props
return children(deviceInfo, error)
}
}
export default EnsureDashboard

88
src/components/Workflow/EnsureGenuine.js

@ -1,88 +0,0 @@
// @flow
import { timeout } from 'rxjs/operators/timeout'
import { PureComponent } from 'react'
import isEqual from 'lodash/isEqual'
import { GENUINE_TIMEOUT } from 'config/constants'
import type { Device } from 'types/common'
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo'
import getIsGenuine from 'commands/getIsGenuine'
type Error = {
message: string,
stack: string,
}
type Props = {
device: ?Device,
deviceInfo: ?DeviceInfo,
children: (isGenuine: ?boolean, error: ?Error) => *,
}
type State = {
genuine: ?boolean,
error: ?Error,
}
class EnsureGenuine extends PureComponent<Props, State> {
static defaultProps = {
children: () => null,
firmwareInfo: null,
}
state = {
error: null,
genuine: null,
}
componentDidMount() {
this.checkIsGenuine()
}
componentDidUpdate() {
this.checkIsGenuine()
}
componentWillUnmount() {
this._unmounting = true
}
_checking = false
_unmounting = false
async checkIsGenuine() {
const { device, deviceInfo } = this.props
if (device && deviceInfo && !this._checking) {
this._checking = true
try {
const res = await getIsGenuine
.send({
devicePath: device.path,
deviceInfo,
})
.pipe(timeout(GENUINE_TIMEOUT))
.toPromise()
if (this._unmounting) return
const isGenuine = res === '0000'
if (!this.state.genuine || this.state.error) {
this.setState({ genuine: isGenuine, error: null })
}
} catch (err) {
if (!isEqual(this.state.error, err)) {
this.setState({ genuine: null, error: err })
}
}
this._checking = false
}
}
render() {
const { error, genuine } = this.state
const { children } = this.props
return children(genuine, error)
}
}
export default EnsureGenuine

171
src/components/Workflow/WorkflowDefault.js

@ -1,171 +0,0 @@
// @flow
/* eslint-disable react/jsx-no-literals */
import React from 'react'
import { Trans, translate } from 'react-i18next'
import styled from 'styled-components'
import isNull from 'lodash/isNull'
import type { Device } from 'types/common'
import Box from 'components/base/Box'
import Spinner from 'components/base/Spinner'
import IconCheck from 'icons/Check'
import IconExclamationCircle from 'icons/ExclamationCircle'
import IconUsb from 'icons/Usb'
import IconHome from 'icons/Home'
const Step = styled(Box).attrs({
borderRadius: 1,
justifyContent: 'center',
fontSize: 4,
})`
border: 1px solid
${p =>
p.validated
? p.theme.colors.wallet
: p.hasErrors
? p.theme.colors.alertRed
: p.theme.colors.fog};
`
const StepIcon = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
})`
width: 64px;
`
const StepContent = styled(Box).attrs({
color: 'dark',
horizontal: true,
alignItems: 'center',
})`
height: 60px;
line-height: 1.2;
strong {
font-weight: 600;
}
`
const WrapperIconCurrency = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
})`
border: 1px solid ${p => p.theme.colors[p.color]};
border-radius: 8px;
height: 24px;
width: 24px;
`
const StepCheck = ({ checked, hasErrors }: { checked: boolean, hasErrors?: boolean }) => (
<Box pr={5}>
{checked ? (
<Box color="wallet">
<IconCheck size={16} />
</Box>
) : hasErrors ? (
<Box color="alertRed">
<IconExclamationCircle size={16} />
</Box>
) : (
<Spinner size={16} />
)}
</Box>
)
StepCheck.defaultProps = {
hasErrors: false,
}
type DeviceInfo = {
targetId: number | string,
version: string,
final: boolean,
mcu: boolean,
}
type Error = {
message: string,
stack: string,
}
type Props = {
// t: T,
device: ?Device,
deviceInfo: ?DeviceInfo,
errors: {
dashboardError: ?Error,
genuineError: ?Error,
},
isGenuine: boolean,
}
const WorkflowDefault = ({ device, deviceInfo, errors, isGenuine }: Props) => (
<Box flow={4} ff="Open Sans">
<Step validated={!!device}>
<StepContent>
<StepIcon>
<IconUsb size={36} />
</StepIcon>
<Box grow shrink>
<Trans i18nKey="app:deviceConnect.step1.connect" parent="div">
Connect and unlock your <strong>Ledger device</strong> <strong />
</Trans>
</Box>
<StepCheck checked={!!device} />
</StepContent>
</Step>
<Step validated={!!device && !!deviceInfo} hasErrors={!!device && !!errors.dashboardError}>
<StepContent>
<StepIcon>
<WrapperIconCurrency>
<IconHome size={12} />
</WrapperIconCurrency>
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:dashboard.open" parent="div">
{'Navigate to the '}
<strong>{'dashboard'}</strong>
{' on your device'}
</Trans>
</Box>
<StepCheck
checked={!!device && !!deviceInfo}
hasErrors={!!device && !!errors.dashboardError}
/>
</StepContent>
</Step>
{/* GENUINE CHECK */}
{/* ------------- */}
<Step
validated={(!!device && !isNull(isGenuine) && isGenuine && !errors.genuineError) || undefined}
hasErrors={(!!device && !isNull(isGenuine) && !isGenuine) || errors.genuineError || undefined}
>
<StepContent>
<StepIcon>
<WrapperIconCurrency>
<IconCheck size={12} />
</WrapperIconCurrency>
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:stepGenuine.open" parent="div">
{'Allow the '}
<strong>{'Ledger Manager'}</strong>
{' on your device'}
</Trans>
</Box>
<StepCheck
checked={!!device && !isNull(isGenuine) && isGenuine}
hasErrors={(!!device && !isNull(isGenuine) && !isGenuine) || undefined}
/>
</StepContent>
</Step>
</Box>
)
export default translate()(WorkflowDefault)

194
src/components/Workflow/WorkflowWithIcon.js

@ -1,194 +0,0 @@
// @flow
/* eslint-disable react/jsx-no-literals */ // FIXME
import React from 'react'
import { Trans, translate } from 'react-i18next'
import styled from 'styled-components'
import isNull from 'lodash/isNull'
import type { Device, T } from 'types/common'
import { i } from 'helpers/staticPath'
import Box from 'components/base/Box'
import Text from 'components/base/Text'
import Spinner from 'components/base/Spinner'
import IconCheck from 'icons/Check'
import IconExclamationCircle from 'icons/ExclamationCircle'
import IconUsb from 'icons/Usb'
import IconHome from 'icons/Home'
const WrapperIconCurrency = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
})`
border: 1px solid ${p => p.theme.colors[p.color]};
border-radius: 8px;
height: 24px;
width: 24px;
`
const Step = styled(Box).attrs({
borderRadius: 1,
justifyContent: 'center',
fontSize: 4,
})`
border: 1px solid
${p =>
p.validated
? p.theme.colors.wallet
: p.hasErrors
? p.theme.colors.alertRed
: p.theme.colors.fog};
`
const StepIcon = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
})`
width: 64px;
`
const StepContent = styled(Box).attrs({
color: 'dark',
horizontal: true,
alignItems: 'center',
})`
height: 60px;
line-height: 1.2;
strong {
font-weight: 600;
}
`
const StepCheck = ({ checked, hasErrors }: { checked: ?boolean, hasErrors?: boolean }) => (
<Box pr={5}>
{checked ? (
<Box color="wallet">
<IconCheck size={16} />
</Box>
) : hasErrors ? (
<Box color="alertRed">
<IconExclamationCircle size={16} />
</Box>
) : (
<Spinner size={16} />
)}
</Box>
)
StepCheck.defaultProps = {
hasErrors: false,
}
type DeviceInfo = {
targetId: number | string,
version: string,
final: boolean,
mcu: boolean,
}
type Error = {
message: string,
stack: string,
}
type Props = {
t: T,
device: ?Device,
deviceInfo: ?DeviceInfo,
errors: {
dashboardError: ?Error,
genuineError: ?Error,
},
isGenuine: boolean,
}
const WorkflowWithIcon = ({ device, deviceInfo, errors, isGenuine, t }: Props) => (
<Box align="center" justify="center" sticky>
<Box align="center" style={{ maxWidth: 460, padding: '0 10px' }}>
<img
src={i('logos/connectDevice.png')}
alt="connect your device"
style={{ marginBottom: 30, maxWidth: 362, width: '100%' }}
/>
<Text ff="Museo Sans|Regular" fontSize={7} color="black" style={{ marginBottom: 10 }}>
{t('app:manager.device.title')}
</Text>
<Text ff="Museo Sans|Light" fontSize={5} color="grey" align="center">
{t('app:manager.device.desc')}
</Text>
</Box>
<Box flow={4} style={{ maxWidth: 460, padding: '60px 10px 0' }} ff="Open Sans|Regular">
{/* DEVICE CHECK */}
<Step validated={!!device}>
<StepContent>
<StepIcon>
<IconUsb size={36} />
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:step1.connect" parent="div">
{'Connect and unlock your '}
<strong>Ledger device</strong>
</Trans>
</Box>
<StepCheck checked={!!device} />
</StepContent>
</Step>
{/* DASHBOARD CHECK */}
<Step validated={!!device && !!deviceInfo} hasErrors={!!device && !!errors.dashboardError}>
<StepContent>
<StepIcon>
<WrapperIconCurrency>
<IconHome size={12} />
</WrapperIconCurrency>
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:dashboard.open" parent="div">
{'Navigate to the '}
<strong>{'dashboard'}</strong>
{' on your device'}
</Trans>
</Box>
<StepCheck
checked={!!device && !!deviceInfo}
hasErrors={!!device && !!errors.dashboardError}
/>
</StepContent>
</Step>
{/* GENUINE CHECK */}
<Step
validated={
(!!device && !isNull(isGenuine) && isGenuine && !errors.genuineError) || undefined
}
hasErrors={
(!!device && !isNull(isGenuine) && !isGenuine) || errors.genuineError || undefined
}
>
<StepContent>
<StepIcon>
<WrapperIconCurrency>
<IconCheck size={12} />
</WrapperIconCurrency>
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:stepGenuine.open" parent="div">
{'Allow '}
<strong>{'Ledger Manager'}</strong>
{' on your device'}
</Trans>
</Box>
<StepCheck
checked={(!!device && !isNull(isGenuine) && isGenuine) || undefined}
hasErrors={(!!device && !isNull(isGenuine) && !isGenuine) || undefined}
/>
</StepContent>
</Step>
</Box>
</Box>
)
export default translate()(WorkflowWithIcon)

93
src/components/Workflow/index.js

@ -1,93 +0,0 @@
// @flow
import React, { PureComponent } from 'react'
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo'
import type { Node } from 'react'
import type { Device } from 'types/common'
import EnsureDevice from './EnsureDevice'
import EnsureDashboard from './EnsureDashboard'
import EnsureGenuine from './EnsureGenuine'
type Error = {
message: string,
stack: string,
}
type Props = {
renderDefault: (
device: ?Device,
deviceInfo: ?DeviceInfo,
isGenuine: ?boolean,
error: {
dashboardError: ?Error,
genuineError: ?Error,
},
) => Node,
renderMcuUpdate?: (device: Device, deviceInfo: DeviceInfo) => Node,
renderFinalUpdate?: (device: Device, deviceInfo: DeviceInfo) => Node,
renderDashboard?: (device: Device, deviceInfo: DeviceInfo, isGenuine: boolean) => Node,
onGenuineCheck?: (isGenuine: boolean) => void,
renderError?: (dashboardError: ?Error, genuineError: ?Error) => Node,
}
type State = {}
// In future, move to meri's approach; this code is way too much specific
class Workflow extends PureComponent<Props, State> {
render() {
const {
renderDashboard,
renderFinalUpdate,
renderMcuUpdate,
renderError,
renderDefault,
onGenuineCheck,
} = this.props
return (
<EnsureDevice>
{(device: Device) => (
<EnsureDashboard device={device}>
{(deviceInfo: ?DeviceInfo, dashboardError: ?Error) => {
if (deviceInfo && deviceInfo.isBootloader && renderMcuUpdate) {
return renderMcuUpdate(device, deviceInfo)
}
if (deviceInfo && deviceInfo.isOSU && renderFinalUpdate) {
return renderFinalUpdate(device, deviceInfo)
}
return (
<EnsureGenuine device={device} deviceInfo={deviceInfo}>
{(isGenuine: ?boolean, genuineError: ?Error) => {
if (dashboardError || genuineError) {
return renderError
? renderError(dashboardError, genuineError)
: renderDefault(device, deviceInfo, isGenuine, {
genuineError,
dashboardError,
})
}
if (isGenuine && deviceInfo && device && !dashboardError && !genuineError) {
if (onGenuineCheck) onGenuineCheck(isGenuine)
if (renderDashboard) return renderDashboard(device, deviceInfo, isGenuine)
}
return renderDefault(device, deviceInfo, isGenuine, {
genuineError,
dashboardError,
})
}}
</EnsureGenuine>
)
}}
</EnsureDashboard>
)}
</EnsureDevice>
)
}
}
export default Workflow

5
src/components/base/SideBar/SideBarListItem.js

@ -33,7 +33,7 @@ class SideBarListItem extends PureComponent<Props> {
<Container <Container
isActive={!disabled && isActive} isActive={!disabled && isActive}
iconActiveColor={iconActiveColor} iconActiveColor={iconActiveColor}
onClick={disabled ? null : onClick} onClick={disabled ? undefined : onClick}
disabled={disabled} disabled={disabled}
> >
{!!Icon && <Icon size={16} />} {!!Icon && <Icon size={16} />}
@ -62,8 +62,7 @@ const Container = styled(Tabbable).attrs({
px: 3, px: 3,
py: 2, py: 2,
})` })`
cursor: ${p => (p.disabled || p.isActive ? 'default' : 'pointer')}; cursor: ${p => (p.disabled ? 'not-allowed' : p.isActive ? 'default' : 'pointer')};
pointer-events: ${p => (p.isDisabled ? 'none' : 'auto')};
color: ${p => (p.isActive ? p.theme.colors.dark : p.theme.colors.smoke)}; color: ${p => (p.isActive ? p.theme.colors.dark : p.theme.colors.smoke)};
background: ${p => (p.isActive ? p.theme.colors.lightGrey : '')}; background: ${p => (p.isActive ? p.theme.colors.lightGrey : '')};
opacity: ${p => (p.disabled ? 0.5 : 1)}; opacity: ${p => (p.disabled ? 0.5 : 1)};

48
src/components/base/Stepper/index.js

@ -12,9 +12,12 @@ import Breadcrumb from 'components/Breadcrumb'
type Props = { type Props = {
t: T, t: T,
title: string, title: string,
steps: Step[],
initialStepId: string, initialStepId: string,
onClose: void => void, onClose: void => void,
steps: Step[], onStepChange?: Step => void,
disabledSteps?: number[],
errorSteps?: number[],
children: any, children: any,
} }
@ -23,7 +26,8 @@ export type Step = {
label: string, label: string,
component: StepProps => React$Node, component: StepProps => React$Node,
footer: StepProps => React$Node, footer: StepProps => React$Node,
preventClose?: boolean, shouldRenderFooter?: StepProps => boolean,
shouldPreventClose?: boolean | (StepProps => boolean),
onBack?: StepProps => void, onBack?: StepProps => void,
} }
@ -41,10 +45,20 @@ class Stepper extends PureComponent<Props, State> {
stepId: this.props.initialStepId, stepId: this.props.initialStepId,
} }
transitionTo = stepId => this.setState({ stepId }) transitionTo = stepId => {
const { onStepChange, steps } = this.props
this.setState({ stepId })
if (onStepChange) {
const stepIndex = steps.findIndex(s => s.id === stepId)
const step = steps[stepIndex]
if (step) {
onStepChange(step)
}
}
}
render() { render() {
const { t, steps, title, onClose, children, ...props } = this.props const { t, steps, title, onClose, disabledSteps, errorSteps, children, ...props } = this.props
const { stepId } = this.state const { stepId } = this.state
const stepIndex = steps.findIndex(s => s.id === stepId) const stepIndex = steps.findIndex(s => s.id === stepId)
@ -52,7 +66,13 @@ class Stepper extends PureComponent<Props, State> {
invariant(step, `Stepper: step ${stepId} doesn't exists`) invariant(step, `Stepper: step ${stepId} doesn't exists`)
const { component: StepComponent, footer: StepFooter, onBack, preventClose } = step const {
component: StepComponent,
footer: StepFooter,
onBack,
shouldPreventClose,
shouldRenderFooter,
} = step
const stepProps: StepProps = { const stepProps: StepProps = {
t, t,
@ -60,15 +80,29 @@ class Stepper extends PureComponent<Props, State> {
...props, ...props,
} }
const renderFooter =
!!StepFooter && (shouldRenderFooter === undefined || shouldRenderFooter(stepProps))
const preventClose =
typeof shouldPreventClose === 'function'
? shouldPreventClose(stepProps)
: !!shouldPreventClose
return ( return (
<ModalBody onClose={preventClose ? undefined : onClose}> <ModalBody onClose={preventClose ? undefined : onClose}>
<ModalTitle onBack={onBack ? () => onBack(stepProps) : undefined}>{title}</ModalTitle> <ModalTitle onBack={onBack ? () => onBack(stepProps) : undefined}>{title}</ModalTitle>
<ModalContent> <ModalContent>
<Breadcrumb mb={6} currentStep={stepIndex} items={steps} /> <Breadcrumb
mb={6}
currentStep={stepIndex}
items={steps}
stepsDisabled={disabledSteps}
stepsErrors={errorSteps}
/>
<StepComponent {...stepProps} /> <StepComponent {...stepProps} />
{children} {children}
</ModalContent> </ModalContent>
{StepFooter && ( {renderFooter && (
<ModalFooter horizontal align="center" justify="flex-end"> <ModalFooter horizontal align="center" justify="flex-end">
<StepFooter {...stepProps} /> <StepFooter {...stepProps} />
</ModalFooter> </ModalFooter>

2
src/components/base/Stepper/stories.js

@ -27,7 +27,7 @@ const steps: Step[] = [
{ {
id: 'second', id: 'second',
label: 'second step', label: 'second step',
preventClose: true, shouldPreventClose: true,
onBack: ({ transitionTo }: StepProps) => transitionTo('first'), onBack: ({ transitionTo }: StepProps) => transitionTo('first'),
component: () => <div>second step (you cant close on this one)</div>, component: () => <div>second step (you cant close on this one)</div>,
footer: ({ transitionTo }: StepProps) => ( footer: ({ transitionTo }: StepProps) => (

2
src/components/modals/Debug.js

@ -7,7 +7,7 @@ import Modal, { ModalBody, ModalTitle, ModalContent } from 'components/base/Moda
import Button from 'components/base/Button' import Button from 'components/base/Button'
import Box from 'components/base/Box' import Box from 'components/base/Box'
import Input from 'components/base/Input' import Input from 'components/base/Input'
import EnsureDevice from 'components/Workflow/EnsureDevice' import EnsureDevice from 'components/EnsureDevice'
import { getDerivations } from 'helpers/derivations' import { getDerivations } from 'helpers/derivations'
import getAddress from 'commands/getAddress' import getAddress from 'commands/getAddress'
import testInterval from 'commands/testInterval' import testInterval from 'commands/testInterval'

25
src/components/modals/Receive/01-step-account.js

@ -1,25 +0,0 @@
// @flow
import React from 'react'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common'
import TrackPage from 'analytics/TrackPage'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import SelectAccount from 'components/SelectAccount'
type Props = {
account: ?Account,
onChangeAccount: Function,
t: T,
}
export default (props: Props) => (
<Box flow={1}>
<TrackPage category="Receive" name="Step1" />
<Label>{props.t('app:receive.steps.chooseAccount.label')}</Label>
<SelectAccount autoFocus onChange={props.onChangeAccount} value={props.account} />
</Box>
)

16
src/components/modals/Receive/02-step-connect-device.js

@ -1,16 +0,0 @@
import React, { Component, Fragment } from 'react'
import TrackPage from 'analytics/TrackPage'
import StepConnectDevice from '../StepConnectDevice'
class ReceiveStepConnectDevice extends Component<*> {
render() {
return (
<Fragment>
<TrackPage category="Receive" name="Step2" />
<StepConnectDevice {...this.props} />
</Fragment>
)
}
}
export default ReceiveStepConnectDevice

59
src/components/modals/Receive/03-step-confirm-address.js

@ -1,59 +0,0 @@
// @flow
import React, { Fragment } from 'react'
import styled from 'styled-components'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { Device, T } from 'types/common'
import TrackPage from 'analytics/TrackPage'
import Box from 'components/base/Box'
import CurrentAddressForAccount from 'components/CurrentAddressForAccount'
import DeviceConfirm from 'components/DeviceConfirm'
const Container = styled(Box).attrs({
alignItems: 'center',
fontSize: 4,
color: 'dark',
px: 7,
})``
const Title = styled(Box).attrs({
ff: 'Museo Sans|Regular',
fontSize: 6,
mb: 1,
})``
const Text = styled(Box).attrs({
color: 'smoke',
})`
text-align: center;
`
type Props = {
account: ?Account,
addressVerified: ?boolean,
device: ?Device,
t: T,
}
export default (props: Props) => (
<Container>
<TrackPage category="Receive" name="Step3" />
{props.addressVerified === false ? (
<Fragment>
<Title>{props.t('app:receive.steps.confirmAddress.error.title')}</Title>
<Text mb={5}>{props.t('app:receive.steps.confirmAddress.error.text')}</Text>
<DeviceConfirm error />
</Fragment>
) : (
<Fragment>
<Title>{props.t('app:receive.steps.confirmAddress.action')}</Title>
<Text>{props.t('app:receive.steps.confirmAddress.text')}</Text>
{props.account && <CurrentAddressForAccount account={props.account} />}
{props.device &&
props.account && <DeviceConfirm mb={2} mt={-1} error={props.addressVerified === false} />}
</Fragment>
)}
</Container>
)

48
src/components/modals/Receive/04-step-receive-funds.js

@ -1,48 +0,0 @@
// @flow
import React from 'react'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common'
import TrackPage from 'analytics/TrackPage'
import Box from 'components/base/Box'
import CurrentAddressForAccount from 'components/CurrentAddressForAccount'
import Label from 'components/base/Label'
import RequestAmount from 'components/RequestAmount'
type Props = {
account: ?Account,
addressVerified: ?boolean,
amount: string | number,
onChangeAmount: Function,
onVerify: Function,
t: T,
}
export default (props: Props) => (
<Box flow={5}>
<TrackPage category="Receive" name="Step4" />
<Box flow={1}>
<Label>{props.t('app:receive.steps.receiveFunds.label')}</Label>
<RequestAmount
account={props.account}
onChange={props.onChangeAmount}
value={props.amount}
withMax={false}
/>
</Box>
{props.account && (
<CurrentAddressForAccount
account={props.account}
addressVerified={props.addressVerified}
amount={props.amount}
onVerify={props.onVerify}
withBadge
withFooter
withQRCode
withVerify={props.addressVerified === false}
/>
)}
</Box>
)

454
src/components/modals/Receive/index.js

@ -1,193 +1,113 @@
// @flow // @flow
import React, { Fragment, PureComponent } from 'react' import React, { PureComponent, Fragment } from 'react'
import { compose } from 'redux' import { compose } from 'redux'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { translate } from 'react-i18next' import { translate } from 'react-i18next'
import { createStructuredSelector } from 'reselect' import { createStructuredSelector } from 'reselect'
import { accountsSelector } from 'reducers/accounts' import SyncSkipUnderPriority from 'components/SyncSkipUnderPriority'
import Track from 'analytics/Track' import Track from 'analytics/Track'
import type { Account } from '@ledgerhq/live-common/lib/types' import type { Account } from '@ledgerhq/live-common/lib/types'
import type { T, Device } from 'types/common'
import { MODAL_RECEIVE } from 'config/constants' import { MODAL_RECEIVE } from 'config/constants'
import { isSegwitAccount } from 'helpers/bip32' import type { T, Device } from 'types/common'
import type { StepProps as DefaultStepProps } from 'components/base/Stepper'
import getAddress from 'commands/getAddress' import { getCurrentDevice } from 'reducers/devices'
import SyncSkipUnderPriority from 'components/SyncSkipUnderPriority' import { accountsSelector } from 'reducers/accounts'
import SyncOneAccountOnMount from 'components/SyncOneAccountOnMount' import { closeModal } from 'reducers/modals'
import Box from 'components/base/Box' import Modal from 'components/base/Modal'
import Breadcrumb from 'components/Breadcrumb' import Stepper from 'components/base/Stepper'
import Button from 'components/base/Button'
import Modal, { ModalBody, ModalTitle, ModalContent, ModalFooter } from 'components/base/Modal'
import { WrongDeviceForAccount } from 'components/EnsureDeviceApp'
import StepAccount from './01-step-account' import StepAccount, { StepAccountFooter } from './steps/01-step-account'
import StepConnectDevice from './02-step-connect-device' import StepConnectDevice, { StepConnectDeviceFooter } from './steps/02-step-connect-device'
import StepConfirmAddress from './03-step-confirm-address' import StepConfirmAddress, { StepConfirmAddressFooter } from './steps/03-step-confirm-address'
import StepReceiveFunds from './04-step-receive-funds' import StepReceiveFunds, { StepReceiveFundsFooter } from './steps/04-step-receive-funds'
type Props = { type Props = {
t: T, t: T,
device: ?Device,
accounts: Account[], accounts: Account[],
closeModal: string => void,
} }
type State = { type State = {
account: Account | null, stepId: string,
addressVerified: null | boolean, account: ?Account,
amount: string | number, isAppOpened: boolean,
appStatus: null | string, isAddressVerified: ?boolean,
deviceSelected: Device | null, disabledSteps: number[],
stepIndex: number, errorSteps: number[],
stepsDisabled: Array<number>,
stepsErrors: Array<number>,
} }
const GET_STEPS = t => [ export type StepProps = DefaultStepProps & {
{ label: t('app:receive.steps.chooseAccount.title'), Comp: StepAccount }, device: ?Device,
{ label: t('app:receive.steps.connectDevice.title'), Comp: StepConnectDevice }, account: ?Account,
{ label: t('app:receive.steps.confirmAddress.title'), Comp: StepConfirmAddress }, closeModal: void => void,
{ label: t('app:receive.steps.receiveFunds.title'), Comp: StepReceiveFunds }, isAppOpened: boolean,
] isAddressVerified: ?boolean,
onRetry: void => void,
const INITIAL_STATE = { onSkipConfirm: void => void,
account: null, onResetSkip: void => void,
addressVerified: null, onChangeAccount: (?Account) => void,
amount: '', onChangeAppOpened: boolean => void,
appStatus: null, onChangeAddressVerified: boolean => void,
deviceSelected: null,
stepIndex: 0,
stepsDisabled: [],
stepsErrors: [],
// FIXME the two above can be derivated from other info (if we keep error etc)
// we can get rid of it after a big refactoring (see how done in Send)
} }
const createSteps = ({ t }: { t: T }) => [
{
id: 'account',
label: t('app:receive.steps.chooseAccount.title'),
component: StepAccount,
footer: StepAccountFooter,
},
{
id: 'device',
label: t('app:receive.steps.connectDevice.title'),
component: StepConnectDevice,
footer: StepConnectDeviceFooter,
onBack: ({ transitionTo }: StepProps) => transitionTo('account'),
},
{
id: 'confirm',
label: t('app:receive.steps.confirmAddress.title'),
component: StepConfirmAddress,
footer: StepConfirmAddressFooter,
shouldRenderFooter: ({ isAddressVerified }: StepProps) => isAddressVerified === false,
shouldPreventClose: ({ isAddressVerified }: StepProps) => isAddressVerified === null,
},
{
id: 'receive',
label: t('app:receive.steps.receiveFunds.title'),
component: StepReceiveFunds,
footer: StepReceiveFundsFooter,
},
]
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
device: getCurrentDevice,
accounts: accountsSelector, accounts: accountsSelector,
}) })
class ReceiveModal extends PureComponent<Props, State> { const mapDispatchToProps = {
state = INITIAL_STATE closeModal,
}
_steps = GET_STEPS(this.props.t)
canNext = () => {
const { account, stepIndex } = this.state
if (stepIndex === 0) {
return account !== null
}
if (stepIndex === 1) {
const { deviceSelected, appStatus } = this.state
return deviceSelected !== null && appStatus === 'success'
}
return false
}
canClose = () => {
const { stepIndex, addressVerified } = this.state
if (stepIndex === 2) {
return addressVerified === false
}
return true
}
canPrev = () => {
const { addressVerified, stepIndex } = this.state
if (stepIndex === 1) {
return true
}
if (stepIndex === 2) {
return addressVerified === false
}
if (stepIndex === 3) {
return true
}
return false
}
handleReset = () => this.setState(INITIAL_STATE)
handleNextStep = () => {
const { stepIndex } = this.state
if (stepIndex >= this._steps.length - 1) {
return
}
this.setState({ stepIndex: stepIndex + 1 })
// TODO: do that better
if (stepIndex === 1) {
this.verifyAddress()
}
}
handlePrevStep = () => {
const { stepIndex } = this.state
let newStepIndex
switch (stepIndex) {
default:
case 1:
newStepIndex = 0
break
case 2:
case 3:
newStepIndex = 1
break
}
this.setState({
addressVerified: null,
appStatus: null,
deviceSelected: null,
stepIndex: newStepIndex,
stepsDisabled: [],
stepsErrors: [],
})
}
handleChangeDevice = d => this.setState({ deviceSelected: d })
handleChangeAccount = account => this.setState({ account })
handleChangeStatus = (deviceStatus, appStatus) => this.setState({ appStatus })
handleCheckAddress = isVerified => {
this.setState({
addressVerified: isVerified,
stepsErrors: isVerified === false ? [2] : [],
})
if (isVerified === true) {
this.handleNextStep()
}
}
handleRetryCheckAddress = () => {
this.setState({
addressVerified: null,
stepsErrors: [],
})
// TODO: do that better const INITIAL_STATE = {
this.verifyAddress() stepId: 'account',
} account: null,
isAppOpened: false,
isAddressVerified: null,
disabledSteps: [],
errorSteps: [],
}
handleChangeAmount = amount => this.setState({ amount }) class ReceiveModal extends PureComponent<Props, State> {
state = INITIAL_STATE
STEPS = createSteps({ t: this.props.t })
handleBeforeOpenModal = ({ data }) => { handleBeforeOpenModal = ({ data }) => {
const { account } = this.state const { account } = this.state
@ -195,175 +115,92 @@ class ReceiveModal extends PureComponent<Props, State> {
if (!account) { if (!account) {
if (data && data.account) { if (data && data.account) {
this.setState({ this.setState({ account: data.account })
account: data.account,
stepIndex: 1,
})
} else { } else {
this.setState({ this.setState({ account: accounts[0] })
account: accounts[0],
})
} }
} }
} }
handleSkipStep = () => handleRetry = () => this.setState({ isAddressVerified: null, isAppOpened: false, errorSteps: [] })
this.setState({ handleReset = () => this.setState({ ...INITIAL_STATE })
addressVerified: false, handleCloseModal = () => this.props.closeModal(MODAL_RECEIVE)
stepsErrors: [], handleStepChange = step => this.setState({ stepId: step.id })
stepsDisabled: [1, 2], handleChangeAccount = (account: ?Account) => this.setState({ account })
stepIndex: this._steps.length - 1, // last step handleChangeAppOpened = (isAppOpened: boolean) => this.setState({ isAppOpened })
}) handleChangeAddressVerified = (isAddressVerified: boolean) => {
if (isAddressVerified) {
verifyAddress = async () => { this.setState({ isAddressVerified })
const { account, deviceSelected: device } = this.state } else {
try { const confirmStepIndex = this.STEPS.findIndex(step => step.id === 'confirm')
if (account && device) { if (confirmStepIndex > -1) {
const { address } = await getAddress this.setState({
.send({ isAddressVerified,
currencyId: account.currency.id, errorSteps: [confirmStepIndex],
devicePath: device.path, })
path: account.freshAddressPath,
segwit: isSegwitAccount(account),
verify: true,
})
.toPromise()
if (address !== account.freshAddress) {
throw new WrongDeviceForAccount(`WrongDeviceForAccount ${account.name}`, {
accountName: account.name,
})
}
this.handleCheckAddress(true)
} else {
this.handleCheckAddress(false)
} }
} catch (err) {
this.handleCheckAddress(false)
} }
} }
renderStep = () => { handleResetSkip = () => this.setState({ disabledSteps: [] })
const { account, amount, addressVerified, deviceSelected, stepIndex } = this.state handleSkipConfirm = () => {
const { t } = this.props const connectStepIndex = this.STEPS.findIndex(step => step.id === 'device')
const step = this._steps[stepIndex] const confirmStepIndex = this.STEPS.findIndex(step => step.id === 'confirm')
if (!step) { if (confirmStepIndex > -1 && connectStepIndex > -1) {
return null this.setState({ disabledSteps: [connectStepIndex, confirmStepIndex] })
}
const { Comp } = step
const props = (predicate, props) => (predicate ? props : {})
const stepProps = {
t,
account,
...props(stepIndex === 0, {
onChangeAccount: this.handleChangeAccount,
}),
...props(stepIndex === 1, {
accountName: account ? account.name : undefined,
deviceSelected,
onChangeDevice: this.handleChangeDevice,
onStatusChange: this.handleChangeStatus,
}),
...props(stepIndex === 2, {
addressVerified,
onCheck: this.handleCheckAddress,
device: deviceSelected,
}),
...props(stepIndex === 3, {
addressVerified,
amount,
onChangeAmount: this.handleChangeAmount,
onVerify: this.handlePrevStep,
}),
} }
return <Comp {...stepProps} />
}
renderButton = () => {
const { t } = this.props
const { stepIndex, addressVerified } = this.state
let onClick
let props
switch (stepIndex) {
case 2:
props = {
primary: true,
onClick: this.handleRetryCheckAddress,
children: t('app:common.retry'),
}
break
default:
onClick = this.handleNextStep
props = {
primary: true,
disabled: !this.canNext(),
onClick,
children: t('app:common.next'),
}
}
return (
<Fragment>
{stepIndex === 1 && (
<Button onClick={this.handleSkipStep} fontSize={4}>
{t('app:receive.steps.connectDevice.withoutDevice')}
</Button>
)}
{stepIndex === 2 &&
addressVerified === false && (
<Button fontSize={4}>{t('app:receive.steps.confirmAddress.support')}</Button>
)}
<Button {...props} />
</Fragment>
)
} }
render() { render() {
const { t } = this.props const { t, device } = this.props
const { stepsErrors, stepsDisabled, stepIndex, account } = this.state const {
stepId,
account,
isAppOpened,
isAddressVerified,
disabledSteps,
errorSteps,
} = this.state
const addtionnalProps = {
device,
account,
isAppOpened,
isAddressVerified,
closeModal: this.handleCloseModal,
onRetry: this.handleRetry,
onSkipConfirm: this.handleSkipConfirm,
onResetSkip: this.handleResetSkip,
onChangeAccount: this.handleChangeAccount,
onChangeAppOpened: this.handleChangeAppOpened,
onChangeAddressVerified: this.handleChangeAddressVerified,
}
const canClose = this.canClose() const isModalLocked = stepId === 'confirm' && isAddressVerified === null
const canPrev = this.canPrev()
return ( return (
<Modal <Modal
name={MODAL_RECEIVE} name={MODAL_RECEIVE}
onBeforeOpen={this.handleBeforeOpenModal} refocusWhenChange={stepId}
onHide={this.handleReset} onHide={this.handleReset}
preventBackdropClick={!canClose} preventBackdropClick={isModalLocked}
onBeforeOpen={this.handleBeforeOpenModal}
render={({ onClose }) => ( render={({ onClose }) => (
<ModalBody onClose={canClose ? onClose : undefined}> <Fragment>
<Track onUnmount event="CloseModalReceive" /> <Track onUnmount event="CloseModalReceive" />
<SyncSkipUnderPriority priority={9} /> <Stepper
{account && <SyncOneAccountOnMount priority={10} accountId={account.id} />} title={t('app:receive.title')}
<ModalTitle onBack={canPrev ? this.handlePrevStep : undefined}> initialStepId={stepId}
{t('app:receive.title')} onStepChange={this.handleStepChange}
</ModalTitle> onClose={onClose}
<ModalContent> steps={this.STEPS}
<Breadcrumb disabledSteps={disabledSteps}
mb={6} errorSteps={errorSteps}
currentStep={stepIndex} {...addtionnalProps}
stepsErrors={stepsErrors} >
stepsDisabled={stepsDisabled} <SyncSkipUnderPriority priority={100} />
items={this._steps} </Stepper>
/> </Fragment>
{this.renderStep()}
</ModalContent>
{stepIndex !== 3 &&
canClose && (
<ModalFooter>
<Box horizontal alignItems="center" justifyContent="flex-end" flow={2}>
{this.renderButton()}
</Box>
</ModalFooter>
)}
</ModalBody>
)} )}
/> />
) )
@ -371,6 +208,9 @@ class ReceiveModal extends PureComponent<Props, State> {
} }
export default compose( export default compose(
connect(mapStateToProps), connect(
mapStateToProps,
mapDispatchToProps,
),
translate(), translate(),
)(ReceiveModal) )(ReceiveModal)

29
src/components/modals/Receive/steps/01-step-account.js

@ -0,0 +1,29 @@
// @flow
import React from 'react'
import TrackPage from 'analytics/TrackPage'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import Button from 'components/base/Button'
import SelectAccount from 'components/SelectAccount'
import type { StepProps } from '../index'
export default function StepAccount({ t, account, onChangeAccount }: StepProps) {
return (
<Box flow={1}>
<TrackPage category="Receive" name="Step1" />
<Label>{t('app:receive.steps.chooseAccount.label')}</Label>
<SelectAccount autoFocus onChange={onChangeAccount} value={account} />
</Box>
)
}
export function StepAccountFooter({ t, transitionTo, account }: StepProps) {
return (
<Button disabled={!account} primary onClick={() => transitionTo('device')}>
{t('app:common.next')}
</Button>
)
}

42
src/components/modals/Receive/steps/02-step-connect-device.js

@ -0,0 +1,42 @@
// @flow
import React from 'react'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import EnsureDeviceApp from 'components/EnsureDeviceApp'
import type { StepProps } from '../index'
export default function StepConnectDevice({ account, onChangeAppOpened }: StepProps) {
return (
<EnsureDeviceApp
account={account}
waitBeforeSuccess={200}
onSuccess={() => onChangeAppOpened(true)}
/>
)
}
export function StepConnectDeviceFooter({
t,
transitionTo,
isAppOpened,
onSkipConfirm,
}: StepProps) {
return (
<Box horizontal flow={2}>
<Button
onClick={() => {
onSkipConfirm()
transitionTo('receive')
}}
>
{t('app:receive.steps.connectDevice.withoutDevice')}
</Button>
<Button disabled={!isAppOpened} primary onClick={() => transitionTo('confirm')}>
{t('app:common.next')}
</Button>
</Box>
)
}

112
src/components/modals/Receive/steps/03-step-confirm-address.js

@ -0,0 +1,112 @@
// @flow
import invariant from 'invariant'
import styled from 'styled-components'
import React, { Fragment, PureComponent } from 'react'
import getAddress from 'commands/getAddress'
import { isSegwitAccount } from 'helpers/bip32'
import TrackPage from 'analytics/TrackPage'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import DeviceConfirm from 'components/DeviceConfirm'
import CurrentAddressForAccount from 'components/CurrentAddressForAccount'
import { WrongDeviceForAccount } from 'components/EnsureDeviceApp'
import type { StepProps } from '../index'
export default class StepConfirmAddress extends PureComponent<StepProps> {
componentDidMount() {
this.confirmAddress()
}
confirmAddress = async () => {
const { account, device, onChangeAddressVerified, transitionTo } = this.props
invariant(account, 'No account given')
invariant(device, 'No device given')
try {
const params = {
currencyId: account.currency.id,
devicePath: device.path,
path: account.freshAddressPath,
segwit: isSegwitAccount(account),
verify: true,
}
const { address } = await getAddress.send(params).toPromise()
if (address !== account.freshAddress) {
throw new WrongDeviceForAccount(`WrongDeviceForAccount ${account.name}`, {
accountName: account.name,
})
}
onChangeAddressVerified(true)
transitionTo('receive')
} catch (err) {
onChangeAddressVerified(false)
}
}
render() {
const { t, device, account, isAddressVerified } = this.props
invariant(account, 'No account given')
invariant(device, 'No device given')
return (
<Container>
<TrackPage category="Receive" name="Step3" />
{isAddressVerified === false ? (
<Fragment>
<Title>{t('app:receive.steps.confirmAddress.error.title')}</Title>
<Text mb={5}>{t('app:receive.steps.confirmAddress.error.text')}</Text>
<DeviceConfirm error />
</Fragment>
) : (
<Fragment>
<Title>{t('app:receive.steps.confirmAddress.action')}</Title>
<Text>{t('app:receive.steps.confirmAddress.text')}</Text>
<CurrentAddressForAccount account={account} />
<DeviceConfirm mb={2} mt={-1} error={isAddressVerified === false} />
</Fragment>
)}
</Container>
)
}
}
export function StepConfirmAddressFooter({ t, transitionTo, onRetry }: StepProps) {
// This will be displayed only if user rejected address
return (
<Fragment>
<Button>{t('app:receive.steps.confirmAddress.support')}</Button>
<Button
ml={2}
primary
onClick={() => {
onRetry()
transitionTo('device')
}}
>
{t('app:common.retry')}
</Button>
</Fragment>
)
}
const Container = styled(Box).attrs({
alignItems: 'center',
fontSize: 4,
color: 'dark',
px: 7,
})``
const Title = styled(Box).attrs({
ff: 'Museo Sans|Regular',
fontSize: 6,
mb: 1,
})``
const Text = styled(Box).attrs({
color: 'smoke',
})`
text-align: center;
`

68
src/components/modals/Receive/steps/04-step-receive-funds.js

@ -0,0 +1,68 @@
// @flow
import invariant from 'invariant'
import React, { PureComponent } from 'react'
import TrackPage from 'analytics/TrackPage'
import Button from 'components/base/Button'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import CurrentAddressForAccount from 'components/CurrentAddressForAccount'
import RequestAmount from 'components/RequestAmount'
import type { StepProps } from '../index'
type State = {
amount: number,
}
export default class StepReceiveFunds extends PureComponent<StepProps, State> {
state = {
amount: 0,
}
handleChangeAmount = (amount: number) => this.setState({ amount })
handleGoPrev = () => {
this.props.onChangeAppOpened(false)
this.props.onResetSkip()
this.props.transitionTo('device')
}
render() {
const { t, account, isAddressVerified } = this.props
const { amount } = this.state
invariant(account, 'No account given')
return (
<Box flow={5}>
<TrackPage category="Receive" name="Step4" />
<Box flow={1}>
<Label>{t('app:receive.steps.receiveFunds.label')}</Label>
<RequestAmount
account={account}
onChange={this.handleChangeAmount}
value={amount}
withMax={false}
/>
</Box>
<CurrentAddressForAccount
account={account}
addressVerified={isAddressVerified === true}
amount={amount}
onVerify={this.handleGoPrev}
withBadge
withFooter
withQRCode
withVerify={isAddressVerified !== true}
/>
</Box>
)
}
}
export function StepReceiveFundsFooter({ t, closeModal }: StepProps) {
return (
<Button primary onClick={closeModal}>
{t('app:common.close')}
</Button>
)
}

43
src/components/modals/StepConnectDevice.js

@ -5,41 +5,30 @@ import React from 'react'
import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types' import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import type { Device } from 'types/common' import type { Device } from 'types/common'
import DeviceConnect from 'components/DeviceConnect'
import EnsureDeviceApp from 'components/EnsureDeviceApp' import EnsureDeviceApp from 'components/EnsureDeviceApp'
type Props = { type Props = {
account?: ?Account, account?: ?Account,
currency?: ?CryptoCurrency, currency?: ?CryptoCurrency,
deviceSelected?: ?Device,
onChangeDevice?: Device => void, onChangeDevice?: Device => void,
onStatusChange: string => void, onStatusChange: (string, string) => void,
} }
// FIXME why is that in modal !? // FIXME why is that in modal !?
const StepConnectDevice = ({ const StepConnectDevice = ({ account, currency, onChangeDevice, onStatusChange }: Props) =>
account, account || currency ? (
currency, <EnsureDeviceApp
deviceSelected, account={account}
onChangeDevice, currency={currency}
onStatusChange, waitBeforeSuccess={500}
}: Props) => ( onSuccess={({ device }) => {
<EnsureDeviceApp // TODO: remove those non-nense callbacks
account={account} if (onChangeDevice) {
currency={currency} onChangeDevice(device)
deviceSelected={deviceSelected} }
onStatusChange={onStatusChange} onStatusChange('success', 'success')
render={({ currency, appStatus, devices, deviceSelected, error }) => ( }}
<DeviceConnect />
currency={currency} ) : null
appOpened={appStatus === 'success' ? 'success' : appStatus === 'fail' ? 'fail' : null}
devices={devices}
deviceSelected={deviceSelected}
onChangeDevice={onChangeDevice}
error={error}
/>
)}
/>
)
export default StepConnectDevice export default StepConnectDevice

1
src/config/constants.js

@ -26,6 +26,7 @@ export const LISTEN_DEVICES_POLLING_INTERVAL = intFromEnv('LISTEN_DEVICES_POLLIN
export const SYNC_MAX_CONCURRENT = intFromEnv('LEDGER_SYNC_MAX_CONCURRENT', 1) export const SYNC_MAX_CONCURRENT = intFromEnv('LEDGER_SYNC_MAX_CONCURRENT', 1)
export const SYNC_BOOT_DELAY = 2 * 1000 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 GENUINE_TIMEOUT = intFromEnv('GENUINE_TIMEOUT', 120 * 1000) export const GENUINE_TIMEOUT = intFromEnv('GENUINE_TIMEOUT', 120 * 1000)
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 OUTDATED_CONSIDERED_DELAY = intFromEnv('OUTDATED_CONSIDERED_DELAY', 5 * 60 * 1000)

13
src/helpers/deviceAccess.js

@ -4,6 +4,7 @@ import type Transport from '@ledgerhq/hw-transport'
import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' import TransportNodeHid from '@ledgerhq/hw-transport-node-hid'
import { DEBUG_DEVICE } from 'config/constants' import { DEBUG_DEVICE } from 'config/constants'
import { retry } from './promise' import { retry } from './promise'
import { createCustomErrorClass } from './errors'
// all open to device must use openDevice so we can prevent race conditions // all open to device must use openDevice so we can prevent race conditions
// and guarantee we do one device access at a time. It also will handle the .close() // and guarantee we do one device access at a time. It also will handle the .close()
@ -13,6 +14,16 @@ type WithDevice = (devicePath: string) => <T>(job: (Transport<*>) => Promise<T>)
const semaphorePerDevice = {} const semaphorePerDevice = {}
const DisconnectedDevice = createCustomErrorClass('DisconnectedDevice')
const remapError = <T>(p: Promise<T>): Promise<T> =>
p.catch(e => {
if (e && e.message && e.message.indexOf('HID') >= 0) {
throw new DisconnectedDevice(e.message)
}
throw e
})
export const withDevice: WithDevice = devicePath => { export const withDevice: WithDevice = devicePath => {
const sem = const sem =
semaphorePerDevice[devicePath] || (semaphorePerDevice[devicePath] = createSemaphore(1)) semaphorePerDevice[devicePath] || (semaphorePerDevice[devicePath] = createSemaphore(1))
@ -23,7 +34,7 @@ export const withDevice: WithDevice = devicePath => {
if (DEBUG_DEVICE) t.setDebugMode(true) if (DEBUG_DEVICE) t.setDebugMode(true)
try { try {
const res = await job(t) const res = await remapError(job(t))
// $FlowFixMe // $FlowFixMe
return res return res
} finally { } finally {

2
src/helpers/getAddressForCurrency/btc.js

@ -6,7 +6,7 @@ import type Transport from '@ledgerhq/hw-transport'
import getBitcoinLikeInfo from '../devices/getBitcoinLikeInfo' import getBitcoinLikeInfo from '../devices/getBitcoinLikeInfo'
import { createCustomErrorClass } from '../errors' import { createCustomErrorClass } from '../errors'
const BtcUnmatchedApp = createCustomErrorClass('BtcUnmatchedApp') export const BtcUnmatchedApp = createCustomErrorClass('BtcUnmatchedApp')
export default async ( export default async (
transport: Transport<*>, transport: Transport<*>,

33
src/helpers/promise.js

@ -31,3 +31,36 @@ export function retry<A>(f: () => Promise<A>, options?: $Shape<typeof defaults>)
export function idleCallback() { export function idleCallback() {
return new Promise(resolve => window.requestIdleCallback(resolve)) return new Promise(resolve => window.requestIdleCallback(resolve))
} }
type CancellablePollingOpts = {
pollingInterval?: number,
shouldThrow?: Error => boolean,
}
export function createCancelablePolling(
job: any => Promise<any>,
{ pollingInterval = 500, shouldThrow }: CancellablePollingOpts = {},
) {
let isUnsub = false
const unsubscribe = () => (isUnsub = true)
const getUnsub = () => isUnsub
const promise = new Promise((resolve, reject) => {
async function poll() {
try {
const res = await job()
if (getUnsub()) return
resolve(res)
} catch (err) {
if (shouldThrow && shouldThrow(err)) {
reject(err)
return
}
await delay(pollingInterval)
if (getUnsub()) return
poll()
}
}
poll()
})
return { unsubscribe, promise }
}

46
static/i18n/en/errors.yml

@ -1,30 +1,32 @@
generic: Oops, an unknown error occurred. Please try again or contact Ledger Support. BtcUnmatchedApp: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.'
RangeError: '{{message}}' DeviceNotGenuine: Device is not genuine
DeviceSocketFail: Oops, device connection failed. Please try again. [device-fail]
DeviceSocketNoBulkStatus: Oops, device connection failed. Please try again [bulk].
DeviceSocketNoHandler: Oops, device connection failed (handler {{query}}). Please try again.
DisconnectedDevice: 'The device was disconnected.'
Error: '{{message}}' Error: '{{message}}'
LedgerAPIErrorWithMessage: '{{message}}'
TransportStatusError: '{{message}}'
TimeoutError: 'The request timed out.'
FeeEstimationFailed: 'Fee estimation error. Try again or set a custom fee (status: {{status}})' FeeEstimationFailed: 'Fee estimation error. Try again or set a custom fee (status: {{status}})'
NotEnoughBalance: 'Insufficient funds to proceed.' generic: Oops, an unknown error occurred. Please try again or contact Ledger Support.
BtcUnmatchedApp: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.' HardResetFail: Reset failed. Please try again.
WrongAppOpened: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.' LatestMCUInstalledError: MCU on device already up to date.
WrongDeviceForAccount: 'Use the device associated with the account ‘{{accountName}}’.'
LedgerAPINotAvailable: 'Ledger API not available for {{currencyName}}.'
LedgerAPIError: 'Ledger API error. Try again. (HTTP {{status}})' LedgerAPIError: 'Ledger API error. Try again. (HTTP {{status}})'
LedgerAPIErrorWithMessage: '{{message}}'
LedgerAPINotAvailable: 'Ledger API not available for {{currencyName}}.'
ManagerAPIsFail: Services are unavailable. Please try again.
ManagerAppAlreadyInstalled: App is already installed
ManagerAppRelyOnBTC: You must install Bitcoin application first
ManagerDeviceLocked: Device is locked
ManagerNotEnoughSpace: Not enough storage on device. Uninstall some apps and try again.
ManagerUnexpected: Unexpected error occurred ({{msg}}). Please try again.
ManagerUninstallBTCDep: You must uninstall other altcoins first
NetworkDown: 'Your internet connection seems down.' NetworkDown: 'Your internet connection seems down.'
NoAddressesFound: 'No accounts were found.' NoAddressesFound: 'No accounts were found.'
NotEnoughBalance: 'Insufficient funds to proceed.'
RangeError: '{{message}}'
TimeoutError: 'The request timed out.'
TransportStatusError: '{{message}}'
UserRefusedOnDevice: Transaction refused on device. UserRefusedOnDevice: Transaction refused on device.
WebsocketConnectionError: Oops, device connection failed. Please try again. [web-err] WebsocketConnectionError: Oops, device connection failed. Please try again. [web-err]
WebsocketConnectionFailed: Oops, device connection failed. Please try again. [web-fail] WebsocketConnectionFailed: Oops, device connection failed. Please try again. [web-fail]
DeviceSocketFail: Oops, device connection failed. Please try again. [device-fail] WrongAppOpened: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.'
DeviceSocketNoBulkStatus: Oops, device connection failed. Please try again [bulk]. WrongDeviceForAccount: 'Use the device associated with the account ‘{{accountName}}’.'
DeviceSocketNoHandler: Oops, device connection failed (handler {{query}}). Please try again.
LatestMCUInstalledError: MCU on device already up to date.
HardResetFail: Reset failed. Please try again.
ManagerAPIsFail: Services are unavailable. Please try again.
ManagerUnexpected: Unexpected error occurred ({{msg}}). Please try again.
ManagerNotEnoughSpace: Not enough storage on device. Uninstall some apps and try again.
ManagerDeviceLocked: Device is locked
ManagerAppAlreadyInstalled: App is already installed
ManagerAppRelyOnBTC: First install the Bitcoin app
ManagerUninstallBTCDep: Other apps depend on Bitcoin, uninstall those first

Loading…
Cancel
Save