Browse Source

Merge pull request #184 from loeck/master

Add counterValues
master
Meriadec Pillet 7 years ago
committed by GitHub
parent
commit
78dfca3000
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      package.json
  2. 66
      src/actions/counterValues.js
  3. 40
      src/components/BalanceSummary/BalanceInfos.js
  4. 66
      src/components/BalanceSummary/index.js
  5. 137
      src/components/CalculateBalance.js
  6. 97
      src/components/DashboardPage/AccountCard.js
  7. 144
      src/components/DashboardPage/index.js
  8. 15
      src/components/ReceiveBox.js
  9. 2
      src/components/SelectAccount/stories.js
  10. 2
      src/components/SideBar/index.js
  11. 265
      src/components/base/Chart/index.js
  12. 7
      src/components/base/FormattedVal.js
  13. 3
      src/components/base/Pills/index.js
  14. 2
      src/components/modals/Receive.js
  15. 117
      src/helpers/btc.js
  16. 2
      src/helpers/db.js
  17. 13
      src/internals/accounts/sync.js
  18. 2
      src/internals/usb/wallet/accounts.js
  19. 3
      src/middlewares/db.js
  20. 17
      src/reducers/accounts.js
  21. 19
      src/reducers/counterValues.js
  22. 12
      src/reducers/index.js
  23. 36
      src/renderer/events.js
  24. 6
      src/renderer/init.js
  25. 6
      src/styles/global.js
  26. 6
      src/types/common.js
  27. 30
      yarn.lock

10
package.json

@ -62,7 +62,7 @@
"color": "^3.0.0",
"cross-env": "^5.1.3",
"debug": "^3.1.0",
"downshift": "^1.28.1",
"downshift": "^1.28.2",
"electron-store": "^1.3.0",
"electron-updater": "^2.20.1",
"fuse.js": "^3.2.0",
@ -75,8 +75,8 @@
"object-path": "^0.11.4",
"qrcode": "^1.2.0",
"query-string": "^5.1.0",
"raven": "^2.4.1",
"raven-js": "^3.22.3",
"raven": "^2.4.2",
"raven-js": "^3.22.4",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-i18next": "^7.4.0",
@ -139,8 +139,8 @@
"js-yaml": "^3.10.0",
"lint-staged": "^7.0.0",
"node-loader": "^0.6.0",
"prettier": "^1.11.0",
"react-hot-loader": "^4.0.0-beta.21",
"prettier": "^1.11.1",
"react-hot-loader": "^4.0.0",
"webpack": "^3.11.0"
}
}

66
src/actions/counterValues.js

@ -0,0 +1,66 @@
// @flow
import axios from 'axios'
import moment from 'moment'
import { getDefaultUnitByCoinType } from '@ledgerhq/currencies'
import get from 'lodash/get'
import db from 'helpers/db'
type InitCounterValues = () => { type: string, payload: Object }
export const initCounterValues: InitCounterValues = () => ({
type: 'UPDATE_COUNTER_VALUES',
payload: db.get('counterValues'),
})
type UpdateCounterValues = Object => { type: string, payload: Object }
export const updateCounterValues: UpdateCounterValues = payload => ({
type: 'DB:UPDATE_COUNTER_VALUES',
payload,
})
type FetchCounterValues = (?number) => (Dispatch<*>, Function) => void
export const fetchCounterValues: FetchCounterValues = coinType => (dispatch, getState) => {
const { accounts, counterValues } = getState()
let coinTypes = []
if (!coinType) {
coinTypes = [...new Set(accounts.map(a => a.coinType))]
} else {
coinTypes = [coinType]
}
const today = moment().format('YYYY-MM-DD')
const fetchCounterValuesByCoinType = coinType => {
const { code } = getDefaultUnitByCoinType(coinType)
const todayCounterValues = get(counterValues, `${code}-USD.${today}`, null)
if (todayCounterValues !== null) {
return {}
}
return axios
.get(
`https://min-api.cryptocompare.com/data/histoday?&extraParams=ledger-test&fsym=${code}&tsym=USD&allData=1`,
)
.then(({ data }) => ({
symbol: `${code}-USD`,
values: data.Data.reduce((result, d) => {
const date = moment(d.time * 1000).format('YYYY-MM-DD')
result[date] = d.close
return result
}, {}),
}))
}
Promise.all(coinTypes.map(fetchCounterValuesByCoinType)).then(result => {
const newCounterValues = result.reduce((r, v) => {
r[v.symbol] = v.values
return r
}, {})
dispatch(updateCounterValues(newCounterValues))
})
}

40
src/components/DashboardPage/BalanceInfos.js → src/components/BalanceSummary/BalanceInfos.js

@ -1,40 +1,33 @@
// @flow
import React from 'react'
import { connect } from 'react-redux'
import styled from 'styled-components'
import { getDefaultUnitByCoinType } from '@ledgerhq/currencies'
import type { MapStateToProps } from 'react-redux'
import { getTotalBalance } from 'reducers/accounts'
import Box from 'components/base/Box'
import Text from 'components/base/Text'
import FormattedVal from 'components/base/FormattedVal'
const mapStateToProps: MapStateToProps<*, *, *> = state => ({
totalBalance: getTotalBalance(state),
})
type Props = {
fiat: string,
since: string,
totalBalance: number,
sinceBalance: number,
}
const Sub = styled(Text).attrs({
ff: 'Open Sans',
color: 'graphite',
color: 'warnGrey',
fontSize: 4,
})``
function BalanceInfos(props: Props) {
const { totalBalance } = props
const { fiat, totalBalance, since, sinceBalance } = props
return (
<Box horizontal alignItems="flex-end" flow={7}>
<Box grow>
<FormattedVal
fiat={fiat}
val={totalBalance}
unit={getDefaultUnitByCoinType(0)}
alwaysShowSign={false}
showCode
fontSize={8}
@ -44,15 +37,26 @@ function BalanceInfos(props: Props) {
<Sub>{'Total balance'}</Sub>
</Box>
<Box alignItems="flex-end">
<FormattedVal isPercent val={9.25} alwaysShowSign fontSize={7} />
<Sub>{'since one week'}</Sub>
<FormattedVal
isPercent
val={Math.floor((totalBalance - sinceBalance) / sinceBalance * 100)}
alwaysShowSign
fontSize={7}
/>
<Sub>since one {since}</Sub>
</Box>
<Box alignItems="flex-end">
<FormattedVal fiat="USD" alwaysShowSign showCode val={6132.23} fontSize={7} />
<Sub>{'since one week'}</Sub>
<FormattedVal
fiat="USD"
alwaysShowSign
showCode
val={totalBalance - sinceBalance}
fontSize={7}
/>
<Sub>since one {since}</Sub>
</Box>
</Box>
)
}
export default connect(mapStateToProps)(BalanceInfos)
export default BalanceInfos

66
src/components/BalanceSummary/index.js

@ -0,0 +1,66 @@
// @flow
import React, { Fragment } from 'react'
import moment from 'moment'
import { formatCurrencyUnit, getFiatUnit } from '@ledgerhq/currencies'
import type { Accounts } from 'types/common'
import { space } from 'styles/theme'
import { AreaChart } from 'components/base/Chart'
import Box, { Card } from 'components/base/Box'
import CalculateBalance from 'components/CalculateBalance'
import BalanceInfos from './BalanceInfos'
type Props = {
accounts: Accounts,
selectedTime: string,
daysCount: number,
}
const BalanceSummary = ({ accounts, selectedTime, daysCount }: Props) => (
<Card flow={3} p={0} py={6}>
<CalculateBalance
accounts={accounts}
daysCount={daysCount}
render={({ allBalances, totalBalance, sinceBalance }) => (
<Fragment>
<Box px={6}>
<BalanceInfos
fiat="USD"
totalBalance={totalBalance}
since={selectedTime}
sinceBalance={sinceBalance}
/>
</Box>
<Box ff="Open Sans" fontSize={4} color="graphite">
<AreaChart
color="#5286f7"
data={allBalances}
height={250}
id="dashboard-chart"
padding={{
top: space[6],
bottom: space[6],
left: space[6] * 2,
right: space[6],
}}
strokeWidth={2}
renderLabels={d =>
formatCurrencyUnit(getFiatUnit('USD'), d.y * 100, {
showCode: true,
})
}
renderTickX={t => moment(t).format('MMM. D')}
/>
</Box>
</Fragment>
)}
/>
</Card>
)
export default BalanceSummary

137
src/components/CalculateBalance.js

@ -0,0 +1,137 @@
// @flow
import { PureComponent } from 'react'
import { connect } from 'react-redux'
import moment from 'moment'
import type { MapStateToProps } from 'react-redux'
import type { Accounts } from 'types/common'
import { getDefaultUnitByCoinType } from '@ledgerhq/currencies'
import first from 'lodash/first'
import get from 'lodash/get'
import last from 'lodash/last'
const mapStateToProps: MapStateToProps<*, *, *> = state => ({
counterValues: state.counterValues,
})
function getAllBalances({
accounts,
counterValues,
daysCount,
}: {
accounts: Accounts,
counterValues: Object,
daysCount: number,
}) {
const getDate = date => moment(date).format('YYYY-MM-DD')
const getValue = (balance, unit, d) =>
balance / 10 ** unit.magnitude * counterValues['BTC-USD'][d]
const allBalancesByCoinType = accounts.reduce((result, account) => {
const { coinType } = account
Object.keys(account.balanceByDay).forEach(k => {
if (!result[coinType]) {
result[coinType] = {}
}
result[coinType][k] = account.balanceByDay[k] + get(result, `${coinType}.${k}`, 0)
})
return result
}, {})
const allBalances = Object.keys(allBalancesByCoinType).reduce((result, coinType) => {
const unit = getDefaultUnitByCoinType(parseInt(coinType, 10))
const balanceByDay = allBalancesByCoinType[coinType]
const balanceByDayKeys = Object.keys(balanceByDay).sort((a, b) => new Date(b) - new Date(a))
const lastDay = balanceByDayKeys[0]
const lastBalance = balanceByDay[lastDay]
let balance = lastBalance
let index = daysCount
result[lastDay] = getValue(balance, unit, lastDay)
let d = getDate(moment(lastDay).subtract(1, 'days'))
while (index !== 0) {
result[d] = getValue(balance, unit, d) + (result[d] || 0)
d = getDate(moment(d).subtract(1, 'days'))
if (balanceByDay[d]) {
balance = balanceByDay[d]
}
index--
}
return result
}, {})
return Object.keys(allBalances)
.sort()
.map(k => ({
name: k,
value: allBalances[k],
}))
}
function calculateBalance(props) {
const allBalances = getAllBalances({
accounts: props.accounts,
counterValues: props.counterValues,
daysCount: props.daysCount,
})
return {
allBalances,
totalBalance: last(allBalances).value,
sinceBalance: first(allBalances).value,
}
}
type Props = {
accounts: Accounts,
counterValues: Object,
daysCount: number,
render: Function,
}
type State = {
allBalances: Array<Object>,
totalBalance: number,
sinceBalance: number,
}
class CalculateBalance extends PureComponent<Props, State> {
state = {
...calculateBalance(this.props),
}
componentWillReceiveProps(nextProps) {
const sameAccounts = this.props.accounts === nextProps.accounts
const sameCounterValues = this.props.counterValues === nextProps.counterValues
const sameDaysCount = this.props.daysCount === nextProps.daysCount
if (!sameAccounts || !sameCounterValues || !sameDaysCount) {
this.setState({
...calculateBalance(nextProps),
})
}
}
render() {
const { render } = this.props
const { allBalances, totalBalance, sinceBalance } = this.state
return render({ allBalances, totalBalance, sinceBalance })
}
}
export default connect(mapStateToProps)(CalculateBalance)

97
src/components/DashboardPage/AccountCard.js

@ -8,51 +8,90 @@ import type { Account } from 'types/common'
import { SimpleAreaChart } from 'components/base/Chart'
import Bar from 'components/base/Bar'
import Box, { Card } from 'components/base/Box'
import CalculateBalance from 'components/CalculateBalance'
import FormattedVal from 'components/base/FormattedVal'
const AccountCard = ({
account,
data,
onClick,
daysCount,
}: {
account: Account,
data: Array<Object>,
onClick: Function,
daysCount: number,
}) => {
const Icon = getIconByCoinType(account.currency.coinType)
return (
<Card p={4} flow={4} flex={1} style={{ cursor: 'pointer' }} onClick={onClick}>
<Box horizontal ff="Open Sans|SemiBold" flow={3} alignItems="center">
<Box alignItems="center" justifyContent="center" style={{ color: account.currency.color }}>
{Icon && <Icon size={20} />}
</Box>
<Box>
<Box style={{ textTransform: 'uppercase' }} fontSize={0} color="graphite">
{account.unit.code}
<Card p={4} flex={1} style={{ cursor: 'pointer' }} onClick={onClick}>
<Box flow={4}>
<Box horizontal ff="Open Sans|SemiBold" flow={3} alignItems="center">
<Box
alignItems="center"
justifyContent="center"
style={{ color: account.currency.color }}
>
{Icon && <Icon size={20} />}
</Box>
<Box fontSize={4} color="dark">
{account.name}
<Box>
<Box style={{ textTransform: 'uppercase' }} fontSize={0} color="graphite">
{account.unit.code}
</Box>
<Box fontSize={4} color="dark">
{account.name}
</Box>
</Box>
</Box>
<Bar size={1} color="fog" />
<Box justifyContent="center">
<FormattedVal
alwaysShowSign={false}
color="dark"
unit={account.unit}
showCode
val={account.balance}
style={{
lineHeight: 1,
}}
/>
</Box>
</Box>
<Bar size={2} color="fog" />
<Box grow justifyContent="center" color="dark">
<FormattedVal
alwaysShowSign={false}
color="dark"
unit={account.unit}
showCode
val={account.balance}
/>
</Box>
<SimpleAreaChart
id={`account-chart-${account.id}`}
color={account.currency.color}
height={52}
data={data}
strokeWidth={1.5}
linearGradient={[[5, 0.2], [75, 0]]}
<CalculateBalance
accounts={[account]}
daysCount={daysCount}
render={({ allBalances, totalBalance, sinceBalance }) => (
<Box flow={4}>
<Box flow={2} horizontal>
<Box justifyContent="center">
<FormattedVal
fiat="USD"
val={totalBalance}
alwaysShowSign={false}
showCode
fontSize={3}
color="graphite"
/>
</Box>
<Box grow justifyContent="center">
<FormattedVal
isPercent
val={Math.floor((totalBalance - sinceBalance) / sinceBalance * 100)}
alwaysShowSign
fontSize={3}
/>
</Box>
</Box>
<SimpleAreaChart
data={allBalances}
color={account.currency.color}
height={52}
id={`account-chart-${account.id}`}
linearGradient={[[5, 0.2], [75, 0]]}
simple
strokeWidth={1.5}
/>
</Box>
)}
/>
</Card>
)

144
src/components/DashboardPage/index.js

@ -8,28 +8,23 @@ import { push } from 'react-router-redux'
import chunk from 'lodash/chunk'
import get from 'lodash/get'
import random from 'lodash/random'
import sortBy from 'lodash/sortBy'
import takeRight from 'lodash/takeRight'
import type { MapStateToProps } from 'react-redux'
import type { Accounts, T } from 'types/common'
import { space } from 'styles/theme'
import type { Account, Accounts, T } from 'types/common'
import { getVisibleAccounts } from 'reducers/accounts'
import { updateOrderAccounts } from 'actions/accounts'
import { saveSettings } from 'actions/settings'
import { AreaChart } from 'components/base/Chart'
import Box, { Card } from 'components/base/Box'
import BalanceSummary from 'components/BalanceSummary'
import Box from 'components/base/Box'
import Pills from 'components/base/Pills'
import Text from 'components/base/Text'
import TransactionsList from 'components/TransactionsList'
import AccountCard from './AccountCard'
import BalanceInfos from './BalanceInfos'
import AccountsOrder from './AccountsOrder'
const mapStateToProps: MapStateToProps<*, *, *> = state => ({
@ -49,46 +44,19 @@ type Props = {
}
type State = {
accountsChunk: Array<any>,
accountsChunk: Array<Array<Account | null>>,
allTransactions: Array<Object>,
fakeDatas: Object,
fakeDatasMerge: Array<any>,
selectedTime: string,
}
const ACCOUNTS_BY_LINE = 3
const ALL_TRANSACTIONS_LIMIT = 10
const TIMEOUT_REFRESH_DATAS = 5e3
const itemsTimes = [{ key: 'day' }, { key: 'week' }, { key: 'month' }, { key: 'year' }]
const generateFakeData = v => ({
index: v,
name: `Day ${v}`,
value: random(10, 100),
})
const generateFakeDatas = accounts =>
accounts.reduce((result, a) => {
result[a.id] = [...Array(25).keys()].map(v => generateFakeData(v + 1))
return result
}, {})
const mergeFakeDatas = fakeDatas =>
takeRight(
Object.keys(fakeDatas).reduce((res, k) => {
const data = fakeDatas[k]
data.forEach((d, i) => {
res[i] = {
name: d.name,
value: (res[i] ? res[i].value : 0) + d.value,
}
})
return res
}, []),
25,
)
const itemsTimes = [
{ key: 'week', value: 7 },
{ key: 'month', value: 30 },
{ key: 'year', value: 365 },
]
const getAllTransactions = accounts => {
const allTransactions = accounts.reduce((result, account) => {
@ -120,43 +88,25 @@ const getAccountsChunk = accounts => {
}
class DashboardPage extends PureComponent<Props, State> {
constructor(props) {
super()
const fakeDatas = generateFakeDatas(props.accounts)
this.state = {
accountsChunk: getAccountsChunk(props.accounts),
allTransactions: getAllTransactions(props.accounts),
fakeDatas,
fakeDatasMerge: mergeFakeDatas(fakeDatas),
selectedTime: 'day',
}
state = {
accountsChunk: getAccountsChunk(this.props.accounts),
allTransactions: getAllTransactions(this.props.accounts),
selectedTime: 'week',
}
componentWillMount() {
this._itemsTimes = itemsTimes.map(item => ({
...item,
value: item.value,
label: this.props.t(`time:${item.key}`),
}))
}
componentDidMount() {
this._mounted = true
this.addFakeDatasOnAccounts()
}
componentWillReceiveProps(nextProps) {
if (Object.keys(this.state.fakeDatas).length === 0) {
const fakeDatas = generateFakeDatas(nextProps.accounts)
this.setState({
fakeDatas,
fakeDatasMerge: mergeFakeDatas(fakeDatas),
})
}
if (nextProps.accounts !== this.props.accounts) {
this.setState({
accountsChunk: getAccountsChunk(nextProps.accounts),
@ -167,46 +117,29 @@ class DashboardPage extends PureComponent<Props, State> {
componentWillUnmount() {
this._mounted = false
clearTimeout(this._timeout)
}
addFakeDatasOnAccounts = () => {
this._timeout = setTimeout(() => {
const { fakeDatas } = this.state
const newFakeDatas = {}
Object.keys(fakeDatas).forEach(k => {
const data = fakeDatas[k]
data.shift()
const lastIndex = data[data.length - 1]
newFakeDatas[k] = [...data, generateFakeData(lastIndex.index + 1)]
})
getDaysCount() {
const { selectedTime } = this.state
window.requestIdleCallback(() => {
if (this._mounted) {
this.setState({
fakeDatas: newFakeDatas,
fakeDatasMerge: mergeFakeDatas(newFakeDatas),
})
}
const selectedTimeItems = this._itemsTimes.find(i => i.key === selectedTime)
this.addFakeDatasOnAccounts()
})
}, TIMEOUT_REFRESH_DATAS)
return selectedTimeItems && selectedTimeItems.value ? selectedTimeItems.value : 7
}
_timeout = undefined
handleChangeSelectedTime = item =>
this.setState({
selectedTime: item.key,
})
_mounted = false
_itemsTimes = []
render() {
const { push, accounts, t } = this.props
const { accountsChunk, allTransactions, selectedTime, fakeDatas, fakeDatasMerge } = this.state
const { accountsChunk, allTransactions, selectedTime } = this.state
const daysCount = this.getDaysCount()
const totalAccounts = accounts.length
return (
@ -226,32 +159,13 @@ class DashboardPage extends PureComponent<Props, State> {
<Pills
items={this._itemsTimes}
activeKey={selectedTime}
onChange={item => this.setState({ selectedTime: item.key })}
onChange={this.handleChangeSelectedTime}
/>
</Box>
</Box>
{totalAccounts > 0 && (
<Fragment>
<Card flow={3} p={0} py={6}>
<Box px={6}>
<BalanceInfos since={selectedTime} />
</Box>
<Box ff="Open Sans" fontSize={4} color="graphite">
<AreaChart
id="dashboard-chart"
padding={{
top: space[6],
bottom: space[6],
left: space[6] * 2,
right: space[6],
}}
color="#5286f7"
height={250}
data={fakeDatasMerge}
strokeWidth={2}
/>
</Box>
</Card>
<BalanceSummary accounts={accounts} selectedTime={selectedTime} daysCount={daysCount} />
<Box flow={4}>
<Box horizontal alignItems="flex-end">
<Text color="dark" ff="Museo Sans" fontSize={6}>
@ -278,9 +192,9 @@ class DashboardPage extends PureComponent<Props, State> {
/>
) : (
<AccountCard
key={account.id}
account={account}
data={fakeDatas[account.id]}
daysCount={daysCount}
key={account.id}
onClick={() => push(`/account/${account.id}`)}
/>
),

15
src/components/ReceiveBox.js

@ -6,7 +6,7 @@ import styled from 'styled-components'
import { ipcRenderer } from 'electron'
import type { MapStateToProps } from 'react-redux'
import type { Device } from 'types/common'
import type { Account, Device } from 'types/common'
import { getCurrentDevice } from 'reducers/devices'
import { sendEvent } from 'renderer/events'
@ -54,9 +54,8 @@ const mapStateToProps: MapStateToProps<*, *, *> = state => ({
type Props = {
currentDevice: Device | null,
address: string,
account: Account,
amount?: string,
path: string,
}
type State = {
@ -83,7 +82,7 @@ class ReceiveBox extends PureComponent<Props, State> {
}
componentWillReceiveProps(nextProps: Props) {
if (this.props.address !== nextProps.address) {
if (this.props.account !== nextProps.account) {
this.setState({
...defaultState,
})
@ -112,12 +111,12 @@ class ReceiveBox extends PureComponent<Props, State> {
}
handleVerifyAddress = () => {
const { currentDevice, path } = this.props
const { currentDevice, account } = this.props
if (currentDevice !== null) {
sendEvent('usb', 'wallet.verifyAddress', {
pathDevice: currentDevice.path,
path,
path: `${account.rootPath}${account.path}`,
})
this.setState({
@ -127,7 +126,7 @@ class ReceiveBox extends PureComponent<Props, State> {
}
render() {
const { amount, address } = this.props
const { amount, account } = this.props
const { isVerified, isDisplay } = this.state
if (!isDisplay) {
@ -138,6 +137,8 @@ class ReceiveBox extends PureComponent<Props, State> {
)
}
const { address } = account
return (
<Box flow={3}>
<Box>

2
src/components/SelectAccount/stories.js

@ -15,11 +15,13 @@ const accounts = [...Array(20)].map(() => ({
address: chance.string(),
addresses: [],
balance: chance.floating({ min: 0, max: 20 }),
balanceByDay: {},
coinType: 0,
currency: getCurrencyByCoinType(0),
index: chance.integer({ min: 0, max: 20 }),
name: chance.name(),
path: '',
rootPath: '',
transactions: [],
unit: getDefaultUnitByCoinType(0),
settings: {

2
src/components/SideBar/index.js

@ -92,7 +92,7 @@ class SideBar extends PureComponent<Props> {
<Box flow={4} grow pt={1}>
<CapsSubtitle horizontal alignItems="center">
<Box grow>{t('sidebar:accounts')}</Box>
<Tooltip render={() => t('addAccount:title')} offset={[0, 1]}>
<Tooltip render={() => t('addAccount:title')}>
<PlusBtn onClick={() => openModal(MODAL_ADD_ACCOUNT)}>
<IconPlus height={14} width={14} />
</PlusBtn>

265
src/components/base/Chart/index.js

@ -1,5 +1,7 @@
// @flow
/* eslint-disable react/no-multi-comp */
import React, { Fragment, PureComponent } from 'react'
import {
VictoryChart,
@ -11,11 +13,15 @@ import {
} from 'victory'
import { radii, space, colors, fontSizes } from 'styles/theme'
import { rgba, ff } from 'styles/helpers'
import { ff } from 'styles/helpers'
import Box from 'components/base/Box'
const ANIMATION_DURATION = 600
const DEFAULT_PROPS = {
color: 'blue',
padding: 0,
}
type Props = {
height: number,
@ -88,7 +94,7 @@ function getLinearGradient({
id: string,
color: string,
}) {
return (
return linearGradient.length > 0 ? (
<svg style={{ height: 0 }}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="100%">
@ -103,20 +109,25 @@ function getLinearGradient({
</linearGradient>
</defs>
</svg>
)
) : null
}
type LinearGradient = Array<Array<*>>
type Chart = {
type GenericChart = {
id: string,
linearGradient: LinearGradient,
strokeWidth: number,
height: number,
padding?: Object | number,
padding: Object | number,
color: string,
data: Array<Object>,
}
type Chart = GenericChart & {
renderLabels: Function,
renderTickX: Function,
renderTickY: Function,
}
export const SimpleAreaChart = ({
linearGradient,
@ -126,10 +137,10 @@ export const SimpleAreaChart = ({
id,
padding,
color,
}: Chart) => (
}: GenericChart) => (
<WrapperChart
height={height}
render={({ width, isAnimationActive }) => (
render={({ width }) => (
<Fragment>
{getLinearGradient({
linearGradient,
@ -137,7 +148,9 @@ export const SimpleAreaChart = ({
color,
})}
<VictoryArea
animate={isAnimationActive ? { duration: ANIMATION_DURATION } : null}
domainPadding={{
y: [0, space[1]],
}}
data={data}
x="name"
y="value"
@ -158,114 +171,142 @@ export const SimpleAreaChart = ({
)
SimpleAreaChart.defaultProps = {
padding: 0,
height: 50,
id: 'simple-chart',
linearGradient: [],
strokeWidth: 1,
...DEFAULT_PROPS,
}
export const AreaChart = ({
strokeWidth,
id,
color,
linearGradient,
padding,
height,
data,
}: Chart) => {
const tickLabelsStyle = {
fill: colors.grey,
fontSize: fontSizes[4],
fontFamily: 'inherit',
fontWeight: 'inherit',
const areaChartTooltip = ({ renderLabels }: { renderLabels: Function }) => (
<VictoryTooltip
corderRadius={radii[1]}
pointerLength={0}
height={25}
labelComponent={
<VictoryLabel
style={{
...ff('Open Sans|SemiBold'),
fontSize: fontSizes[2],
fill: colors.white,
}}
/>
}
flyoutStyle={{
fill: colors.dark,
stroke: null,
}}
width={a => space[2] * 2 + renderLabels(a).length * 5.2} // Approximatif size of char for calculate Tooltip witdh
/>
)
const AreaChartContainer = <VictoryVoronoiContainer voronoiDimension="x" />
export class AreaChart extends PureComponent<Chart> {
static defaultProps = {
height: 100,
id: 'chart',
linearGradient: [[5, 0.2], [50, 0]],
strokeWidth: 2,
renderLabels: (d: Object) => d.y,
renderTickX: (t: any) => t,
renderTickY: (t: any) => t,
...DEFAULT_PROPS,
}
return (
<WrapperChart
height={height}
render={({ width, isAnimationActive }) => (
<Fragment>
{getLinearGradient({
linearGradient,
id,
color,
})}
<VictoryChart
height={height}
width={width}
padding={padding}
containerComponent={<VictoryVoronoiContainer voronoiDimension="x" />}
>
<VictoryAxis
tickCount={6}
style={{
axis: {
stroke: colors.lightGrey,
},
tickLabels: {
...tickLabelsStyle,
padding: space[2],
},
}}
/>
<VictoryAxis
dependentAxis
tickCount={4}
style={{
grid: {
stroke: colors.lightGrey,
strokeDasharray: 5,
},
axis: {
stroke: null,
},
tickLabels: {
...tickLabelsStyle,
padding: space[4],
},
render() {
const {
color,
data,
height,
id,
linearGradient,
padding,
renderLabels,
renderTickX,
renderTickY,
strokeWidth,
} = this.props
const tickLabelsStyle = {
fill: colors.grey,
fontSize: fontSizes[4],
fontFamily: 'inherit',
fontWeight: 'inherit',
}
return (
<WrapperChart
height={height}
render={({ width, isAnimationActive }) => (
<Fragment>
{getLinearGradient({
linearGradient,
id,
color,
})}
<VictoryChart
height={height}
width={width}
padding={padding}
domainPadding={{
y: [0, space[1]],
}}
/>
<VictoryArea
animate={isAnimationActive ? { duration: ANIMATION_DURATION } : null}
data={data}
x="name"
y="value"
labelComponent={
<VictoryTooltip
corderRadius={radii[1]}
pointerLength={0}
height={25}
labelComponent={
<VictoryLabel
style={{
...ff('Open Sans|SemiBold'),
fontSize: fontSizes[2],
fill: colors.white,
}}
/>
}
flyoutStyle={{
fill: rgba(colors.dark, 0.8),
containerComponent={AreaChartContainer}
>
<VictoryAxis
tickCount={6}
tickFormat={renderTickX}
style={{
axis: {
stroke: colors.lightGrey,
},
tickLabels: {
...tickLabelsStyle,
padding: space[2],
},
}}
/>
<VictoryAxis
dependentAxis
tickCount={4}
tickFormat={renderTickY}
style={{
grid: {
stroke: colors.lightGrey,
strokeDasharray: 5,
},
axis: {
stroke: null,
}}
width={a => space[1] * 2 + a.value.length}
/>
}
labels={d => d.y}
style={{
data: {
stroke: color,
fill: `url(#${id})`,
strokeWidth,
},
}}
width={width}
/>
</VictoryChart>
</Fragment>
)}
/>
)
}
AreaChart.defaultProps = {
linearGradient: [[5, 0.2], [50, 0]],
padding: undefined,
},
tickLabels: {
...tickLabelsStyle,
padding: space[4],
},
}}
/>
<VictoryArea
animate={isAnimationActive ? { duration: ANIMATION_DURATION } : null}
data={data}
x="name"
y="value"
labelComponent={areaChartTooltip({
renderLabels,
})}
labels={renderLabels}
style={{
data: {
stroke: color,
fill: `url(#${id})`,
strokeWidth,
},
}}
width={width}
/>
</VictoryChart>
</Fragment>
)}
/>
)
}
}

7
src/components/base/FormattedVal.js

@ -23,18 +23,19 @@ type Props = {
}
function FormattedVal(props: Props) {
const { val, fiat, isPercent, alwaysShowSign, showCode, ...p } = props
let { unit } = props
const { fiat, isPercent, alwaysShowSign, showCode, ...p } = props
let { val, unit } = props
const isNegative = val < 0
let text = ''
if (isPercent) {
text = `${alwaysShowSign ? (isNegative ? '- ' : '+ ') : ''}${val} %`
text = `${alwaysShowSign ? (isNegative ? '- ' : '+ ') : ''}${isNegative ? val * -1 : val} %`
} else {
if (fiat) {
unit = getFiatUnit(fiat)
val *= 100
} else if (!unit) {
return ''
}

3
src/components/base/Pills/index.js

@ -8,8 +8,9 @@ import Box, { Tabbable } from 'components/base/Box'
import BoldToggle from 'components/base/BoldToggle'
type Item = {
key: string,
label: string,
key: string,
value?: any,
}
type Props = {

2
src/components/modals/Receive.js

@ -83,7 +83,7 @@ class ReceiveModal extends PureComponent<Props, State> {
onChange={this.handleChangeInput('amount')}
/>
</Box>
<ReceiveBox path={account.path} amount={amount} address={account.address || ''} />
<ReceiveBox account={account} amount={amount} />
</Fragment>
)}
<Box horizontal justifyContent="center">

117
src/helpers/btc.js

@ -2,7 +2,14 @@
import ledger from 'ledger-test-library'
import bitcoin from 'bitcoinjs-lib'
import groupBy from 'lodash/groupBy'
import noop from 'lodash/noop'
import uniqBy from 'lodash/uniqBy'
import type { Transactions } from 'types/common'
const GAP_LIMIT_ADDRESSES = 20
export const networks = [
{
@ -15,36 +22,65 @@ export const networks = [
},
]
export function computeTransaction(addresses: Array<*>) {
return (transaction: Object) => {
const outputVal = transaction.outputs
export function computeTransaction(addresses: Array<string>) {
return (t: Object) => {
const outputVal = t.outputs
.filter(o => addresses.includes(o.address))
.reduce((acc, cur) => acc + cur.value, 0)
const inputVal = transaction.inputs
const inputVal = t.inputs
.filter(i => addresses.includes(i.address))
.reduce((acc, cur) => acc + cur.value, 0)
const balance = outputVal - inputVal
return {
...transaction,
address: t.balance > 0 ? t.inputs[0].address : t.outputs[0].address,
balance,
confirmations: t.confirmations,
hash: t.hash,
receivedAt: t.received_at,
}
}
}
export function getBalanceByDay(transactions: Transactions) {
const txsByDate = groupBy(transactions, tx => {
const [date] = new Date(tx.receivedAt).toISOString().split('T')
return date
})
let balance = 0
return Object.keys(txsByDate)
.sort()
.reduce((result, k) => {
const txs = txsByDate[k]
balance += txs.reduce((r, v) => {
r += v.balance
return r
}, 0)
result[k] = balance
return result
}, {})
}
export async function getAccount({
rootPath,
allAddresses = [],
allTxsHash = [],
currentIndex = 0,
path,
hdnode,
segwit,
network,
coinType,
asyncDelay = 500,
asyncDelay = 250,
onProgress = noop,
}: {
rootPath: string,
allAddresses?: Array<string>,
allTxsHash?: Array<string>,
currentIndex?: number,
path: string,
hdnode: Object,
segwit: boolean,
coinType: number,
@ -52,7 +88,6 @@ export async function getAccount({
asyncDelay?: number,
onProgress?: Function,
}) {
const gapLimit = 20
const script = segwit ? parseInt(network.scriptHash, 10) : parseInt(network.pubKeyHash, 10)
let balance = 0
@ -75,28 +110,26 @@ export async function getAccount({
const getPath = (type, index) => `${type === 'external' ? 0 : 1}/${index}`
const getAddress = ({ type, index }) => ({
type,
index,
address: getPublicAddress({ hdnode, path: getPath(type, index), script, segwit }),
})
const getAddress = ({ type, index }) => {
const p = getPath(type, index)
return {
type,
index,
path: `${rootPath}/${p}`,
address: getPublicAddress({ hdnode, path: p, script, segwit }),
}
}
const getAsyncAddress = params =>
new Promise(resolve => setTimeout(() => resolve(getAddress(params)), asyncDelay))
const getLastAddress = (addresses, txs) => {
const txsAddresses = [...txs.inputs.map(tx => tx.address), ...txs.outputs.map(tx => tx.address)]
const lastAddress = addresses.reverse().find(a => txsAddresses.includes(a.address)) || {
index: 0,
}
return {
...lastAddress,
address: getAddress({ type: 'external', index: lastAddress.index + 1 }).address,
}
const txsAddresses = [...txs.inputs.map(t => t.address), ...txs.outputs.map(t => t.address)]
return addresses.find(a => txsAddresses.includes(a.address)) || null
}
const nextPath = (index = 0) =>
Array.from(new Array(gapLimit).keys())
Array.from(new Array(GAP_LIMIT_ADDRESSES).keys())
.reduce(
(promise, v) =>
promise.then(async results => {
@ -115,41 +148,45 @@ export async function getAccount({
allAddresses = [...new Set([...allAddresses, ...listAddresses])]
const transactionsOpts = { coin_type: coinType }
const txs = await ledger.getTransactions(listAddresses, transactionsOpts)
let txs = await ledger.getTransactions(listAddresses, transactionsOpts)
txs = txs.filter(t => !allTxsHash.includes(t.hash)).reverse()
const hasTransactions = txs.length > 0
if (hasTransactions) {
const newTransactions = txs.map(computeTransaction(allAddresses))
const txHashs = transactions.map(t => t.hash)
balance = newTransactions
.filter(t => !txHashs.includes(t.hash))
.reduce((result, v) => result + v.balance, balance)
lastAddress = getLastAddress(addresses, txs[0])
transactions = [...transactions, ...newTransactions]
balance = newTransactions.reduce((result, v) => result + v.balance, balance)
transactions = uniqBy([...transactions, ...newTransactions], t => t.hash)
onProgress({
balance,
transactions: transactions.length,
})
return nextPath(index + (gapLimit - 1))
return nextPath(index + (GAP_LIMIT_ADDRESSES - 1))
}
const currentAddress =
lastAddress !== null ? lastAddress : getAddress({ type: 'external', index: 0 })
const { type, ...nextAddress } =
lastAddress !== null
? getAddress({
type: 'external',
index: lastAddress.index + 1,
})
: getAddress({ type: 'external', index: 0 })
return {
address: currentAddress.address,
addresses: allAddresses,
...nextAddress,
addresses: transactions.length > 0 ? allAddresses : [],
balance,
index: currentAddress.index,
path: `${path}/${getPath('external', currentAddress.index + 1)}`,
transactions: transactions.map(t => ({
confirmations: t.confirmations,
address: t.balance > 0 ? t.inputs[0].address : t.outputs[0].address,
balance: t.balance,
hash: t.hash,
receivedAt: t.received_at,
})),
balanceByDay: getBalanceByDay(transactions),
rootPath,
transactions,
}
})

2
src/helpers/db.js

@ -6,7 +6,7 @@ import get from 'lodash/get'
import { serializeAccounts, deserializeAccounts } from 'reducers/accounts'
type DBKey = 'settings' | 'accounts'
type DBKey = 'settings' | 'accounts' | 'counterValues'
const encryptionKey = {}

13
src/internals/accounts/sync.js

@ -4,12 +4,15 @@ import { getAccount, getHDNode, networks } from 'helpers/btc'
const network = networks[1]
function syncAccount({ id, ...currentAccount }) {
function syncAccount({ id, transactions, ...currentAccount }) {
const hdnode = getHDNode({ xpub58: id, network })
return getAccount({ hdnode, network, segwit: true, ...currentAccount }).then(account => ({
id,
...account,
}))
const allTxsHash = transactions.map(t => t.hash)
return getAccount({ hdnode, network, allTxsHash, segwit: true, ...currentAccount }).then(
account => ({
id,
...account,
}),
)
}
export default (send: Function) => ({

2
src/internals/usb/wallet/accounts.js

@ -148,7 +148,7 @@ export default async ({
const hdnode = getHDNode({ xpub58, network })
const account = await getAccount({
path,
rootPath: path,
hdnode,
coinType,
network,

3
src/middlewares/db.js

@ -15,10 +15,11 @@ export default store => next => action => {
dispatch({ type, payload: action.payload })
const state = getState()
const { settings } = state
const { settings, counterValues } = state
const accounts = getAccounts(state)
db.set('settings', settings)
db.set('accounts', accounts)
db.set('counterValues', counterValues)
}

17
src/reducers/accounts.js

@ -53,17 +53,18 @@ const handlers: Object = {
return existingAccount
}
const { transactions, index } = account
const { balance, balanceByDay, transactions } = existingAccount
const updatedAccount = {
...existingAccount,
...account,
balance: transactions.reduce((result, v) => {
result += v.balance
balance: balance + account.balance,
balanceByDay: Object.keys(balanceByDay).reduce((result, k) => {
result[k] = balanceByDay[k] + (account.balanceByDay[k] || 0)
return result
}, 0),
index: index || get(existingAccount, 'currentIndex', 0),
transactions,
}, {}),
index: account.index || get(existingAccount, 'index', 0),
transactions: [...transactions, ...account.transactions],
}
return orderAccountsTransactions(updatedAccount)
@ -114,11 +115,13 @@ export function serializeAccounts(accounts: Array<Object>) {
address: account.address,
addresses: account.addresses,
balance: account.balance,
balanceByDay: account.balanceByDay,
coinType: account.coinType,
currency: getCurrencyByCoinType(account.coinType),
index: account.index,
name: account.name || `${key}`,
path: account.path,
rootPath: account.rootPath,
unit: account.unit || getDefaultUnitByCoinType(account.coinType),
settings: account.settings,
}
@ -139,10 +142,12 @@ export function deserializeAccounts(accounts: Accounts) {
address: account.address,
addresses: account.addresses,
balance: account.balance,
balanceByDay: account.balanceByDay,
coinType: account.coinType,
index: account.index,
name: account.name,
path: account.path,
rootPath: account.rootPath,
transactions: account.transactions.map(({ account, ...t }) => t),
unit: account.unit,
settings: account.settings,

19
src/reducers/counterValues.js

@ -0,0 +1,19 @@
// @flow
import { handleActions } from 'redux-actions'
export type CounterValuesState = {}
const state: CounterValuesState = {}
const handlers = {
UPDATE_COUNTER_VALUES: (
state: CounterValuesState,
{ payload: counterValues }: { payload: CounterValuesState },
): CounterValuesState => ({
...state,
...counterValues,
}),
}
export default handleActions(handlers, state)

12
src/reducers/index.js

@ -5,23 +5,26 @@ import { routerReducer as router } from 'react-router-redux'
import type { LocationShape } from 'react-router'
import application from './application'
import accounts from './accounts'
import application from './application'
import counterValues from './counterValues'
import devices from './devices'
import modals from './modals'
import settings from './settings'
import update from './update'
import type { ApplicationState } from './application'
import type { AccountsState } from './accounts'
import type { ApplicationState } from './application'
import type { CounterValuesState } from './counterValues'
import type { DevicesState } from './devices'
import type { ModalsState } from './modals'
import type { SettingsState } from './settings'
import type { UpdateState } from './update'
export type State = {
application: ApplicationState,
accounts: AccountsState,
application: ApplicationState,
counterValues: CounterValuesState,
devices: DevicesState,
modals: ModalsState,
router: LocationShape,
@ -30,8 +33,9 @@ export type State = {
}
export default combineReducers({
application,
accounts,
application,
counterValues,
devices,
modals,
router,

36
src/renderer/events.js

@ -2,8 +2,6 @@
import { ipcRenderer } from 'electron'
import objectPath from 'object-path'
import get from 'lodash/get'
import uniqBy from 'lodash/uniqBy'
import debug from 'debug'
import type { Accounts } from 'types/common'
@ -53,12 +51,13 @@ export function startSyncAccounts(accounts: Accounts) {
syncAccounts = true
sendEvent('accounts', 'sync.all', {
accounts: accounts.map(account => {
const index = get(account, 'index', 0)
const addresses = get(account, 'addresses', [])
const { id, rootPath, addresses, index, transactions } = account
return {
id: account.id,
id,
allAddresses: addresses,
currentIndex: index,
rootPath,
transactions,
}
}),
})
@ -86,22 +85,17 @@ export default ({ store, locked }: { store: Object, locked: boolean }) => {
success: account => {
if (syncAccounts) {
const state = store.getState()
const currentAccount = getAccountById(state, account.id) || {}
const currentAccountTransactions = get(currentAccount, 'transactions', [])
const transactions = uniqBy(
[...currentAccountTransactions, ...account.transactions],
tx => tx.hash,
)
if (currentAccountTransactions.length !== transactions.length) {
d.sync(`Update account - ${currentAccount.name}`)
store.dispatch(
updateAccount({
...account,
transactions,
}),
)
const currentAccount = getAccountById(state, account.id)
if (!currentAccount) {
return
}
const { name } = currentAccount
if (account.transactions.length > 0) {
d.sync(`Update account - ${name}`)
store.dispatch(updateAccount(account))
}
}
},

6
src/renderer/init.js

@ -11,6 +11,7 @@ import events from 'renderer/events'
import { fetchAccounts } from 'actions/accounts'
import { fetchSettings } from 'actions/settings'
import { initCounterValues, fetchCounterValues } from 'actions/counterValues'
import { isLocked } from 'reducers/application'
import { getLanguage } from 'reducers/settings'
@ -20,13 +21,15 @@ import App from 'components/App'
import 'styles/global'
// Init settings with defaults if needed
// Init db with defaults if needed
db.init('settings', {})
db.init('counterValues', {})
const history = createHistory()
const store = createStore(history)
const rootNode = document.getElementById('app')
store.dispatch(initCounterValues())
store.dispatch(fetchSettings())
const state = store.getState() || {}
@ -38,6 +41,7 @@ if (!locked) {
db.init('accounts', [])
store.dispatch(fetchAccounts())
store.dispatch(fetchCounterValues())
}
function r(Comp) {

6
src/styles/global.js

@ -8,7 +8,7 @@ import '@fortawesome/fontawesome-free-solid'
import '@fortawesome/fontawesome-free-regular'
import '@fortawesome/fontawesome-free-brands'
import { fontFace, rgba } from 'styles/helpers'
import { fontFace } from 'styles/helpers'
import { radii, colors } from 'styles/theme'
import reset from './reset'
@ -90,11 +90,11 @@ injectGlobal`
${reset};
.tippy-tooltip {
background-color: ${rgba(colors.dark, 0.8)};
background-color: ${colors.dark};
border-radius: ${radii[1]}px;
}
.tippy-popper .tippy-roundarrow {
fill: ${rgba(colors.dark, 0.8)};
fill: ${colors.dark};
}
`

6
src/types/common.js

@ -21,6 +21,8 @@ export type Transaction = {
confirmations: number,
}
export type Transactions = Array<Transaction>
// -------------------- Accounts
export type AccountSettings = {
@ -32,13 +34,15 @@ export type Account = {
addresses: Array<string>,
archived?: boolean,
balance: number,
balanceByDay: Object,
coinType: number,
currency: Currency,
id: string,
index: number,
name: string,
path: string,
transactions: Array<Transaction>,
rootPath: string,
transactions: Transactions,
unit: Unit,
settings: AccountSettings,
}

30
yarn.lock

@ -3427,9 +3427,9 @@ dotenv@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef"
downshift@^1.28.1:
version "1.28.1"
resolved "https://registry.yarnpkg.com/downshift/-/downshift-1.28.1.tgz#8e787eda4e31c8a5519dccaf0e16dd90787720a4"
downshift@^1.28.2:
version "1.28.2"
resolved "https://registry.yarnpkg.com/downshift/-/downshift-1.28.2.tgz#ff5b4e89ff439943a8e58890993015199604e1e6"
duplexer3@^0.1.4:
version "0.1.4"
@ -7845,9 +7845,9 @@ preserve@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
prettier@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.11.0.tgz#c024f70cab158c993f50fc0c25ffe738cb8b0f85"
prettier@^1.11.1:
version "1.11.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.11.1.tgz#61e43fc4cd44e68f2b0dfc2c38cd4bb0fccdcc75"
pretty-bytes@^1.0.2:
version "1.0.4"
@ -8089,13 +8089,13 @@ range-parser@^1.0.3, range-parser@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e"
raven-js@^3.22.3:
version "3.22.3"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.22.3.tgz#8330dcc102b699ffbc2f48790978b997bf4d8f75"
raven-js@^3.22.4:
version "3.22.4"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.22.4.tgz#e5ac2aef7cdbbe639eef0db04703e99b6a0bcb28"
raven@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/raven/-/raven-2.4.1.tgz#7a6a6ff1c42d0a3892308f44c94273e7f88677fd"
raven@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/raven/-/raven-2.4.2.tgz#0129e2adc30788646fd530b67d08a8ce25d4f6dc"
dependencies:
cookie "0.3.1"
md5 "^2.2.1"
@ -8170,9 +8170,9 @@ react-fuzzy@^0.5.1:
fuse.js "^3.0.1"
prop-types "^15.5.9"
react-hot-loader@^4.0.0-beta.21:
version "4.0.0-rc.0"
resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.0.0-rc.0.tgz#54d931dafeface5119c741d44ccc3e75fbb432e8"
react-hot-loader@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.0.0.tgz#3452fa9bc0d0ba9dfc5b0ccfa25101ca8dbd2de2"
dependencies:
fast-levenshtein "^2.0.6"
global "^4.3.0"

Loading…
Cancel
Save