diff --git a/__tests__/components/__snapshots__/TransactionHistoryPanel.test.js.snap b/__tests__/components/__snapshots__/TransactionHistoryPanel.test.js.snap index b869f301f..ed4cc3936 100644 --- a/__tests__/components/__snapshots__/TransactionHistoryPanel.test.js.snap +++ b/__tests__/components/__snapshots__/TransactionHistoryPanel.test.js.snap @@ -1,9 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`TransactionHistoryPanel renders without crashing 1`] = ` - `; diff --git a/app/actions/transactionHistoryActions.js b/app/actions/transactionHistoryActions.js index d90b1e837..b25ef28b6 100644 --- a/app/actions/transactionHistoryActions.js +++ b/app/actions/transactionHistoryActions.js @@ -13,7 +13,11 @@ type Props = { shouldIncrementPagination: boolean, } -async function parseAbstractData(data, currentUserAddress, net) { +export async function parseAbstractData( + data: Array, + currentUserAddress: string, + net: string, +) { const parsedTxType = abstract => { if ( abstract.address_to === currentUserAddress && @@ -48,6 +52,7 @@ async function parseAbstractData(data, currentUserAddress, net) { time: abstract.time, amount: abstract.amount, asset, + symbol: asset.symbol, image: asset.image, label: type === TX_TYPES.CLAIM ? 'GAS Claim' : asset.symbol, type, diff --git a/app/components/Contacts/ContactForm/ContactForm.jsx b/app/components/Contacts/ContactForm/ContactForm.jsx index 75025a839..bca143a28 100644 --- a/app/components/Contacts/ContactForm/ContactForm.jsx +++ b/app/components/Contacts/ContactForm/ContactForm.jsx @@ -193,11 +193,13 @@ export default class ContactForm extends React.Component { handleChangeName = (event: Object) => { this.clearErrors(event.target.name) this.props.setName(event.target.value) + this.validate(event.target.value, this.props.formAddress) } handleChangeAddress = (event: Object) => { this.clearErrors(event.target.name) this.props.setAddress(event.target.value) + this.validate(this.props.formName, event.target.value) } handleSubmit = (event: Object) => { diff --git a/app/components/Contacts/ContactsPanel/ContactsPanel.jsx b/app/components/Contacts/ContactsPanel/ContactsPanel.jsx index 060f373ea..57f0ddcd5 100644 --- a/app/components/Contacts/ContactsPanel/ContactsPanel.jsx +++ b/app/components/Contacts/ContactsPanel/ContactsPanel.jsx @@ -149,7 +149,7 @@ export default class ContactsPanel extends React.Component {
{ className={styles.settingsDonations} > diff --git a/app/components/Contacts/ContactsPanel/ContactsPanel.scss b/app/components/Contacts/ContactsPanel/ContactsPanel.scss index 094a5ab5a..aa657a86e 100644 --- a/app/components/Contacts/ContactsPanel/ContactsPanel.scss +++ b/app/components/Contacts/ContactsPanel/ContactsPanel.scss @@ -26,7 +26,7 @@ align-items: center; justify-content: space-between; flex-wrap: nowrap; - padding: 12px 24px; + padding: 12px 12px 12px 24px; button { background-color: transparent; @@ -53,29 +53,15 @@ display: flex; align-items: center; + span { + margin-left: 6px; + } + svg { path { fill: var(--tx-list-button-icon); } } - - .editButton { - width: 100px; - margin-right: 10px; - } - - .infoButton { - width: 150px; - margin-right: 10px; - } - - .sendButton { - width: 150px; - } - - .deleteButton { - width: 100px; - } } } } diff --git a/app/components/ErrorBoundaries/Main/Main.jsx b/app/components/ErrorBoundaries/Main/Main.jsx new file mode 100644 index 000000000..cea884b8f --- /dev/null +++ b/app/components/ErrorBoundaries/Main/Main.jsx @@ -0,0 +1,63 @@ +// @flow +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import styles from './Main.scss' +import Button from '../../Button' +import { ROUTES } from '../../../core/constants' +import themes from '../../../themes' +import Arrow from '../../../assets/icons/arrow.svg' +import lightLogo from '../../../assets/images/logo-light.png' +import darkLogo from '../../../assets/images/logo-dark.png' + +type Props = { + children: React$Node, + theme: string, + logout: () => void, +} + +type State = { + hasError: boolean, +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + componentDidCatch(error: Error, info: any) { + console.warn('An unkown error has occurred!', { error, info }) + this.setState({ hasError: true }) + } + + render() { + const { theme, children, logout } = this.props + const dynamicImage = theme === 'Light' ? lightLogo : darkLogo + + if (this.state.hasError) { + return ( +
+ +

Oops something went wrong...

+ + + +
+ ) + } + + return children + } +} diff --git a/app/components/ErrorBoundaries/Main/Main.scss b/app/components/ErrorBoundaries/Main/Main.scss new file mode 100644 index 000000000..cb3e22715 --- /dev/null +++ b/app/components/ErrorBoundaries/Main/Main.scss @@ -0,0 +1,18 @@ +.errorContainer { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.logo { + width: 172px; + margin-bottom: 24px; +} + +.backButton { + width: 250px; + margin-top: 48px; +} diff --git a/app/components/ErrorBoundaries/Main/index.js b/app/components/ErrorBoundaries/Main/index.js new file mode 100644 index 000000000..f95cac0ec --- /dev/null +++ b/app/components/ErrorBoundaries/Main/index.js @@ -0,0 +1,20 @@ +// @flow +import { compose } from 'recompose' +import { withActions } from 'spunky' + +import Main from './Main' +import withThemeData from '../../../hocs/withThemeData' +import { logoutActions } from '../../../actions/authActions' + +type Props = { + logout: Function, +} + +const mapActionsToProps = (actions): Props => ({ + logout: () => actions.call(), +}) + +export default compose( + withActions(logoutActions, mapActionsToProps), + withThemeData(), +)(Main) diff --git a/app/components/PanelHeaderButton/PanelHeaderButton.jsx b/app/components/PanelHeaderButton/PanelHeaderButton.jsx index 2d378e56f..6603b5c43 100644 --- a/app/components/PanelHeaderButton/PanelHeaderButton.jsx +++ b/app/components/PanelHeaderButton/PanelHeaderButton.jsx @@ -5,7 +5,7 @@ import classNames from 'classnames' import styles from './PanelHeaderButton.scss' type Props = { - onClick: () => void, + onClick: () => void | Promise, renderIcon: () => React$Node, buttonText: string, className?: string, diff --git a/app/components/TransactionHistory/TransactionHistoryPanel/TransactionHistoryPanel.jsx b/app/components/TransactionHistory/TransactionHistoryPanel/TransactionHistoryPanel.jsx index f9e594054..838ec4eb1 100644 --- a/app/components/TransactionHistory/TransactionHistoryPanel/TransactionHistoryPanel.jsx +++ b/app/components/TransactionHistory/TransactionHistoryPanel/TransactionHistoryPanel.jsx @@ -16,6 +16,7 @@ type Props = { handleRefreshTxData: () => void, pendingTransactions: Array, address: string, + showSuccessNotification: ({ message: string }) => void, } const REFRESH_INTERVAL_MS = 30000 @@ -55,9 +56,13 @@ export default class TransactionHistory extends React.Component { } addPolling = () => { + const { showSuccessNotification } = this.props this.transactionDataInterval = setInterval(async () => { await this.props.handleGetPendingTransactionInfo() this.props.handleRefreshTxData() + showSuccessNotification({ + message: 'Recevied latest transaction information.', + }) }, REFRESH_INTERVAL_MS) } diff --git a/app/components/TransactionHistory/TransactionHistoryPanel/index.js b/app/components/TransactionHistory/TransactionHistoryPanel/index.js index 37b246567..91f487d54 100644 --- a/app/components/TransactionHistory/TransactionHistoryPanel/index.js +++ b/app/components/TransactionHistory/TransactionHistoryPanel/index.js @@ -1,7 +1,9 @@ // @flow import { compose } from 'recompose' import { withData, withActions, withCall } from 'spunky' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' import TransactionHistoryPanel from './TransactionHistoryPanel' import transactionHistoryActions from '../../../actions/transactionHistoryActions' import { getPendingTransactionInfo } from '../../../actions/pendingTransactionActions' @@ -9,7 +11,11 @@ import withProgressPanel from '../../../hocs/withProgressPanel' import withAuthData from '../../../hocs/withAuthData' import withNetworkData from '../../../hocs/withNetworkData' import withLoadingProp from '../../../hocs/withLoadingProp' -import withSuccessNotification from '../../../hocs/withSuccessNotification' +import { + showErrorNotification, + showSuccessNotification, + showInfoNotification, +} from '../../../modules/notifications' const mapTransactionsDataToProps = transactions => ({ transactions, @@ -42,7 +48,20 @@ const mapPendingTransactionInfoToProps = pendingTransactions => ({ pendingTransactions: pendingTransactions || [], }) +const actionCreators = { + showErrorNotification, + showSuccessNotification, + showInfoNotification, +} + +const mapDispatchToProps = dispatch => + bindActionCreators(actionCreators, dispatch) + export default compose( + connect( + null, + mapDispatchToProps, + ), withAuthData(), withNetworkData(), withProgressPanel(transactionHistoryActions, { @@ -54,8 +73,4 @@ export default compose( withData(getPendingTransactionInfo, mapPendingTransactionInfoToProps), withLoadingProp(transactionHistoryActions), withData(transactionHistoryActions, mapTransactionsDataToProps), - withSuccessNotification( - transactionHistoryActions, - 'Received latest transaction information.', - ), )(TransactionHistoryPanel) diff --git a/app/containers/App/App.jsx b/app/containers/App/App.jsx index 2db48d56a..5bf7ee2cf 100644 --- a/app/containers/App/App.jsx +++ b/app/containers/App/App.jsx @@ -10,6 +10,7 @@ import { upgradeUserWalletNEP6 } from '../../modules/generateWallet' import styles from './App.scss' import themes from '../../themes' +import ErrorBoundary from '../../components/ErrorBoundaries/Main' type Props = { children: React$Node, @@ -47,17 +48,19 @@ class App extends Component { const { children, address, theme, location } = this.props return ( -
- {address && - routesWithSideBar.includes(location.pathname) && ( - - )} -
-
{children}
- - + +
+ {address && + routesWithSideBar.includes(location.pathname) && ( + + )} +
+
{children}
+ + +
-
+ ) } } diff --git a/app/containers/Buttons/RefreshButton/RefreshButton.jsx b/app/containers/Buttons/RefreshButton/RefreshButton.jsx index 000e5b981..e6363074d 100644 --- a/app/containers/Buttons/RefreshButton/RefreshButton.jsx +++ b/app/containers/Buttons/RefreshButton/RefreshButton.jsx @@ -7,7 +7,7 @@ import RefreshIcon from '../../../assets/icons/refresh.svg' import styles from './RefreshButton.scss' type Props = { - loadWalletData: () => void, + loadWalletData?: () => void, loading?: boolean, } diff --git a/app/containers/TransactionHistory/TransactionHistory.jsx b/app/containers/TransactionHistory/TransactionHistory.jsx index 2a8bb7323..4e28779c7 100644 --- a/app/containers/TransactionHistory/TransactionHistory.jsx +++ b/app/containers/TransactionHistory/TransactionHistory.jsx @@ -1,15 +1,168 @@ -import React from 'react' +// @flow +import React, { Component } from 'react' +import fs from 'fs' +import { Parser } from 'json2csv' +import { api } from '@cityofzion/neon-js' +import axios from 'axios' +import { omit } from 'lodash-es' +import moment from 'moment' import HeaderBar from '../../components/HeaderBar' import TransactionHistoryPanel from '../../components/TransactionHistory/TransactionHistoryPanel' - import styles from './TransactionHistory.scss' +import RefreshButton from '../Buttons/RefreshButton' +import ExportIcon from '../../assets/icons/export.svg' +import PanelHeaderButton from '../../components/PanelHeaderButton/PanelHeaderButton' +import { parseAbstractData } from '../../actions/transactionHistoryActions' + +const { dialog, app } = require('electron').remote + +type Props = { + showSuccessNotification: ({ message: string }) => string, + showErrorNotification: ({ message: string }) => string, + showInfoNotification: ({ message: string }) => string, + hideNotification: string => void, + net: string, + address: string, +} + +type State = { + isExporting: boolean, +} -export default function TransactionHistory() { - return ( -
- - +export default class TransactionHistory extends Component { + state = { + isExporting: false, + } + + render() { + return ( +
+ this.renderPanelHeaderContent()} + /> + +
+ ) + } + + renderPanelHeaderContent = () => ( +
+ } + buttonText="Export" + /> +
) + + fetchHistory = async () => { + const { showInfoNotification, net, address } = this.props + const infoNotification = showInfoNotification({ + message: 'Fetching entire transaction history...', + }) + const abstracts = [] + const endpoint = api.neoscan.getAPIEndpoint(net) + let numberOfPages = 1 + let currentPage = 1 + + while (currentPage !== numberOfPages || currentPage === 1) { + const { data } = await axios.get( + `${endpoint}/v1/get_address_abstracts/${address}/${currentPage}`, + ) + abstracts.push(...data.entries) + numberOfPages = data.total_pages + currentPage += 1 + } + + const parsedAbstracts = await parseAbstractData(abstracts, address, net) + return { infoNotification, parsedAbstracts } + } + + saveHistoryFile = async () => { + this.setState({ + isExporting: true, + }) + const { + showErrorNotification, + showSuccessNotification, + hideNotification, + } = this.props + const fields = [ + 'to', + 'from', + 'txid', + 'time', + 'amount', + 'symbol', + 'type', + 'id', + ] + try { + const { infoNotification, parsedAbstracts } = await this.fetchHistory() + const parser = new Parser(fields) + const csv = parser.parse( + parsedAbstracts.map(abstract => { + const omitted = omit(abstract, [ + 'isNetworkFee', + 'asset', + 'image', + 'label', + ]) + omitted.time = moment + .unix(omitted.time) + .format('MM/DD/YYYY | HH:mm:ss') + return omitted + }), + ) + hideNotification(infoNotification) + dialog.showSaveDialog( + { + defaultPath: `${app.getPath( + 'documents', + )}/neon-wallet-activity-${moment().unix()}.csv`, + filters: [ + { + name: 'CSV', + extensions: ['csv'], + }, + ], + }, + fileName => { + this.setState({ + isExporting: false, + }) + if (fileName === undefined) { + return + } + // fileName is a string that contains the path and filename created in the save file dialog. + fs.writeFile(fileName, csv, errorWriting => { + if (errorWriting) { + showErrorNotification({ + message: `An error occurred creating the file: ${ + errorWriting.message + }`, + }) + } else { + showSuccessNotification({ + message: 'The file has been succesfully saved', + }) + } + }) + }, + ) + } catch (err) { + console.error(err) + this.setState({ + isExporting: false, + }) + showErrorNotification({ + message: `An error occurred creating the file: ${err.message}`, + }) + } + } } diff --git a/app/containers/TransactionHistory/TransactionHistory.scss b/app/containers/TransactionHistory/TransactionHistory.scss index cf0da5226..9a215cf5d 100644 --- a/app/containers/TransactionHistory/TransactionHistory.scss +++ b/app/containers/TransactionHistory/TransactionHistory.scss @@ -7,3 +7,19 @@ flex: 1 1 auto; } } + +.panelHeaderButtons { + width: 225px; + display: flex; + justify-content: space-between; +} + +.exportButton { + font-size: 16px !important; + + svg { + path { + fill: var(--header-bar-default-icon-color); + } + } +} diff --git a/app/containers/TransactionHistory/index.js b/app/containers/TransactionHistory/index.js index 8d3e92e06..5ab206231 100644 --- a/app/containers/TransactionHistory/index.js +++ b/app/containers/TransactionHistory/index.js @@ -1,6 +1,8 @@ // @flow import { compose } from 'recompose' import { withCall } from 'spunky' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' import TransactionHistory from './TransactionHistory' import transactionHistoryActions from '../../actions/transactionHistoryActions' @@ -10,8 +12,28 @@ import balancesActions from '../../actions/balancesActions' import withLoadingProp from '../../hocs/withLoadingProp' import withSuccessNotification from '../../hocs/withSuccessNotification' import withFailureNotification from '../../hocs/withFailureNotification' +import { + showErrorNotification, + showSuccessNotification, + showInfoNotification, + hideNotification, +} from '../../modules/notifications' + +const actionCreators = { + showErrorNotification, + showSuccessNotification, + showInfoNotification, + hideNotification, +} + +const mapDispatchToProps = dispatch => + bindActionCreators(actionCreators, dispatch) export default compose( + connect( + null, + mapDispatchToProps, + ), withNetworkData(), withAuthData(), withLoadingProp(balancesActions), diff --git a/app/core/nep5.js b/app/core/nep5.js index 54740b679..97e8f7db6 100644 --- a/app/core/nep5.js +++ b/app/core/nep5.js @@ -1,5 +1,6 @@ // @flow -import { map } from 'lodash-es' +import { map, isEmpty } from 'lodash-es' +import axios from 'axios' import { toBigNumber } from './math' import { COIN_DECIMAL_LENGTH } from './formatters' @@ -11,6 +12,8 @@ import { } from './constants' import { imageMap } from '../assets/nep5/png' +let fetchedTokens + export const adjustDecimalAmountForTokenTransfer = (value: string): string => toBigNumber(value) .times(10 ** COIN_DECIMAL_LENGTH) @@ -19,36 +22,54 @@ export const adjustDecimalAmountForTokenTransfer = (value: string): string => const getTokenEntry = ((): Function => { let id = 1 + return ( symbol: string, scriptHash: string, networkId: string, name: string, decimals: number, + networkData: Object = {}, ) => ({ id: `${id++}`, // eslint-disable-line no-plusplus symbol, scriptHash, networkId, isUserGenerated: false, + totalSupply: networkData.totalSupply, + decimals: networkData.decimals, image: imageMap[symbol], - name, - decimals, }) })() export const getDefaultTokens = async (): Promise> => { const tokens = [] + // Prevent duplicate requests here + if (!fetchedTokens) { + const response = await axios + // use a time stamp query param to prevent caching + .get( + `https://raw.githubusercontent.com/CityOfZion/neo-tokens/master/tokenList.json?timestamp=${new Date().getTime()}`, + ) + .catch(error => { + console.error('Falling back to hardcoded list of NEP5 tokens!', error) + // if request to gh fails use hardcoded list + fetchedTokens = TOKENS + }) + if (response && response.data && !isEmpty(response.data)) { + fetchedTokens = response.data + } else { + fetchedTokens = TOKENS + } + } tokens.push( - ...map(TOKENS, tokenData => + ...map(fetchedTokens, tokenData => getTokenEntry( tokenData.symbol, tokenData.networks['1'].hash, MAIN_NETWORK_ID, - - tokenData.networks['1'].name, - tokenData.networks['1'].decimals, + tokenData.networks['1'], ), ), ) diff --git a/app/core/tokenList.json b/app/core/tokenList.json index fd7fdaa04..51e4b7de3 100644 --- a/app/core/tokenList.json +++ b/app/core/tokenList.json @@ -784,16 +784,16 @@ "image": "https://rawgit.com/CityOfZion/neo-tokens/master/assets/svg/tnc.svg" }, - "TOLL": { - "symbol": "TOLL", + "BRDG": { + "symbol": "BRDG", "companyName": "Bridge Protocol", "type": "NEP5", "networks": { "1": { "name": "Bridge Protocol", - "hash": "78fd589f7894bf9642b4a573ec0e6957dfd84c48", + "hash": "bac0d143a547dc66a1d6a2b7d66b06de42614971", "decimals": 8, - "totalSupply": 708097040 + "totalSupply": 450000000 } } }, diff --git a/package.json b/package.json index 7e389fe2f..671f1b03d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Neon", - "version": "2.1.0", + "version": "2.2.0", "main": "./main.js", "description": "Light wallet for NEO blockchain", "homepage": "https://github.com/CityOfZion/neon-wallet", @@ -92,6 +92,7 @@ "howler": "2.0.15", "instascan": "1.0.0", "isomorphic-fetch": "2.2.1", + "json2csv": "^4.3.5", "jsqr": "1.1.1", "lodash-es": "4.17.11", "moment": "2.22.2", diff --git a/yarn.lock b/yarn.lock index d1df16b48..4f641f038 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3325,7 +3325,7 @@ commander@2.17.x, commander@~2.17.1: version "2.17.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" -commander@^2.11.0, commander@^2.15.0, commander@^2.9.0: +commander@^2.11.0, commander@^2.15.0, commander@^2.15.1, commander@^2.9.0: version "2.19.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" @@ -7522,6 +7522,14 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" +json2csv@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-4.3.5.tgz#859c82c361cb12cca64fc012be96bd2c33d4860e" + dependencies: + commander "^2.15.1" + jsonparse "^1.3.1" + lodash.get "^4.4.2" + json3@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" @@ -7558,6 +7566,10 @@ jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -7842,7 +7854,7 @@ lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" -lodash.get@^4.0.0: +lodash.get@^4.0.0, lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"