@ -0,0 +1,13 @@ |
|||
<!-- Description of what the PR does go here... screenshot might be good if appropriate --> |
|||
|
|||
### Type |
|||
|
|||
<!-- e.g. Bug Fix, Feature, Code Quality Improvement, UI Polish... --> |
|||
|
|||
### Context |
|||
|
|||
<!-- e.g. GitHub issue #45 / contextual discussion --> |
|||
|
|||
### Parts of the app affected / Test plan |
|||
|
|||
<!-- Which part of the app is affected? What to do to test it, any special thing to do? --> |
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 138 KiB |
After Width: | Height: | Size: 152 KiB |
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 216 KiB |
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 151 KiB |
After Width: | Height: | Size: 157 KiB |
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 151 KiB |
@ -1,3 +1,3 @@ |
|||
#/bin/bash |
|||
#!/bin/bash |
|||
|
|||
yarn compile && DEBUG=electron-builder electron-builder --dir -c.compression=store -c.mac.identity=null |
|||
|
@ -1,3 +1,3 @@ |
|||
#/bin/bash |
|||
#!/bin/bash |
|||
|
|||
yarn compile && DEBUG=electron-builder electron-builder |
|||
|
@ -0,0 +1,10 @@ |
|||
#!/bin/bash |
|||
|
|||
if [ -z $ANALYTICS_KEY ]; then |
|||
echo 'ANALYTICS_KEY must be set' |
|||
exit 1 |
|||
fi |
|||
|
|||
cd `dirname $0`/.. |
|||
|
|||
wget https://cdn.segment.com/analytics.js/v1/$ANALYTICS_KEY/analytics.min.js -O static/analytics.min.js |
@ -0,0 +1,38 @@ |
|||
#!/bin/bash |
|||
|
|||
set -e |
|||
|
|||
echo "> Getting user data folder..." |
|||
|
|||
TMP_FILE=`mktemp` |
|||
cat <<EOF > $TMP_FILE |
|||
const { app } = require('electron') |
|||
console.log(app.getPath('userData')) |
|||
EOF |
|||
USER_DATA_FOLDER=`timeout 0.5 electron $TMP_FILE || echo` # echo used to ensure status 0 |
|||
|
|||
if [ "$USER_DATA_FOLDER" == "" ]; then |
|||
echo "You probably are on a slow computer. Be patient..." |
|||
USER_DATA_FOLDER=`timeout 3 electron $TMP_FILE || echo` # echo used to ensure status 0 |
|||
fi |
|||
|
|||
if [ "$USER_DATA_FOLDER" == "" ]; then |
|||
echo "Apparently, very very slow computer..." |
|||
USER_DATA_FOLDER=`timeout 6 electron $TMP_FILE || echo` # echo used to ensure status 0 |
|||
fi |
|||
|
|||
rm $TMP_FILE |
|||
|
|||
if [ "$USER_DATA_FOLDER" == "" ]; then |
|||
echo "Could not find the data folder. Bye" |
|||
exit 0 |
|||
fi |
|||
|
|||
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 |
@ -0,0 +1,32 @@ |
|||
import logger from 'logger' |
|||
import { PureComponent } from 'react' |
|||
import { track } from './segment' |
|||
|
|||
class Track extends PureComponent<{ |
|||
onMount?: boolean, |
|||
onUnmount?: boolean, |
|||
onUpdate?: boolean, |
|||
event: string, |
|||
}> { |
|||
componentDidMount() { |
|||
if (typeof this.props.event !== 'string') { |
|||
logger.warn('analytics Track: invalid event=', this.props.event) |
|||
} |
|||
if (this.props.onMount) this.track() |
|||
} |
|||
componentDidUpdate() { |
|||
if (this.props.onUpdate) this.track() |
|||
} |
|||
componentWillUnmount() { |
|||
if (this.props.onUnmount) this.track() |
|||
} |
|||
track = () => { |
|||
const { event, onMount, onUnmount, onUpdate, ...properties } = this.props |
|||
track(event, properties) |
|||
} |
|||
render() { |
|||
return null |
|||
} |
|||
} |
|||
|
|||
export default Track |
@ -0,0 +1,14 @@ |
|||
import { PureComponent } from 'react' |
|||
import { page } from './segment' |
|||
|
|||
class TrackPage extends PureComponent<{ category: string, name?: string }> { |
|||
componentDidMount() { |
|||
const { category, name, ...properties } = this.props |
|||
page(category, name, properties) |
|||
} |
|||
render() { |
|||
return null |
|||
} |
|||
} |
|||
|
|||
export default TrackPage |
@ -0,0 +1,18 @@ |
|||
/* eslint-disable */ |
|||
import { getPath } from 'helpers/staticPath' |
|||
|
|||
// prettier-ignore
|
|||
!function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on"];analytics.factory=function(t){return function(){var e=Array.prototype.slice.call(arguments);e.unshift(t);analytics.push(e);return analytics}};for(var t=0;t<analytics.methods.length;t++){var e=analytics.methods[t];analytics[e]=analytics.factory(e)};analytics.SNIPPET_VERSION="4.1.0"; |
|||
}}(); |
|||
|
|||
let loaded = false |
|||
export const load = () => { |
|||
if (loaded) return |
|||
loaded = true |
|||
var n = document.createElement('script') |
|||
n.type = 'text/javascript' |
|||
n.async = !0 |
|||
n.src = getPath('analytics.min.js') |
|||
var a = document.getElementsByTagName('script')[0] |
|||
a.parentNode.insertBefore(n, a) |
|||
} |
@ -0,0 +1,91 @@ |
|||
// @flow
|
|||
|
|||
import uuid from 'uuid/v4' |
|||
import logger from 'logger' |
|||
import invariant from 'invariant' |
|||
import user from 'helpers/user' |
|||
import { langAndRegionSelector } from 'reducers/settings' |
|||
import { getSystemLocale } from 'helpers/systemLocale' |
|||
import { load } from './inject-in-window' |
|||
|
|||
invariant(typeof window !== 'undefined', 'analytics/segment must be called on renderer thread') |
|||
|
|||
const sessionId = uuid() |
|||
|
|||
const getContext = store => { |
|||
const state = store.getState() |
|||
const { language, region } = langAndRegionSelector(state) |
|||
const systemLocale = getSystemLocale() |
|||
return { |
|||
ip: '0.0.0.0', |
|||
appVersion: __APP_VERSION__, |
|||
language, |
|||
region, |
|||
environment: __DEV__ ? 'development' : 'production', |
|||
systemLanguage: systemLocale.language, |
|||
systemRegion: systemLocale.region, |
|||
sessionId, |
|||
} |
|||
} |
|||
|
|||
let storeInstance // is the redux store. it's also used as a flag to know if analytics is on or off.
|
|||
|
|||
export const start = (store: *) => { |
|||
const { id } = user() |
|||
logger.analyticsStart(id) |
|||
storeInstance = store |
|||
const { analytics } = window |
|||
if (typeof analytics === 'undefined') { |
|||
logger.error('analytics is not available') |
|||
return |
|||
} |
|||
load() |
|||
analytics.identify( |
|||
id, |
|||
{}, |
|||
{ |
|||
context: getContext(store), |
|||
}, |
|||
) |
|||
} |
|||
|
|||
export const stop = () => { |
|||
logger.analyticsStop() |
|||
storeInstance = null |
|||
const { analytics } = window |
|||
if (typeof analytics === 'undefined') { |
|||
logger.error('analytics is not available') |
|||
return |
|||
} |
|||
analytics.reset() |
|||
} |
|||
|
|||
export const track = (event: string, properties: ?Object) => { |
|||
logger.analyticsTrack(event, properties) |
|||
if (!storeInstance) { |
|||
return |
|||
} |
|||
const { analytics } = window |
|||
if (typeof analytics === 'undefined') { |
|||
logger.error('analytics is not available') |
|||
return |
|||
} |
|||
analytics.track(event, properties, { |
|||
context: getContext(storeInstance), |
|||
}) |
|||
} |
|||
|
|||
export const page = (category: string, name: ?string, properties: ?Object) => { |
|||
logger.analyticsPage(category, name, properties) |
|||
if (!storeInstance) { |
|||
return |
|||
} |
|||
const { analytics } = window |
|||
if (typeof analytics === 'undefined') { |
|||
logger.error('analytics is not available') |
|||
return |
|||
} |
|||
analytics.page(category, name, properties, { |
|||
context: getContext(storeInstance), |
|||
}) |
|||
} |
@ -0,0 +1,15 @@ |
|||
// @flow
|
|||
|
|||
import { createCommand, Command } from 'helpers/ipc' |
|||
import { fromPromise } from 'rxjs/observable/fromPromise' |
|||
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' |
|||
|
|||
import listAppVersions from 'helpers/apps/listAppVersions' |
|||
|
|||
type Result = * |
|||
|
|||
const cmd: Command<DeviceInfo, Result> = createCommand('listAppVersions', deviceInfo => |
|||
fromPromise(listAppVersions(deviceInfo)), |
|||
) |
|||
|
|||
export default cmd |
@ -0,0 +1,16 @@ |
|||
// @flow
|
|||
|
|||
import { createCommand, Command } from 'helpers/ipc' |
|||
import { fromPromise } from 'rxjs/observable/fromPromise' |
|||
|
|||
import listCategories from 'helpers/apps/listCategories' |
|||
|
|||
type Input = {} |
|||
|
|||
type Result = * |
|||
|
|||
const cmd: Command<Input, Result> = createCommand('listCategories', () => |
|||
fromPromise(listCategories()), |
|||
) |
|||
|
|||
export default cmd |
@ -0,0 +1,125 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import { createStructuredSelector } from 'reselect' |
|||
import { compose } from 'redux' |
|||
import { connect } from 'react-redux' |
|||
import { translate } from 'react-i18next' |
|||
import type { Currency, Account } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import type { T } from 'types/common' |
|||
|
|||
import { saveSettings } from 'actions/settings' |
|||
import { accountSelector } from 'reducers/accounts' |
|||
import { counterValueCurrencySelector, selectedTimeRangeSelector } from 'reducers/settings' |
|||
import type { TimeRange } from 'reducers/settings' |
|||
|
|||
import { |
|||
BalanceTotal, |
|||
BalanceSinceDiff, |
|||
BalanceSincePercent, |
|||
} from 'components/BalanceSummary/BalanceInfos' |
|||
import Box from 'components/base/Box' |
|||
import FormattedVal from 'components/base/FormattedVal' |
|||
import PillsDaysCount from 'components/PillsDaysCount' |
|||
|
|||
type OwnProps = { |
|||
isAvailable: boolean, |
|||
totalBalance: number, |
|||
sinceBalance: number, |
|||
refBalance: number, |
|||
accountId: string, // eslint-disable-line
|
|||
} |
|||
|
|||
type Props = OwnProps & { |
|||
counterValue: Currency, |
|||
t: T, |
|||
account: Account, |
|||
saveSettings: ({ selectedTimeRange: TimeRange }) => *, |
|||
selectedTimeRange: TimeRange, |
|||
} |
|||
|
|||
const mapStateToProps = createStructuredSelector({ |
|||
account: accountSelector, |
|||
counterValue: counterValueCurrencySelector, |
|||
selectedTimeRange: selectedTimeRangeSelector, |
|||
}) |
|||
|
|||
const mapDispatchToProps = { |
|||
saveSettings, |
|||
} |
|||
|
|||
class AccountBalanceSummaryHeader extends PureComponent<Props> { |
|||
handleChangeSelectedTime = item => { |
|||
this.props.saveSettings({ selectedTimeRange: item.key }) |
|||
} |
|||
|
|||
render() { |
|||
const { |
|||
account, |
|||
t, |
|||
counterValue, |
|||
selectedTimeRange, |
|||
isAvailable, |
|||
totalBalance, |
|||
sinceBalance, |
|||
refBalance, |
|||
} = this.props |
|||
|
|||
return ( |
|||
<Box flow={4} mb={2}> |
|||
<Box horizontal> |
|||
<BalanceTotal |
|||
showCryptoEvenIfNotAvailable |
|||
isAvailable={isAvailable} |
|||
totalBalance={account.balance} |
|||
unit={account.unit} |
|||
> |
|||
<FormattedVal |
|||
animateTicker |
|||
disableRounding |
|||
alwaysShowSign={false} |
|||
color="warmGrey" |
|||
unit={counterValue.units[0]} |
|||
fontSize={6} |
|||
showCode |
|||
val={totalBalance} |
|||
/> |
|||
</BalanceTotal> |
|||
<Box> |
|||
<PillsDaysCount selected={selectedTimeRange} onChange={this.handleChangeSelectedTime} /> |
|||
</Box> |
|||
</Box> |
|||
<Box horizontal justifyContent="center" flow={7}> |
|||
<BalanceSincePercent |
|||
isAvailable={isAvailable} |
|||
t={t} |
|||
alignItems="center" |
|||
totalBalance={totalBalance} |
|||
sinceBalance={sinceBalance} |
|||
refBalance={refBalance} |
|||
since={selectedTimeRange} |
|||
/> |
|||
<BalanceSinceDiff |
|||
isAvailable={isAvailable} |
|||
t={t} |
|||
counterValue={counterValue} |
|||
alignItems="center" |
|||
totalBalance={totalBalance} |
|||
sinceBalance={sinceBalance} |
|||
refBalance={refBalance} |
|||
since={selectedTimeRange} |
|||
/> |
|||
</Box> |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default compose( |
|||
connect( |
|||
mapStateToProps, |
|||
mapDispatchToProps, |
|||
), |
|||
translate(), // FIXME t() is not even needed directly here. should be underlying component responsability to inject it
|
|||
)(AccountBalanceSummaryHeader) |
@ -0,0 +1,100 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent, Fragment } from 'react' |
|||
import { compose } from 'redux' |
|||
import { connect } from 'react-redux' |
|||
import { translate } from 'react-i18next' |
|||
import styled from 'styled-components' |
|||
import type { Account } from '@ledgerhq/live-common/lib/types' |
|||
import Tooltip from 'components/base/Tooltip' |
|||
|
|||
import { MODAL_SEND, MODAL_RECEIVE, MODAL_SETTINGS_ACCOUNT } from 'config/constants' |
|||
|
|||
import type { T } from 'types/common' |
|||
|
|||
import { rgba } from 'styles/helpers' |
|||
|
|||
import { openModal } from 'reducers/modals' |
|||
|
|||
import IconAccountSettings from 'icons/AccountSettings' |
|||
import IconReceive from 'icons/Receive' |
|||
import IconSend from 'icons/Send' |
|||
|
|||
import Box, { Tabbable } from 'components/base/Box' |
|||
import Button from 'components/base/Button' |
|||
|
|||
const ButtonSettings = styled(Tabbable).attrs({ |
|||
cursor: 'pointer', |
|||
align: 'center', |
|||
justify: 'center', |
|||
borderRadius: 1, |
|||
})` |
|||
width: 40px; |
|||
height: 40px; |
|||
|
|||
&:hover { |
|||
color: ${p => (p.disabled ? '' : p.theme.colors.dark)}; |
|||
background: ${p => (p.disabled ? '' : rgba(p.theme.colors.fog, 0.2))}; |
|||
} |
|||
|
|||
&:active { |
|||
background: ${p => (p.disabled ? '' : rgba(p.theme.colors.fog, 0.3))}; |
|||
} |
|||
` |
|||
|
|||
const mapStateToProps = null |
|||
|
|||
const mapDispatchToProps = { |
|||
openModal, |
|||
} |
|||
|
|||
type OwnProps = { |
|||
account: Account, |
|||
} |
|||
|
|||
type Props = OwnProps & { |
|||
t: T, |
|||
openModal: Function, |
|||
} |
|||
|
|||
class AccountHeaderActions extends PureComponent<Props> { |
|||
render() { |
|||
const { account, openModal, t } = this.props |
|||
return ( |
|||
<Box horizontal alignItems="center" justifyContent="flex-end" flow={2}> |
|||
{account.operations.length > 0 && ( |
|||
<Fragment> |
|||
<Button small primary onClick={() => openModal(MODAL_SEND, { account })}> |
|||
<Box horizontal flow={1} alignItems="center"> |
|||
<IconSend size={12} /> |
|||
<Box>{t('app:send.title')}</Box> |
|||
</Box> |
|||
</Button> |
|||
|
|||
<Button small primary onClick={() => openModal(MODAL_RECEIVE, { account })}> |
|||
<Box horizontal flow={1} alignItems="center"> |
|||
<IconReceive size={12} /> |
|||
<Box>{t('app:receive.title')}</Box> |
|||
</Box> |
|||
</Button> |
|||
</Fragment> |
|||
)} |
|||
<Tooltip render={() => t('app:account.settings.title')}> |
|||
<ButtonSettings onClick={() => openModal(MODAL_SETTINGS_ACCOUNT, { account })}> |
|||
<Box justifyContent="center"> |
|||
<IconAccountSettings size={16} /> |
|||
</Box> |
|||
</ButtonSettings> |
|||
</Tooltip> |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default compose( |
|||
connect( |
|||
mapStateToProps, |
|||
mapDispatchToProps, |
|||
), |
|||
translate(), |
|||
)(AccountHeaderActions) |
@ -1,321 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import styled from 'styled-components' |
|||
import { Trans, translate } from 'react-i18next' |
|||
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import type { T, Device } from 'types/common' |
|||
|
|||
import noop from 'lodash/noop' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import Spinner from 'components/base/Spinner' |
|||
import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon' |
|||
import TranslatedError from 'components/TranslatedError' |
|||
|
|||
import IconCheck from 'icons/Check' |
|||
import IconExclamationCircle from 'icons/ExclamationCircle' |
|||
import IconInfoCircle from 'icons/InfoCircle' |
|||
import IconUsb from 'icons/Usb' |
|||
import IconHome from 'icons/Home' |
|||
|
|||
import * as IconDevice from 'icons/device' |
|||
|
|||
// TODO: CHECK IF COMPONENT CAN BE REMOVED
|
|||
|
|||
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 ListDevices = styled(Box).attrs({ |
|||
p: 3, |
|||
pt: 1, |
|||
flow: 2, |
|||
})`` |
|||
|
|||
const DeviceItem = styled(Box).attrs({ |
|||
bg: 'lightGrey', |
|||
borderRadius: 1, |
|||
alignItems: 'center', |
|||
color: 'dark', |
|||
ff: 'Open Sans|SemiBold', |
|||
fontSize: 4, |
|||
horizontal: true, |
|||
pr: 3, |
|||
pl: 0, |
|||
})` |
|||
cursor: pointer; |
|||
height: 54px; |
|||
` |
|||
const DeviceIcon = styled(Box).attrs({ |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
color: 'graphite', |
|||
})` |
|||
width: 55px; |
|||
` |
|||
const DeviceSelected = styled(Box).attrs({ |
|||
alignItems: 'center', |
|||
bg: p => (p.selected ? 'wallet' : 'white'), |
|||
color: 'white', |
|||
justifyContent: 'center', |
|||
})` |
|||
border-radius: 50%; |
|||
border: 1px solid ${p => (p.selected ? p.theme.colors.wallet : p.theme.colors.fog)}; |
|||
height: 18px; |
|||
width: 18px; |
|||
` |
|||
|
|||
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 Info = styled(Box).attrs({ |
|||
alignItems: 'center', |
|||
color: p => (p.hasErrors ? 'alertRed' : 'grey'), |
|||
flow: 2, |
|||
fontSize: 3, |
|||
horizontal: true, |
|||
ml: 1, |
|||
})` |
|||
strong { |
|||
font-weight: 600; |
|||
} |
|||
` |
|||
|
|||
const StepCheck = ({ checked, hasErrors }: { checked: boolean, hasErrors?: boolean }) => ( |
|||
<Box pr={5}> |
|||
{checked ? ( |
|||
<Box color="wallet"> |
|||
<IconCheck size={16} /> |
|||
</Box> |
|||
) : hasErrors ? ( |
|||
<Box color="alertRed"> |
|||
<IconExclamationCircle size={16} /> |
|||
</Box> |
|||
) : ( |
|||
<Spinner color="grey" size={16} /> |
|||
)} |
|||
</Box> |
|||
) |
|||
|
|||
StepCheck.defaultProps = { |
|||
hasErrors: false, |
|||
} |
|||
|
|||
type Props = { |
|||
appOpened: null | 'success' | 'fail', |
|||
genuineCheckStatus: null | 'success' | 'fail', |
|||
withGenuineCheck: boolean, |
|||
currency: CryptoCurrency, |
|||
devices: Device[], |
|||
deviceSelected: ?Device, |
|||
onChangeDevice: Device => void, |
|||
t: T, |
|||
error: ?Error, |
|||
} |
|||
|
|||
const emitChangeDevice = props => { |
|||
const { onChangeDevice, deviceSelected, devices } = props |
|||
|
|||
if (deviceSelected === null && devices.length > 0) { |
|||
onChangeDevice(devices[0]) |
|||
} |
|||
} |
|||
|
|||
class DeviceConnect extends PureComponent<Props> { |
|||
static defaultProps = { |
|||
accountName: null, |
|||
appOpened: null, |
|||
devices: [], |
|||
deviceSelected: null, |
|||
onChangeDevice: noop, |
|||
withGenuineCheck: false, |
|||
} |
|||
|
|||
componentDidMount() { |
|||
emitChangeDevice(this.props) |
|||
} |
|||
|
|||
componentWillReceiveProps(nextProps) { |
|||
emitChangeDevice(nextProps) |
|||
} |
|||
|
|||
getStepState = stepStatus => ({ |
|||
success: stepStatus === 'success', |
|||
fail: stepStatus === 'fail', |
|||
}) |
|||
|
|||
render() { |
|||
const { |
|||
deviceSelected, |
|||
genuineCheckStatus, |
|||
withGenuineCheck, |
|||
appOpened, |
|||
error, |
|||
currency, |
|||
t, |
|||
onChangeDevice, |
|||
devices, |
|||
} = this.props |
|||
|
|||
const appState = this.getStepState(appOpened) |
|||
const genuineCheckState = this.getStepState(genuineCheckStatus) |
|||
|
|||
const hasDevice = devices.length > 0 |
|||
const hasMultipleDevices = devices.length > 1 |
|||
// TODO: place custom wording in trans tags into yml file
|
|||
/* eslint-disable react/jsx-no-literals */ |
|||
return ( |
|||
<Box flow={4} ff="Open Sans"> |
|||
<Step validated={hasDevice}> |
|||
<StepContent> |
|||
<StepIcon> |
|||
<IconUsb size={36} /> |
|||
</StepIcon> |
|||
<Box grow shrink> |
|||
<Trans i18nKey="app:deviceConnect.step1.connect" parent="div"> |
|||
Connect and unlock your <strong>Ledger device</strong> |
|||
</Trans> |
|||
</Box> |
|||
<StepCheck checked={hasDevice} /> |
|||
</StepContent> |
|||
{hasMultipleDevices && ( |
|||
<ListDevices> |
|||
<Box color="graphite" fontSize={3}> |
|||
{t('app:deviceConnect.step1.choose', { count: devices.length })} |
|||
</Box> |
|||
<Box flow={2}> |
|||
{devices.map(d => { |
|||
const Icon = IconDevice[d.product.replace(/\s/g, '')] |
|||
return ( |
|||
<DeviceItem key={d.path} onClick={() => onChangeDevice(d)}> |
|||
<DeviceIcon> |
|||
<Icon size={28} /> |
|||
</DeviceIcon> |
|||
<Box grow noShrink> |
|||
{`${d.manufacturer} ${d.product}`} |
|||
</Box> |
|||
<Box> |
|||
<DeviceSelected selected={d === deviceSelected}> |
|||
<IconCheck size={10} /> |
|||
</DeviceSelected> |
|||
</Box> |
|||
</DeviceItem> |
|||
) |
|||
})} |
|||
</Box> |
|||
</ListDevices> |
|||
)} |
|||
</Step> |
|||
|
|||
<Step validated={appState.success} hasErrors={appState.fail}> |
|||
{currency ? ( |
|||
<StepContent> |
|||
<StepIcon> |
|||
<WrapperIconCurrency> |
|||
<CryptoCurrencyIcon currency={currency} size={12} /> |
|||
</WrapperIconCurrency> |
|||
</StepIcon> |
|||
<Box grow shrink> |
|||
<Trans i18nKey="deviceConnect:step2.open" parent="div"> |
|||
{'Open the '} |
|||
<strong>{currency.name}</strong> |
|||
{' app on your device'} |
|||
</Trans> |
|||
</Box> |
|||
<StepCheck checked={appState.success} hasErrors={appState.fail} /> |
|||
</StepContent> |
|||
) : ( |
|||
<StepContent> |
|||
<StepIcon> |
|||
<WrapperIconCurrency> |
|||
<IconHome size={12} /> |
|||
</WrapperIconCurrency> |
|||
</StepIcon> |
|||
<Box grow shrink> |
|||
<Trans i18nKey="app:dashboard.open" parent="div"> |
|||
{'Navigate to the '} |
|||
<strong>{'dashboard'}</strong> |
|||
{' on your device'} |
|||
</Trans> |
|||
</Box> |
|||
<StepCheck checked={appState.success} hasErrors={appState.fail} /> |
|||
</StepContent> |
|||
)} |
|||
</Step> |
|||
|
|||
{/* GENUINE CHECK */} |
|||
{/* ------------- */} |
|||
|
|||
{withGenuineCheck && ( |
|||
<Step validated={genuineCheckState.success} hasErrors={genuineCheckState.fail}> |
|||
<StepContent> |
|||
<StepIcon> |
|||
<WrapperIconCurrency> |
|||
<IconCheck size={12} /> |
|||
</WrapperIconCurrency> |
|||
</StepIcon> |
|||
<Box grow shrink> |
|||
<Trans i18nKey="deviceConnect:stepGenuine.open" parent="div"> |
|||
{'Allow the '} |
|||
<strong>{'Ledger Manager'}</strong> |
|||
{' on your device'} |
|||
</Trans> |
|||
</Box> |
|||
<StepCheck checked={genuineCheckState.success} hasErrors={genuineCheckState.fail} /> |
|||
</StepContent> |
|||
</Step> |
|||
)} |
|||
|
|||
{error ? ( |
|||
<Info hasErrors> |
|||
<IconInfoCircle size={12} /> |
|||
<Box shrink selectable> |
|||
<TranslatedError error={error} /> |
|||
</Box> |
|||
</Info> |
|||
) : null} |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default translate()(DeviceConnect) |
@ -1,64 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
import { storiesOf } from '@storybook/react' |
|||
import { select } from '@storybook/addon-knobs' |
|||
import { action } from '@storybook/addon-actions' |
|||
import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies' |
|||
import { listCryptoCurrencies } from 'config/cryptocurrencies' |
|||
|
|||
import type { Currency } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import DeviceConnect from 'components/DeviceConnect' |
|||
|
|||
const currencies = listCryptoCurrencies().map(c => c.id) |
|||
const stories = storiesOf('Components', module) |
|||
|
|||
const devices = [ |
|||
{ |
|||
manufacturer: 'Ledger', |
|||
product: 'Nano S', |
|||
vendorId: '1', |
|||
productId: '11', |
|||
path: '111', |
|||
}, |
|||
{ |
|||
manufacturer: 'Ledger', |
|||
product: 'Blue', |
|||
vendorId: '2', |
|||
productId: '22', |
|||
path: '222', |
|||
}, |
|||
{ |
|||
manufacturer: 'Ledger', |
|||
product: 'Nano S', |
|||
vendorId: '3', |
|||
productId: '33', |
|||
path: '333', |
|||
}, |
|||
] |
|||
|
|||
stories.add('DeviceConnect', () => ( |
|||
<Wrapper currencyId={select('currencyId', currencies, 'bitcoin_testnet')}> |
|||
{({ currency }) => ( |
|||
<DeviceConnect |
|||
currency={currency} |
|||
appOpened={select('appOpened', ['', 'success', 'fail'], '')} |
|||
devices={devices.slice(0, Number(select('devices', [0, 1, 2, 3], '0')))} |
|||
deviceSelected={devices[select('deviceSelected', ['', 0, 1, 2], '')] || null} |
|||
onChangeDevice={action('onChangeDevice')} |
|||
/> |
|||
)} |
|||
</Wrapper> |
|||
)) |
|||
|
|||
function Wrapper({ |
|||
currencyId, |
|||
children, |
|||
}: { |
|||
currencyId: string, |
|||
children: (props: { currency: Currency }) => any, |
|||
}) { |
|||
const currency = getCryptoCurrencyById(currencyId) |
|||
return children({ currency }) |
|||
} |
@ -0,0 +1,203 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import { delay } from 'helpers/promise' |
|||
|
|||
import { |
|||
DeviceInteractionStepContainer, |
|||
SpinnerContainer, |
|||
IconContainer, |
|||
SuccessContainer, |
|||
ErrorContainer, |
|||
} from './components' |
|||
|
|||
export type Step = { |
|||
id: string, |
|||
title?: React$Node | (Object => React$Node), |
|||
desc?: React$Node, |
|||
icon: React$Node, |
|||
run?: Object => Promise<any> | { promise: Promise<any>, unsubscribe: void => any }, |
|||
render?: ({ onSuccess: Object => any, onFail: Error => void }, any) => React$Node, |
|||
minMs?: number, |
|||
} |
|||
|
|||
type Status = 'idle' | 'running' |
|||
|
|||
type Props = { |
|||
isFirst: boolean, |
|||
isLast: boolean, |
|||
isActive: boolean, |
|||
isFinished: boolean, |
|||
isPrecedentActive: boolean, |
|||
isError: boolean, |
|||
isSuccess: boolean, |
|||
isPassed: boolean, |
|||
step: Step, |
|||
onSuccess: (any, Step) => any, |
|||
onFail: (Error, Step) => any, |
|||
data: any, |
|||
} |
|||
|
|||
class DeviceInteractionStep extends PureComponent< |
|||
Props, |
|||
{ |
|||
status: Status, |
|||
}, |
|||
> { |
|||
static defaultProps = { |
|||
data: {}, |
|||
} |
|||
|
|||
state = { |
|||
status: this.props.isFirst ? 'running' : 'idle', |
|||
} |
|||
|
|||
componentDidMount() { |
|||
if (this.props.isFirst) { |
|||
this.run() |
|||
} |
|||
} |
|||
|
|||
componentDidUpdate(prevProps: Props) { |
|||
const { isActive, isError } = this.props |
|||
const { status } = this.state |
|||
|
|||
const didActivated = isActive && !prevProps.isActive |
|||
const didDeactivated = !isActive && prevProps.isActive |
|||
const stillActivated = isActive && prevProps.isActive |
|||
const didResetError = !isError && !!prevProps.isError |
|||
|
|||
if (didActivated && status !== 'running') { |
|||
this.run() |
|||
} |
|||
|
|||
if (didResetError && stillActivated) { |
|||
this.run() |
|||
} |
|||
|
|||
if (didDeactivated && status === 'running') { |
|||
this.cancel() |
|||
} |
|||
} |
|||
|
|||
componentWillUnmount() { |
|||
if (this._unsubscribe) { |
|||
this._unsubscribe() |
|||
} |
|||
this._unmounted = true |
|||
} |
|||
|
|||
_unsubscribe = null |
|||
_unmounted = false |
|||
|
|||
handleSuccess = (res: any) => { |
|||
const { onSuccess, step, isError } = this.props |
|||
if (isError) return |
|||
this.setState({ status: 'idle' }) |
|||
onSuccess(res, step) |
|||
} |
|||
|
|||
handleFail = (e: Error) => { |
|||
const { onFail, step } = this.props |
|||
this.setState({ status: 'idle' }) |
|||
onFail(e, step) |
|||
} |
|||
|
|||
run = async () => { |
|||
const { step, data } = this.props |
|||
const { status } = this.state |
|||
|
|||
if (status !== 'running') { |
|||
this.setState({ status: 'running' }) |
|||
} |
|||
|
|||
if (!step.run) { |
|||
return |
|||
} |
|||
|
|||
try { |
|||
const d1 = Date.now() |
|||
|
|||
// $FlowFixMe JUST TESTED THE `run` 6 LINES BEFORE!!!
|
|||
const res = (await step.run(data)) || {} |
|||
if (this._unmounted) return |
|||
|
|||
if (step.minMs) { |
|||
const d2 = Date.now() |
|||
// $FlowFixMe SAME THING, JUST TESTED THE MINMS KEY, BUT EH
|
|||
if (d2 - d1 < step.minMs) { |
|||
// $FlowFixMe nice type checking
|
|||
await delay(step.minMs - (d2 - d1)) |
|||
if (this._unmounted) return |
|||
} |
|||
} |
|||
if (res.promise) { |
|||
this._unsubscribe = res.unsubscribe |
|||
const realRes = await res.promise |
|||
if (this._unmounted) return |
|||
this.handleSuccess(realRes) |
|||
} else { |
|||
this.handleSuccess(res) |
|||
} |
|||
} catch (e) { |
|||
this.handleFail(e) |
|||
} |
|||
} |
|||
|
|||
cancel = () => this.setState({ status: 'idle' }) |
|||
|
|||
render() { |
|||
const { |
|||
isFirst, |
|||
isLast, |
|||
isActive, |
|||
isFinished, |
|||
isPrecedentActive, |
|||
isSuccess, |
|||
isError, |
|||
isPassed, |
|||
step, |
|||
data, |
|||
} = this.props |
|||
|
|||
const { status } = this.state |
|||
const title = typeof step.title === 'function' ? step.title(data) : step.title |
|||
const { render: CustomRender } = step |
|||
const isRunning = status === 'running' |
|||
|
|||
return ( |
|||
<DeviceInteractionStepContainer |
|||
isFirst={isFirst} |
|||
isLast={isLast} |
|||
isFinished={isFinished} |
|||
isSuccess={isSuccess} |
|||
isActive={isActive} |
|||
isPrecedentActive={isPrecedentActive} |
|||
isError={isError} |
|||
> |
|||
<IconContainer>{step.icon}</IconContainer> |
|||
<Box py={4} justify="center" grow shrink> |
|||
{title && ( |
|||
<Box color={isActive && !isSuccess ? 'dark' : ''} ff="Open Sans|SemiBold"> |
|||
{title} |
|||
</Box> |
|||
)} |
|||
{step.desc && step.desc} |
|||
{CustomRender && ( |
|||
<CustomRender onSuccess={this.handleSuccess} onFail={this.handleFail} data={data} /> |
|||
)} |
|||
</Box> |
|||
|
|||
<div style={{ width: 70, position: 'relative', overflow: 'hidden', pointerEvents: 'none' }}> |
|||
<SpinnerContainer isVisible={isRunning} isPassed={isPassed} isError={isError} /> |
|||
<ErrorContainer isVisible={isError} /> |
|||
<SuccessContainer isVisible={isSuccess} /> |
|||
</div> |
|||
</DeviceInteractionStepContainer> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default DeviceInteractionStep |
@ -0,0 +1,121 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
import styled from 'styled-components' |
|||
|
|||
import { radii } from 'styles/theme' |
|||
import { rgba } from 'styles/helpers' |
|||
|
|||
import TranslatedError from 'components/TranslatedError' |
|||
import Box from 'components/base/Box' |
|||
import FakeLink from 'components/base/FakeLink' |
|||
import Spinner from 'components/base/Spinner' |
|||
import IconCheck from 'icons/Check' |
|||
import IconCross from 'icons/Cross' |
|||
import IconExclamationCircle from 'icons/ExclamationCircle' |
|||
|
|||
export const DeviceInteractionStepContainer = styled(Box).attrs({ |
|||
horizontal: true, |
|||
ff: 'Open Sans', |
|||
fontSize: 3, |
|||
bg: 'white', |
|||
color: 'graphite', |
|||
})` |
|||
position: relative; |
|||
z-index: ${p => (p.isActive ? 1 : '')}; |
|||
max-width: 500px; |
|||
min-height: 80px; |
|||
border: 1px solid ${p => p.theme.colors.fog}; |
|||
border-color: ${p => |
|||
p.isError ? p.theme.colors.alertRed : p.isActive && !p.isFinished ? p.theme.colors.wallet : ''}; |
|||
border-top-color: ${p => (p.isFirst || p.isActive ? '' : 'transparent')}; |
|||
border-bottom-color: ${p => (p.isPrecedentActive ? 'transparent' : '')}; |
|||
border-bottom-left-radius: ${p => (p.isLast ? `${radii[1]}px` : 0)}; |
|||
border-bottom-right-radius: ${p => (p.isLast ? `${radii[1]}px` : 0)}; |
|||
border-top-left-radius: ${p => (p.isFirst ? `${radii[1]}px` : 0)}; |
|||
border-top-right-radius: ${p => (p.isFirst ? `${radii[1]}px` : 0)}; |
|||
box-shadow: ${p => |
|||
p.isActive && !p.isSuccess |
|||
? ` |
|||
${rgba(p.isError ? p.theme.colors.alertRed : p.theme.colors.wallet, 0.2)} 0 0 3px 2px |
|||
` |
|||
: 'none'}; |
|||
` |
|||
|
|||
export const IconContainer = ({ children }: { children: any }) => ( |
|||
<Box align="center" justify="center" style={{ width: 70 }}> |
|||
{children} |
|||
</Box> |
|||
) |
|||
|
|||
const SpinnerContainerWrapper = styled.div` |
|||
color: ${p => p.theme.colors.grey}; |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
transition: 350ms cubic-bezier(0.62, 0.28, 0.39, 0.94); |
|||
transition-property: transform opacity; |
|||
opacity: ${p => (p.isVisible ? 1 : 0)}; |
|||
transform: translate3d(0, ${p => (!p.isVisible ? -40 : 0)}px, 0); |
|||
` |
|||
|
|||
export const SpinnerContainer = ({ isVisible }: { isVisible: boolean }) => ( |
|||
<SpinnerContainerWrapper isVisible={isVisible}> |
|||
<Spinner size={16} /> |
|||
</SpinnerContainerWrapper> |
|||
) |
|||
|
|||
const SuccessContainerWrapper = styled(SpinnerContainerWrapper)` |
|||
color: ${p => p.theme.colors.wallet}; |
|||
transform: translate3d(0, ${p => (!p.isVisible ? 40 : 0)}px, 0); |
|||
` |
|||
|
|||
export const SuccessContainer = ({ isVisible }: { isVisible: boolean }) => ( |
|||
<SuccessContainerWrapper isVisible={isVisible}> |
|||
<IconCheck size={16} /> |
|||
</SuccessContainerWrapper> |
|||
) |
|||
|
|||
const ErrorContainerWrapper = styled(SpinnerContainerWrapper)` |
|||
color: ${p => p.theme.colors.alertRed}; |
|||
transform: translate3d(0, ${p => (!p.isVisible ? 40 : 0)}px, 0); |
|||
` |
|||
|
|||
export const ErrorContainer = ({ isVisible }: { isVisible: boolean }) => ( |
|||
<ErrorContainerWrapper isVisible={isVisible}> |
|||
<IconCross size={16} /> |
|||
</ErrorContainerWrapper> |
|||
) |
|||
|
|||
export const ErrorDescContainer = ({ |
|||
error, |
|||
onRetry, |
|||
...p |
|||
}: { |
|||
error: Error, |
|||
onRetry: void => void, |
|||
}) => ( |
|||
<Box |
|||
horizontal |
|||
fontSize={3} |
|||
color="alertRed" |
|||
align="center" |
|||
cursor="text" |
|||
ff="Open Sans|SemiBold" |
|||
style={{ maxWidth: 500 }} |
|||
{...p} |
|||
> |
|||
<IconExclamationCircle size={16} /> |
|||
<Box ml={2} mr={1} shrink grow style={{ maxWidth: 300 }}> |
|||
<TranslatedError error={error} /> |
|||
</Box> |
|||
<FakeLink ml="auto" underline color="alertRed" onClick={onRetry}> |
|||
{'Retry'} |
|||
</FakeLink> |
|||
</Box> |
|||
) |
@ -0,0 +1,116 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
|
|||
import { delay } from 'helpers/promise' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import DeviceInteractionStep from './DeviceInteractionStep' |
|||
import { ErrorDescContainer } from './components' |
|||
|
|||
import type { Step } from './DeviceInteractionStep' |
|||
|
|||
type Props = { |
|||
steps: Step[], |
|||
onSuccess?: any => void, |
|||
onFail?: any => void, |
|||
waitBeforeSuccess?: number, |
|||
|
|||
// when true and there is an error, display the error + retry button
|
|||
shouldRenderRetry?: boolean, |
|||
} |
|||
|
|||
type State = { |
|||
stepIndex: number, |
|||
isSuccess: boolean, |
|||
error: ?Error, |
|||
data: Object, |
|||
} |
|||
|
|||
const INITIAL_STATE = { |
|||
stepIndex: 0, |
|||
isSuccess: false, |
|||
error: null, |
|||
data: {}, |
|||
} |
|||
|
|||
class DeviceInteraction extends PureComponent<Props, State> { |
|||
state = INITIAL_STATE |
|||
|
|||
componentWillUnmount() { |
|||
this._unmounted = true |
|||
} |
|||
|
|||
_unmounted = false |
|||
|
|||
reset = () => this.setState(INITIAL_STATE) |
|||
|
|||
handleSuccess = async (res: any, step: Step) => { |
|||
const { onSuccess, steps, waitBeforeSuccess } = this.props |
|||
const { stepIndex, data: prevData } = this.state |
|||
const isCurrentStep = step.id === steps[stepIndex].id |
|||
if (!isCurrentStep) { |
|||
return |
|||
} |
|||
const data = { ...prevData, [step.id]: res || true } |
|||
const isLast = stepIndex === steps.length - 1 |
|||
if (isLast) { |
|||
if (!waitBeforeSuccess) { |
|||
onSuccess && onSuccess(data) |
|||
} |
|||
this.setState({ isSuccess: true, data }) |
|||
if (waitBeforeSuccess) { |
|||
await delay(waitBeforeSuccess) |
|||
if (this._unmounted) return |
|||
onSuccess && onSuccess(data) |
|||
} |
|||
} else { |
|||
this.setState({ stepIndex: stepIndex + 1, data }) |
|||
} |
|||
} |
|||
|
|||
handleFail = (error: Error, step: Step) => { |
|||
const { steps, onFail } = this.props |
|||
const { stepIndex } = this.state |
|||
const isCurrentStep = step === steps[stepIndex] |
|||
if (!isCurrentStep) { |
|||
return |
|||
} |
|||
this.setState({ error }) |
|||
onFail && onFail(error) |
|||
} |
|||
|
|||
render() { |
|||
const { steps, shouldRenderRetry, ...props } = this.props |
|||
const { stepIndex, error, isSuccess, data } = this.state |
|||
|
|||
return ( |
|||
<Box {...props}> |
|||
{steps.map((step, i) => { |
|||
const isError = !!error && i === stepIndex |
|||
return ( |
|||
<DeviceInteractionStep |
|||
key={step.id} |
|||
step={step} |
|||
isError={isError} |
|||
isFirst={i === 0} |
|||
isLast={i === steps.length - 1} |
|||
isPrecedentActive={i === stepIndex - 1} |
|||
isActive={i === stepIndex} |
|||
isPassed={i < stepIndex} |
|||
isSuccess={i < stepIndex || (i === stepIndex && isSuccess)} |
|||
isFinished={isSuccess} |
|||
onSuccess={this.handleSuccess} |
|||
onFail={this.handleFail} |
|||
data={data} |
|||
/> |
|||
) |
|||
})} |
|||
{error && |
|||
shouldRenderRetry && <ErrorDescContainer error={error} onRetry={this.reset} mt={4} />} |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default DeviceInteraction |
@ -0,0 +1,82 @@ |
|||
// @flow
|
|||
|
|||
import React, { Fragment } from 'react' |
|||
import styled from 'styled-components' |
|||
import { storiesOf } from '@storybook/react' |
|||
import { action } from '@storybook/addon-actions' |
|||
|
|||
import DeviceInteraction from 'components/DeviceInteraction' |
|||
import Box from 'components/base/Box' |
|||
import Button from 'components/base/Button' |
|||
|
|||
import IconUsb from 'icons/Usb' |
|||
|
|||
const stories = storiesOf('Components', module) |
|||
|
|||
stories.add('DeviceInteraction', () => <Wrapper />) |
|||
|
|||
const MockIcon = styled.div` |
|||
width: ${p => p.size}px; |
|||
height: ${p => p.size}px; |
|||
background: ${p => p.theme.colors.lightFog}; |
|||
border-radius: 50%; |
|||
` |
|||
|
|||
const mockIcon = <MockIcon size={36} /> |
|||
|
|||
class Wrapper extends React.Component<any> { |
|||
_ref = null |
|||
handleReset = () => this._ref && this._ref.reset() |
|||
render() { |
|||
return ( |
|||
<Fragment> |
|||
<button style={{ marginBottom: 40 }} onClick={this.handleReset}> |
|||
{'reset'} |
|||
</button> |
|||
<DeviceInteraction |
|||
shouldRenderRetry |
|||
ref={n => (this._ref = n)} |
|||
steps={[ |
|||
{ |
|||
id: 'deviceConnect', |
|||
title: 'Connect your device', |
|||
icon: <IconUsb size={36} />, |
|||
desc: 'If you dont connect your device, we wont be able to read on it', |
|||
render: ({ onSuccess, onFail }) => ( |
|||
<Box p={2} bg="lightGrey" mt={2} borderRadius={1}> |
|||
<Box horizontal flow={2}> |
|||
<Button small primary onClick={() => onSuccess({ name: 'Nano S' })}> |
|||
{'Nano S'} |
|||
</Button> |
|||
<Button small primary onClick={() => onSuccess({ name: 'Blue' })}> |
|||
{'Blue'} |
|||
</Button> |
|||
<Button small danger onClick={onFail}> |
|||
{'make it fail'} |
|||
</Button> |
|||
</Box> |
|||
</Box> |
|||
), |
|||
}, |
|||
{ |
|||
id: 'deviceOpen', |
|||
title: ({ deviceConnect: device }) => |
|||
`Open the Bitcoin application on your ${device ? `${device.name} ` : ''}device`, |
|||
desc: 'To be able to retriev your Bitcoins', |
|||
icon: mockIcon, |
|||
run: () => new Promise(resolve => setTimeout(resolve, 1 * 1000)), |
|||
}, |
|||
{ |
|||
id: 'check', |
|||
title: 'Checking if all is alright...', |
|||
desc: 'This should take only 1 second...', |
|||
icon: mockIcon, |
|||
run: () => new Promise(resolve => setTimeout(resolve, 1 * 1000)), |
|||
}, |
|||
]} |
|||
onSuccess={action('onSuccess')} |
|||
/> |
|||
</Fragment> |
|||
) |
|||
} |
|||
} |
@ -0,0 +1,156 @@ |
|||
// @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 { delay, createCancelablePolling } from 'helpers/promise' |
|||
|
|||
import logger from 'logger' |
|||
import type { T, Device } from 'types/common' |
|||
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' |
|||
|
|||
import { GENUINE_TIMEOUT, DEVICE_INFOS_TIMEOUT, GENUINE_CACHE_DELAY } from 'config/constants' |
|||
|
|||
import { getCurrentDevice } from 'reducers/devices' |
|||
import { createCustomErrorClass } from 'helpers/errors' |
|||
|
|||
import getDeviceInfo from 'commands/getDeviceInfo' |
|||
import getIsGenuine from 'commands/getIsGenuine' |
|||
|
|||
import DeviceInteraction from 'components/DeviceInteraction' |
|||
import Text from 'components/base/Text' |
|||
|
|||
import IconUsb from 'icons/Usb' |
|||
import IconHome from 'icons/Home' |
|||
import IconEye from 'icons/Eye' |
|||
|
|||
const DeviceNotGenuineError = createCustomErrorClass('DeviceNotGenuine') |
|||
|
|||
type Props = { |
|||
t: T, |
|||
onSuccess: void => void, |
|||
onFail: Error => void, |
|||
onUnavailable: Error => void, |
|||
device: ?Device, |
|||
} |
|||
|
|||
const usbIcon = <IconUsb size={36} /> |
|||
const homeIcon = <IconHome size={24} /> |
|||
const eyeIcon = <IconEye size={24} /> |
|||
|
|||
const mapStateToProps = state => ({ |
|||
device: getCurrentDevice(state), |
|||
}) |
|||
|
|||
const Bold = props => <Text ff="Open Sans|Bold" {...props} /> |
|||
|
|||
// to speed up genuine check, cache result by device id
|
|||
const genuineDevices = new WeakSet() |
|||
|
|||
class GenuineCheck extends PureComponent<Props> { |
|||
connectInteractionHandler = () => |
|||
createCancelablePolling(() => { |
|||
const { device } = this.props |
|||
if (!device) return Promise.reject() |
|||
return Promise.resolve(device) |
|||
}) |
|||
|
|||
checkDashboardInteractionHandler = ({ device }: { device: Device }) => |
|||
createCancelablePolling(() => |
|||
getDeviceInfo |
|||
.send({ devicePath: device.path }) |
|||
.pipe(timeout(DEVICE_INFOS_TIMEOUT)) |
|||
.toPromise(), |
|||
) |
|||
|
|||
checkGenuineInteractionHandler = async ({ |
|||
device, |
|||
deviceInfo, |
|||
}: { |
|||
device: Device, |
|||
deviceInfo: DeviceInfo, |
|||
}) => { |
|||
if (genuineDevices.has(device)) { |
|||
logger.log("genuine was already checked. don't check again") |
|||
await delay(GENUINE_CACHE_DELAY) |
|||
return true |
|||
} |
|||
const res = await getIsGenuine |
|||
.send({ devicePath: device.path, deviceInfo }) |
|||
.pipe(timeout(GENUINE_TIMEOUT)) |
|||
.toPromise() |
|||
const isGenuine = res === '0000' |
|||
if (!isGenuine) { |
|||
throw new DeviceNotGenuineError() |
|||
} |
|||
genuineDevices.add(device) |
|||
return true |
|||
} |
|||
|
|||
handleFail = (err: Error) => { |
|||
const { onFail, onUnavailable } = this.props |
|||
if (err instanceof DeviceNotGenuineError) { |
|||
onFail(err) |
|||
} else { |
|||
onUnavailable(err) |
|||
} |
|||
} |
|||
|
|||
render() { |
|||
const { onSuccess, ...props } = this.props |
|||
const steps = [ |
|||
{ |
|||
id: 'device', |
|||
title: ( |
|||
<Trans i18nKey="app:deviceConnect.step1.connect" parent="div"> |
|||
{'Connect and unlock your '} |
|||
<Bold>{'Ledger device'}</Bold> |
|||
</Trans> |
|||
), |
|||
icon: usbIcon, |
|||
run: this.connectInteractionHandler, |
|||
}, |
|||
{ |
|||
id: 'deviceInfo', |
|||
title: ( |
|||
<Trans i18nKey="deviceConnect:dashboard.open" parent="div"> |
|||
{'Navigate to the '} |
|||
<Bold>{'dashboard'}</Bold> |
|||
{' on your device'} |
|||
</Trans> |
|||
), |
|||
icon: homeIcon, |
|||
run: this.checkDashboardInteractionHandler, |
|||
}, |
|||
{ |
|||
id: 'isGenuine', |
|||
title: ( |
|||
<Trans i18nKey="deviceConnect:stepGenuine.open" parent="div"> |
|||
{'Allow '} |
|||
<Bold>{'Ledger Manager'}</Bold> |
|||
{' on your device'} |
|||
</Trans> |
|||
), |
|||
icon: eyeIcon, |
|||
run: this.checkGenuineInteractionHandler, |
|||
}, |
|||
] |
|||
|
|||
return ( |
|||
<DeviceInteraction |
|||
{...props} |
|||
waitBeforeSuccess={500} |
|||
steps={steps} |
|||
onSuccess={onSuccess} |
|||
onFail={this.handleFail} |
|||
/> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default compose( |
|||
translate(), |
|||
connect(mapStateToProps), |
|||
)(GenuineCheck) |
@ -0,0 +1,37 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import { translate } from 'react-i18next' |
|||
|
|||
import type { T } from 'types/common' |
|||
|
|||
import Modal, { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal' |
|||
import GenuineCheck from 'components/GenuineCheck' |
|||
|
|||
type Props = { |
|||
t: T, |
|||
onSuccess: void => void, |
|||
onFail: void => void, |
|||
onUnavailable: Error => void, |
|||
} |
|||
|
|||
class GenuineCheckModal extends PureComponent<Props> { |
|||
renderBody = ({ onClose }) => { |
|||
const { t, onSuccess, onFail, onUnavailable } = this.props |
|||
return ( |
|||
<ModalBody onClose={onClose}> |
|||
<ModalTitle>{t('app:genuinecheck.modal.title')}</ModalTitle> |
|||
<ModalContent> |
|||
<GenuineCheck onSuccess={onSuccess} onFail={onFail} onUnavailable={onUnavailable} /> |
|||
</ModalContent> |
|||
</ModalBody> |
|||
) |
|||
} |
|||
|
|||
render() { |
|||
const { t, ...props } = this.props |
|||
return <Modal {...props} render={this.renderBody} /> |
|||
} |
|||
} |
|||
|
|||
export default translate()(GenuineCheckModal) |
@ -1,90 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent, Fragment } from 'react' |
|||
import { translate } from 'react-i18next' |
|||
|
|||
import type { T } from 'types/common' |
|||
|
|||
import Modal, { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal' |
|||
import Workflow from 'components/Workflow' |
|||
import WorkflowDefault from 'components/Workflow/WorkflowDefault' |
|||
|
|||
type Props = { |
|||
t: T, |
|||
onGenuineCheckPass: () => void, |
|||
onGenuineCheckFailed: () => void, |
|||
onGenuineCheckUnavailable: Error => void, |
|||
} |
|||
|
|||
type State = {} |
|||
|
|||
class GenuineCheckStatus extends PureComponent<*> { |
|||
componentDidUpdate() { |
|||
this.sideEffect() |
|||
} |
|||
sideEffect() { |
|||
const { |
|||
isGenuine, |
|||
error, |
|||
onGenuineCheckPass, |
|||
onGenuineCheckFailed, |
|||
onGenuineCheckUnavailable, |
|||
} = this.props |
|||
if (isGenuine !== null) { |
|||
if (isGenuine) { |
|||
onGenuineCheckPass() |
|||
} else { |
|||
onGenuineCheckFailed() |
|||
} |
|||
} else if (error) { |
|||
onGenuineCheckUnavailable(error) |
|||
} |
|||
} |
|||
render() { |
|||
return null |
|||
} |
|||
} |
|||
|
|||
/* eslint-disable react/no-multi-comp */ |
|||
class GenuineCheck extends PureComponent<Props, State> { |
|||
renderBody = ({ onClose }) => { |
|||
const { t, onGenuineCheckPass, onGenuineCheckFailed, onGenuineCheckUnavailable } = this.props |
|||
|
|||
// TODO: use the real devices list. for now we force choosing only
|
|||
// the current device because we don't handle multi device in MVP
|
|||
|
|||
return ( |
|||
<ModalBody onClose={onClose}> |
|||
<ModalTitle>{t('app:genuinecheck.modal.title')}</ModalTitle> |
|||
<ModalContent> |
|||
<Workflow |
|||
renderDefault={(device, deviceInfo, isGenuine, errors) => ( |
|||
<Fragment> |
|||
<GenuineCheckStatus |
|||
isGenuine={isGenuine} |
|||
error={errors.genuineError} |
|||
onGenuineCheckPass={onGenuineCheckPass} |
|||
onGenuineCheckFailed={onGenuineCheckFailed} |
|||
onGenuineCheckUnavailable={onGenuineCheckUnavailable} |
|||
/> |
|||
<WorkflowDefault |
|||
device={device} |
|||
deviceInfo={deviceInfo} |
|||
isGenuine={isGenuine} |
|||
errors={errors} // TODO: FIX ERRORS
|
|||
/> |
|||
</Fragment> |
|||
)} |
|||
/> |
|||
</ModalContent> |
|||
</ModalBody> |
|||
) |
|||
} |
|||
|
|||
render() { |
|||
const { ...props } = this.props |
|||
return <Modal {...props} render={({ onClose }) => this.renderBody({ onClose })} /> |
|||
} |
|||
} |
|||
|
|||
export default translate()(GenuineCheck) |
@ -1,62 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import { translate } from 'react-i18next' |
|||
import logger from 'logger' |
|||
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' |
|||
|
|||
import type { Device, T } from 'types/common' |
|||
|
|||
import installFinalFirmware from 'commands/installFinalFirmware' |
|||
|
|||
import Box, { Card } from 'components/base/Box' |
|||
// import Button from 'components/base/Button'
|
|||
|
|||
type Props = { |
|||
t: T, |
|||
device: Device, |
|||
deviceInfo: DeviceInfo, |
|||
} |
|||
|
|||
type State = {} |
|||
|
|||
class FirmwareFinalUpdate extends PureComponent<Props, State> { |
|||
componentDidMount() {} |
|||
|
|||
componentWillUnmount() { |
|||
this._unmounting = true |
|||
} |
|||
|
|||
_unmounting = false |
|||
|
|||
installFinalFirmware = async () => { |
|||
try { |
|||
const { device, deviceInfo } = this.props |
|||
const { success } = await installFinalFirmware |
|||
.send({ devicePath: device.path, deviceInfo }) |
|||
.toPromise() |
|||
if (success) { |
|||
this.setState() |
|||
} |
|||
} catch (err) { |
|||
logger.log(err) |
|||
} |
|||
} |
|||
|
|||
render() { |
|||
const { t, ...props } = this.props |
|||
|
|||
return ( |
|||
<Box flow={4} {...props}> |
|||
<Box color="dark" ff="Museo Sans" fontSize={6}> |
|||
{t('app:manager.firmware.update')} |
|||
</Box> |
|||
<Card flow={2} {...props}> |
|||
<Box horizontal align="center" flow={2} /> |
|||
</Card> |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default translate()(FirmwareFinalUpdate) |
@ -1,50 +0,0 @@ |
|||
// @flow
|
|||
import React, { PureComponent } from 'react' |
|||
|
|||
import type { Device } from 'types/common' |
|||
import installMcu from 'commands/installMcu' |
|||
|
|||
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' |
|||
|
|||
type Props = { |
|||
device: Device, |
|||
deviceInfo: DeviceInfo, |
|||
} |
|||
|
|||
type State = { |
|||
flashing: boolean, |
|||
} |
|||
|
|||
class FlashMcu extends PureComponent<Props, State> { |
|||
state = { |
|||
flashing: false, |
|||
} |
|||
|
|||
flashMCU = async () => { |
|||
const { device, deviceInfo } = this.props |
|||
const { flashing } = this.state |
|||
|
|||
if (!flashing) { |
|||
this.setState({ flashing: true }) |
|||
await installMcu |
|||
.send({ |
|||
devicePath: device.path, |
|||
targetId: deviceInfo.targetId, |
|||
version: deviceInfo.seVersion, |
|||
}) |
|||
.toPromise() |
|||
this.setState({ flashing: false }) |
|||
} |
|||
} |
|||
|
|||
render() { |
|||
return ( |
|||
<div> |
|||
<h1>Flashing MCU</h1> |
|||
<button onClick={this.flashMCU}>flash</button> |
|||
</div> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default FlashMcu |
@ -0,0 +1,48 @@ |
|||
// @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' |
|||
import TrackPage from 'analytics/TrackPage' |
|||
|
|||
type Props = { |
|||
t: T, |
|||
onSuccess: void => void, |
|||
} |
|||
|
|||
class ManagerGenuineCheck extends PureComponent<Props> { |
|||
render() { |
|||
const { t, onSuccess } = this.props |
|||
return ( |
|||
<Box align="center"> |
|||
<TrackPage category="Manager" name="Genuine Check" /> |
|||
<Space of={60} /> |
|||
<Box align="center" style={{ maxWidth: 460 }}> |
|||
<img |
|||
src={i('logos/connectDevice.png')} |
|||
alt="connect your device" |
|||
style={{ marginBottom: 30, maxWidth: 362, width: '100%' }} |
|||
/> |
|||
<Text ff="Museo Sans|Regular" fontSize={7} color="black" style={{ marginBottom: 10 }}> |
|||
{t('app:manager.device.title')} |
|||
</Text> |
|||
<Text ff="Museo Sans|Light" fontSize={5} color="grey" align="center"> |
|||
{t('app:manager.device.desc')} |
|||
</Text> |
|||
</Box> |
|||
<Space of={40} /> |
|||
<GenuineCheck shouldRenderRetry onSuccess={onSuccess} /> |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default translate()(ManagerGenuineCheck) |
@ -1,81 +0,0 @@ |
|||
// @flow
|
|||
import { PureComponent } from 'react' |
|||
import isEqual from 'lodash/isEqual' |
|||
|
|||
import type { Node } from 'react' |
|||
import type { Device } from 'types/common' |
|||
|
|||
import getDeviceInfo from 'commands/getDeviceInfo' |
|||
|
|||
import type { DeviceInfo } from 'helpers/devices/getDeviceInfo' |
|||
|
|||
type Error = { |
|||
message: string, |
|||
stack: string, |
|||
} |
|||
|
|||
type Props = { |
|||
device: ?Device, |
|||
children: (deviceInfo: ?DeviceInfo, error: ?Error) => Node, |
|||
} |
|||
|
|||
type State = { |
|||
deviceInfo: ?DeviceInfo, |
|||
error: ?Error, |
|||
} |
|||
|
|||
class EnsureDashboard extends PureComponent<Props, State> { |
|||
static defaultProps = { |
|||
children: null, |
|||
device: null, |
|||
} |
|||
|
|||
state = { |
|||
deviceInfo: null, |
|||
error: null, |
|||
} |
|||
|
|||
componentDidMount() { |
|||
this.checkForDashboard() |
|||
} |
|||
|
|||
componentDidUpdate({ device }: Props) { |
|||
if (this.props.device !== device && this.props.device) { |
|||
this.checkForDashboard() |
|||
} |
|||
} |
|||
|
|||
componentWillUnmount() { |
|||
this._unmounting = true |
|||
} |
|||
|
|||
_checking = false |
|||
_unmounting = false |
|||
|
|||
checkForDashboard = async () => { |
|||
const { device } = this.props |
|||
if (device && !this._checking) { |
|||
this._checking = true |
|||
try { |
|||
const deviceInfo = await getDeviceInfo.send({ devicePath: device.path }).toPromise() |
|||
if (!isEqual(this.state.deviceInfo, deviceInfo) || this.state.error) { |
|||
!this._unmounting && this.setState({ deviceInfo, error: null }) |
|||
} |
|||
} catch (err) { |
|||
if (!isEqual(err, this.state.error)) { |
|||
!this._unmounting && this.setState({ error: err, deviceInfo: null }) |
|||
} |
|||
} |
|||
this._checking = false |
|||
} |
|||
} |
|||
|
|||
render() { |
|||
const { deviceInfo, error } = this.state |
|||
const { children } = this.props |
|||
|
|||
return children(deviceInfo, error) |
|||
} |
|||
} |
|||
|
|||
export default EnsureDashboard |