diff --git a/package.json b/package.json index 2cb56d5d..ebbe3e54 100644 --- a/package.json +++ b/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" } } diff --git a/src/actions/counterValues.js b/src/actions/counterValues.js new file mode 100644 index 00000000..c30169bb --- /dev/null +++ b/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)) + }) +} diff --git a/src/components/DashboardPage/BalanceInfos.js b/src/components/BalanceSummary/BalanceInfos.js similarity index 53% rename from src/components/DashboardPage/BalanceInfos.js rename to src/components/BalanceSummary/BalanceInfos.js index 070968ce..0c7d87e0 100644 --- a/src/components/DashboardPage/BalanceInfos.js +++ b/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 ( {'Total balance'} - - {'since one week'} + + since one {since} - - {'since one week'} + + since one {since} ) } -export default connect(mapStateToProps)(BalanceInfos) +export default BalanceInfos diff --git a/src/components/BalanceSummary/index.js b/src/components/BalanceSummary/index.js new file mode 100644 index 00000000..71cf4d03 --- /dev/null +++ b/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) => ( + + ( + + + + + + + formatCurrencyUnit(getFiatUnit('USD'), d.y * 100, { + showCode: true, + }) + } + renderTickX={t => moment(t).format('MMM. D')} + /> + + + )} + /> + +) + +export default BalanceSummary diff --git a/src/components/CalculateBalance.js b/src/components/CalculateBalance.js new file mode 100644 index 00000000..393c435f --- /dev/null +++ b/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, + totalBalance: number, + sinceBalance: number, +} + +class CalculateBalance extends PureComponent { + 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) diff --git a/src/components/DashboardPage/AccountCard.js b/src/components/DashboardPage/AccountCard.js index 91b189c2..f41e69c7 100644 --- a/src/components/DashboardPage/AccountCard.js +++ b/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, onClick: Function, + daysCount: number, }) => { const Icon = getIconByCoinType(account.currency.coinType) return ( - - - - {Icon && } - - - - {account.unit.code} + + + + + {Icon && } - - {account.name} + + + {account.unit.code} + + + {account.name} + + + + + - - - - - ( + + + + + + + + + + + + )} /> ) diff --git a/src/components/DashboardPage/index.js b/src/components/DashboardPage/index.js index 2b2af8ee..287440cc 100644 --- a/src/components/DashboardPage/index.js +++ b/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, + accountsChunk: Array>, allTransactions: Array, - fakeDatas: Object, - fakeDatasMerge: Array, 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 { - 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 { 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 { this.setState({ selectedTime: item.key })} + onChange={this.handleChangeSelectedTime} /> {totalAccounts > 0 && ( - - - - - - - - + @@ -278,9 +192,9 @@ class DashboardPage extends PureComponent { /> ) : ( push(`/account/${account.id}`)} /> ), diff --git a/src/components/ReceiveBox.js b/src/components/ReceiveBox.js index d66fe42b..98435405 100644 --- a/src/components/ReceiveBox.js +++ b/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 { } 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 { } 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 { } 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 { ) } + const { address } = account + return ( diff --git a/src/components/SelectAccount/stories.js b/src/components/SelectAccount/stories.js index 91da4439..b93c15d8 100644 --- a/src/components/SelectAccount/stories.js +++ b/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: { diff --git a/src/components/SideBar/index.js b/src/components/SideBar/index.js index ffbafb06..e80a5216 100644 --- a/src/components/SideBar/index.js +++ b/src/components/SideBar/index.js @@ -92,7 +92,7 @@ class SideBar extends PureComponent { {t('sidebar:accounts')} - t('addAccount:title')} offset={[0, 1]}> + t('addAccount:title')}> openModal(MODAL_ADD_ACCOUNT)}> diff --git a/src/components/base/Chart/index.js b/src/components/base/Chart/index.js index 965fcf5d..85b94102 100644 --- a/src/components/base/Chart/index.js +++ b/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 ? ( @@ -103,20 +109,25 @@ function getLinearGradient({ - ) + ) : null } type LinearGradient = Array> -type Chart = { +type GenericChart = { id: string, linearGradient: LinearGradient, strokeWidth: number, height: number, - padding?: Object | number, + padding: Object | number, color: string, data: Array, } +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) => ( ( + render={({ width }) => ( {getLinearGradient({ linearGradient, @@ -137,7 +148,9 @@ export const SimpleAreaChart = ({ color, })} { - const tickLabelsStyle = { - fill: colors.grey, - fontSize: fontSizes[4], - fontFamily: 'inherit', - fontWeight: 'inherit', +const areaChartTooltip = ({ renderLabels }: { renderLabels: Function }) => ( + + } + 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 = + +export class AreaChart extends PureComponent { + 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 ( - ( - - {getLinearGradient({ - linearGradient, - id, - color, - })} - } - > - - ( + + {getLinearGradient({ + linearGradient, + id, + color, + })} + - - } - flyoutStyle={{ - fill: rgba(colors.dark, 0.8), + containerComponent={AreaChartContainer} + > + + space[1] * 2 + a.value.length} - /> - } - labels={d => d.y} - style={{ - data: { - stroke: color, - fill: `url(#${id})`, - strokeWidth, - }, - }} - width={width} - /> - - - )} - /> - ) -} - -AreaChart.defaultProps = { - linearGradient: [[5, 0.2], [50, 0]], - padding: undefined, + }, + tickLabels: { + ...tickLabelsStyle, + padding: space[4], + }, + }} + /> + + + + )} + /> + ) + } } diff --git a/src/components/base/FormattedVal.js b/src/components/base/FormattedVal.js index f3beb91d..e7e9d8cb 100644 --- a/src/components/base/FormattedVal.js +++ b/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 '' } diff --git a/src/components/base/Pills/index.js b/src/components/base/Pills/index.js index c6aaf1f9..b32e4456 100644 --- a/src/components/base/Pills/index.js +++ b/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 = { diff --git a/src/components/modals/Receive.js b/src/components/modals/Receive.js index b4657417..86eb9e1a 100644 --- a/src/components/modals/Receive.js +++ b/src/components/modals/Receive.js @@ -83,7 +83,7 @@ class ReceiveModal extends PureComponent { onChange={this.handleChangeInput('amount')} /> - + )} diff --git a/src/helpers/btc.js b/src/helpers/btc.js index 9be3c1dd..9df36216 100644 --- a/src/helpers/btc.js +++ b/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) { + 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, + allTxsHash?: Array, 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, } }) diff --git a/src/helpers/db.js b/src/helpers/db.js index 499b7011..6110a036 100644 --- a/src/helpers/db.js +++ b/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 = {} diff --git a/src/internals/accounts/sync.js b/src/internals/accounts/sync.js index f660f96b..68064960 100644 --- a/src/internals/accounts/sync.js +++ b/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) => ({ diff --git a/src/internals/usb/wallet/accounts.js b/src/internals/usb/wallet/accounts.js index 982c113c..d2f9359e 100644 --- a/src/internals/usb/wallet/accounts.js +++ b/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, diff --git a/src/middlewares/db.js b/src/middlewares/db.js index 4a0c12aa..be324035 100644 --- a/src/middlewares/db.js +++ b/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) } diff --git a/src/reducers/accounts.js b/src/reducers/accounts.js index 22da5f68..19c103cd 100644 --- a/src/reducers/accounts.js +++ b/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) { 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, diff --git a/src/reducers/counterValues.js b/src/reducers/counterValues.js new file mode 100644 index 00000000..5ac78eb6 --- /dev/null +++ b/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) diff --git a/src/reducers/index.js b/src/reducers/index.js index 743439c1..9f6a78f9 100644 --- a/src/reducers/index.js +++ b/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, diff --git a/src/renderer/events.js b/src/renderer/events.js index 9079c377..3123b62c 100644 --- a/src/renderer/events.js +++ b/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)) } } }, diff --git a/src/renderer/init.js b/src/renderer/init.js index 080c6eae..59bf5154 100644 --- a/src/renderer/init.js +++ b/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) { diff --git a/src/styles/global.js b/src/styles/global.js index c0dfa575..5a02d28c 100644 --- a/src/styles/global.js +++ b/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}; } ` diff --git a/src/types/common.js b/src/types/common.js index 960f9fea..20d517a3 100644 --- a/src/types/common.js +++ b/src/types/common.js @@ -21,6 +21,8 @@ export type Transaction = { confirmations: number, } +export type Transactions = Array + // -------------------- Accounts export type AccountSettings = { @@ -32,13 +34,15 @@ export type Account = { addresses: Array, archived?: boolean, balance: number, + balanceByDay: Object, coinType: number, currency: Currency, id: string, index: number, name: string, path: string, - transactions: Array, + rootPath: string, + transactions: Transactions, unit: Unit, settings: AccountSettings, } diff --git a/yarn.lock b/yarn.lock index 4a8c3407..8fe90257 100644 --- a/yarn.lock +++ b/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"