diff --git a/app/actions/transactionNotification/index.js b/app/actions/transactionNotification/index.js new file mode 100644 index 00000000000..8c63d107e08 --- /dev/null +++ b/app/actions/transactionNotification/index.js @@ -0,0 +1,15 @@ +export function hideTransactionNotification() { + return { + type: 'HIDE_TRANSACTION_NOTIFICATION' + }; +} + +export function showTransactionNotification({ autodismiss, transaction, status }) { + return { + type: 'SHOW_TRANSACTION_NOTIFICATION', + isVisible: true, + autodismiss, + transaction, + status + }; +} diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 750a74f1e29..2e028edc196 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -15,7 +15,6 @@ import { createStackNavigator } from 'react-navigation-stack'; import { createBottomTabNavigator } from 'react-navigation-tabs'; import ENS from 'ethjs-ens'; import GlobalAlert from '../../UI/GlobalAlert'; -import FlashMessage from 'react-native-flash-message'; import BackgroundTimer from 'react-native-background-timer'; import Browser from '../../Views/Browser'; import AddBookmark from '../../Views/AddBookmark'; @@ -59,7 +58,6 @@ import PaymentChannel from '../../Views/PaymentChannel'; import ImportPrivateKeySuccess from '../../Views/ImportPrivateKeySuccess'; import PaymentRequest from '../../UI/PaymentRequest'; import PaymentRequestSuccess from '../../UI/PaymentRequestSuccess'; -import { TransactionNotification } from '../../UI/TransactionNotification'; import TransactionsNotificationManager from '../../../core/TransactionsNotificationManager'; import Engine from '../../../core/Engine'; import AppConstants from '../../../core/AppConstants'; @@ -100,6 +98,8 @@ import Amount from '../../Views/SendFlow/Amount'; import Confirm from '../../Views/SendFlow/Confirm'; import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import TransactionTypes from '../../../core/TransactionTypes'; +import TxNotification from '../../UI/TxNotification'; +import { showTransactionNotification, hideTransactionNotification } from '../../../actions/transactionNotification'; const styles = StyleSheet.create({ flex: { @@ -417,7 +417,15 @@ class Main extends PureComponent { /** * A string representing the network name */ - providerType: PropTypes.string + providerType: PropTypes.string, + /** + * Dispatch showing a transaction notification + */ + showTransactionNotification: PropTypes.func, + /** + * Dispatch hiding a transaction notification + */ + hideTransactionNotification: PropTypes.func }; state = { @@ -515,7 +523,11 @@ class Main extends PureComponent { }); setTimeout(() => { - TransactionsNotificationManager.init(this.props.navigation); + TransactionsNotificationManager.init( + this.props.navigation, + this.props.showTransactionNotification, + this.props.hideTransactionNotification + ); this.pollForIncomingTransactions(); this.initializeWalletConnect(); @@ -1063,18 +1075,13 @@ class Main extends PureComponent { render() { const { forceReload } = this.state; - return ( {!forceReload ? : this.renderLoader()} - + {this.renderSigningModal()} {this.renderWalletConnectSessionRequestModal()} @@ -1098,7 +1105,9 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ setEtherTransaction: transaction => dispatch(setEtherTransaction(transaction)), - setTransactionObject: transaction => dispatch(setTransactionObject(transaction)) + setTransactionObject: transaction => dispatch(setTransactionObject(transaction)), + showTransactionNotification: args => dispatch(showTransactionNotification(args)), + hideTransactionNotification: () => dispatch(hideTransactionNotification()) }); export default connect( diff --git a/app/components/UI/ActionModal/ActionContent/index.js b/app/components/UI/ActionModal/ActionContent/index.js new file mode 100644 index 00000000000..763df49b8e8 --- /dev/null +++ b/app/components/UI/ActionModal/ActionContent/index.js @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View } from 'react-native'; +import { colors } from '../../../../styles/common'; +import StyledButton from '../../StyledButton'; +import { strings } from '../../../../../locales/i18n'; + +const styles = StyleSheet.create({ + viewWrapper: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 24 + }, + viewContainer: { + width: '100%', + backgroundColor: colors.white, + borderRadius: 10 + }, + actionContainer: { + borderTopColor: colors.grey200, + borderTopWidth: 1, + flexDirection: 'row', + padding: 16 + }, + childrenContainer: { + minHeight: 250, + width: '100%', + + flexDirection: 'row', + alignItems: 'center' + }, + button: { + flex: 1 + }, + cancel: { + marginRight: 8 + }, + confirm: { + marginLeft: 8 + } +}); + +/** + * View that renders the content of an action modal + * The objective of this component is to reuse it in other places and not + * only on ActionModal component + */ +export default function ActionContent({ + cancelTestID, + confirmTestID, + cancelText, + children, + confirmText, + confirmDisabled, + cancelButtonMode, + confirmButtonMode, + displayCancelButton, + displayConfirmButton, + onCancelPress, + onConfirmPress +}) { + return ( + + + {children} + + {displayCancelButton && ( + + {cancelText} + + )} + {displayConfirmButton && ( + + {confirmText} + + )} + + + + ); +} + +ActionContent.defaultProps = { + cancelButtonMode: 'neutral', + confirmButtonMode: 'warning', + confirmTestID: '', + cancelTestID: '', + cancelText: strings('action_view.cancel'), + confirmText: strings('action_view.confirm'), + confirmDisabled: false, + displayCancelButton: true, + displayConfirmButton: true +}; + +ActionContent.propTypes = { + /** + * TestID for the cancel button + */ + cancelTestID: PropTypes.string, + /** + * TestID for the confirm button + */ + confirmTestID: PropTypes.string, + /** + * Text to show in the cancel button + */ + cancelText: PropTypes.string, + /** + * Content to display above the action buttons + */ + children: PropTypes.node, + /** + * Type of button to show as the cancel button + */ + cancelButtonMode: PropTypes.oneOf(['cancel', 'neutral', 'confirm', 'normal']), + /** + * Type of button to show as the confirm button + */ + confirmButtonMode: PropTypes.oneOf(['normal', 'confirm', 'warning']), + /** + * Whether confirm button is disabled + */ + confirmDisabled: PropTypes.bool, + /** + * Text to show in the confirm button + */ + confirmText: PropTypes.string, + /** + * Whether cancel button should be displayed + */ + displayCancelButton: PropTypes.bool, + /** + * Whether confirm button should be displayed + */ + displayConfirmButton: PropTypes.bool, + /** + * Called when the cancel button is clicked + */ + onCancelPress: PropTypes.func, + /** + * Called when the confirm button is clicked + */ + onConfirmPress: PropTypes.func +}; diff --git a/app/components/UI/ActionModal/__snapshots__/index.test.js.snap b/app/components/UI/ActionModal/__snapshots__/index.test.js.snap index 86d54eff6b6..2e7076fe32b 100644 --- a/app/components/UI/ActionModal/__snapshots__/index.test.js.snap +++ b/app/components/UI/ActionModal/__snapshots__/index.test.js.snap @@ -41,99 +41,16 @@ exports[`ActionModal should render correctly 1`] = ` swipeThreshold={100} useNativeDriver={false} > - - - - - - Cancel - - - Confirm - - - - + `; diff --git a/app/components/UI/ActionModal/index.js b/app/components/UI/ActionModal/index.js index 3098d9ffef4..0e6a5699f30 100644 --- a/app/components/UI/ActionModal/index.js +++ b/app/components/UI/ActionModal/index.js @@ -1,46 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; import Modal from 'react-native-modal'; -import { colors } from '../../../styles/common'; -import StyledButton from '../StyledButton'; import { strings } from '../../../../locales/i18n'; +import ActionContent from './ActionContent'; const styles = StyleSheet.create({ modal: { margin: 0, width: '100%' - }, - modalView: { - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - marginHorizontal: 24 - }, - modalContainer: { - width: '100%', - backgroundColor: colors.white, - borderRadius: 10 - }, - actionContainer: { - borderTopColor: colors.grey200, - borderTopWidth: 1, - flexDirection: 'row', - padding: 16 - }, - childrenContainer: { - minHeight: 250, - flexDirection: 'row', - alignItems: 'center' - }, - button: { - flex: 1 - }, - cancel: { - marginRight: 8 - }, - confirm: { - marginLeft: 8 } }); @@ -72,34 +40,21 @@ export default function ActionModal({ onSwipeComplete={onRequestClose} swipeDirection={'down'} > - - - {children} - - {displayCancelButton && ( - - {cancelText} - - )} - {displayConfirmButton && ( - - {confirmText} - - )} - - - + + {children} + ); } diff --git a/app/components/UI/AnimatedSpinner/__snapshots__/index.test.js.snap b/app/components/UI/AnimatedSpinner/__snapshots__/index.test.js.snap index 337b67336c3..5a6e4835cb6 100644 --- a/app/components/UI/AnimatedSpinner/__snapshots__/index.test.js.snap +++ b/app/components/UI/AnimatedSpinner/__snapshots__/index.test.js.snap @@ -34,7 +34,7 @@ exports[`AnimatedSpinner should render correctly 1`] = ` > diff --git a/app/components/UI/EthereumAddress/index.js b/app/components/UI/EthereumAddress/index.js index f195bd0c74f..213ae91e4dc 100644 --- a/app/components/UI/EthereumAddress/index.js +++ b/app/components/UI/EthereumAddress/index.js @@ -72,7 +72,6 @@ class EthereumAddress extends PureComponent { formatAndResolveIfNeeded() { const { address, type } = this.props; const formattedAddress = this.formatAddress(address, type); - // eslint-disable-next-line react/no-did-update-set-state this.setState({ address: formattedAddress, ensName: null }); this.doReverseLookup(); } diff --git a/app/components/UI/TransactionActionModal/TransactionActionContent/index.js b/app/components/UI/TransactionActionModal/TransactionActionContent/index.js new file mode 100644 index 00000000000..3cc0ae7b504 --- /dev/null +++ b/app/components/UI/TransactionActionModal/TransactionActionContent/index.js @@ -0,0 +1,105 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View, Text } from 'react-native'; +import { colors, fontStyles } from '../../../../styles/common'; +import { strings } from '../../../../../locales/i18n'; + +const styles = StyleSheet.create({ + modalView: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 24 + }, + feeWrapper: { + backgroundColor: colors.grey000, + textAlign: 'center', + padding: 16, + borderRadius: 8 + }, + fee: { + ...fontStyles.bold, + fontSize: 24, + textAlign: 'center' + }, + modalText: { + ...fontStyles.normal, + fontSize: 14, + textAlign: 'center', + paddingVertical: 8 + }, + modalTitle: { + ...fontStyles.bold, + fontSize: 22, + textAlign: 'center' + }, + gasTitle: { + ...fontStyles.bold, + fontSize: 16, + textAlign: 'center', + marginVertical: 8 + }, + warningText: { + ...fontStyles.normal, + fontSize: 12, + color: colors.red, + paddingVertical: 8, + textAlign: 'center' + } +}); + +/** + * View that renders a modal to be used for speed up or cancel transaction modal + */ +export default function TransactionActionContent({ + confirmDisabled, + feeText, + titleText, + gasTitleText, + descriptionText +}) { + return ( + + {titleText} + {gasTitleText} + + {feeText} + + {descriptionText} + {confirmDisabled && {strings('transaction.insufficient')}} + + ); +} + +TransactionActionContent.defaultProps = { + cancelButtonMode: 'neutral', + confirmButtonMode: 'warning', + cancelText: strings('action_view.cancel'), + confirmText: strings('action_view.confirm'), + confirmDisabled: false, + displayCancelButton: true, + displayConfirmButton: true +}; + +TransactionActionContent.propTypes = { + /** + * Whether confirm button is disabled + */ + confirmDisabled: PropTypes.bool, + /** + * Text to show as fee + */ + feeText: PropTypes.string, + /** + * Text to show as tit;e + */ + titleText: PropTypes.string, + /** + * Text to show as title of gas section + */ + gasTitleText: PropTypes.string, + /** + * Text to show as description + */ + descriptionText: PropTypes.string +}; diff --git a/app/components/UI/TransactionActionModal/index.js b/app/components/UI/TransactionActionModal/index.js new file mode 100644 index 00000000000..f566a7522f9 --- /dev/null +++ b/app/components/UI/TransactionActionModal/index.js @@ -0,0 +1,103 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { strings } from '../../../../locales/i18n'; +import ActionModal from '../ActionModal'; +import TransactionActionContent from './TransactionActionContent'; + +/** + * View that renders a modal to be used for speed up or cancel transaction modal + */ +export default function TransactionActionModal({ + isVisible, + confirmDisabled, + onCancelPress, + onConfirmPress, + confirmText, + cancelText, + feeText, + titleText, + gasTitleText, + descriptionText, + cancelButtonMode, + confirmButtonMode +}) { + return ( + + + + ); +} + +TransactionActionModal.defaultProps = { + cancelButtonMode: 'neutral', + confirmButtonMode: 'warning', + cancelText: strings('action_view.cancel'), + confirmText: strings('action_view.confirm'), + confirmDisabled: false, + displayCancelButton: true, + displayConfirmButton: true +}; + +TransactionActionModal.propTypes = { + isVisible: PropTypes.bool, + /** + * Text to show in the cancel button + */ + cancelText: PropTypes.string, + /** + * Whether confirm button is disabled + */ + confirmDisabled: PropTypes.bool, + /** + * Text to show in the confirm button + */ + confirmText: PropTypes.string, + /** + * Called when the cancel button is clicked + */ + onCancelPress: PropTypes.func, + /** + * Called when the confirm button is clicked + */ + onConfirmPress: PropTypes.func, + /** + * Cancel button enabled or disabled + */ + cancelButtonMode: PropTypes.string, + /** + * Confirm button enabled or disabled + */ + confirmButtonMode: PropTypes.string, + /** + * Text to show as fee + */ + feeText: PropTypes.string, + /** + * Text to show as tit;e + */ + titleText: PropTypes.string, + /** + * Text to show as title of gas section + */ + gasTitleText: PropTypes.string, + /** + * Text to show as description + */ + descriptionText: PropTypes.string +}; diff --git a/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.js.snap b/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.js.snap index c4485d565cd..69ce5a159dd 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.js.snap +++ b/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.js.snap @@ -4,44 +4,31 @@ exports[`TransactionDetails should render correctly 1`] = ` - - - Hash - + "paddingVertical": 16, + }, + Object { + "flexDirection": "row", + }, + Object { + "borderBottomColor": "#d6d9dc", + "borderBottomWidth": 1, + }, + ] + } + > - + - 0x2 ... 0x2 - - + Status + + + Confirmed + + + - - + + Date + + + [missing "en.date.months.NaN" translation] NaN at NaN:NaNam + + - - From - - - - - - - - - To - - - - - - - - Details - - - - Amount - - + - 2 TKN - - - - + From + + - Gas Limit (Units) - - + + - 21000 - - - - - Gas Price (GWEI) - - + To + + - 2 - + } + } + type="short" + /> + - + - - Total - - - 2 TKN / 0.001 ETH - - + "marginVertical": 8, + }, + Object { + "marginVertical": 24, + }, + ] + } + > + `; diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.js b/app/components/UI/TransactionElement/TransactionDetails/index.js index d6330030456..64f1420fe12 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.js +++ b/app/components/UI/TransactionElement/TransactionDetails/index.js @@ -1,86 +1,88 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Clipboard, TouchableOpacity, StyleSheet, Text, View } from 'react-native'; -import { colors, fontStyles } from '../../../../styles/common'; +import { TouchableOpacity, StyleSheet, Text, View } from 'react-native'; +import { colors, fontStyles, baseStyles } from '../../../../styles/common'; import { strings } from '../../../../../locales/i18n'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import { getNetworkTypeById, findBlockExplorerForRpc, getBlockExplorerName } from '../../../../util/networks'; +import NetworkList, { + getNetworkTypeById, + findBlockExplorerForRpc, + getBlockExplorerName +} from '../../../../util/networks'; import { getEtherscanTransactionUrl, getEtherscanBaseUrl } from '../../../../util/etherscan'; import Logger from '../../../../util/Logger'; import { connect } from 'react-redux'; import URL from 'url-parse'; -import Device from '../../../../util/Device'; import EthereumAddress from '../../EthereumAddress'; - -const HASH_LENGTH = Device.isSmallDevice() ? 18 : 20; +import TransactionSummary from '../../../Views/TransactionSummary'; +import { toDateFormat } from '../../../../util/date'; +import StyledButton from '../../StyledButton'; +import { safeToChecksumAddress } from '../../../../util/address'; +import AppConstants from '../../../../core/AppConstants'; const styles = StyleSheet.create({ detailRowWrapper: { - flex: 1, - backgroundColor: colors.grey000, - paddingVertical: 10, - paddingHorizontal: 15, - marginTop: 10 + paddingHorizontal: 15 }, detailRowTitle: { - flex: 1, - paddingVertical: 10, - fontSize: 15, - color: colors.fontPrimary, + fontSize: 10, + color: colors.grey500, + marginBottom: 8, ...fontStyles.normal }, - detailRowInfo: { - borderRadius: 5, - shadowColor: colors.grey400, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.5, - shadowRadius: 3, - backgroundColor: colors.white, - padding: 10, - marginBottom: 5 + flexRow: { + flexDirection: 'row' + }, + section: { + paddingVertical: 16 + }, + sectionBorderBottom: { + borderBottomColor: colors.grey100, + borderBottomWidth: 1 }, - detailRowInfoItem: { + flexEnd: { flex: 1, - flexDirection: 'row', - borderBottomWidth: StyleSheet.hairlineWidth, - borderColor: colors.grey100, - marginBottom: 10, - paddingBottom: 5 + alignItems: 'flex-end' }, - noBorderBottom: { - borderBottomWidth: 0 + textUppercase: { + textTransform: 'uppercase' }, detailRowText: { - flex: 1, fontSize: 12, - color: colors.fontSecondary, + color: colors.fontPrimary, ...fontStyles.normal }, - alignLeft: { - textAlign: 'left', - width: '40%' - }, - alignRight: { - textAlign: 'right', - width: '60%' - }, viewOnEtherscan: { - fontSize: 14, + fontSize: 16, color: colors.blue, ...fontStyles.normal, - textAlign: 'center', - marginTop: 15, - marginBottom: 10, - textTransform: 'uppercase' + textAlign: 'center' }, - hash: { - fontSize: 12 + touchableViewOnEtherscan: { + marginVertical: 24 }, - singleRow: { - flexDirection: 'row' + summaryWrapper: { + marginVertical: 8 + }, + statusText: { + fontSize: 12, + ...fontStyles.normal + }, + actionContainerStyle: { + height: 25, + width: 70, + padding: 0 }, - copyIcon: { - paddingRight: 5 + speedupActionContainerStyle: { + marginRight: 10 + }, + actionStyle: { + fontSize: 10, + padding: 0, + paddingHorizontal: 10 + }, + transactionActionsContainer: { + flexDirection: 'row', + paddingTop: 10 } }); @@ -104,26 +106,28 @@ class TransactionDetails extends PureComponent { */ transactionObject: PropTypes.object, /** - * Boolean to determine if this network supports a block explorer + * Object with information to render */ - blockExplorer: PropTypes.bool, + transactionDetails: PropTypes.object, /** - * Action that shows the global alert + * Frequent RPC list from PreferencesController */ - showAlert: PropTypes.func, + frequentRpcList: PropTypes.array, /** - * Object with information to render + * Callback to close the view */ - transactionDetails: PropTypes.object, + close: PropTypes.func, /** - * Frequent RPC list from PreferencesController + * A string representing the network name */ - frequentRpcList: PropTypes.array + providerType: PropTypes.string, + showSpeedUpModal: PropTypes.func, + showCancelModal: PropTypes.func }; state = { - cancelIsOpen: false, - rpcBlockExplorer: undefined + rpcBlockExplorer: undefined, + renderTxActions: true }; componentDidMount = () => { @@ -140,77 +144,14 @@ class TransactionDetails extends PureComponent { this.setState({ rpcBlockExplorer: blockExplorer }); }; - renderTxHash = transactionHash => { - if (!transactionHash) return null; - return ( - - {strings('transactions.hash')} - - {`${transactionHash.substr( - 0, - HASH_LENGTH - )} ... ${transactionHash.substr(-HASH_LENGTH)}`} - {this.renderCopyIcon()} - - - ); - }; - - copy = async () => { - await Clipboard.setString(this.props.transactionDetails.transactionHash); - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('transactions.hash_copied_to_clipboard') } - }); - }; - - copyFrom = async () => { - await Clipboard.setString(this.props.transactionDetails.renderFrom); - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('transactions.address_copied_to_clipboard') } - }); - }; - - copyTo = async () => { - await Clipboard.setString(this.props.transactionDetails.renderTo); - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('transactions.address_copied_to_clipboard') } - }); - }; - - renderCopyIcon = () => ( - - - - ); - - renderCopyToIcon = () => ( - - - - ); - - renderCopyFromIcon = () => ( - - - - ); - viewOnEtherscan = () => { const { transactionObject: { networkID }, transactionDetails: { transactionHash }, network: { provider: { type } - } + }, + close } = this.props; const { rpcBlockExplorer } = this.state; try { @@ -230,83 +171,125 @@ class TransactionDetails extends PureComponent { title: etherscan_url }); } + close && close(); } catch (e) { // eslint-disable-next-line no-console Logger.error(e, { message: `can't get a block explorer link for network `, networkID }); } }; - showCancelModal = () => { - this.setState({ cancelIsOpen: true }); + renderStatusText = status => { + status = status && status.charAt(0).toUpperCase() + status.slice(1); + switch (status) { + case 'Confirmed': + return {status}; + case 'Pending': + case 'Submitted': + return {status}; + case 'Failed': + case 'Cancelled': + return {status}; + } }; - hideCancelModal = () => { - this.setState({ cancelIsOpen: false }); - }; + renderSpeedUpButton = () => ( + + {strings('transaction.speedup')} + + ); + + renderCancelButton = () => ( + + {strings('transaction.cancel')} + + ); render = () => { - const { blockExplorer, transactionObject } = this.props; + const { + transactionObject, + transactionObject: { + status, + time, + transaction: { nonce, to } + }, + providerType + } = this.props; + const networkId = NetworkList[providerType].networkId; + const renderTxActions = status === 'submitted' || status === 'approved'; + const renderSpeedUpAction = safeToChecksumAddress(to) !== AppConstants.CONNEXT.CONTRACTS[networkId]; const { rpcBlockExplorer } = this.state; return ( - {this.renderTxHash(this.props.transactionDetails.transactionHash)} - {strings('transactions.from')} - - - {this.renderCopyFromIcon()} - - {strings('transactions.to')} - - - {this.renderCopyToIcon()} - - {strings('transactions.details')} - - - - {this.props.transactionDetails.valueLabel || strings('transactions.amount')} - - - {this.props.transactionDetails.renderValue} - - - - - {strings('transactions.gas_limit')} - - - {this.props.transactionDetails.renderGas} - + + + + {strings('transactions.status')} + {this.renderStatusText(status)} + {!!renderTxActions && ( + + {renderSpeedUpAction && this.renderSpeedUpButton()} + {this.renderCancelButton()} + + )} + + + {strings('transactions.date')} + {toDateFormat(time)} + - - - {strings('transactions.gas_price')} - - - {this.props.transactionDetails.renderGasPrice} - + + + + + {strings('transactions.from')} + + + + {strings('transactions.to')} + + - - {strings('transactions.total')} - - {this.props.transactionDetails.renderTotalValue} + + {!!nonce && ( + + + {strings('transactions.nonce')} + {`#${parseInt(nonce.replace(/^#/, ''), 16)}`} - {this.props.transactionDetails.renderTotalValueFiat ? ( - - - {this.props.transactionDetails.renderTotalValueFiat} - - - ) : null} + )} + + + {this.props.transactionDetails.transactionHash && transactionObject.status !== 'cancelled' && - blockExplorer && rpcBlockExplorer !== NO_RPC_BLOCK_EXPLORER && ( - + {(rpcBlockExplorer && `${strings('transactions.view_on')} ${getBlockExplorerName(rpcBlockExplorer)}`) || @@ -321,6 +304,7 @@ class TransactionDetails extends PureComponent { const mapStateToProps = state => ({ network: state.engine.backgroundState.NetworkController, - frequentRpcList: state.engine.backgroundState.PreferencesController.frequentRpcList + frequentRpcList: state.engine.backgroundState.PreferencesController.frequentRpcList, + providerType: state.engine.backgroundState.NetworkController.provider.type }); export default connect(mapStateToProps)(TransactionDetails); diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.test.js b/app/components/UI/TransactionElement/TransactionDetails/index.test.js index 250c4770bb4..7de7b36a91e 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.test.js +++ b/app/components/UI/TransactionElement/TransactionDetails/index.test.js @@ -20,7 +20,7 @@ describe('TransactionDetails', () => { NetworkController: { provider: { rpcTarget: '', - type: '' + type: 'rpc' } } } @@ -30,7 +30,11 @@ describe('TransactionDetails', () => { const wrapper = shallow( - - -`; diff --git a/app/components/UI/TransactionElement/TransferElement/index.js b/app/components/UI/TransactionElement/TransferElement/index.js deleted file mode 100644 index 057bc76c23b..00000000000 --- a/app/components/UI/TransactionElement/TransferElement/index.js +++ /dev/null @@ -1,269 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { TouchableOpacity, StyleSheet, View } from 'react-native'; -import { colors } from '../../../../styles/common'; -import { strings } from '../../../../../locales/i18n'; -import { - renderFromWei, - hexToBN, - renderFromTokenMinimalUnit, - fromTokenMinimalUnit, - balanceToFiat, - toBN, - isBN, - balanceToFiatNumber, - renderToGwei, - weiToFiatNumber -} from '../../../../util/number'; -import { getActionKey, decodeTransferData, isCollectibleAddress } from '../../../../util/transactions'; -import { renderFullAddress, safeToChecksumAddress } from '../../../../util/address'; - -const styles = StyleSheet.create({ - row: { - backgroundColor: colors.white, - flex: 1, - borderBottomWidth: StyleSheet.hairlineWidth, - borderColor: colors.grey100 - }, - rowContent: { - padding: 0 - } -}); - -/** - * View that renders a transfer transaction item, part of transactions list - */ -export default class TransferElement extends PureComponent { - static propTypes = { - /** - * Transaction object - */ - tx: PropTypes.object, - /** - * Object containing token exchange rates in the format address => exchangeRate - */ - contractExchangeRates: PropTypes.object, - /** - * ETH to current currency conversion rate - */ - conversionRate: PropTypes.number, - /** - * Currency code of the currently-active currency - */ - currentCurrency: PropTypes.string, - /** - * Callback function that will adjust the scroll - * position once the transaction detail is visible - */ - selected: PropTypes.bool, - /** - * String of selected address - */ - selectedAddress: PropTypes.string, - /** - * Callback to render transaction details view - */ - onPressItem: PropTypes.func, - /** - * An array that represents the user tokens - */ - tokens: PropTypes.object, - /** - * Current element of the list index - */ - i: PropTypes.number, - /** - * Callback to render corresponding transaction element - */ - renderTxElement: PropTypes.func, - /** - * Callback to render corresponding transaction element - */ - renderTxDetails: PropTypes.func, - /** - * An array that represents the user collectible contracts - */ - collectibleContracts: PropTypes.array - }; - - state = { - actionKey: undefined, - addressTo: '', - encodedAmount: '', - isCollectible: false - }; - - mounted = false; - - componentDidMount = async () => { - this.mounted = true; - const { - tx, - tx: { - transaction: { data, to } - }, - selectedAddress - } = this.props; - const actionKey = await getActionKey(tx, selectedAddress); - const [addressTo, encodedAmount] = decodeTransferData('transfer', data); - const isCollectible = await isCollectibleAddress(to, encodedAmount); - this.mounted && this.setState({ actionKey, addressTo, encodedAmount, isCollectible }); - }; - - componentWillUnmount() { - this.mounted = false; - } - - onPressItem = () => { - const { tx, i, onPressItem } = this.props; - onPressItem(tx.id, i); - }; - - getTokenTransfer = totalGas => { - const { - tx: { - transaction: { to } - }, - conversionRate, - currentCurrency, - tokens, - contractExchangeRates - } = this.props; - - const { actionKey, encodedAmount } = this.state; - - const amount = toBN(encodedAmount); - - const userHasToken = safeToChecksumAddress(to) in tokens; - const token = userHasToken ? tokens[safeToChecksumAddress(to)] : null; - const renderActionKey = token ? strings('transactions.sent') + ' ' + token.symbol : actionKey; - const renderTokenAmount = token - ? renderFromTokenMinimalUnit(amount, token.decimals) + ' ' + token.symbol - : undefined; - const exchangeRate = token ? contractExchangeRates[token.address] : undefined; - let renderTokenFiatAmount, renderTokenFiatNumber; - if (exchangeRate) { - renderTokenFiatAmount = - '- ' + - balanceToFiat( - fromTokenMinimalUnit(amount, token.decimals) || 0, - conversionRate, - exchangeRate, - currentCurrency - ).toUpperCase(); - renderTokenFiatNumber = balanceToFiatNumber( - fromTokenMinimalUnit(amount, token.decimals) || 0, - conversionRate, - exchangeRate - ); - } - - const renderToken = token - ? renderFromTokenMinimalUnit(amount, token.decimals) + ' ' + token.symbol - : strings('transaction.value_not_available'); - const totalFiatNumber = renderTokenFiatNumber - ? weiToFiatNumber(totalGas, conversionRate) + renderTokenFiatNumber - : undefined; - - const transactionDetails = { - renderValue: renderToken, - renderTotalValue: `${renderToken} ${strings('unit.divisor')} ${renderFromWei(totalGas)} ${strings( - 'unit.eth' - )}`, - renderTotalValueFiat: totalFiatNumber ? `${totalFiatNumber} ${currentCurrency}` : undefined - }; - - const transactionElement = { - actionKey: renderActionKey, - value: !renderTokenAmount ? strings('transaction.value_not_available') : renderTokenAmount, - fiatValue: renderTokenFiatAmount - }; - - return [transactionElement, transactionDetails]; - }; - - getCollectibleTransfer = totalGas => { - const { - tx: { - transaction: { to } - }, - collectibleContracts - } = this.props; - const { encodedAmount } = this.state; - let actionKey; - const tokenId = encodedAmount; - const collectible = collectibleContracts.find( - collectible => collectible.address.toLowerCase() === to.toLowerCase() - ); - if (collectible) { - actionKey = strings('transactions.sent') + ' ' + collectible.name; - } else { - actionKey = strings('transactions.sent_collectible'); - } - - const renderCollectible = collectible - ? strings('unit.token_id') + tokenId + ' ' + collectible.symbol - : strings('unit.token_id') + tokenId; - - const transactionDetails = { - renderValue: renderCollectible, - renderTotalValue: - renderCollectible + - ' ' + - strings('unit.divisor') + - ' ' + - renderFromWei(totalGas) + - ' ' + - strings('unit.eth'), - renderTotalValueFiat: undefined - }; - - const transactionElement = { - actionKey, - value: `${strings('unit.token_id')}${tokenId}`, - fiatValue: collectible ? collectible.symbol : undefined - }; - - return [transactionElement, transactionDetails]; - }; - - render = () => { - const { - selected, - tx, - tx: { - transaction: { from, gas, gasPrice }, - transactionHash - } - } = this.props; - const { addressTo } = this.state; - const gasBN = hexToBN(gas); - const gasPriceBN = hexToBN(gasPrice); - const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); - const renderGas = parseInt(gas, 16).toString(); - const renderGasPrice = renderToGwei(gasPrice); - - let [transactionElement, transactionDetails] = this.state.isCollectible - ? this.getCollectibleTransfer(totalGas) - : this.getTokenTransfer(totalGas); - transactionElement = { ...transactionElement, renderTo: addressTo }; - transactionDetails = { - ...transactionDetails, - ...{ - renderFrom: renderFullAddress(from), - renderTo: renderFullAddress(addressTo), - transactionHash, - renderGas, - renderGasPrice - } - }; - return ( - - - {this.props.renderTxElement(transactionElement)} - {this.props.renderTxDetails(selected, tx, transactionDetails)} - - - ); - }; -} diff --git a/app/components/UI/TransactionElement/TransferElement/index.test.js b/app/components/UI/TransactionElement/TransferElement/index.test.js deleted file mode 100644 index 2c20f5b0d05..00000000000 --- a/app/components/UI/TransactionElement/TransferElement/index.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import TransferElement from './'; -import configureMockStore from 'redux-mock-store'; -import { shallow } from 'enzyme'; - -const mockStore = configureMockStore(); - -describe('TransferElement', () => { - it('should render correctly', () => { - const initialState = {}; - - const wrapper = shallow( - ''} - // eslint-disable-next-line react/jsx-no-bind - renderTxDetails={() => ''} - />, - { - context: { store: mockStore(initialState) } - } - ); - expect(wrapper.dive()).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/TransactionElement/__snapshots__/index.test.js.snap b/app/components/UI/TransactionElement/__snapshots__/index.test.js.snap index 8c2257d5f93..27335c29c4c 100644 --- a/app/components/UI/TransactionElement/__snapshots__/index.test.js.snap +++ b/app/components/UI/TransactionElement/__snapshots__/index.test.js.snap @@ -1,140 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TransactionElement should render correctly 1`] = ` - - - - - #1 - - Invalid Date Invalid Date - - - - - - - CONFIRMED - - - - - 0 ETH - - - 0 USD - - - - - - -`; +exports[`TransactionElement should render correctly 1`] = ``; diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js index 0592c5f7352..ea40543050b 100644 --- a/app/components/UI/TransactionElement/index.js +++ b/app/components/UI/TransactionElement/index.js @@ -3,22 +3,21 @@ import PropTypes from 'prop-types'; import { TouchableHighlight, StyleSheet, Text, View, Image } from 'react-native'; import { colors, fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; -import { toLocaleDateTime } from '../../../util/date'; -import { renderFromWei, weiToFiat, hexToBN, toBN, isBN, renderToGwei, balanceToFiat } from '../../../util/number'; +import { toDateFormat } from '../../../util/date'; import Identicon from '../Identicon'; -import { getActionKey, decodeTransferData, getTicker } from '../../../util/transactions'; import TransactionDetails from './TransactionDetails'; -import { renderFullAddress, safeToChecksumAddress } from '../../../util/address'; +import { safeToChecksumAddress } from '../../../util/address'; import FadeIn from 'react-native-fade-in-image'; import TokenImage from '../TokenImage'; import contractMap from 'eth-contract-metadata'; -import TransferElement from './TransferElement'; import { connect } from 'react-redux'; import AppConstants from '../../../core/AppConstants'; import Ionicons from 'react-native-vector-icons/Ionicons'; import StyledButton from '../StyledButton'; import Networks from '../../../util/networks'; import Device from '../../../util/Device'; +import Modal from 'react-native-modal'; +import decodeTransaction from './utils'; const { CONNEXT: { CONTRACTS } @@ -141,7 +140,36 @@ const styles = StyleSheet.create({ flexDirection: 'row', paddingTop: 10, paddingLeft: 40 - } + }, + modalContainer: { + width: '90%', + backgroundColor: colors.white, + borderRadius: 10 + }, + modal: { + margin: 0, + width: '100%' + }, + modalView: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center' + }, + titleWrapper: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: colors.grey100, + flexDirection: 'row' + }, + title: { + flex: 1, + textAlign: 'center', + fontSize: 18, + marginVertical: 12, + marginHorizontal: 24, + color: colors.fontPrimary, + ...fontStyles.bold + }, + closeIcon: { paddingTop: 4, position: 'absolute', right: 16 } }); const ethLogo = require('../../../images/eth-logo.png'); // eslint-disable-line @@ -162,20 +190,18 @@ class TransactionElement extends PureComponent { /** * Object containing token exchange rates in the format address => exchangeRate */ + // eslint-disable-next-line react/no-unused-prop-types contractExchangeRates: PropTypes.object, /** * ETH to current currency conversion rate */ + // eslint-disable-next-line react/no-unused-prop-types conversionRate: PropTypes.number, /** * Currency code of the currently-active currency */ + // eslint-disable-next-line react/no-unused-prop-types currentCurrency: PropTypes.string, - /** - * Callback function that will adjust the scroll - * position once the transaction detail is visible - */ - selected: PropTypes.bool, /** * String of selected address */ @@ -191,26 +217,22 @@ class TransactionElement extends PureComponent { /** * An array that represents the user tokens */ + // eslint-disable-next-line react/no-unused-prop-types tokens: PropTypes.object, /** * An array that represents the user collectible contracts */ + // eslint-disable-next-line react/no-unused-prop-types collectibleContracts: PropTypes.array, - /** - * Boolean to determine if this network supports a block explorer - */ - blockExplorer: PropTypes.bool, - /** - * Action that shows the global alert - */ - showAlert: PropTypes.func, /** * Current provider ticker */ + // eslint-disable-next-line react/no-unused-prop-types ticker: PropTypes.string, /** * Current exchange rate */ + // eslint-disable-next-line react/no-unused-prop-types exchangeRate: PropTypes.number, /** * Callback to speed up tx @@ -223,27 +245,30 @@ class TransactionElement extends PureComponent { /** * A string representing the network name */ - providerType: PropTypes.string + providerType: PropTypes.string, + /** + * Primary currency, either ETH or Fiat + */ + // eslint-disable-next-line react/no-unused-prop-types + primaryCurrency: PropTypes.string }; state = { actionKey: undefined, cancelIsOpen: false, - speedUpIsOpen: false + speedUpIsOpen: false, + detailsModalVisible: false, + transactionGas: { gasBN: undefined, gasPriceBN: undefined, gasTotal: undefined }, + transactionElement: undefined, + transactionDetails: undefined }; mounted = false; componentDidMount = async () => { + const [transactionElement, transactionDetails] = await decodeTransaction(this.props); this.mounted = true; - const { - tx, - tx: { paymentChannelTransaction }, - selectedAddress, - ticker - } = this.props; - const actionKey = tx.actionKey || (await getActionKey(tx, selectedAddress, ticker, paymentChannelTransaction)); - this.mounted && this.setState({ actionKey }); + this.mounted && this.setState({ transactionElement, transactionDetails }); }; componentWillUnmount() { @@ -264,6 +289,11 @@ class TransactionElement extends PureComponent { onPressItem = () => { const { tx, i, onPressItem } = this.props; onPressItem(tx.id, i); + this.setState({ detailsModalVisible: true }); + }; + + onCloseDetailsModal = () => { + this.setState({ detailsModalVisible: false }); }; renderTxTime = () => { @@ -273,24 +303,11 @@ class TransactionElement extends PureComponent { return ( {(!incoming || selfSent) && tx.transaction.nonce && `#${parseInt(tx.transaction.nonce, 16)} - `} - {`${toLocaleDateTime(tx.time)}`} + {`${toDateFormat(tx.time)}`} ); }; - renderTxDetails = (selected, tx, transactionDetails) => { - const { showAlert, blockExplorer } = this.props; - return selected ? ( - - ) : null; - }; - renderTxElementImage = transactionElement => { const { renderTo, @@ -367,11 +384,7 @@ class TransactionElement extends PureComponent { }, providerType } = this.props; - const { renderTo, actionKey, value, fiatValue = false } = transactionElement; - let symbol; - if (renderTo in contractMap) { - symbol = contractMap[renderTo].symbol; - } + const { value, fiatValue = false, actionKey } = transactionElement; const networkId = Networks[providerType].networkId; const renderTxActions = status === 'submitted' || status === 'approved'; const renderSpeedUpAction = safeToChecksumAddress(to) !== AppConstants.CONNEXT.CONTRACTS[networkId]; @@ -382,7 +395,7 @@ class TransactionElement extends PureComponent { {this.renderTxElementImage(transactionElement)} - {symbol ? symbol + ' ' + actionKey : actionKey} + {actionKey} {status} @@ -401,202 +414,9 @@ class TransactionElement extends PureComponent { ); }; - decodeTransferFromTx = () => { - const { - tx: { - transaction: { gas, gasPrice, data, to }, - transactionHash - }, - collectibleContracts - } = this.props; - let { actionKey } = this.state; - const [addressFrom, addressTo, tokenId] = decodeTransferData('transferFrom', data); - const collectible = collectibleContracts.find( - collectible => collectible.address.toLowerCase() === to.toLowerCase() - ); - if (collectible) { - actionKey = strings('transactions.sent') + ' ' + collectible.name; - } - - const gasBN = hexToBN(gas); - const gasPriceBN = hexToBN(gasPrice); - const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); - const renderCollectible = collectible - ? strings('unit.token_id') + tokenId + ' ' + collectible.symbol - : strings('unit.token_id') + tokenId; - - const renderFrom = renderFullAddress(addressFrom); - const renderTo = renderFullAddress(addressTo); - - const transactionDetails = { - renderFrom, - renderTo, - transactionHash, - renderValue: renderCollectible, - renderGas: parseInt(gas, 16).toString(), - renderGasPrice: renderToGwei(gasPrice), - renderTotalValue: - renderCollectible + - ' ' + - strings('unit.divisor') + - ' ' + - renderFromWei(totalGas) + - ' ' + - strings('unit.eth'), - renderTotalValueFiat: undefined - }; - - const transactionElement = { - renderTo, - renderFrom, - actionKey, - value: `${strings('unit.token_id')}${tokenId}`, - fiatValue: collectible ? collectible.symbol : undefined - }; - - return [transactionElement, transactionDetails]; - }; - - decodeConfirmTx = () => { - const { - tx: { - transaction: { value, gas, gasPrice, from, to }, - transactionHash - }, - conversionRate, - currentCurrency - } = this.props; - const ticker = getTicker(this.props.ticker); - const { actionKey } = this.state; - const totalEth = hexToBN(value); - const renderTotalEth = renderFromWei(totalEth) + ' ' + ticker; - const renderTotalEthFiat = weiToFiat(totalEth, conversionRate, currentCurrency); - - const gasBN = hexToBN(gas); - const gasPriceBN = hexToBN(gasPrice); - const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); - const totalValue = isBN(totalEth) ? totalEth.add(totalGas) : totalGas; - - const renderFrom = renderFullAddress(from); - const renderTo = renderFullAddress(to); - - const transactionDetails = { - renderFrom, - renderTo, - transactionHash, - renderValue: renderFromWei(value) + ' ' + ticker, - renderGas: parseInt(gas, 16).toString(), - renderGasPrice: renderToGwei(gasPrice), - renderTotalValue: renderFromWei(totalValue) + ' ' + ticker, - renderTotalValueFiat: weiToFiat(totalValue, conversionRate, currentCurrency) - }; - - const transactionElement = { - renderTo, - renderFrom, - actionKey, - value: renderTotalEth, - fiatValue: renderTotalEthFiat - }; - - return [transactionElement, transactionDetails]; - }; - - decodeDeploymentTx = () => { - const { - tx: { - transaction: { value, gas, gasPrice, from }, - transactionHash - }, - conversionRate, - currentCurrency - } = this.props; - const ticker = getTicker(this.props.ticker); - const { actionKey } = this.state; - const gasBN = hexToBN(gas); - const gasPriceBN = hexToBN(gasPrice); - const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); - - const renderTotalEth = renderFromWei(totalGas) + ' ' + ticker; - const renderTotalEthFiat = weiToFiat(totalGas, conversionRate, currentCurrency); - const totalEth = isBN(value) ? value.add(totalGas) : totalGas; - - const renderFrom = renderFullAddress(from); - const renderTo = strings('transactions.to_contract'); - - const transactionElement = { - renderTo, - renderFrom, - actionKey, - value: renderTotalEth, - fiatValue: renderTotalEthFiat, - contractDeployment: true - }; - const transactionDetails = { - renderFrom, - renderTo, - transactionHash, - renderValue: renderFromWei(value) + ' ' + ticker, - renderGas: parseInt(gas, 16).toString(), - renderGasPrice: renderToGwei(gasPrice), - renderTotalValue: renderFromWei(totalEth) + ' ' + ticker, - renderTotalValueFiat: weiToFiat(totalEth, conversionRate, currentCurrency) - }; - - return [transactionElement, transactionDetails]; - }; - - decodePaymentChannelTx = () => { - const { - tx: { - networkID, - transactionHash, - transaction: { value, gas, gasPrice, from, to } - }, - conversionRate, - currentCurrency, - exchangeRate - } = this.props; - const { actionKey } = this.state; - const contract = CONTRACTS[networkID]; - const isDeposit = contract && to.toLowerCase() === contract.toLowerCase(); - const totalEth = hexToBN(value); - const totalEthFiat = weiToFiat(totalEth, conversionRate, currentCurrency); - const readableTotalEth = renderFromWei(totalEth); - const renderTotalEth = readableTotalEth + ' ' + (isDeposit ? strings('unit.eth') : strings('unit.sai')); - const renderTotalEthFiat = isDeposit - ? totalEthFiat - : balanceToFiat(parseFloat(readableTotalEth), conversionRate, exchangeRate, currentCurrency); - - const renderFrom = renderFullAddress(from); - const renderTo = renderFullAddress(to); - - const transactionDetails = { - renderFrom, - renderTo, - transactionHash, - renderGas: gas ? parseInt(gas, 16).toString() : strings('transactions.tx_details_not_available'), - renderGasPrice: gasPrice ? renderToGwei(gasPrice) : strings('transactions.tx_details_not_available'), - renderValue: renderTotalEth, - renderTotalValue: renderTotalEth, - renderTotalValueFiat: isDeposit && totalEthFiat - }; - - const transactionElement = { - renderFrom, - renderTo, - actionKey, - value: renderTotalEth, - fiatValue: renderTotalEthFiat, - paymentChannelTransaction: true - }; - - return [transactionElement, transactionDetails]; - }; - renderCancelButton = () => ( - ); - } - if (paymentChannelTransaction) { - [transactionElement, transactionDetails] = this.decodePaymentChannelTx(); - } else { - switch (actionKey) { - case strings('transactions.sent_collectible'): - [transactionElement, transactionDetails] = this.decodeTransferFromTx(totalGas); - break; - case strings('transactions.contract_deploy'): - [transactionElement, transactionDetails] = this.decodeDeploymentTx(totalGas); - break; - default: - [transactionElement, transactionDetails] = this.decodeConfirmTx(totalGas); - } - } + const { tx } = this.props; + const { detailsModalVisible, transactionElement, transactionDetails } = this.state; + + if (!transactionElement || !transactionDetails) return ; return ( - - - {this.renderTxElement(transactionElement)} - {this.renderTxDetails(selected, tx, transactionDetails)} - - + + + {this.renderTxElement(transactionElement)} + + + + + + + {transactionElement.actionKey} + + + + + + + + ); } } const mapStateToProps = state => ({ ticker: state.engine.backgroundState.NetworkController.provider.ticker, - providerType: state.engine.backgroundState.NetworkController.provider.type + providerType: state.engine.backgroundState.NetworkController.provider.type, + primaryCurrency: state.settings.primaryCurrency }); export default connect(mapStateToProps)(TransactionElement); diff --git a/app/components/UI/TransactionElement/index.test.js b/app/components/UI/TransactionElement/index.test.js index 096028e73e6..de7ee3ba3d5 100644 --- a/app/components/UI/TransactionElement/index.test.js +++ b/app/components/UI/TransactionElement/index.test.js @@ -21,6 +21,9 @@ describe('TransactionElement', () => { } } } + }, + settings: { + primaryCurrency: 'ETH' } }; diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js new file mode 100644 index 00000000000..476a72ced23 --- /dev/null +++ b/app/components/UI/TransactionElement/utils.js @@ -0,0 +1,500 @@ +import AppConstants from '../../../core/AppConstants'; +import { + hexToBN, + weiToFiat, + renderFromWei, + balanceToFiat, + renderToGwei, + isBN, + renderFromTokenMinimalUnit, + fromTokenMinimalUnit, + balanceToFiatNumber, + weiToFiatNumber, + addCurrencySymbol, + toBN +} from '../../../util/number'; +import { strings } from '../../../../locales/i18n'; +import { renderFullAddress, safeToChecksumAddress } from '../../../util/address'; +import { decodeTransferData, isCollectibleAddress, getTicker, getActionKey } from '../../../util/transactions'; +import contractMap from 'eth-contract-metadata'; + +const { + CONNEXT: { CONTRACTS } +} = AppConstants; + +function decodePaymentChannelTx(args) { + const { + tx: { + networkID, + transaction: { to } + } + } = args; + const contract = CONTRACTS[networkID]; + const isDeposit = contract && to && to.toLowerCase() === contract.toLowerCase(); + if (isDeposit) return decodeConfirmTx(args, true); + return decodeTransferPaymentChannel(args); +} + +function decodeTransferPaymentChannel(args) { + const { + tx: { + transaction: { value, from, to } + }, + conversionRate, + currentCurrency, + exchangeRate, + actionKey, + primaryCurrency + } = args; + const totalSAI = hexToBN(value); + const readableTotalSAI = renderFromWei(totalSAI); + const renderTotalSAI = `${readableTotalSAI} ${strings('unit.sai')}`; + const renderTotalSAIFiat = balanceToFiat(parseFloat(renderTotalSAI), conversionRate, exchangeRate, currentCurrency); + + const renderFrom = renderFullAddress(from); + const renderTo = renderFullAddress(to); + + let transactionDetails = { + renderFrom, + renderTo, + renderValue: renderTotalSAI + }; + + if (primaryCurrency === 'ETH') { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderTotalSAI, + summaryTotalAmount: renderTotalSAI, + summarySecondaryTotalAmount: renderTotalSAIFiat + }; + } else { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderTotalSAIFiat, + summaryTotalAmount: renderTotalSAIFiat, + summarySecondaryTotalAmount: renderTotalSAI + }; + } + + const transactionElement = { + renderFrom, + renderTo, + actionKey, + value: renderTotalSAI, + fiatValue: renderTotalSAIFiat, + paymentChannelTransaction: true + }; + + return [transactionElement, transactionDetails]; +} + +function getTokenTransfer(args) { + const { + tx: { + transaction: { to, data } + }, + conversionRate, + currentCurrency, + tokens, + contractExchangeRates, + totalGas, + actionKey, + primaryCurrency + } = args; + + const [, encodedAmount] = decodeTransferData('transfer', data); + const amount = toBN(encodedAmount); + const userHasToken = safeToChecksumAddress(to) in tokens; + const token = userHasToken ? tokens[safeToChecksumAddress(to)] : null; + const renderActionKey = token ? `${strings('transactions.sent')} ${token.symbol}` : actionKey; + const renderTokenAmount = token + ? `${renderFromTokenMinimalUnit(amount, token.decimals)} ${token.symbol}` + : undefined; + const exchangeRate = token ? contractExchangeRates[token.address] : undefined; + let renderTokenFiatAmount, renderTokenFiatNumber; + if (exchangeRate) { + renderTokenFiatAmount = balanceToFiat( + fromTokenMinimalUnit(amount, token.decimals) || 0, + conversionRate, + exchangeRate, + currentCurrency + ); + renderTokenFiatNumber = balanceToFiatNumber( + fromTokenMinimalUnit(amount, token.decimals) || 0, + conversionRate, + exchangeRate + ); + } + + const renderToken = token + ? `${renderFromTokenMinimalUnit(amount, token.decimals)} ${token.symbol}` + : strings('transaction.value_not_available'); + const totalFiatNumber = renderTokenFiatNumber + ? weiToFiatNumber(totalGas, conversionRate) + renderTokenFiatNumber + : weiToFiatNumber(totalGas, conversionRate); + + const ticker = getTicker(args.ticker); + + let transactionDetails = { + renderTotalGas: `${renderFromWei(totalGas)} ${ticker}`, + renderValue: renderToken + }; + if (primaryCurrency === 'ETH') { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderToken, + summaryFee: `${renderFromWei(totalGas)} ${ticker}`, + summaryTotalAmount: `${renderToken} ${strings('unit.divisor')} ${renderFromWei(totalGas)} ${ticker}`, + summarySecondaryTotalAmount: totalFiatNumber + ? `${addCurrencySymbol(totalFiatNumber, currentCurrency)}` + : undefined + }; + } else { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderTokenFiatAmount + ? `${renderTokenFiatAmount}` + : `${addCurrencySymbol(0, currentCurrency)}`, + summaryFee: weiToFiat(totalGas, conversionRate, currentCurrency), + summaryTotalAmount: totalFiatNumber ? `${addCurrencySymbol(totalFiatNumber, currentCurrency)}` : undefined, + summarySecondaryTotalAmount: `${renderToken} ${strings('unit.divisor')} ${renderFromWei( + totalGas + )} ${ticker}` + }; + } + + const transactionElement = { + actionKey: renderActionKey, + value: !renderTokenAmount ? strings('transaction.value_not_available') : renderTokenAmount, + fiatValue: `- ${renderTokenFiatAmount}` + }; + + return [transactionElement, transactionDetails]; +} + +function getCollectibleTransfer(args) { + const { + tx: { + transaction: { to, data } + }, + collectibleContracts, + totalGas, + conversionRate, + currentCurrency, + primaryCurrency + } = args; + let actionKey; + const [, tokenId] = decodeTransferData('transfer', data); + const collectible = collectibleContracts.find( + collectible => collectible.address.toLowerCase() === to.toLowerCase() + ); + if (collectible) { + actionKey = `${strings('transactions.sent')} ${collectible.name}`; + } else { + actionKey = strings('transactions.sent_collectible'); + } + + const renderCollectible = collectible + ? `${strings('unit.token_id')} ${tokenId} ${collectible.symbol}` + : `${strings('unit.token_id')} ${tokenId}`; + + let transactionDetails = { renderValue: renderCollectible }; + + if (primaryCurrency === 'ETH') { + transactionDetails = { + ...transactionDetails, + summaryTotalAmount: `${renderCollectible} ${strings('unit.divisor')} ${renderFromWei(totalGas)} ${strings( + 'unit.eth' + )}`, + summarySecondaryTotalAmount: weiToFiat(totalGas, conversionRate, currentCurrency) + }; + } else { + transactionDetails = { + ...transactionDetails, + summaryTotalAmount: weiToFiat(totalGas, conversionRate, currentCurrency), + summarySecondaryTotalAmount: `${renderCollectible} ${strings('unit.divisor')} ${renderFromWei( + totalGas + )} ${strings('unit.eth')}` + }; + } + + const transactionElement = { + actionKey, + value: `${strings('unit.token_id')}${tokenId}`, + fiatValue: collectible ? collectible.symbol : undefined + }; + + return [transactionElement, transactionDetails]; +} + +async function decodeTransferTx(args) { + const { + tx: { + transaction: { from, gas, gasPrice, data, to }, + transactionHash + } + } = args; + + const decodedData = decodeTransferData('transfer', data); + const addressTo = decodedData[0]; + const isCollectible = await isCollectibleAddress(to, decodedData[1]); + + const gasBN = hexToBN(gas); + const gasPriceBN = hexToBN(gasPrice); + const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); + const renderGas = parseInt(gas, 16).toString(); + const renderGasPrice = renderToGwei(gasPrice); + + let [transactionElement, transactionDetails] = isCollectible + ? getCollectibleTransfer({ ...args, totalGas }) + : getTokenTransfer({ ...args, totalGas }); + transactionElement = { ...transactionElement, renderTo: addressTo }; + transactionDetails = { + ...transactionDetails, + ...{ + renderFrom: renderFullAddress(from), + renderTo: renderFullAddress(addressTo), + transactionHash, + renderGas, + renderGasPrice + } + }; + return [transactionElement, transactionDetails]; +} + +function decodeTransferFromTx(args) { + const { + tx: { + transaction: { gas, gasPrice, data, to }, + transactionHash + }, + collectibleContracts, + conversionRate, + currentCurrency, + primaryCurrency + } = args; + const [addressFrom, addressTo, tokenId] = decodeTransferData('transferFrom', data); + const collectible = collectibleContracts.find( + collectible => collectible.address.toLowerCase() === to.toLowerCase() + ); + let actionKey = args.actionKey; + if (collectible) { + actionKey = `${strings('transactions.sent')} ${collectible.name}`; + } + + const gasBN = hexToBN(gas); + const gasPriceBN = hexToBN(gasPrice); + const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); + const renderCollectible = collectible + ? `${strings('unit.token_id')}${tokenId} ${collectible.symbol}` + : `${strings('unit.token_id')}${tokenId}`; + + const renderFrom = renderFullAddress(addressFrom); + const renderTo = renderFullAddress(addressTo); + const ticker = getTicker(args.ticker); + + let transactionDetails = { + renderFrom, + renderTo, + transactionHash, + renderValue: renderCollectible, + renderGas: parseInt(gas, 16).toString(), + renderGasPrice: renderToGwei(gasPrice), + renderTotalGas: `${renderFromWei(totalGas)} ${ticker}` + }; + + if (primaryCurrency === 'ETH') { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderCollectible, + summaryFee: `${renderFromWei(totalGas)} ${ticker}`, + summarySecondaryTotalAmount: weiToFiat(totalGas, conversionRate, currentCurrency), + summaryTotalAmount: `${renderCollectible} ${strings('unit.divisor')} ${renderFromWei(totalGas)} ${ticker}` + }; + } else { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderCollectible, + summaryFee: weiToFiat(totalGas, conversionRate, currentCurrency), + summarySecondaryTotalAmount: `${renderCollectible} ${strings('unit.divisor')} ${renderFromWei( + totalGas + )} ${ticker}`, + summaryTotalAmount: weiToFiat(totalGas, conversionRate, currentCurrency) + }; + } + + const transactionElement = { + renderTo, + renderFrom, + actionKey, + value: `${strings('unit.token_id')}${tokenId}`, + fiatValue: collectible ? collectible.symbol : undefined + }; + + return [transactionElement, transactionDetails]; +} + +function decodeDeploymentTx(args) { + const { + tx: { + transaction: { value, gas, gasPrice, from }, + transactionHash + }, + conversionRate, + currentCurrency, + actionKey, + primaryCurrency + } = args; + const ticker = getTicker(args.ticker); + const gasBN = hexToBN(gas); + const gasPriceBN = hexToBN(gasPrice); + const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); + + const renderTotalEth = `${renderFromWei(totalGas)} ${ticker}`; + const renderTotalEthFiat = weiToFiat(totalGas, conversionRate, currentCurrency); + const totalEth = isBN(value) ? value.add(totalGas) : totalGas; + + const renderFrom = renderFullAddress(from); + const renderTo = strings('transactions.to_contract'); + + const transactionElement = { + renderTo, + renderFrom, + actionKey, + value: renderTotalEth, + fiatValue: renderTotalEthFiat, + contractDeployment: true + }; + let transactionDetails = { + renderFrom, + renderTo, + transactionHash, + renderValue: `${renderFromWei(value)} ${ticker}`, + renderGas: parseInt(gas, 16).toString(), + renderGasPrice: renderToGwei(gasPrice), + renderTotalGas: `${renderFromWei(totalGas)} ${ticker}` + }; + + if (primaryCurrency === 'ETH') { + transactionDetails = { + ...transactionDetails, + summaryAmount: `${renderFromWei(value)} ${ticker}`, + summaryFee: `${renderFromWei(totalGas)} ${ticker}`, + summarySecondaryTotalAmount: weiToFiat(totalEth, conversionRate, currentCurrency), + summaryTotalAmount: `${renderFromWei(totalEth)} ${ticker}` + }; + } else { + transactionDetails = { + ...transactionDetails, + summaryAmount: weiToFiat(value, conversionRate, currentCurrency), + summaryFee: weiToFiat(totalGas, conversionRate, currentCurrency), + summarySecondaryTotalAmount: `${renderFromWei(totalEth)} ${ticker}`, + summaryTotalAmount: weiToFiat(totalEth, conversionRate, currentCurrency) + }; + } + + return [transactionElement, transactionDetails]; +} + +function decodeConfirmTx(args, paymentChannelTransaction) { + const { + tx: { + transaction: { value, gas, gasPrice, from, to }, + transactionHash + }, + conversionRate, + currentCurrency, + actionKey, + primaryCurrency + } = args; + const ticker = getTicker(args.ticker); + const totalEth = hexToBN(value); + const renderTotalEth = `${renderFromWei(totalEth)} ${ticker}`; + const renderTotalEthFiat = weiToFiat(totalEth, conversionRate, currentCurrency); + + const gasBN = hexToBN(gas); + const gasPriceBN = hexToBN(gasPrice); + const totalGas = isBN(gasBN) && isBN(gasPriceBN) ? gasBN.mul(gasPriceBN) : toBN('0x0'); + const totalValue = isBN(totalEth) ? totalEth.add(totalGas) : totalGas; + + const renderFrom = renderFullAddress(from); + const renderTo = renderFullAddress(to); + let transactionDetails = { + renderFrom, + renderTo, + transactionHash, + renderValue: `${renderFromWei(value)} ${ticker}`, + renderGas: parseInt(gas, 16).toString(), + renderGasPrice: renderToGwei(gasPrice), + renderTotalGas: `${renderFromWei(totalGas)} ${ticker}` + }; + + if (primaryCurrency === 'ETH') { + transactionDetails = { + ...transactionDetails, + summaryAmount: renderTotalEth, + summaryFee: `${renderFromWei(totalGas)} ${ticker}`, + summarySecondaryTotalAmount: weiToFiat(totalValue, conversionRate, currentCurrency), + summaryTotalAmount: `${renderFromWei(totalValue)} ${ticker}` + }; + } else { + transactionDetails = { + ...transactionDetails, + summaryAmount: weiToFiat(totalEth, conversionRate, currentCurrency), + summaryFee: weiToFiat(totalGas, conversionRate, currentCurrency), + summarySecondaryTotalAmount: `${renderFromWei(totalValue)} ${ticker}`, + summaryTotalAmount: weiToFiat(totalValue, conversionRate, currentCurrency) + }; + } + + let symbol; + if (renderTo in contractMap) { + symbol = contractMap[renderTo].symbol; + } + + const transactionElement = { + renderTo, + renderFrom, + actionKey: symbol ? `${symbol} ${actionKey}` : actionKey, + value: renderTotalEth, + fiatValue: renderTotalEthFiat, + paymentChannelTransaction + }; + + return [transactionElement, transactionDetails]; +} + +/** + * Parse transaction with wallet information to render + * + * @param {*} args - Should contain tx, selectedAddress, ticker, conversionRate, + * currentCurrency, exchangeRate, contractExchangeRates, collectibleContracts, tokens + */ +export default async function decodeTransaction(args) { + const { + tx, + tx: { paymentChannelTransaction }, + selectedAddress, + ticker + } = args; + const actionKey = tx.actionKey || (await getActionKey(tx, selectedAddress, ticker, paymentChannelTransaction)); + let transactionElement, transactionDetails; + if (paymentChannelTransaction) { + [transactionElement, transactionDetails] = decodePaymentChannelTx({ ...args, actionKey }); + } else { + switch (actionKey) { + case strings('transactions.sent_tokens'): + [transactionElement, transactionDetails] = await decodeTransferTx({ ...args, actionKey }); + break; + case strings('transactions.sent_collectible'): + [transactionElement, transactionDetails] = decodeTransferFromTx({ ...args, actionKey }); + break; + case strings('transactions.contract_deploy'): + [transactionElement, transactionDetails] = decodeDeploymentTx({ ...args, actionKey }); + break; + default: + [transactionElement, transactionDetails] = decodeConfirmTx({ ...args, actionKey }); + } + } + return [transactionElement, transactionDetails]; +} diff --git a/app/components/UI/TransactionNotification/index.js b/app/components/UI/TransactionNotification/index.js index 60fb05060dc..472b501da4b 100644 --- a/app/components/UI/TransactionNotification/index.js +++ b/app/components/UI/TransactionNotification/index.js @@ -1,27 +1,23 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import { TouchableOpacity, StyleSheet, View, Text } from 'react-native'; import PropTypes from 'prop-types'; -import { colors, baseStyles, fontStyles } from '../../../styles/common'; -import ElevatedView from 'react-native-elevated-view'; -import Icon from 'react-native-vector-icons/Ionicons'; +import { colors, fontStyles, baseStyles } from '../../../styles/common'; import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import Device from '../../../util/Device'; import AnimatedSpinner from '../AnimatedSpinner'; -import { hideMessage } from 'react-native-flash-message'; import { strings } from '../../../../locales/i18n'; -import GestureRecognizer from 'react-native-swipe-gestures'; +import IonicIcon from 'react-native-vector-icons/Ionicons'; const styles = StyleSheet.create({ defaultFlashFloating: { + flex: 1, backgroundColor: colors.normalAlert, - padding: 15, - marginTop: 10, - marginLeft: 0, - marginRight: 0, - height: Device.isIphoneX() ? 90 : 70, - flexDirection: 'row' + padding: 16, + marginHorizontal: 8, + flexDirection: 'row', + borderRadius: 8 }, flashLabel: { + flex: 1, flexDirection: 'column', color: colors.white }, @@ -31,6 +27,7 @@ const styles = StyleSheet.create({ color: colors.white }, flashTitle: { + flex: 1, fontSize: 14, marginBottom: 2, lineHeight: 18, @@ -39,6 +36,17 @@ const styles = StyleSheet.create({ }, flashIcon: { marginRight: 15 + }, + closeTouchable: { + flex: 0.1, + flexDirection: 'column', + alignItems: 'flex-end' + }, + closeIcon: { + flex: 1, + color: colors.white, + alignItems: 'flex-start', + marginTop: -8 } }); @@ -46,18 +54,12 @@ const styles = StyleSheet.create({ * TransactionNotification component used to render * in-app notifications for the transctions */ -// eslint-disable-next-line import/prefer-default-export -export const TransactionNotification = props => { - const { - message: { - type, - message: { transaction, callback } - } - } = props; - +export default function TransactionNotification(props) { + const { status, transaction, onPress, onHide } = props; + console.log('TransactionNotification', status); // eslint-disable-next-line _getIcon = () => { - switch (type) { + switch (status) { case 'pending': case 'pending_withdrawal': case 'pending_deposit': @@ -68,7 +70,7 @@ export const TransactionNotification = props => { case 'success': case 'received': case 'received_payment': - return ; + return ; case 'cancelled': case 'error': return ( @@ -79,7 +81,7 @@ export const TransactionNotification = props => { // eslint-disable-next-line no-undef _getTitle = () => { - switch (type) { + switch (status) { case 'pending': return strings('notifications.pending_title'); case 'pending_deposit': @@ -87,7 +89,7 @@ export const TransactionNotification = props => { case 'pending_withdrawal': return strings('notifications.pending_withdrawal_title'); case 'success': - return strings('notifications.success_title', { nonce: transaction.nonce }); + return strings('notifications.success_title', { nonce: parseInt(transaction.nonce) }); case 'success_deposit': return strings('notifications.success_deposit_title'); case 'success_withdrawal': @@ -98,7 +100,7 @@ export const TransactionNotification = props => { assetType: transaction.assetType }); case 'speedup': - return strings('notifications.speedup_title', { nonce: transaction.nonce }); + return strings('notifications.speedup_title', { nonce: parseInt(transaction.nonce) }); case 'received_payment': return strings('notifications.received_payment_title'); case 'cancelled': @@ -111,59 +113,37 @@ export const TransactionNotification = props => { // eslint-disable-next-line no-undef _getDescription = () => { if (transaction && transaction.amount) { - return strings(`notifications.${type}_message`, { amount: transaction.amount }); - } - return strings(`notifications.${type}_message`); - }; - - // eslint-disable-next-line - _getContent = () => ( - - {this._getIcon()} - - - {this._getTitle()} - - {this._getDescription()} - - - ); - - // eslint-disable-next-line - _onPress = () => { - if (callback) { - hideMessage(); - setTimeout(() => { - callback(); - }, 300); + return strings(`notifications.${status}_message`, { amount: transaction.amount }); } + return strings(`notifications.${status}_message`); }; return ( - - hideMessage()} - config={{ - velocityThreshold: 0.2, - directionalOffsetThreshold: 50 - }} - style={baseStyles.flex} + + - - {this._getContent()} + {this._getIcon()} + + + {this._getTitle()} + + {this._getDescription()} + + + - - + + ); -}; +} TransactionNotification.propTypes = { - message: PropTypes.object + status: PropTypes.string, + transaction: PropTypes.object, + onPress: PropTypes.func, + onHide: PropTypes.func }; diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 4e1778a083e..e03d52551b9 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -16,16 +16,15 @@ import { colors, fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import TransactionElement from '../TransactionElement'; import Engine from '../../../core/Engine'; -import { hasBlockExplorer } from '../../../util/networks'; import { showAlert } from '../../../actions/alert'; import TransactionsNotificationManager from '../../../core/TransactionsNotificationManager'; -import ActionModal from '../ActionModal'; import { CANCEL_RATE, SPEED_UP_RATE } from 'gaba'; import { renderFromWei } from '../../../util/number'; import { safeToChecksumAddress } from '../../../util/address'; import Device from '../../../util/Device'; import { hexToBN } from 'gaba/dist/util'; import { BN } from 'ethereumjs-util'; +import TransactionActionModal from '../TransactionActionModal'; const styles = StyleSheet.create({ wrapper: { @@ -46,47 +45,6 @@ const styles = StyleSheet.create({ fontSize: 20, color: colors.fontTertiary, ...fontStyles.normal - }, - modalView: { - alignItems: 'stretch', - flex: 1, - flexDirection: 'column', - justifyContent: 'space-between', - padding: 20 - }, - modalText: { - ...fontStyles.normal, - fontSize: 14, - textAlign: 'center', - paddingVertical: 8 - }, - modalTitle: { - ...fontStyles.bold, - fontSize: 22, - textAlign: 'center' - }, - gasTitle: { - ...fontStyles.bold, - fontSize: 16, - textAlign: 'center', - marginVertical: 8 - }, - cancelFeeWrapper: { - backgroundColor: colors.grey000, - textAlign: 'center', - padding: 15 - }, - cancelFee: { - ...fontStyles.bold, - fontSize: 24, - textAlign: 'center' - }, - warningText: { - ...fontStyles.normal, - fontSize: 12, - color: colors.red, - paddingVertical: 8, - textAlign: 'center' } }); @@ -133,10 +91,6 @@ class Transactions extends PureComponent { * A string that represents the selected address */ selectedAddress: PropTypes.string, - /** - * String representing the selected the selected network - */ - networkType: PropTypes.string, /** * ETH to current currency conversion rate */ @@ -145,10 +99,6 @@ class Transactions extends PureComponent { * Currency code of the currently-active currency */ currentCurrency: PropTypes.string, - /** - * Action that shows the global alert - */ - showAlert: PropTypes.func, /** * Loading flag from an external call */ @@ -281,8 +231,6 @@ class Transactions extends PureComponent { keyExtractor = item => item.id.toString(); - blockExplorer = () => hasBlockExplorer(this.props.networkType); - validateBalance = (tx, rate) => { const { accounts } = this.props; try { @@ -320,7 +268,7 @@ class Transactions extends PureComponent { onCancelAction = (cancelAction, existingGasPriceDecimal, tx) => { this.existingGasPriceDecimal = existingGasPriceDecimal; this.cancelTxId = tx.id; - if (this.validateBalance(tx, SPEED_UP_RATE)) { + if (this.validateBalance(tx, CANCEL_RATE)) { this.setState({ cancelIsOpen: cancelAction, cancelConfirmDisabled: true }); } else { this.setState({ cancelIsOpen: cancelAction, cancelConfirmDisabled: false }); @@ -361,17 +309,14 @@ class Transactions extends PureComponent { onSpeedUpAction={this.onSpeedUpAction} onCancelAction={this.onCancelAction} testID={'txn-item'} - selectedAddress={this.props.selectedAddress} - selected={!!this.state.selectedTx.get(item.id)} onPressItem={this.toggleDetailsView} - blockExplorer + selectedAddress={this.props.selectedAddress} tokens={this.props.tokens} collectibleContracts={this.props.collectibleContracts} contractExchangeRates={this.props.contractExchangeRates} exchangeRate={this.props.exchangeRate} conversionRate={this.props.conversionRate} currentCurrency={this.props.currentCurrency} - showAlert={this.props.showAlert} navigation={this.props.navigation} /> ); @@ -406,57 +351,36 @@ class Transactions extends PureComponent { onEndReachedThreshold={0.5} ListHeaderComponent={header} /> - - - {strings('transaction.cancel_tx_title')} - {strings('transaction.gas_cancel_fee')} - - - {`${renderFromWei(Math.floor(this.existingGasPriceDecimal * CANCEL_RATE))} ${strings( - 'unit.eth' - )}`} - - - {strings('transaction.cancel_tx_message')} - {cancelConfirmDisabled && ( - {strings('transaction.insufficient')} - )} - - - - + + - - {strings('transaction.speedup_tx_title')} - {strings('transaction.gas_speedup_fee')} - - - {`${renderFromWei(Math.floor(this.existingGasPriceDecimal * SPEED_UP_RATE))} ${strings( - 'unit.eth' - )}`} - - - {strings('transaction.speedup_tx_message')} - {speedUpConfirmDisabled && ( - {strings('transaction.insufficient')} - )} - - + confirmText={strings('transaction.lets_try')} + cancelText={strings('transaction.nevermind')} + feeText={`${renderFromWei(Math.floor(this.existingGasPriceDecimal * SPEED_UP_RATE))} ${strings( + 'unit.eth' + )}`} + titleText={strings('transaction.speedup_tx_title')} + gasTitleText={strings('transaction.gas_speedup_fee')} + descriptionText={strings('transaction.speedup_tx_message')} + /> ); }; diff --git a/app/components/UI/TxNotification/index.js b/app/components/UI/TxNotification/index.js new file mode 100644 index 00000000000..eb46b81e613 --- /dev/null +++ b/app/components/UI/TxNotification/index.js @@ -0,0 +1,501 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View, Text, Dimensions, InteractionManager } from 'react-native'; +import { hideTransactionNotification } from '../../../actions/transactionNotification'; +import { connect } from 'react-redux'; +import { colors, fontStyles } from '../../../styles/common'; +import Ionicons from 'react-native-vector-icons/Ionicons'; +import TransactionDetails from '../TransactionElement/TransactionDetails'; +import decodeTransaction from '../TransactionElement/utils'; +import TransactionNotification from '../TransactionNotification'; +import Device from '../../../util/Device'; +import Animated, { Easing } from 'react-native-reanimated'; +import ElevatedView from 'react-native-elevated-view'; +import { strings } from '../../../../locales/i18n'; +import { CANCEL_RATE, SPEED_UP_RATE, BN } from 'gaba'; +import ActionContent from '../ActionModal/ActionContent'; +import TransactionActionContent from '../TransactionActionModal/TransactionActionContent'; +import { renderFromWei } from '../../../util/number'; +import Engine from '../../../core/Engine'; +import { safeToChecksumAddress } from '../../../util/address'; +import { hexToBN } from 'gaba/dist/util'; + +const BROWSER_ROUTE = 'BrowserView'; + +const styles = StyleSheet.create({ + modalView: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 200, + marginBottom: -300 + }, + modalContainer: { + width: '90%', + borderRadius: 10, + backgroundColor: colors.white + }, + titleWrapper: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: colors.grey100, + flexDirection: 'row' + }, + title: { + flex: 1, + textAlign: 'center', + fontSize: 18, + marginVertical: 12, + marginHorizontal: 24, + color: colors.fontPrimary, + ...fontStyles.bold + }, + modalTypeView: { + position: 'absolute', + bottom: 0, + paddingBottom: Device.isIphoneX() ? 20 : 10, + left: 0, + right: 0, + backgroundColor: colors.transparent + }, + modalViewInBrowserView: { + paddingBottom: Device.isIos() ? 130 : 120 + }, + modalTypeViewDetailsVisible: { + height: '100%', + backgroundColor: colors.greytransparent + }, + modalTypeViewBrowser: { + bottom: Device.isIphoneX() ? 70 : 60 + }, + closeIcon: { + paddingTop: 4, + position: 'absolute', + right: 16 + }, + notificationContainer: { + flex: 0.1, + flexDirection: 'row', + alignItems: 'flex-end' + }, + notificationWrapper: { + height: 70, + width: '100%' + }, + detailsContainer: { + flex: 1, + width: '200%', + flexDirection: 'row' + }, + transactionAction: { + width: '100%' + } +}); + +const WINDOW_WIDTH = Dimensions.get('window').width; +const ACTION_CANCEL = 'cancel'; +const ACTION_SPEEDUP = 'speedup'; + +/** + * Wrapper component for a global alert + * connected to redux + */ +class TxNotification extends PureComponent { + static propTypes = { + /** + * Map of accounts to information objects including balances + */ + accounts: PropTypes.object, + /** + /* navigation object required to push new views + */ + navigation: PropTypes.object, + /** + * Boolean that determines if the modal should be shown + */ + isVisible: PropTypes.bool.isRequired, + /** + * Number that determines when it should be autodismissed (in miliseconds) + */ + autodismiss: PropTypes.number, + /** + * function that dismisses de modal + */ + hideTransactionNotification: PropTypes.func, + /** + * An array that represents the user transactions on chain + */ + transactions: PropTypes.array, + /** + * Corresponding transaction can contain id, nonce and amount + */ + transaction: PropTypes.object, + /** + * String of selected address + */ + // eslint-disable-next-line react/no-unused-prop-types + selectedAddress: PropTypes.string, + /** + * Current provider ticker + */ + // eslint-disable-next-line react/no-unused-prop-types + ticker: PropTypes.string, + /** + * ETH to current currency conversion rate + */ + // eslint-disable-next-line react/no-unused-prop-types + conversionRate: PropTypes.number, + /** + * Currency code of the currently-active currency + */ + // eslint-disable-next-line react/no-unused-prop-types + currentCurrency: PropTypes.string, + /** + * Current exchange rate + */ + // eslint-disable-next-line react/no-unused-prop-types + exchangeRate: PropTypes.number, + /** + * Object containing token exchange rates in the format address => exchangeRate + */ + // eslint-disable-next-line react/no-unused-prop-types + contractExchangeRates: PropTypes.object, + /** + * An array that represents the user collectible contracts + */ + // eslint-disable-next-line react/no-unused-prop-types + collectibleContracts: PropTypes.array, + /** + * An array that represents the user tokens + */ + // eslint-disable-next-line react/no-unused-prop-types + tokens: PropTypes.object, + /** + * Transaction status + */ + status: PropTypes.string, + /** + * Primary currency, either ETH or Fiat + */ + // eslint-disable-next-line react/no-unused-prop-types + primaryCurrency: PropTypes.string + }; + + state = { + transactionDetails: undefined, + transactionElement: undefined, + tx: {}, + transactionDetailsIsVisible: false, + internalIsVisible: true, + inBrowserView: false + }; + + notificationAnimated = new Animated.Value(100); + detailsYAnimated = new Animated.Value(0); + actionXAnimated = new Animated.Value(0); + detailsAnimated = new Animated.Value(0); + + existingGasPriceDecimal = '0x0'; + + animatedTimingStart = (animatedRef, toValue) => { + Animated.timing(animatedRef, { + toValue, + duration: 500, + easing: Easing.linear, + useNativeDriver: true + }).start(); + }; + + detailsFadeIn = async () => { + await this.setState({ transactionDetailsIsVisible: true }); + this.animatedTimingStart(this.detailsAnimated, 1); + }; + + componentDidMount = () => { + this.props.hideTransactionNotification(); + // To get the notificationAnimated ref when component mounts + setTimeout(() => this.setState({ internalIsVisible: this.props.isVisible }), 100); + }; + + isInBrowserView = () => { + const currentRouteName = this.findRouteNameFromNavigatorState(this.props.navigation.state); + return currentRouteName === BROWSER_ROUTE; + }; + + componentDidUpdate = async prevProps => { + // Check whether current view is browser + if (this.props.isVisible && prevProps.navigation.state !== this.props.navigation.state) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ inBrowserView: this.isInBrowserView(prevProps) }); + } + if (!prevProps.isVisible && this.props.isVisible) { + // Auto dismiss notification in case of confirmations + this.props.autodismiss && + setTimeout(() => { + this.props.hideTransactionNotification(); + }, this.props.autodismiss); + const { paymentChannelTransaction } = this.props.transaction; + const tx = paymentChannelTransaction + ? { paymentChannelTransaction, transaction: {} } + : this.props.transactions.find(({ id }) => id === this.props.transaction.id); + const [transactionElement, transactionDetails] = await decodeTransaction({ ...this.props, tx }); + const existingGasPrice = tx.transaction ? tx.transaction.gasPrice : '0x0'; + this.existingGasPriceDecimal = parseInt(existingGasPrice === undefined ? '0x0' : existingGasPrice, 16); + // eslint-disable-next-line react/no-did-update-set-state + await this.setState({ + tx, + transactionElement, + transactionDetails, + internalIsVisible: true, + transactionDetailsIsVisible: false, + inBrowserView: this.isInBrowserView(prevProps) + }); + + setTimeout(() => this.animatedTimingStart(this.notificationAnimated, 0), 100); + } else if (prevProps.isVisible && !this.props.isVisible) { + this.animatedTimingStart(this.notificationAnimated, 200); + this.animatedTimingStart(this.detailsAnimated, 0); + // eslint-disable-next-line react/no-did-update-set-state + setTimeout( + () => + this.setState({ + internalIsVisible: false, + tx: undefined, + transactionElement: undefined, + transactionDetails: undefined + }), + 500 + ); + } + }; + + findRouteNameFromNavigatorState({ routes }) { + let route = routes[routes.length - 1]; + while (route.index !== undefined) route = route.routes[route.index]; + return route.routeName; + } + + componentWillUnmount = () => { + this.props.hideTransactionNotification(); + }; + + onClose = () => { + this.onCloseDetails(); + this.props.hideTransactionNotification(); + }; + + onCloseDetails = () => { + this.animatedTimingStart(this.detailsAnimated, 0); + setTimeout(() => this.setState({ transactionDetailsIsVisible: false }), 1000); + }; + + onPress = () => { + this.setState({ transactionDetailsIsVisible: true }); + }; + + onNotificationPress = () => { + const { + tx: { paymentChannelTransaction } + } = this.state; + if (paymentChannelTransaction) { + this.props.navigation.navigate('PaymentChannelHome'); + } else { + this.detailsFadeIn(); + } + }; + + onSpeedUpPress = () => { + const transactionActionDisabled = this.validateBalance(this.state.tx, SPEED_UP_RATE); + this.setState({ transactionAction: ACTION_SPEEDUP, transactionActionDisabled }); + this.animateActionTo(-WINDOW_WIDTH); + }; + + onCancelPress = () => { + const transactionActionDisabled = this.validateBalance(this.state.tx, CANCEL_RATE); + this.setState({ transactionAction: ACTION_CANCEL, transactionActionDisabled }); + this.animateActionTo(-WINDOW_WIDTH); + }; + + onActionFinish = () => this.animateActionTo(0); + + validateBalance = (tx, rate) => { + const { accounts } = this.props; + try { + const checksummedFrom = safeToChecksumAddress(tx.transaction.from); + const balance = accounts[checksummedFrom].balance; + return hexToBN(balance).lt( + hexToBN(tx.transaction.gasPrice) + .mul(new BN(rate * 10)) + .mul(new BN(10)) + .mul(hexToBN(tx.transaction.gas)) + .add(hexToBN(tx.transaction.value)) + ); + } catch (e) { + return false; + } + }; + + animateActionTo = position => { + this.animatedTimingStart(this.detailsYAnimated, position); + this.animatedTimingStart(this.actionXAnimated, position); + }; + + speedUpTransaction = () => { + InteractionManager.runAfterInteractions(() => { + try { + Engine.context.TransactionController.speedUpTransaction(this.state.tx.id); + } catch (e) { + // ignore because transaction already went through + } + this.onActionFinish(); + }); + }; + + cancelTransaction = () => { + InteractionManager.runAfterInteractions(() => { + try { + Engine.context.TransactionController.stopTransaction(this.state.tx.id); + } catch (e) { + // ignore because transaction already went through + } + this.onActionFinish(); + }); + }; + + render = () => { + const { navigation, status } = this.props; + const { + transactionElement, + transactionDetails, + tx, + transactionDetailsIsVisible, + internalIsVisible, + inBrowserView, + transactionAction, + transactionActionDisabled + } = this.state; + + if (!internalIsVisible) return null; + const { paymentChannelTransaction } = tx; + const isActionCancel = transactionAction === ACTION_CANCEL; + return ( + + {transactionDetailsIsVisible && !paymentChannelTransaction && ( + + + + + + {transactionElement.actionKey} + + + + + + + + + + + + + + + + )} + + + + + + + + ); + }; +} + +const mapStateToProps = state => ({ + accounts: state.engine.backgroundState.AccountTrackerController.accounts, + isVisible: state.transactionNotification.isVisible, + autodismiss: state.transactionNotification.autodismiss, + transaction: state.transactionNotification.transaction, + status: state.transactionNotification.status, + selectedAddress: state.engine.backgroundState.PreferencesController.selectedAddress, + transactions: state.engine.backgroundState.TransactionController.transactions, + ticker: state.engine.backgroundState.NetworkController.provider.ticker, + tokens: state.engine.backgroundState.AssetsController.tokens.reduce((tokens, token) => { + tokens[token.address] = token; + return tokens; + }, {}), + collectibleContracts: state.engine.backgroundState.AssetsController.collectibleContracts, + contractExchangeRates: state.engine.backgroundState.TokenRatesController.contractExchangeRates, + conversionRate: state.engine.backgroundState.CurrencyRateController.conversionRate, + currentCurrency: state.engine.backgroundState.CurrencyRateController.currentCurrency, + primaryCurrency: state.settings.primaryCurrency +}); + +const mapDispatchToProps = dispatch => ({ + hideTransactionNotification: () => dispatch(hideTransactionNotification()) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TxNotification); diff --git a/app/components/Views/PaymentChannel/index.js b/app/components/Views/PaymentChannel/index.js index 6c4b072e9e0..42f9e002a7a 100644 --- a/app/components/Views/PaymentChannel/index.js +++ b/app/components/Views/PaymentChannel/index.js @@ -640,7 +640,7 @@ class PaymentChannel extends PureComponent { } renderTransactionsHistory() { - const { navigation, conversionRate, currentCurrency, selectedAddress } = this.props; + const { navigation, conversionRate, currentCurrency, selectedAddress, primaryCurrency } = this.props; return ( ); diff --git a/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.js.snap b/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.js.snap index c2d50d2a1f6..6611a6176e9 100644 --- a/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.js.snap +++ b/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.js.snap @@ -86,166 +86,13 @@ exports[`Confirm should render correctly 1`] = ` - - - Amount - - - - - - Transaction fee - - - - - - - - - Total amount - - - - - - - - - - { + console.log('transactionnnn', { ...transactionMeta, assetType: transactionMeta.transaction.assetType }); TransactionsNotificationManager.watchSubmittedTransaction({ ...transactionMeta, assetType @@ -674,34 +636,14 @@ class Confirm extends PureComponent { )} - - - {strings('transaction.amount')} - {transactionValueFiat} - - - {strings('transaction.transaction_fee')} - {this.renderIfGastEstimationReady( - {transactionFeeFiat} - )} - - - - - {strings('transaction.total_amount')} - - {this.renderIfGastEstimationReady( - - {transactionTotalAmountFiat} - - )} - - - {this.renderIfGastEstimationReady( - {transactionTotalAmount} - )} - + {errorMessage && ( diff --git a/app/components/Views/TransactionSummary/index.js b/app/components/Views/TransactionSummary/index.js new file mode 100644 index 00000000000..a196332438b --- /dev/null +++ b/app/components/Views/TransactionSummary/index.js @@ -0,0 +1,111 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View, ActivityIndicator, Text } from 'react-native'; +import { colors, fontStyles } from '../../../styles/common'; +import { strings } from '../../../../locales/i18n'; + +const styles = StyleSheet.create({ + summaryWrapper: { + flexDirection: 'column', + borderWidth: 1, + borderColor: colors.grey050, + borderRadius: 8, + padding: 16 + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginVertical: 6 + }, + totalCryptoRow: { + alignItems: 'flex-end', + marginTop: 8 + }, + textSummary: { + ...fontStyles.normal, + color: colors.black, + fontSize: 12 + }, + textSummaryAmount: { + textTransform: 'uppercase' + }, + textFee: { + fontStyle: 'italic' + }, + textCrypto: { + ...fontStyles.normal, + textAlign: 'right', + fontSize: 12, + textTransform: 'uppercase', + color: colors.grey500 + }, + textBold: { + ...fontStyles.bold, + alignSelf: 'flex-end' + }, + separator: { + borderBottomWidth: 1, + borderBottomColor: colors.grey050, + marginVertical: 6 + }, + loader: { + backgroundColor: colors.white, + height: 10 + } +}); + +export default class TransactionSummary extends PureComponent { + static propTypes = { + amount: PropTypes.string, + fee: PropTypes.string, + totalAmount: PropTypes.string, + secondaryTotalAmount: PropTypes.string, + gasEstimationReady: PropTypes.bool + }; + + renderIfGastEstimationReady = children => { + const { gasEstimationReady } = this.props; + return !gasEstimationReady ? ( + + + + ) : ( + children + ); + }; + + render = () => { + const { amount, fee, totalAmount, secondaryTotalAmount } = this.props; + return ( + + + {strings('transaction.amount')} + {amount} + + + + {!fee ? strings('transaction.transaction_fee_less') : strings('transaction.transaction_fee')} + + {!!fee && + this.renderIfGastEstimationReady( + {fee} + )} + + + + {strings('transaction.total_amount')} + {this.renderIfGastEstimationReady( + + {totalAmount} + + )} + + + {this.renderIfGastEstimationReady( + {secondaryTotalAmount} + )} + + + ); + }; +} diff --git a/app/components/Views/Wallet/index.js b/app/components/Views/Wallet/index.js index 6a1be6214da..4b6fd3881d0 100644 --- a/app/components/Views/Wallet/index.js +++ b/app/components/Views/Wallet/index.js @@ -16,6 +16,7 @@ import Analytics from '../../../core/Analytics'; import { ANALYTICS_EVENT_OPTS } from '../../../util/analytics'; import { getTicker } from '../../../util/transactions'; import OnboardingWizard from '../../UI/OnboardingWizard'; +import { showTransactionNotification, hideTransactionNotification } from '../../../actions/transactionNotification'; const styles = StyleSheet.create({ wrapper: { @@ -260,4 +261,12 @@ const mapStateToProps = state => ({ wizardStep: state.wizard.step }); -export default connect(mapStateToProps)(Wallet); +const mapDispatchToProps = dispatch => ({ + showTransactionNotification: args => dispatch(showTransactionNotification(args)), + hideTransactionNotification: () => dispatch(hideTransactionNotification()) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Wallet); diff --git a/app/core/TransactionsNotificationManager.js b/app/core/TransactionsNotificationManager.js index 5565baa7e3c..a361de5e86c 100644 --- a/app/core/TransactionsNotificationManager.js +++ b/app/core/TransactionsNotificationManager.js @@ -1,6 +1,5 @@ 'use strict'; -import { showMessage, hideMessage } from 'react-native-flash-message'; import PushNotification from 'react-native-push-notification'; import Engine from './Engine'; import Networks, { isKnownNetwork } from '../util/networks'; @@ -58,8 +57,8 @@ class TransactionsNotificationManager { let message = ''; let nonce = ''; - if (data && data.message && data.message.transaction && data.message.transaction.nonce) { - nonce = data.message.transaction.nonce; + if (data && data.transaction && data.transaction.nonce) { + nonce = data.transaction.nonce; } switch (data.type) { @@ -101,15 +100,15 @@ class TransactionsNotificationManager { break; case 'received': title = strings('notifications.received_title', { - amount: data.message.transaction.amount, - assetType: data.message.transaction.assetType + amount: data.transaction.amount, + assetType: data.transaction.assetType }); message = strings('notifications.received_message'); break; case 'received_payment': title = strings('notifications.received_payment_title'); message = strings('notifications.received_payment_message', { - amount: data.message.transaction.amount + amount: data.transaction.amount }); break; } @@ -120,8 +119,7 @@ class TransactionsNotificationManager { largeIcon: 'ic_notification', smallIcon: 'ic_notification_small' }; - - const id = (data && data.message && data.message.transaction && data.message.transaction.id) || null; + const id = (data && data.transaction && data.transaction.id) || null; const extraData = { action: 'tx', id }; if (Device.isAndroid()) { @@ -135,7 +133,11 @@ class TransactionsNotificationManager { this._transactionToView.push(id); } } else { - showMessage(data); + this._showTransactionNotification({ + autodismiss: data.duration, + transaction: data.transaction, + status: data.type + }); } } @@ -157,18 +159,14 @@ class TransactionsNotificationManager { _finishedCallback = transactionMeta => { this._handleTransactionsWatchListUpdate(transactionMeta); // If it fails we hide the pending tx notification - hideMessage(); + this._hideTransactionNotification(transactionMeta.id); this._transactionsWatchTable[transactionMeta.transaction.nonce].length && setTimeout(() => { // Then we show the error notification this._showNotification({ type: transactionMeta.status === 'cancelled' ? 'cancelled' : 'error', autoHide: true, - message: { - transaction: transactionMeta, - id: transactionMeta.id, - callback: () => this._viewTransaction(transactionMeta.id) - }, + transaction: { id: transactionMeta.id }, duration: 5000 }); // Clean up @@ -180,20 +178,17 @@ class TransactionsNotificationManager { _confirmedCallback = (transactionMeta, originalTransaction) => { this._handleTransactionsWatchListUpdate(transactionMeta); // Once it's confirmed we hide the pending tx notification - hideMessage(); + this._hideTransactionNotification(transactionMeta.id); this._transactionsWatchTable[transactionMeta.transaction.nonce].length && setTimeout(() => { // Then we show the success notification this._showNotification({ type: 'success', - message: { - transaction: { - nonce: `${hexToBN(transactionMeta.transaction.nonce).toString()}`, - id: transactionMeta.id - }, - callback: () => this._viewTransaction(transactionMeta.id) - }, autoHide: true, + transaction: { + id: transactionMeta.id, + nonce: `${hexToBN(transactionMeta.transaction.nonce).toString()}` + }, duration: 5000 }); // Clean up @@ -232,12 +227,9 @@ class TransactionsNotificationManager { this._showNotification({ autoHide: false, type: 'speedup', - message: { - transaction: { - nonce: `${hexToBN(transactionMeta.transaction.nonce).toString()}`, - id: transactionMeta.id - }, - callback: () => this._viewTransaction(transactionMeta.id) + transaction: { + id: transactionMeta.id, + nonce: `${hexToBN(transactionMeta.transaction.nonce).toString()}` } }); }, 2000); @@ -246,9 +238,11 @@ class TransactionsNotificationManager { /** * Creates a TransactionsNotificationManager instance */ - constructor(_navigation) { + constructor(_navigation, _showTransactionNotification, _hideTransactionNotification) { if (!TransactionsNotificationManager.instance) { this._navigation = _navigation; + this._showTransactionNotification = _showTransactionNotification; + this._hideTransactionNotification = _hideTransactionNotification; this._transactionToView = []; this._backgroundMode = false; TransactionsNotificationManager.instance = this; @@ -328,12 +322,8 @@ class TransactionsNotificationManager { this._showNotification({ type: 'pending', autoHide: false, - message: { - transaction: { - nonce: `${parseInt(nonce, 16)}`, - id: transaction.id - }, - callback: () => this._viewTransaction(transaction.id) + transaction: { + id: transaction.id } }); @@ -384,14 +374,11 @@ class TransactionsNotificationManager { if (txs.length > 0) { this._showNotification({ type: 'received', - message: { - transaction: { - nonce: `${hexToBN(txs[0].transaction.nonce).toString()}`, - amount: `${renderFromWei(hexToBN(txs[0].transaction.value))}`, - assetType: strings('unit.eth'), - id: txs[0].id - }, - callback: () => this._viewTransaction(txs[0].id) + transaction: { + nonce: `${hexToBN(txs[0].transaction.nonce).toString()}`, + amount: `${renderFromWei(hexToBN(txs[0].transaction.value))}`, + id: txs[0].id, + assetType: strings('unit.eth') }, autoHide: true, duration: 5000 @@ -409,8 +396,12 @@ class TransactionsNotificationManager { let instance; export default { - init(_navigation) { - instance = new TransactionsNotificationManager(_navigation); + init(_navigation, _showTransactionNotification, _hideTransactionNotification) { + instance = new TransactionsNotificationManager( + _navigation, + _showTransactionNotification, + _hideTransactionNotification + ); return instance; }, watchSubmittedTransaction(transaction) { @@ -429,14 +420,12 @@ export default { return instance.requestPushNotificationsPermission(); }, showInstantPaymentNotification(type) { - hideMessage(); setTimeout(() => { const notification = { type, autoHide: type.indexOf('success') !== -1, - message: { - transaction: null, - callback: () => null + transaction: { + paymentChannelTransaction: true } }; if (notification.autoHide) { @@ -446,17 +435,17 @@ export default { return instance._showNotification(notification); }, 300); }, - showIncomingPaymentNotification: amount => + showIncomingPaymentNotification: amount => { instance._showNotification({ type: 'received_payment', - message: { - transaction: { - amount, - assetType: '' - }, - callback: () => instance.goTo('PaymentChannelHome') + transaction: { + amount, + assetType: '', + paymentChannelTransaction: true }, + callback: () => instance.goTo('PaymentChannelHome'), autoHide: true, duration: 5000 - }) + }); + } }; diff --git a/app/reducers/index.js b/app/reducers/index.js index e6a795b3753..cda9b9a4741 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -11,6 +11,7 @@ import userReducer from './user'; import wizardReducer from './wizard'; import analyticsReducer from './analytics'; import onboardingReducer from './onboarding'; +import transactionNotificationReducer from './transactionNotification'; import { combineReducers } from 'redux'; const rootReducer = combineReducers({ @@ -26,7 +27,8 @@ const rootReducer = combineReducers({ newTransaction: newTransactionReducer, user: userReducer, wizard: wizardReducer, - onboarding: onboardingReducer + onboarding: onboardingReducer, + transactionNotification: transactionNotificationReducer }); export default rootReducer; diff --git a/app/reducers/transactionNotification/index.js b/app/reducers/transactionNotification/index.js new file mode 100644 index 00000000000..f20805cfc79 --- /dev/null +++ b/app/reducers/transactionNotification/index.js @@ -0,0 +1,28 @@ +const initialState = { + isVisible: false, + autodismiss: null, + status: undefined, + transaction: undefined +}; + +const transactionNotificationReducer = (state = initialState, action) => { + switch (action.type) { + case 'SHOW_TRANSACTION_NOTIFICATION': + return { + ...state, + isVisible: true, + autodismiss: action.autodismiss, + transaction: action.transaction, + status: action.status + }; + case 'HIDE_TRANSACTION_NOTIFICATION': + return { + ...state, + isVisible: false, + autodismiss: null + }; + default: + return state; + } +}; +export default transactionNotificationReducer; diff --git a/app/styles/common.js b/app/styles/common.js index 04f606d337e..51eff67b248 100644 --- a/app/styles/common.js +++ b/app/styles/common.js @@ -23,6 +23,7 @@ export const colors = { grey100: '#d6d9dc', grey050: '#D8D8D8', grey000: '#f2f3f4', + greytransparent: 'rgba(36, 41, 46, 0.6)', grey: '#333333', red: '#D73A49', red000: '#fcf2f3', @@ -41,7 +42,7 @@ export const colors = { orange: '#f66a0a', orange300: '#faa66c', orange000: '#fef5ef', - spinnerColor: '#F758AC', + spinnerColor: '#037DD6', dimmed: '#00000080', transparent: 'transparent', lightOverlay: 'rgba(0,0,0,.2)', diff --git a/app/util/date.js b/app/util/date.js index 4c093bb1d89..bde111ceded 100644 --- a/app/util/date.js +++ b/app/util/date.js @@ -1,3 +1,5 @@ +import { strings } from '../../locales/i18n'; + export function toLocaleDateTime(timestamp) { const dateObj = new Date(timestamp); const date = dateObj.toLocaleDateString(); @@ -5,6 +7,21 @@ export function toLocaleDateTime(timestamp) { return `${date} ${time}`; } +export function toDateFormat(timestamp) { + const dateObj = new Date(timestamp); + const month = strings(`date.months.${dateObj.getDay()}`); + const day = dateObj.getMonth(); + let meridiem = 'am'; + let hour = dateObj.getHours(); + if (hour > 12) { + meridiem = 'pm'; + hour -= 12; + } + let min = dateObj.getMinutes(); + if (`${min}`.length === 1) min = `0${min}`; + return `${month} ${day} ${strings('date.connector')} ${hour}:${min}${meridiem}`; +} + export function toLocaleDate(timestamp) { return new Date(timestamp).toLocaleDateString(); } diff --git a/app/util/number.js b/app/util/number.js index f48a0341c94..0ed9341e5c0 100644 --- a/app/util/number.js +++ b/app/util/number.js @@ -290,6 +290,9 @@ export function renderToGwei(value, unit = 'ether') { export function weiToFiat(wei, conversionRate, currencyCode) { if (!conversionRate) return undefined; if (!wei || !isBN(wei) || !conversionRate) { + if (currencySymbols[currencyCode]) { + return `${currencySymbols[currencyCode]}${0.0}`; + } return `0.00 ${currencyCode}`; } const value = weiToFiatNumber(wei, conversionRate); @@ -299,6 +302,20 @@ export function weiToFiat(wei, conversionRate, currencyCode) { return `${value} ${currencyCode}`; } +/** + * Adds currency symbol to a value + * + * @param {number} wei - BN corresponding to an amount of wei + * @param {string} currencyCode - Current currency code to display + * @returns {string} - Currency-formatted string + */ +export function addCurrencySymbol(value, currencyCode) { + if (currencySymbols[currencyCode]) { + return `${currencySymbols[currencyCode]}${value}`; + } + return `${value} ${currencyCode}`; +} + /** * Converts wei expressed as a BN instance into a human-readable fiat string * diff --git a/locales/en.json b/locales/en.json index 00c08937cf5..aaf4c9bf21e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,4 +1,21 @@ { + "date": { + "months": { + "0": "Jan", + "1": "Feb", + "2": "Mar", + "3": "Apr", + "4": "May", + "5": "Jun", + "6": "Jul", + "7": "Aug", + "8": "Sept", + "9": "Oct", + "10": "Nov", + "11": "Dec" + }, + "connector": "at" + }, "autocomplete": { "placeholder": "Search or Type URL" }, @@ -504,6 +521,7 @@ "set_gas": "Set", "cancel_gas": "Cancel", "transaction_fee": "Transaction fee", + "transaction_fee_less": "No fee", "total_amount": "Total amount", "adjust_transaction_fee": "Adjust transaction fee", "could_not_resolve_ens": "Couldn't resolve ENS", @@ -637,12 +655,15 @@ "gas_price": "Gas Price (GWEI)", "total": "Total", "view_on": "VIEW ON", - "view_on_etherscan": "VIEW ON ETHERSCAN", + "view_on_etherscan": "View on Etherscan", "hash_copied_to_clipboard": "Transaction hash copied to clipboard", "address_copied_to_clipboard": "Address copied to clipboard", "transaction_error": "Transaction error", "address_to_placeholder": "Search, public address (0x), or ENS", - "address_from_balance": "Balance:" + "address_from_balance": "Balance:", + "status": "Status", + "date": "Date", + "nonce": "Nonce" }, "address_book": { "recents": "Recents", @@ -672,7 +693,7 @@ "transaction_submitted": { "title": "Transaction Submitted", "your_tx_hash_is": "Your transaction hash is:", - "view_on_etherscan": "VIEW ON ETHERSCAN" + "view_on_etherscan": "View on Etherscan" }, "sync_with_extension": { "title": "Sync data from Extension", @@ -900,7 +921,7 @@ "back_to_safety": "Back to safety" }, "notifications": { - "pending_title": "Transaction submitted!", + "pending_title": "Transaction submitted", "pending_deposit_title": "Deposit in progress!", "pending_withdrawal_title": "Withdrawal in progress!", "cancelled_title": "Transaction cancelled!", @@ -911,7 +932,7 @@ "error_title": "Oops, something went wrong :/", "received_title": "You received {{amount}} {{assetType}}!", "received_payment_title": "Instant payment received", - "pending_message": "Waiting for confirmation...", + "pending_message": "Waiting for confirmation", "pending_deposit_message": "Waiting for deposit to complete", "pending_withdrawal_message": "Waiting for withdrawal to complete", "error_message": "Tap to view this transaction", diff --git a/locales/es.json b/locales/es.json index 986b0212d62..25a097d3166 100644 --- a/locales/es.json +++ b/locales/es.json @@ -623,7 +623,7 @@ "gas_price": "Precio del gas (GWEI)", "total": "Total", "view_on": "VER EN", - "view_on_etherscan": "VER EN ETHERSCAN", + "view_on_etherscan": "Ver en Etherscan", "hash_copied_to_clipboard": "El hash de la transacción fue copiado al portapapeles", "address_copied_to_clipboard": "La dirección fue copiada al portapapeles", "transaction_error": "Error de transacción", @@ -658,7 +658,7 @@ "transaction_submitted": { "title": "Transacción Enviada", "your_tx_hash_is": "El has de tu transacción es:", - "view_on_etherscan": "VER EN ETHERSCAN" + "view_on_etherscan": "Ver en Etherscan" }, "sync_with_extension": { "title": "Sincronizar datos con la extensión", @@ -871,7 +871,7 @@ "back_to_safety": "Volver al area segura" }, "notifications": { - "pending_title": "Transacción enviada!", + "pending_title": "Transacción enviada", "pending_deposit_title": "Depósito en progreso!", "pending_withdrawal_title": "Retiro en progreso!", "success_title": "Transacción #{{nonce}} Completa!", @@ -881,7 +881,7 @@ "error_title": "Oops, algo ha salido mal :/", "received_title": "Has recibido {{amount}} {{assetType}}!", "received_payment_title": "Pago instantáneo recibido", - "pending_message": "Tu transacción esta en proceso", + "pending_message": "Tu transacción está en proceso", "pending_deposit_message": "Esperando que el deposito sea compleado", "pending_withdrawal_message": "Esperando que el retiro sea completado", "error_message": "Presiona para ver esta transacción",