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"