diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index f97490fb..0914791c 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -1,17 +1,13 @@ -## What is the type of this PR? + - - -## Any background context and/or relevant tickets/issues you want to provide with? +### Type - - -## Short description on what this PR suppose to do? + - +### Context -## Any special conditions required for testing? + - +### Parts of the app affected / Test plan -## Screenshots (if appropriate) + diff --git a/README.md b/README.md index e0a9745b..03d83eef 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,14 @@ yarn flow # launch flow yarn test # launch unit tests ``` -### Programmaically reset hard the app - -Stop the app and to clean accounts, settings, etc, run +### Programmatically reset app files ```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 diff --git a/package.json b/package.json index afa5a012..c41bca4b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "release": "bash ./scripts/release.sh", "start": "bash ./scripts/start.sh", "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": { "title": true, diff --git a/scripts/reset-files.sh b/scripts/reset-files.sh new file mode 100644 index 00000000..11820bc7 --- /dev/null +++ b/scripts/reset-files.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +echo "> Getting user data folder..." + +TMP_FILE=`mktemp` +cat < $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 diff --git a/src/components/AccountPage/AccountBalanceSummaryHeader.js b/src/components/AccountPage/AccountBalanceSummaryHeader.js new file mode 100644 index 00000000..b396e801 --- /dev/null +++ b/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 { + handleChangeSelectedTime = item => { + this.props.saveSettings({ selectedTimeRange: item.key }) + } + + render() { + const { + account, + t, + counterValue, + selectedTimeRange, + isAvailable, + totalBalance, + sinceBalance, + refBalance, + } = this.props + + return ( + + + + + + + + + + + + + + + ) + } +} + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), + translate(), // FIXME t() is not even needed directly here. should be underlying component responsability to inject it +)(AccountBalanceSummaryHeader) diff --git a/src/components/AccountPage/AccountHeaderActions.js b/src/components/AccountPage/AccountHeaderActions.js new file mode 100644 index 00000000..eb60494b --- /dev/null +++ b/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 { + render() { + const { account, openModal, t } = this.props + return ( + + {account.operations.length > 0 && ( + + + + + + )} + t('app:account.settings.title')}> + openModal(MODAL_SETTINGS_ACCOUNT, { account })}> + + + + + + + ) + } +} + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), + translate(), +)(AccountHeaderActions) diff --git a/src/components/AccountPage/index.js b/src/components/AccountPage/index.js index 730ba135..397c6f4e 100644 --- a/src/components/AccountPage/index.js +++ b/src/components/AccountPage/index.js @@ -5,19 +5,8 @@ import { compose } from 'redux' import { connect } from 'react-redux' import { translate } from 'react-i18next' import { Redirect } from 'react-router' -import styled from 'styled-components' 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 { rgba } from 'styles/helpers' - -import { saveSettings } from 'actions/settings' import { accountSelector } from 'reducers/accounts' import { counterValueCurrencySelector, @@ -26,47 +15,19 @@ import { timeRangeDaysByKey, } 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 { - 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 Box from 'components/base/Box' import OperationsList from 'components/OperationsList' import StickyBackToTop from 'components/StickyBackToTop' import AccountHeader from './AccountHeader' +import AccountHeaderActions from './AccountHeaderActions' +import AccountBalanceSummaryHeader from './AccountBalanceSummaryHeader' 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) => ({ account: accountSelector(state, { accountId: props.match.params.id }), counterValue: counterValueCurrencySelector(state), @@ -74,74 +35,54 @@ const mapStateToProps = (state, props) => ({ selectedTimeRange: selectedTimeRangeSelector(state), }) -const mapDispatchToProps = { - openModal, - saveSettings, -} +const mapDispatchToProps = null type Props = { counterValue: Currency, t: T, account?: Account, - openModal: Function, - saveSettings: ({ selectedTimeRange: TimeRange }) => *, selectedTimeRange: TimeRange, } class AccountPage extends PureComponent { - handleChangeSelectedTime = item => { - this.props.saveSettings({ selectedTimeRange: item.key }) + renderBalanceSummaryHeader = ({ isAvailable, totalBalance, sinceBalance, refBalance }) => { + const { account } = this.props + if (!account) return null + return ( + + ) } - _cacheBalance = null - render() { - const { account, openModal, t, counterValue, selectedTimeRange } = this.props + const { account, t, counterValue, selectedTimeRange } = this.props const daysCount = timeRangeDaysByKey[selectedTimeRange] - // Don't even throw if we jumped in wrong account route if (!account) { return } return ( - // Force re-render account page, for avoid animation + // `key` forces re-render account page when going an another account (skip animations) + + - - {account.operations.length > 0 && ( - - - - - - )} - t('app:account.settings.title')}> - openModal(MODAL_SETTINGS_ACCOUNT, { account })}> - - - - - - + + {account.operations.length > 0 ? ( @@ -152,59 +93,12 @@ class AccountPage extends PureComponent { counterValue={counterValue} daysCount={daysCount} selectedTimeRange={selectedTimeRange} - renderHeader={({ isAvailable, totalBalance, sinceBalance, refBalance }) => ( - - - - - - - - - - - - - - - )} + renderHeader={this.renderBalanceSummaryHeader} /> + + ) : ( diff --git a/src/components/BalanceSummary/index.js b/src/components/BalanceSummary/index.js index a517c3ad..e0b9c02e 100644 --- a/src/components/BalanceSummary/index.js +++ b/src/components/BalanceSummary/index.js @@ -35,6 +35,7 @@ const BalanceSummary = ({ selectedTimeRange, }: Props) => { const account = accounts.length === 1 ? accounts[0] : undefined + // FIXME This nesting 😱 return ( diff --git a/src/components/CalculateBalance.js b/src/components/CalculateBalance.js index d94f0b6e..532372e8 100644 --- a/src/components/CalculateBalance.js +++ b/src/components/CalculateBalance.js @@ -32,6 +32,7 @@ type Props = OwnProps & { balanceStart: number, balanceEnd: number, isAvailable: boolean, + hash: string, } const mapStateToProps = (state: State, props: OwnProps) => { @@ -71,19 +72,20 @@ const mapStateToProps = (state: State, props: OwnProps) => { ({ ...item, originalValue: originalValues[i] || 0 }), ) + const balanceEnd = balanceHistory[balanceHistory.length - 1].value + return { isAvailable, balanceHistory, 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 { shouldComponentUpdate(nextProps) { - return hash(nextProps) !== hash(this.props) + return nextProps.hash !== this.props.hash } render() { const { children } = this.props diff --git a/src/components/ConfettiParty/Confetti.js b/src/components/ConfettiParty/Confetti.js index 478d26ad..0d8e8028 100644 --- a/src/components/ConfettiParty/Confetti.js +++ b/src/components/ConfettiParty/Confetti.js @@ -1,3 +1,4 @@ +// @flow import React, { PureComponent } from 'react' import Animated from 'animated/lib/targets/react-dom' @@ -13,7 +14,7 @@ class Confetti extends PureComponent< delta: [number, number], }, { - value: *, + progress: Animated.Value, }, > { state = { diff --git a/src/components/ConfettiParty/index.js b/src/components/ConfettiParty/index.js index ae6dbeb8..31b46d8b 100644 --- a/src/components/ConfettiParty/index.js +++ b/src/components/ConfettiParty/index.js @@ -1,3 +1,5 @@ +// @flow + import React, { PureComponent } from 'react' import { i } from 'helpers/staticPath' import Confetti from './Confetti' @@ -9,22 +11,68 @@ const shapes = [ i('confetti-shapes/4.svg'), ] -class ConfettiParty extends PureComponent<{}> { - state = { - confettis: Array(64) - .fill(null) - .map((_, i) => ({ - id: i, +let id = 1 + +const nextConfetti = (mode: ?string) => + mode === 'emit' + ? { + id: id++, shape: shapes[Math.floor(shapes.length * 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(), initialScale: 1, - rotations: 4 + 4 * Math.random(), + rotations: 8 * Math.random() - 4, delta: [(Math.random() - 0.5) * 600, 300 + 300 * Math.random()], duration: 6000 + 5000 * Math.random(), - })), + } + +class ConfettiParty extends PureComponent<{ emit: boolean }, { confettis: Array }> { + 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() { const { confettis } = this.state diff --git a/src/components/DeviceInteraction/DeviceInteractionStep.js b/src/components/DeviceInteraction/DeviceInteractionStep.js new file mode 100644 index 00000000..9b77fb4a --- /dev/null +++ b/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 | { promise: Promise, 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 ( + + {step.icon} + + {title && ( + + {title} + + )} + {step.desc && step.desc} + {CustomRender && ( + + )} + + +
+ + + +
+
+ ) + } +} + +export default DeviceInteractionStep diff --git a/src/components/DeviceInteraction/components.js b/src/components/DeviceInteraction/components.js new file mode 100644 index 00000000..fe678da4 --- /dev/null +++ b/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 }) => ( + + {children} + +) + +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 }) => ( + + + +) + +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 }) => ( + + + +) + +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 }) => ( + + + +) + +export const ErrorDescContainer = ({ + error, + onRetry, + ...p +}: { + error: Error, + onRetry: void => void, +}) => ( + + + + + + + {'Retry'} + + +) diff --git a/src/components/DeviceInteraction/index.js b/src/components/DeviceInteraction/index.js new file mode 100644 index 00000000..b11e038b --- /dev/null +++ b/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 { + 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 ( + + {steps.map((step, i) => { + const isError = !!error && i === stepIndex + return ( + + ) + })} + {error && + shouldRenderRetry && } + + ) + } +} + +export default DeviceInteraction diff --git a/src/components/DeviceInteraction/stories.js b/src/components/DeviceInteraction/stories.js new file mode 100644 index 00000000..e111336b --- /dev/null +++ b/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', () => ) + +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 = + +class Wrapper extends React.Component { + _ref = null + handleReset = () => this._ref && this._ref.reset() + render() { + return ( + + + (this._ref = n)} + steps={[ + { + id: 'deviceConnect', + title: 'Connect your device', + icon: , + desc: 'If you dont connect your device, we wont be able to read on it', + render: ({ onSuccess, onFail }) => ( + + + + + + + + ), + }, + { + 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')} + /> + + ) + } +} diff --git a/src/components/Workflow/EnsureDevice.js b/src/components/EnsureDevice.js similarity index 100% rename from src/components/Workflow/EnsureDevice.js rename to src/components/EnsureDevice.js diff --git a/src/components/EnsureDeviceApp.js b/src/components/EnsureDeviceApp.js index 5759e6c8..7685c77c 100644 --- a/src/components/EnsureDeviceApp.js +++ b/src/components/EnsureDeviceApp.js @@ -1,153 +1,57 @@ // @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 { Device } from 'types/common' +import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react' -import { getDevices } from 'reducers/devices' -import type { State as StoreState } from 'reducers/index' +import logger from 'logger' import getAddress from 'commands/getAddress' +import { createCancelablePolling } from 'helpers/promise' import { standardDerivation } from 'helpers/derivations' -import isDashboardOpen from 'commands/isDashboardOpen' -import { createCustomErrorClass } from 'helpers/errors' - -import { CHECK_APP_INTERVAL_WHEN_VALID, CHECK_APP_INTERVAL_WHEN_INVALID } from 'config/constants' - -export const WrongAppOpened = createCustomErrorClass('WrongAppOpened') -export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount') +import { isSegwitAccount } from 'helpers/bip32' +import { BtcUnmatchedApp } from 'helpers/getAddressForCurrency/btc' -type OwnProps = { - currency?: ?CryptoCurrency, - 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, -} +import DeviceInteraction from 'components/DeviceInteraction' +import Text from 'components/base/Text' -type Props = OwnProps & { - devices: Device[], -} +import IconUsb from 'icons/Usb' -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 = { - deviceStatus: DeviceStatus, - appStatus: AppStatus, - error: ?Error, - genuineCheckStatus: GenuineCheckStatus, -} +const usbIcon = +const Bold = props => -const mapStateToProps = (state: StoreState) => ({ - devices: getDevices(state), +const mapStateToProps = state => ({ + device: getCurrentDevice(state), }) -// TODO we want to split into and -// and minimize the current codebase AF -class EnsureDeviceApp extends PureComponent { - state = { - appStatus: 'progress', - deviceStatus: this.props.deviceSelected ? 'connected' : 'unconnected', - error: null, - genuineCheckStatus: 'progress', - } - - componentDidMount() { - if (this.props.deviceSelected !== null) { - this.checkAppOpened() - } - } - - componentWillReceiveProps(nextProps) { - const { deviceStatus } = this.state - 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 - }) - +class EnsureDeviceApp extends Component<{ + device: ?Device, + account?: ?Account, + currency?: ?CryptoCurrency, +}> { + connectInteractionHandler = () => + createCancelablePolling(() => { + if (!this.props.device) return Promise.reject() + return Promise.resolve(this.props.device) + }) + + openAppInteractionHandler = ({ device }) => + createCancelablePolling( + async () => { + const { account, currency: _currency } = this.props + const currency = account ? account.currency : _currency + invariant(currency, 'No currency given') + const address = await getAddressFromAccountOrCurrency(device, account, currency) if (account) { const { freshAddress } = account if (account && freshAddress !== address) { @@ -157,82 +61,74 @@ class EnsureDeviceApp extends PureComponent { }) } } - } else { - 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() - - if (!isDashboard) { - throw new Error(`dashboard is not opened`) - } - } - - this.handleStatusChange(this.state.deviceStatus, 'success') - - if (withGenuineCheck && appStatus !== 'success') { - this.handleGenuineCheck() - } - } catch (e) { - this.handleStatusChange(this.state.deviceStatus, 'fail', e) - isSuccess = false - } - - // TODO: refacto to more generic/global way - 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) - } + return address + }, + { + shouldThrow: (err: Error) => { + const isWrongApp = err instanceof BtcUnmatchedApp + const isWrongDevice = err instanceof WrongDeviceForAccount + return isWrongApp || isWrongDevice + }, + }, + ) + + renderOpenAppTitle = () => { + const { account, currency } = this.props + const cur = account ? account.currency : currency + invariant(cur, 'No currency given') + return ( + + {'Open the '} + {cur.name} + {' app on your device'} + + ) } render() { - const { currency, account, devices, deviceSelected, render } = this.props - const { appStatus, deviceStatus, genuineCheckStatus, error } = this.state - - if (render) { - // if cur is not provided, we assume we want to check if user is on - // the dashboard - const cur = account ? account.currency : currency - - return render({ - appStatus, - currency: cur, - devices, - deviceSelected: deviceStatus === 'connected' ? deviceSelected : null, - deviceStatus, - genuineCheckStatus, - error, - }) - } - - return null + const { account, currency, ...props } = this.props + const cur = account ? account.currency : currency + const Icon = cur ? getCryptoCurrencyIcon(cur) : null + return ( + + {'Connect and unlock your '} + {'Ledger device'} + + ), + icon: usbIcon, + run: this.connectInteractionHandler, + }, + { + id: 'address', + title: this.renderOpenAppTitle, + icon: Icon ? : null, + run: this.openAppInteractionHandler, + }, + ]} + {...props} + /> + ) } } -export default connect(mapStateToProps)(EnsureDeviceApp) - -async function sleep(s) { - return new Promise(resolve => setTimeout(resolve, s * 1e3)) +async function getAddressFromAccountOrCurrency(device, account, currency) { + const { address } = await getAddress + .send({ + 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) diff --git a/src/components/GenuineCheck.js b/src/components/GenuineCheck.js new file mode 100644 index 00000000..b96fee29 --- /dev/null +++ b/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 = +const homeIcon = +const eyeIcon = + +const mapStateToProps = state => ({ + device: getCurrentDevice(state), +}) + +const 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 { + 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: ( + + {'Connect and unlock your '} + {'Ledger device'} + + ), + icon: usbIcon, + run: this.connectInteractionHandler, + }, + { + id: 'deviceInfo', + title: ( + + {'Navigate to the '} + {'dashboard'} + {' on your device'} + + ), + icon: homeIcon, + run: this.checkDashboardInteractionHandler, + }, + { + id: 'isGenuine', + title: ( + + {'Allow '} + {'Ledger Manager'} + {' on your device'} + + ), + icon: eyeIcon, + run: this.checkGenuineInteractionHandler, + }, + ] + + return ( + + ) + } +} + +export default compose( + translate(), + connect(mapStateToProps), +)(GenuineCheck) diff --git a/src/components/GenuineCheckModal.js b/src/components/GenuineCheckModal.js new file mode 100644 index 00000000..d36c46d7 --- /dev/null +++ b/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 { + renderBody = ({ onClose }) => { + const { t, onSuccess, onFail, onUnavailable } = this.props + return ( + + {t('app:genuinecheck.modal.title')} + + + + + ) + } + + render() { + const { t, ...props } = this.props + return + } +} + +export default translate()(GenuineCheckModal) diff --git a/src/components/GenuineCheckModal/index.js b/src/components/GenuineCheckModal/index.js deleted file mode 100644 index 8afe0b3b..00000000 --- a/src/components/GenuineCheckModal/index.js +++ /dev/null @@ -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 { - 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 ( - - {t('app:genuinecheck.modal.title')} - - ( - - - - - )} - /> - - - ) - } - - render() { - const { ...props } = this.props - return this.renderBody({ onClose })} /> - } -} - -export default translate()(GenuineCheck) diff --git a/src/components/ManagerPage/Dashboard.js b/src/components/ManagerPage/Dashboard.js index 51410c31..78f8e383 100644 --- a/src/components/ManagerPage/Dashboard.js +++ b/src/components/ManagerPage/Dashboard.js @@ -31,12 +31,7 @@ const Dashboard = ({ device, deviceInfo, t }: Props) => ( - + ) diff --git a/src/components/ManagerPage/FirmwareUpdate.js b/src/components/ManagerPage/FirmwareUpdate.js index e6b80de1..4cb3dc9c 100644 --- a/src/components/ManagerPage/FirmwareUpdate.js +++ b/src/components/ManagerPage/FirmwareUpdate.js @@ -25,7 +25,7 @@ import Button from 'components/base/Button' import NanoS from 'icons/device/NanoS' import CheckFull from 'icons/CheckFull' -import { PreventDeviceChangeRecheck } from '../Workflow/EnsureDevice' +import { PreventDeviceChangeRecheck } from 'components/EnsureDevice' import UpdateFirmwareButton from './UpdateFirmwareButton' let CACHED_LATEST_FIRMWARE = null diff --git a/src/components/ManagerPage/ManagerGenuineCheck.js b/src/components/ManagerPage/ManagerGenuineCheck.js new file mode 100644 index 00000000..27d34841 --- /dev/null +++ b/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 { + render() { + const { t, onSuccess } = this.props + return ( + + + + connect your device + + {t('app:manager.device.title')} + + + {t('app:manager.device.desc')} + + + + + + ) + } +} + +export default translate()(ManagerGenuineCheck) diff --git a/src/components/ManagerPage/index.js b/src/components/ManagerPage/index.js index 49a7312b..56035d87 100644 --- a/src/components/ManagerPage/index.js +++ b/src/components/ManagerPage/index.js @@ -1,52 +1,51 @@ // @flow -/* eslint-disable react/jsx-no-literals */ // FIXME: remove import React, { PureComponent } from 'react' +import invariant from 'invariant' import type { Device } from 'types/common' import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' -import Workflow from 'components/Workflow' -import WorkflowWithIcon from 'components/Workflow/WorkflowWithIcon' import Dashboard from './Dashboard' -import FlashMcu from './FlashMcu' +// import FlashMcu from './FlashMcu' -type Error = { - message: string, - stack: string, +import ManagerGenuineCheck from './ManagerGenuineCheck' + +type Props = {} + +type State = { + isGenuine: ?boolean, + device: ?Device, + deviceInfo: ?DeviceInfo, } -class ManagerPage extends PureComponent<*, *> { +class ManagerPage extends PureComponent { + 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() { - return ( - ( -

UPDATE FINAL FIRMARE (TEMPLATE + ACTION WIP) {deviceInfo.isOSU}

- )} - renderMcuUpdate={(device: Device, deviceInfo: DeviceInfo) => ( - - )} - renderDashboard={(device: Device, deviceInfo: DeviceInfo) => ( - - )} - renderDefault={( - device: ?Device, - deviceInfo: ?DeviceInfo, - isGenuine: ?boolean, - errors: { - dashboardError: ?Error, - genuineError: ?Error, - }, - ) => ( - - )} - /> - ) + const { isGenuine, device, deviceInfo } = this.state + + if (!isGenuine) { + return + } + + invariant(device, 'Inexistant device considered genuine') + invariant(deviceInfo, 'Inexistant device infos for genuine device') + + // TODO + // renderFinalUpdate + // renderMcuUpdate + + return } } diff --git a/src/components/Onboarding/helperComponents.js b/src/components/Onboarding/helperComponents.js index a45ca150..d0e02d79 100644 --- a/src/components/Onboarding/helperComponents.js +++ b/src/components/Onboarding/helperComponents.js @@ -61,10 +61,10 @@ export const LiveLogoContainer = styled(Box).attrs({ alignItems: 'center', justifyContent: 'center', })` + background-color: white; box-shadow: 0 2px 24px 0 #00000014; width: ${p => (p.width ? p.width : 80)} height: ${p => (p.height ? p.height : 80)} - ` // INSTRUCTION LIST diff --git a/src/components/Onboarding/steps/Finish.js b/src/components/Onboarding/steps/Finish.js index 2fb41697..05ba2f96 100644 --- a/src/components/Onboarding/steps/Finish.js +++ b/src/components/Onboarding/steps/Finish.js @@ -1,6 +1,6 @@ // @flow -import React from 'react' +import React, { Component } from 'react' import { shell } from 'electron' import styled from 'styled-components' import { i } from 'helpers/staticPath' @@ -48,39 +48,63 @@ const socialMedia = [ }, ] -export default (props: StepProps) => { - const { finish, t } = props - return ( - - - - - - - } - /> - - +export default class Finish extends Component { + state = { emit: false } + onMouseUp = () => this.setState({ emit: false }) + onMouseDown = () => { + this.setState({ emit: true }) + } + onMouseLeave = () => { + this.setState({ emit: false }) + } + render() { + const { finish, t } = this.props + const { emit } = this.state + return ( + + + + + + + + } + /> + + + - - - {t('onboarding:finish.title')} - {t('onboarding:finish.desc')} - - - - - - {socialMedia.map(socMed => )} + + {t('onboarding:finish.title')} + {t('onboarding:finish.desc')} + + + + + + {socialMedia.map(socMed => )} + - - ) + ) + } } type SocMed = { diff --git a/src/components/Onboarding/steps/GenuineCheck/index.js b/src/components/Onboarding/steps/GenuineCheck/index.js index d00ce863..2516d65c 100644 --- a/src/components/Onboarding/steps/GenuineCheck/index.js +++ b/src/components/Onboarding/steps/GenuineCheck/index.js @@ -263,9 +263,9 @@ class GenuineCheck extends PureComponent { ) diff --git a/src/components/Workflow/EnsureDashboard.js b/src/components/Workflow/EnsureDashboard.js deleted file mode 100644 index bc762558..00000000 --- a/src/components/Workflow/EnsureDashboard.js +++ /dev/null @@ -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 { - 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 diff --git a/src/components/Workflow/EnsureGenuine.js b/src/components/Workflow/EnsureGenuine.js deleted file mode 100644 index 3be90a83..00000000 --- a/src/components/Workflow/EnsureGenuine.js +++ /dev/null @@ -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 { - 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 diff --git a/src/components/Workflow/WorkflowDefault.js b/src/components/Workflow/WorkflowDefault.js deleted file mode 100644 index 664fedd8..00000000 --- a/src/components/Workflow/WorkflowDefault.js +++ /dev/null @@ -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 }) => ( - - {checked ? ( - - - - ) : hasErrors ? ( - - - - ) : ( - - )} - -) - -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) => ( - - - - - - - - - Connect and unlock your Ledger device - - - - - - - - - - - - - - - - {'Navigate to the '} - {'dashboard'} - {' on your device'} - - - - - - - {/* GENUINE CHECK */} - {/* ------------- */} - - - - - - - - - - - {'Allow the '} - {'Ledger Manager'} - {' on your device'} - - - - - - -) - -export default translate()(WorkflowDefault) diff --git a/src/components/Workflow/WorkflowWithIcon.js b/src/components/Workflow/WorkflowWithIcon.js deleted file mode 100644 index a7c7ac79..00000000 --- a/src/components/Workflow/WorkflowWithIcon.js +++ /dev/null @@ -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 }) => ( - - {checked ? ( - - - - ) : hasErrors ? ( - - - - ) : ( - - )} - -) - -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) => ( - - - connect your device - - {t('app:manager.device.title')} - - - {t('app:manager.device.desc')} - - - - {/* DEVICE CHECK */} - - - - - - - - {'Connect and unlock your '} - Ledger device - - - - - - - {/* DASHBOARD CHECK */} - - - - - - - - - - {'Navigate to the '} - {'dashboard'} - {' on your device'} - - - - - - - {/* GENUINE CHECK */} - - - - - - - - - - {'Allow '} - {'Ledger Manager'} - {' on your device'} - - - - - - - -) - -export default translate()(WorkflowWithIcon) diff --git a/src/components/Workflow/index.js b/src/components/Workflow/index.js deleted file mode 100644 index 728ff9ec..00000000 --- a/src/components/Workflow/index.js +++ /dev/null @@ -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 { - render() { - const { - renderDashboard, - renderFinalUpdate, - renderMcuUpdate, - renderError, - renderDefault, - onGenuineCheck, - } = this.props - return ( - - {(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 ( - - {(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, - }) - }} - - ) - }} - - )} - - ) - } -} - -export default Workflow diff --git a/src/components/base/SideBar/SideBarListItem.js b/src/components/base/SideBar/SideBarListItem.js index 671dee6f..d241fa3c 100644 --- a/src/components/base/SideBar/SideBarListItem.js +++ b/src/components/base/SideBar/SideBarListItem.js @@ -33,7 +33,7 @@ class SideBarListItem extends PureComponent { {!!Icon && } @@ -62,8 +62,7 @@ const Container = styled(Tabbable).attrs({ px: 3, py: 2, })` - cursor: ${p => (p.disabled || p.isActive ? 'default' : 'pointer')}; - pointer-events: ${p => (p.isDisabled ? 'none' : 'auto')}; + cursor: ${p => (p.disabled ? 'not-allowed' : p.isActive ? 'default' : 'pointer')}; color: ${p => (p.isActive ? p.theme.colors.dark : p.theme.colors.smoke)}; background: ${p => (p.isActive ? p.theme.colors.lightGrey : '')}; opacity: ${p => (p.disabled ? 0.5 : 1)}; diff --git a/src/components/base/Stepper/index.js b/src/components/base/Stepper/index.js index 038aef23..8daf4acb 100644 --- a/src/components/base/Stepper/index.js +++ b/src/components/base/Stepper/index.js @@ -12,9 +12,12 @@ import Breadcrumb from 'components/Breadcrumb' type Props = { t: T, title: string, + steps: Step[], initialStepId: string, onClose: void => void, - steps: Step[], + onStepChange?: Step => void, + disabledSteps?: number[], + errorSteps?: number[], children: any, } @@ -23,7 +26,8 @@ export type Step = { label: string, component: StepProps => React$Node, footer: StepProps => React$Node, - preventClose?: boolean, + shouldRenderFooter?: StepProps => boolean, + shouldPreventClose?: boolean | (StepProps => boolean), onBack?: StepProps => void, } @@ -41,10 +45,20 @@ class Stepper extends PureComponent { 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() { - 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 stepIndex = steps.findIndex(s => s.id === stepId) @@ -52,7 +66,13 @@ class Stepper extends PureComponent { 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 = { t, @@ -60,15 +80,29 @@ class Stepper extends PureComponent { ...props, } + const renderFooter = + !!StepFooter && (shouldRenderFooter === undefined || shouldRenderFooter(stepProps)) + + const preventClose = + typeof shouldPreventClose === 'function' + ? shouldPreventClose(stepProps) + : !!shouldPreventClose + return ( onBack(stepProps) : undefined}>{title} - + {children} - {StepFooter && ( + {renderFooter && ( diff --git a/src/components/base/Stepper/stories.js b/src/components/base/Stepper/stories.js index 4cb41487..b03a0be9 100644 --- a/src/components/base/Stepper/stories.js +++ b/src/components/base/Stepper/stories.js @@ -27,7 +27,7 @@ const steps: Step[] = [ { id: 'second', label: 'second step', - preventClose: true, + shouldPreventClose: true, onBack: ({ transitionTo }: StepProps) => transitionTo('first'), component: () =>
second step (you cant close on this one)
, footer: ({ transitionTo }: StepProps) => ( diff --git a/src/components/modals/Debug.js b/src/components/modals/Debug.js index a18abcb0..22968053 100644 --- a/src/components/modals/Debug.js +++ b/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 Box from 'components/base/Box' import Input from 'components/base/Input' -import EnsureDevice from 'components/Workflow/EnsureDevice' +import EnsureDevice from 'components/EnsureDevice' import { getDerivations } from 'helpers/derivations' import getAddress from 'commands/getAddress' import testInterval from 'commands/testInterval' diff --git a/src/components/modals/Receive/01-step-account.js b/src/components/modals/Receive/01-step-account.js deleted file mode 100644 index 8e8a6de9..00000000 --- a/src/components/modals/Receive/01-step-account.js +++ /dev/null @@ -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) => ( - - - - - -) diff --git a/src/components/modals/Receive/02-step-connect-device.js b/src/components/modals/Receive/02-step-connect-device.js deleted file mode 100644 index 82e61aeb..00000000 --- a/src/components/modals/Receive/02-step-connect-device.js +++ /dev/null @@ -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 ( - - - - - ) - } -} - -export default ReceiveStepConnectDevice diff --git a/src/components/modals/Receive/03-step-confirm-address.js b/src/components/modals/Receive/03-step-confirm-address.js deleted file mode 100644 index 27e859ab..00000000 --- a/src/components/modals/Receive/03-step-confirm-address.js +++ /dev/null @@ -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) => ( - - - {props.addressVerified === false ? ( - - {props.t('app:receive.steps.confirmAddress.error.title')} - {props.t('app:receive.steps.confirmAddress.error.text')} - - - ) : ( - - {props.t('app:receive.steps.confirmAddress.action')} - {props.t('app:receive.steps.confirmAddress.text')} - {props.account && } - {props.device && - props.account && } - - )} - -) diff --git a/src/components/modals/Receive/04-step-receive-funds.js b/src/components/modals/Receive/04-step-receive-funds.js deleted file mode 100644 index 8dcceb2d..00000000 --- a/src/components/modals/Receive/04-step-receive-funds.js +++ /dev/null @@ -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) => ( - - - - - - - {props.account && ( - - )} - -) diff --git a/src/components/modals/Receive/index.js b/src/components/modals/Receive/index.js index 866fa66f..d5cb47a8 100644 --- a/src/components/modals/Receive/index.js +++ b/src/components/modals/Receive/index.js @@ -1,193 +1,113 @@ // @flow -import React, { Fragment, PureComponent } from 'react' +import React, { PureComponent, Fragment } from 'react' import { compose } from 'redux' import { connect } from 'react-redux' import { translate } from 'react-i18next' import { createStructuredSelector } from 'reselect' -import { accountsSelector } from 'reducers/accounts' +import SyncSkipUnderPriority from 'components/SyncSkipUnderPriority' import Track from 'analytics/Track' import type { Account } from '@ledgerhq/live-common/lib/types' -import type { T, Device } from 'types/common' 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 SyncSkipUnderPriority from 'components/SyncSkipUnderPriority' -import SyncOneAccountOnMount from 'components/SyncOneAccountOnMount' +import { getCurrentDevice } from 'reducers/devices' +import { accountsSelector } from 'reducers/accounts' +import { closeModal } from 'reducers/modals' -import Box from 'components/base/Box' -import Breadcrumb from 'components/Breadcrumb' -import Button from 'components/base/Button' -import Modal, { ModalBody, ModalTitle, ModalContent, ModalFooter } from 'components/base/Modal' -import { WrongDeviceForAccount } from 'components/EnsureDeviceApp' +import Modal from 'components/base/Modal' +import Stepper from 'components/base/Stepper' -import StepAccount from './01-step-account' -import StepConnectDevice from './02-step-connect-device' -import StepConfirmAddress from './03-step-confirm-address' -import StepReceiveFunds from './04-step-receive-funds' +import StepAccount, { StepAccountFooter } from './steps/01-step-account' +import StepConnectDevice, { StepConnectDeviceFooter } from './steps/02-step-connect-device' +import StepConfirmAddress, { StepConfirmAddressFooter } from './steps/03-step-confirm-address' +import StepReceiveFunds, { StepReceiveFundsFooter } from './steps/04-step-receive-funds' type Props = { t: T, + device: ?Device, accounts: Account[], + closeModal: string => void, } type State = { - account: Account | null, - addressVerified: null | boolean, - amount: string | number, - appStatus: null | string, - deviceSelected: Device | null, - stepIndex: number, - stepsDisabled: Array, - stepsErrors: Array, + stepId: string, + account: ?Account, + isAppOpened: boolean, + isAddressVerified: ?boolean, + disabledSteps: number[], + errorSteps: number[], } -const GET_STEPS = t => [ - { label: t('app:receive.steps.chooseAccount.title'), Comp: StepAccount }, - { label: t('app:receive.steps.connectDevice.title'), Comp: StepConnectDevice }, - { label: t('app:receive.steps.confirmAddress.title'), Comp: StepConfirmAddress }, - { label: t('app:receive.steps.receiveFunds.title'), Comp: StepReceiveFunds }, -] - -const INITIAL_STATE = { - account: null, - addressVerified: null, - amount: '', - appStatus: null, - 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) +export type StepProps = DefaultStepProps & { + device: ?Device, + account: ?Account, + closeModal: void => void, + isAppOpened: boolean, + isAddressVerified: ?boolean, + onRetry: void => void, + onSkipConfirm: void => void, + onResetSkip: void => void, + onChangeAccount: (?Account) => void, + onChangeAppOpened: boolean => void, + onChangeAddressVerified: boolean => void, } +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({ + device: getCurrentDevice, accounts: accountsSelector, }) -class ReceiveModal extends PureComponent { - state = INITIAL_STATE - - _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: [], - }) +const mapDispatchToProps = { + closeModal, +} - // TODO: do that better - this.verifyAddress() - } +const INITIAL_STATE = { + stepId: 'account', + account: null, + isAppOpened: false, + isAddressVerified: null, + disabledSteps: [], + errorSteps: [], +} - handleChangeAmount = amount => this.setState({ amount }) +class ReceiveModal extends PureComponent { + state = INITIAL_STATE + STEPS = createSteps({ t: this.props.t }) handleBeforeOpenModal = ({ data }) => { const { account } = this.state @@ -195,175 +115,92 @@ class ReceiveModal extends PureComponent { if (!account) { if (data && data.account) { - this.setState({ - account: data.account, - stepIndex: 1, - }) + this.setState({ account: data.account }) } else { - this.setState({ - account: accounts[0], - }) + this.setState({ account: accounts[0] }) } } } - handleSkipStep = () => - this.setState({ - addressVerified: false, - stepsErrors: [], - stepsDisabled: [1, 2], - stepIndex: this._steps.length - 1, // last step - }) - - verifyAddress = async () => { - const { account, deviceSelected: device } = this.state - try { - if (account && device) { - const { address } = await getAddress - .send({ - currencyId: account.currency.id, - 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) + handleRetry = () => this.setState({ isAddressVerified: null, isAppOpened: false, errorSteps: [] }) + handleReset = () => this.setState({ ...INITIAL_STATE }) + handleCloseModal = () => this.props.closeModal(MODAL_RECEIVE) + handleStepChange = step => this.setState({ stepId: step.id }) + handleChangeAccount = (account: ?Account) => this.setState({ account }) + handleChangeAppOpened = (isAppOpened: boolean) => this.setState({ isAppOpened }) + handleChangeAddressVerified = (isAddressVerified: boolean) => { + if (isAddressVerified) { + this.setState({ isAddressVerified }) + } else { + const confirmStepIndex = this.STEPS.findIndex(step => step.id === 'confirm') + if (confirmStepIndex > -1) { + this.setState({ + isAddressVerified, + errorSteps: [confirmStepIndex], + }) } - } catch (err) { - this.handleCheckAddress(false) } } - renderStep = () => { - const { account, amount, addressVerified, deviceSelected, stepIndex } = this.state - const { t } = this.props - const step = this._steps[stepIndex] - if (!step) { - return null - } - 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, - }), + handleResetSkip = () => this.setState({ disabledSteps: [] }) + handleSkipConfirm = () => { + const connectStepIndex = this.STEPS.findIndex(step => step.id === 'device') + const confirmStepIndex = this.STEPS.findIndex(step => step.id === 'confirm') + if (confirmStepIndex > -1 && connectStepIndex > -1) { + this.setState({ disabledSteps: [connectStepIndex, confirmStepIndex] }) } - - return - } - - 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 ( - - {stepIndex === 1 && ( - - )} - {stepIndex === 2 && - addressVerified === false && ( - - )} - + ) +} diff --git a/src/components/modals/Receive/steps/02-step-connect-device.js b/src/components/modals/Receive/steps/02-step-connect-device.js new file mode 100644 index 00000000..539d6b99 --- /dev/null +++ b/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 ( + onChangeAppOpened(true)} + /> + ) +} + +export function StepConnectDeviceFooter({ + t, + transitionTo, + isAppOpened, + onSkipConfirm, +}: StepProps) { + return ( + + + + + ) +} diff --git a/src/components/modals/Receive/steps/03-step-confirm-address.js b/src/components/modals/Receive/steps/03-step-confirm-address.js new file mode 100644 index 00000000..debdc74a --- /dev/null +++ b/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 { + 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 ( + + + {isAddressVerified === false ? ( + + {t('app:receive.steps.confirmAddress.error.title')} + {t('app:receive.steps.confirmAddress.error.text')} + + + ) : ( + + {t('app:receive.steps.confirmAddress.action')} + {t('app:receive.steps.confirmAddress.text')} + + + + )} + + ) + } +} + +export function StepConfirmAddressFooter({ t, transitionTo, onRetry }: StepProps) { + // This will be displayed only if user rejected address + return ( + + + + + ) +} + +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; +` diff --git a/src/components/modals/Receive/steps/04-step-receive-funds.js b/src/components/modals/Receive/steps/04-step-receive-funds.js new file mode 100644 index 00000000..7909625d --- /dev/null +++ b/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 { + 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 ( + + + + + + + + + ) + } +} + +export function StepReceiveFundsFooter({ t, closeModal }: StepProps) { + return ( + + ) +} diff --git a/src/components/modals/StepConnectDevice.js b/src/components/modals/StepConnectDevice.js index 6a422da5..5a037481 100644 --- a/src/components/modals/StepConnectDevice.js +++ b/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 { Device } from 'types/common' -import DeviceConnect from 'components/DeviceConnect' import EnsureDeviceApp from 'components/EnsureDeviceApp' type Props = { account?: ?Account, currency?: ?CryptoCurrency, - deviceSelected?: ?Device, onChangeDevice?: Device => void, - onStatusChange: string => void, + onStatusChange: (string, string) => void, } // FIXME why is that in modal !? -const StepConnectDevice = ({ - account, - currency, - deviceSelected, - onChangeDevice, - onStatusChange, -}: Props) => ( - ( - - )} - /> -) +const StepConnectDevice = ({ account, currency, onChangeDevice, onStatusChange }: Props) => + account || currency ? ( + { + // TODO: remove those non-nense callbacks + if (onChangeDevice) { + onChangeDevice(device) + } + onStatusChange('success', 'success') + }} + /> + ) : null export default StepConnectDevice diff --git a/src/config/constants.js b/src/config/constants.js index d4d76055..efa893bd 100644 --- a/src/config/constants.js +++ b/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_BOOT_DELAY = 2 * 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 SYNC_TIMEOUT = intFromEnv('SYNC_TIMEOUT', 30 * 1000) export const OUTDATED_CONSIDERED_DELAY = intFromEnv('OUTDATED_CONSIDERED_DELAY', 5 * 60 * 1000) diff --git a/src/helpers/deviceAccess.js b/src/helpers/deviceAccess.js index c380fe0c..f6fbc478 100644 --- a/src/helpers/deviceAccess.js +++ b/src/helpers/deviceAccess.js @@ -4,6 +4,7 @@ import type Transport from '@ledgerhq/hw-transport' import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' import { DEBUG_DEVICE } from 'config/constants' import { retry } from './promise' +import { createCustomErrorClass } from './errors' // 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() @@ -13,6 +14,16 @@ type WithDevice = (devicePath: string) => (job: (Transport<*>) => Promise) const semaphorePerDevice = {} +const DisconnectedDevice = createCustomErrorClass('DisconnectedDevice') + +const remapError = (p: Promise): Promise => + p.catch(e => { + if (e && e.message && e.message.indexOf('HID') >= 0) { + throw new DisconnectedDevice(e.message) + } + throw e + }) + export const withDevice: WithDevice = devicePath => { const sem = semaphorePerDevice[devicePath] || (semaphorePerDevice[devicePath] = createSemaphore(1)) @@ -23,7 +34,7 @@ export const withDevice: WithDevice = devicePath => { if (DEBUG_DEVICE) t.setDebugMode(true) try { - const res = await job(t) + const res = await remapError(job(t)) // $FlowFixMe return res } finally { diff --git a/src/helpers/getAddressForCurrency/btc.js b/src/helpers/getAddressForCurrency/btc.js index b2dbdd26..16fe52ac 100644 --- a/src/helpers/getAddressForCurrency/btc.js +++ b/src/helpers/getAddressForCurrency/btc.js @@ -6,7 +6,7 @@ import type Transport from '@ledgerhq/hw-transport' import getBitcoinLikeInfo from '../devices/getBitcoinLikeInfo' import { createCustomErrorClass } from '../errors' -const BtcUnmatchedApp = createCustomErrorClass('BtcUnmatchedApp') +export const BtcUnmatchedApp = createCustomErrorClass('BtcUnmatchedApp') export default async ( transport: Transport<*>, diff --git a/src/helpers/promise.js b/src/helpers/promise.js index 09c2828b..2918c5f7 100644 --- a/src/helpers/promise.js +++ b/src/helpers/promise.js @@ -31,3 +31,36 @@ export function retry(f: () => Promise, options?: $Shape) export function idleCallback() { return new Promise(resolve => window.requestIdleCallback(resolve)) } + +type CancellablePollingOpts = { + pollingInterval?: number, + shouldThrow?: Error => boolean, +} + +export function createCancelablePolling( + job: any => Promise, + { 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 } +} diff --git a/static/i18n/en/errors.yml b/static/i18n/en/errors.yml index 582e751c..01564ee9 100644 --- a/static/i18n/en/errors.yml +++ b/static/i18n/en/errors.yml @@ -1,30 +1,32 @@ -generic: Oops, an unknown error occurred. Please try again or contact Ledger Support. -RangeError: '{{message}}' +BtcUnmatchedApp: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.' +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}}' -LedgerAPIErrorWithMessage: '{{message}}' -TransportStatusError: '{{message}}' -TimeoutError: 'The request timed out.' FeeEstimationFailed: 'Fee estimation error. Try again or set a custom fee (status: {{status}})' -NotEnoughBalance: 'Insufficient funds to proceed.' -BtcUnmatchedApp: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.' -WrongAppOpened: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.' -WrongDeviceForAccount: 'Use the device associated with the account ‘{{accountName}}’.' -LedgerAPINotAvailable: 'Ledger API not available for {{currencyName}}.' +generic: Oops, an unknown error occurred. Please try again or contact Ledger Support. +HardResetFail: Reset failed. Please try again. +LatestMCUInstalledError: MCU on device already up to date. 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.' NoAddressesFound: 'No accounts were found.' +NotEnoughBalance: 'Insufficient funds to proceed.' +RangeError: '{{message}}' +TimeoutError: 'The request timed out.' +TransportStatusError: '{{message}}' UserRefusedOnDevice: Transaction refused on device. WebsocketConnectionError: Oops, device connection failed. Please try again. [web-err] WebsocketConnectionFailed: Oops, device connection failed. Please try again. [web-fail] -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. -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 +WrongAppOpened: 'Open the ‘{{currencyName}}’ app on your Ledger device to proceed.' +WrongDeviceForAccount: 'Use the device associated with the account ‘{{accountName}}’.'