diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f22eea72f..e66591efc 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1603,6 +1603,9 @@ "transactionTime": { "message": "Transaction Time" }, + "showTransactionTimeDescription": { + "message": "Select this to display pending transaction time estimates in the activity tab while on the Main Ethereum Network. Note: estimates are approximations based on network conditions." + }, "transfer": { "message": "Transfer" }, diff --git a/app/scripts/ui.js b/app/scripts/ui.js index 5a403828d..9d45082d8 100644 --- a/app/scripts/ui.js +++ b/app/scripts/ui.js @@ -4,6 +4,7 @@ import './lib/freezeGlobals' // polyfills import 'abortcontroller-polyfill/dist/polyfill-patch-fetch' +import '@formatjs/intl-relativetimeformat/polyfill' import PortStream from 'extension-port-stream' import { getEnvironmentType } from './lib/util' diff --git a/package.json b/package.json index 6199e332f..7901bbbaf 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "3box": "^1.10.2", "@babel/runtime": "^7.5.5", "@download/blockies": "^1.0.3", + "@formatjs/intl-relativetimeformat": "^5.2.6", "@fortawesome/fontawesome-free": "^5.13.0", "@material-ui/core": "1.0.0", "@metamask/controllers": "^2.0.0", diff --git a/ui/app/components/app/index.scss b/ui/app/components/app/index.scss index 6fbe4389b..cf413f001 100644 --- a/ui/app/components/app/index.scss +++ b/ui/app/components/app/index.scss @@ -92,6 +92,8 @@ @import '../ui/icon/index'; +@import '../ui/icon-with-label/index'; + @import '../ui/circle-icon/index'; @import '../ui/alert-circle-icon/index'; diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js index b42d8c7b6..a92880c52 100644 --- a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js @@ -22,6 +22,8 @@ import { import { useShouldShowSpeedUp } from '../../../hooks/useShouldShowSpeedUp' import TransactionStatus from '../transaction-status/transaction-status.component' import TransactionIcon from '../transaction-icon' +import { useTransactionTimeRemaining } from '../../../hooks/useTransactionTimeRemaining' +import IconWithLabel from '../../ui/icon-with-label' export default function TransactionListItem ({ transactionGroup, isEarliestNonce = false }) { @@ -30,8 +32,7 @@ export default function TransactionListItem ({ transactionGroup, isEarliestNonce const { hasCancelled } = transactionGroup const [showDetails, setShowDetails] = useState(false) - const { initialTransaction: { id }, primaryTransaction } = transactionGroup - + const { initialTransaction: { id }, primaryTransaction: { err, submittedTime, gasPrice } } = transactionGroup const [cancelEnabled, cancelTransaction] = useCancelTransaction(transactionGroup) const retryTransaction = useRetryTransaction(transactionGroup) const shouldShowSpeedUp = useShouldShowSpeedUp(transactionGroup, isEarliestNonce) @@ -49,6 +50,9 @@ export default function TransactionListItem ({ transactionGroup, isEarliestNonce senderAddress, } = useTransactionDisplayData(transactionGroup) + const timeRemaining = useTransactionTimeRemaining(isPending, isEarliestNonce, submittedTime, gasPrice) + + const isSignatureReq = category === TRANSACTION_CATEGORY_SIGNATURE_REQUEST const isUnapproved = status === UNAPPROVED_STATUS @@ -112,9 +116,9 @@ export default function TransactionListItem ({ transactionGroup, isEarliestNonce className={className} title={title} titleIcon={!isUnapproved && isPending && isEarliestNonce && ( - } + label={timeRemaining} /> )} icon={} @@ -123,7 +127,7 @@ export default function TransactionListItem ({ transactionGroup, isEarliestNonce diff --git a/ui/app/components/app/transaction-time-remaining/index.js b/ui/app/components/app/transaction-time-remaining/index.js deleted file mode 100644 index 87c6821d8..000000000 --- a/ui/app/components/app/transaction-time-remaining/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './transaction-time-remaining.container' diff --git a/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.component.js b/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.component.js deleted file mode 100644 index c9598d69b..000000000 --- a/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.component.js +++ /dev/null @@ -1,52 +0,0 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import { calcTransactionTimeRemaining } from './transaction-time-remaining.util' - -export default class TransactionTimeRemaining extends PureComponent { - static propTypes = { - className: PropTypes.string, - initialTimeEstimate: PropTypes.number, - submittedTime: PropTypes.number, - } - - constructor (props) { - super(props) - const { initialTimeEstimate, submittedTime } = props - this.state = { - timeRemaining: calcTransactionTimeRemaining(initialTimeEstimate, submittedTime), - } - this.interval = setInterval( - () => this.setState({ timeRemaining: calcTransactionTimeRemaining(initialTimeEstimate, submittedTime) }), - 1000 - ) - } - - componentDidUpdate (prevProps) { - const { initialTimeEstimate, submittedTime } = this.props - if (initialTimeEstimate !== prevProps.initialTimeEstimate) { - clearInterval(this.interval) - const calcedTimeRemaining = calcTransactionTimeRemaining(initialTimeEstimate, submittedTime) - this.setState({ timeRemaining: calcedTimeRemaining }) - this.interval = setInterval( - () => this.setState({ timeRemaining: calcTransactionTimeRemaining(initialTimeEstimate, submittedTime) }), - 1000 - ) - } - } - - componentWillUnmount () { - clearInterval(this.interval) - } - - render () { - const { className } = this.props - const { timeRemaining } = this.state - - return ( -
- { timeRemaining } -
- - ) - } -} diff --git a/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.container.js b/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.container.js deleted file mode 100644 index 754d84991..000000000 --- a/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.container.js +++ /dev/null @@ -1,33 +0,0 @@ -import { connect } from 'react-redux' -import TransactionTimeRemaining from './transaction-time-remaining.component' -import { - getEstimatedGasPrices, - getEstimatedGasTimes, -} from '../../../selectors' -import { getRawTimeEstimateData } from '../../../helpers/utils/gas-time-estimates.util' -import { hexWEIToDecGWEI } from '../../../helpers/utils/conversions.util' - -const mapStateToProps = (state, ownProps) => { - const { transaction } = ownProps - const { gasPrice: currentGasPrice } = transaction.txParams - const customGasPrice = calcCustomGasPrice(currentGasPrice) - const gasPrices = getEstimatedGasPrices(state) - const estimatedTimes = getEstimatedGasTimes(state) - - const { - newTimeEstimate: initialTimeEstimate, - } = getRawTimeEstimateData(customGasPrice, gasPrices, estimatedTimes) - - const submittedTime = transaction.submittedTime - - return { - initialTimeEstimate, - submittedTime, - } -} - -export default connect(mapStateToProps)(TransactionTimeRemaining) - -function calcCustomGasPrice (customGasPriceInHex) { - return Number(hexWEIToDecGWEI(customGasPriceInHex)) -} diff --git a/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.util.js b/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.util.js deleted file mode 100644 index 0ba81edfc..000000000 --- a/ui/app/components/app/transaction-time-remaining/transaction-time-remaining.util.js +++ /dev/null @@ -1,13 +0,0 @@ -import { formatTimeEstimate } from '../../../helpers/utils/gas-time-estimates.util' - -export function calcTransactionTimeRemaining (initialTimeEstimate, submittedTime) { - const currentTime = (new Date()).getTime() - const timeElapsedSinceSubmission = (currentTime - submittedTime) / 1000 - const timeRemainingOnEstimate = initialTimeEstimate - timeElapsedSinceSubmission - - const renderingTimeRemainingEstimate = timeRemainingOnEstimate < 30 - ? '< 30 s' - : formatTimeEstimate(timeRemainingOnEstimate) - - return renderingTimeRemainingEstimate -} diff --git a/ui/app/components/ui/icon-with-label/icon-with-label.js b/ui/app/components/ui/icon-with-label/icon-with-label.js new file mode 100644 index 000000000..9e282611b --- /dev/null +++ b/ui/app/components/ui/icon-with-label/icon-with-label.js @@ -0,0 +1,18 @@ +import React from 'react' +import classnames from 'classnames' +import PropTypes from 'prop-types' + +export default function IconWithLabel ({ icon, label, className }) { + return ( +
+ {icon} + {label && {label}} +
+ ) +} + +IconWithLabel.propTypes = { + icon: PropTypes.node.isRequired, + className: PropTypes.string, + label: PropTypes.string, +} diff --git a/ui/app/components/ui/icon-with-label/index.js b/ui/app/components/ui/icon-with-label/index.js new file mode 100644 index 000000000..10432ac8f --- /dev/null +++ b/ui/app/components/ui/icon-with-label/index.js @@ -0,0 +1 @@ +export { default } from './icon-with-label' diff --git a/ui/app/components/ui/icon-with-label/index.scss b/ui/app/components/ui/icon-with-label/index.scss new file mode 100644 index 000000000..628a54272 --- /dev/null +++ b/ui/app/components/ui/icon-with-label/index.scss @@ -0,0 +1,10 @@ +.icon-with-label { + display: flex; + align-items: center; + + &__label { + font-size: 10px; + margin-left: 4px; + color: $Grey-500; + } +} diff --git a/ui/app/components/ui/list-item/index.scss b/ui/app/components/ui/list-item/index.scss index f424bc4c6..749d4f6b6 100644 --- a/ui/app/components/ui/list-item/index.scss +++ b/ui/app/components/ui/list-item/index.scss @@ -33,12 +33,11 @@ font-size: 16px; line-height: 160%; position: relative; + display: flex; + align-items: center; &-wrap { display: inline-block; - position: absolute; - width: 16px; - height: 16px; margin-left: 8px; } } diff --git a/ui/app/components/ui/list-item/list-item.component.js b/ui/app/components/ui/list-item/list-item.component.js index 6f04c5590..60d395ab3 100644 --- a/ui/app/components/ui/list-item/list-item.component.js +++ b/ui/app/components/ui/list-item/list-item.component.js @@ -26,9 +26,9 @@ export default function ListItem ({ )}

{ title } {titleIcon && ( - +
{titleIcon} - +
)}

diff --git a/ui/app/hooks/useTransactionTimeRemaining.js b/ui/app/hooks/useTransactionTimeRemaining.js new file mode 100644 index 000000000..c1d5ddd25 --- /dev/null +++ b/ui/app/hooks/useTransactionTimeRemaining.js @@ -0,0 +1,98 @@ +import { getEstimatedGasPrices, getEstimatedGasTimes, getFeatureFlags, getIsMainnet } from '../selectors' +import { hexWEIToDecGWEI } from '../helpers/utils/conversions.util' +import { useSelector } from 'react-redux' +import { useRef, useEffect, useState, useMemo } from 'react' +import { isEqual } from 'lodash' +import { getRawTimeEstimateData } from '../helpers/utils/gas-time-estimates.util' +import { getCurrentLocale } from '../ducks/metamask/metamask' + + +/** + * Calculate the number of minutes remaining until the transaction completes. + * @param {number} initialTimeEstimate - timestamp for the projected completion time + * @param {number} submittedTime - timestamp of when the tx was submitted + * @return {number} minutes remaining + */ +function calcTransactionTimeRemaining (initialTimeEstimate, submittedTime) { + const currentTime = (new Date()).getTime() + const timeElapsedSinceSubmission = (currentTime - submittedTime) / 1000 + const timeRemainingOnEstimate = initialTimeEstimate - timeElapsedSinceSubmission + + const renderingTimeRemainingEstimate = Math.round(timeRemainingOnEstimate / 60) + return renderingTimeRemainingEstimate +} + +/** + * returns a string representing the number of minutes predicted for the transaction to be + * completed. Only returns this prediction if the transaction is the earliest pending + * transaction, and the feature flag for showing timing is enabled. + * @param {bool} isPending - is the transaction currently pending + * @param {bool} isEarliestNonce - is this transaction the earliest nonce in list + * @param {number} submittedTime - the timestamp for when the transaction was submitted + * @param {number} currentGasPrice - gas price to use for calculation of time + * @returns {string | undefined} i18n formatted string if applicable + */ +export function useTransactionTimeRemaining ( + isPending, + isEarliestNonce, + submittedTime, + currentGasPrice +) { + // the following two selectors return the result of mapping over an array, as such they + // will always be new objects and trigger effects. To avoid this, we use isEqual as the + // equalityFn to only update when the data is new. + const gasPrices = useSelector(getEstimatedGasPrices, isEqual) + const estimatedTimes = useSelector(getEstimatedGasTimes, isEqual) + const locale = useSelector(getCurrentLocale) + const isMainNet = useSelector(getIsMainnet) + const interval = useRef() + const [timeRemaining, setTimeRemaining] = useState(null) + const featureFlags = useSelector(getFeatureFlags) + const transactionTimeFeatureActive = featureFlags?.transactionTime + + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }) + + // Memoize this value so it can be used as a dependency in the effect below + const initialTimeEstimate = useMemo(() => { + const customGasPrice = Number(hexWEIToDecGWEI(currentGasPrice)) + const { + newTimeEstimate, + } = getRawTimeEstimateData(customGasPrice, gasPrices, estimatedTimes) + return newTimeEstimate + }, [ currentGasPrice, gasPrices, estimatedTimes ]) + + useEffect(() => { + if ( + isMainNet && + transactionTimeFeatureActive && + isPending && + isEarliestNonce && + !isNaN(initialTimeEstimate) + ) { + clearInterval(interval.current) + setTimeRemaining( + calcTransactionTimeRemaining(initialTimeEstimate, submittedTime) + ) + interval.current = setInterval(() => { + setTimeRemaining( + calcTransactionTimeRemaining(initialTimeEstimate, submittedTime) + ) + }, 10000) + return () => clearInterval(interval.current) + } + }, [ + isMainNet, + transactionTimeFeatureActive, + isEarliestNonce, + isPending, + submittedTime, + initialTimeEstimate, + ]) + + // there are numerous checks to determine if time should be displayed. + // if any of the following are true, the timeRemaining will be null + // User is currently not on the mainnet + // User does not have the transactionTime feature flag enabled + // The transaction is not pending, or isn't the earliest nonce + return timeRemaining ? rtf.format(timeRemaining, 'minute') : undefined +} diff --git a/ui/app/pages/settings/advanced-tab/advanced-tab.component.js b/ui/app/pages/settings/advanced-tab/advanced-tab.component.js index 84be34342..9c83f1fa0 100644 --- a/ui/app/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/app/pages/settings/advanced-tab/advanced-tab.component.js @@ -24,6 +24,8 @@ export default class AdvancedTab extends PureComponent { sendHexData: PropTypes.bool, setAdvancedInlineGasFeatureFlag: PropTypes.func, advancedInlineGas: PropTypes.bool, + setTransactionTimeFeatureFlag: PropTypes.func, + transactionTime: PropTypes.bool, showFiatInTestnets: PropTypes.bool, autoLockTimeLimit: PropTypes.number, setAutoLockTimeLimit: PropTypes.func.isRequired, @@ -194,6 +196,32 @@ export default class AdvancedTab extends PureComponent { ) } + renderTransactionTimeEstimates () { + const { t } = this.context + const { transactionTime, setTransactionTimeFeatureFlag } = this.props + + return ( +
+
+ { t('transactionTime') } +
+ { t('showTransactionTimeDescription') } +
+
+
+
+ setTransactionTimeFeatureFlag(!value)} + offLabel={t('off')} + onLabel={t('on')} + /> +
+
+
+ ) + } + renderShowConversionInTestnets () { const { t } = this.context const { @@ -447,6 +475,7 @@ export default class AdvancedTab extends PureComponent { { this.renderMobileSync() } { this.renderResetAccount() } { this.renderAdvancedGasInputInline() } + { this.renderTransactionTimeEstimates() } { this.renderHexDataOptIn() } { this.renderShowConversionInTestnets() } { this.renderUseNonceOptIn() } diff --git a/ui/app/pages/settings/advanced-tab/advanced-tab.container.js b/ui/app/pages/settings/advanced-tab/advanced-tab.container.js index 067186b55..be473f5e5 100644 --- a/ui/app/pages/settings/advanced-tab/advanced-tab.container.js +++ b/ui/app/pages/settings/advanced-tab/advanced-tab.container.js @@ -20,6 +20,7 @@ export const mapStateToProps = (state) => { const { featureFlags: { sendHexData, + transactionTime, advancedInlineGas, } = {}, threeBoxSyncingAllowed, @@ -33,6 +34,7 @@ export const mapStateToProps = (state) => { warning, sendHexData, advancedInlineGas, + transactionTime, showFiatInTestnets, autoLockTimeLimit, threeBoxSyncingAllowed, @@ -48,6 +50,7 @@ export const mapDispatchToProps = (dispatch) => { displayWarning: (warning) => dispatch(displayWarning(warning)), showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), setAdvancedInlineGasFeatureFlag: (shouldShow) => dispatch(setFeatureFlag('advancedInlineGas', shouldShow)), + setTransactionTimeFeatureFlag: (shouldShow) => dispatch(setFeatureFlag('transactionTime', shouldShow)), setUseNonceField: (value) => dispatch(setUseNonceField(value)), setShowFiatConversionOnTestnetsPreference: (value) => { return dispatch(setShowFiatConversionOnTestnetsPreference(value)) diff --git a/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js b/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js index e43617c77..aeb048d3f 100644 --- a/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js +++ b/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js @@ -24,7 +24,7 @@ describe('AdvancedTab Component', function () { } ) - assert.equal(root.find('.settings-page__content-row').length, 10) + assert.equal(root.find('.settings-page__content-row').length, 11) }) it('should update autoLockTimeLimit', function () { @@ -46,7 +46,7 @@ describe('AdvancedTab Component', function () { } ) - const autoTimeout = root.find('.settings-page__content-row').at(7) + const autoTimeout = root.find('.settings-page__content-row').at(8) const textField = autoTimeout.find(TextField) textField.props().onChange({ target: { value: 1440 } }) diff --git a/yarn.lock b/yarn.lock index fc3b9f117..956ae2e5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1261,6 +1261,20 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@formatjs/intl-relativetimeformat@^5.2.6": + version "5.2.6" + resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-5.2.6.tgz#3d67b75a900e7b5416615beeb2d0eeff33a1e01a" + integrity sha512-UPCY7IoyeqieUxdbfhINVjbCGXCzRr4xZpoiNsr1da4Fwm4uV6l53OXsx1zDRXoiNmMtDuKCKkRzlSfBL89L1g== + dependencies: + "@formatjs/intl-utils" "^3.3.1" + +"@formatjs/intl-utils@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-utils/-/intl-utils-3.3.1.tgz#7ceadbb7e251318729d9bf693731e1a5dcdfa15a" + integrity sha512-7AAicg2wqCJQ+gFEw5Nxp+ttavajBrPAD1HDmzA4jzvUCrF5a2NCJm/c5qON3VBubWWF2cu8HglEouj2h/l7KQ== + dependencies: + emojis-list "^3.0.0" + "@fortawesome/fontawesome-free@^5.13.0": version "5.13.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz#fcb113d1aca4b471b709e8c9c168674fbd6e06d9" @@ -9162,6 +9176,11 @@ emojis-list@^2.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + emotion-theming@^10.0.19: version "10.0.27" resolved "https://registry.yarnpkg.com/emotion-theming/-/emotion-theming-10.0.27.tgz#1887baaec15199862c89b1b984b79806f2b9ab10"