diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0fb6fb92fbac..df3211fd2303 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -588,6 +588,9 @@ "failed": { "message": "Failed" }, + "failureMessage": { + "message": "Something went wrong, and we were unable to complete the action" + }, "fast": { "message": "Fast" }, @@ -1530,6 +1533,12 @@ "unapproved": { "message": "Unapproved" }, + "unconnectedAccountAlertTitle": { + "message": "Not connected" + }, + "unconnectedAccountAlertDescription": { + "message": "This account is not connected to this site" + }, "units": { "message": "units" }, diff --git a/development/states/confirm-sig-requests.json b/development/states/confirm-sig-requests.json index 580690147b96..9b4955cd9c5b 100644 --- a/development/states/confirm-sig-requests.json +++ b/development/states/confirm-sig-requests.json @@ -521,5 +521,8 @@ ], "priceAndTimeEstimatesLastRetrieved": 1541527901281, "errors": {} + }, + "unconnectedAccount": { + "state": "CLOSED" } } diff --git a/development/states/currency-localization.json b/development/states/currency-localization.json index 358816badc00..64b21e0066b3 100644 --- a/development/states/currency-localization.json +++ b/development/states/currency-localization.json @@ -472,5 +472,8 @@ ], "priceAndTimeEstimatesLastRetrieved": 1541527901281, "errors": {} + }, + "unconnectedAccount": { + "state": "CLOSED" } } diff --git a/development/states/tx-list-items.json b/development/states/tx-list-items.json index b4affd2b7b7e..560313a8bf7c 100644 --- a/development/states/tx-list-items.json +++ b/development/states/tx-list-items.json @@ -1403,5 +1403,8 @@ "priceAndTimeEstimatesLastRetrieved": 1541527901281, "errors": {} }, - "confirmTransaction": {} + "confirmTransaction": {}, + "unconnectedAccount": { + "state": "CLOSED" + } } diff --git a/package.json b/package.json index 5e5fc5a58cd7..2e32e7ae1445 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@metamask/eth-token-tracker": "^2.0.0", "@metamask/etherscan-link": "^1.1.0", "@metamask/inpage-provider": "^5.0.0", + "@reduxjs/toolkit": "^1.3.2", "@sentry/browser": "^5.11.1", "@sentry/integrations": "^5.11.1", "@zxing/library": "^0.8.0", diff --git a/test/unit/ui/app/actions.spec.js b/test/unit/ui/app/actions.spec.js index 5c76e0296740..068bc90f90ad 100644 --- a/test/unit/ui/app/actions.spec.js +++ b/test/unit/ui/app/actions.spec.js @@ -232,6 +232,7 @@ describe('Actions', function () { const expectedActions = [ 'SHOW_LOADING_INDICATION', + 'SELECTED_ADDRESS_CHANGED', 'UPDATE_METAMASK_STATE', 'HIDE_LOADING_INDICATION', 'SHOW_ACCOUNTS_PAGE', @@ -979,7 +980,7 @@ describe('Actions', function () { it('#showAccountDetail', async function () { setSelectedAddressSpy = sinon.stub(background, 'setSelectedAddress') .callsArgWith(1, null) - const store = mockStore() + const store = mockStore({ metamask: { selectedAddress: '0x123' } }) await store.dispatch(actions.showAccountDetail()) assert(setSelectedAddressSpy.calledOnce) @@ -988,7 +989,7 @@ describe('Actions', function () { it('displays warning if setSelectedAddress throws', async function () { setSelectedAddressSpy = sinon.stub(background, 'setSelectedAddress') .callsArgWith(1, new Error('error')) - const store = mockStore() + const store = mockStore({ metamask: { selectedAddress: '0x123' } }) const expectedActions = [ { type: 'SHOW_LOADING_INDICATION', value: undefined }, { type: 'HIDE_LOADING_INDICATION' }, diff --git a/ui/app/components/app/alerts/alerts.js b/ui/app/components/app/alerts/alerts.js new file mode 100644 index 000000000000..5d376494cf5f --- /dev/null +++ b/ui/app/components/app/alerts/alerts.js @@ -0,0 +1,19 @@ +import React from 'react' +import { useSelector } from 'react-redux' + +import UnconnectedAccountAlert from './unconnected-account-alert' +import { alertIsOpen as unconnectedAccountAlertIsOpen } from '../../../ducks/alerts/unconnected-account' + +const Alerts = () => { + const _unconnectedAccountAlertIsOpen = useSelector(unconnectedAccountAlertIsOpen) + + if (_unconnectedAccountAlertIsOpen) { + return ( + + ) + } + + return null +} + +export default Alerts diff --git a/ui/app/components/app/alerts/alerts.scss b/ui/app/components/app/alerts/alerts.scss new file mode 100644 index 000000000000..cbd0a3b052cd --- /dev/null +++ b/ui/app/components/app/alerts/alerts.scss @@ -0,0 +1 @@ +@import './unconnected-account-alert/unconnected-account-alert.scss'; diff --git a/ui/app/components/app/alerts/index.js b/ui/app/components/app/alerts/index.js new file mode 100644 index 000000000000..b52e5d6502f2 --- /dev/null +++ b/ui/app/components/app/alerts/index.js @@ -0,0 +1 @@ +export { default } from './alerts' diff --git a/ui/app/components/app/alerts/unconnected-account-alert/index.js b/ui/app/components/app/alerts/unconnected-account-alert/index.js new file mode 100644 index 000000000000..495f14c33371 --- /dev/null +++ b/ui/app/components/app/alerts/unconnected-account-alert/index.js @@ -0,0 +1 @@ +export { default } from './unconnected-account-alert' diff --git a/ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.js b/ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.js new file mode 100644 index 000000000000..a522e78039b1 --- /dev/null +++ b/ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.js @@ -0,0 +1,63 @@ +import React, { useContext } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { + ALERT_STATE, + connectAccount, + dismissAlert, + getAlertState, +} from '../../../../ducks/alerts/unconnected-account' +import { I18nContext } from '../../../../contexts/i18n' +import Popover from '../../../ui/popover' +import Button from '../../../ui/button' + +const { + ERROR, + LOADING, +} = ALERT_STATE + +const SwitchToUnconnectedAccountAlert = () => { + const t = useContext(I18nContext) + const dispatch = useDispatch() + const alertState = useSelector(getAlertState) + + return ( + dispatch(dismissAlert())} + footer={( + <> + { + alertState === ERROR + ? ( +
+ { t('failureMessage') } +
+ ) + : null + } +
+ + +
+ + )} + footerClassName="unconnected-account-alert__footer" + /> + ) +} + +export default SwitchToUnconnectedAccountAlert diff --git a/ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.scss b/ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.scss new file mode 100644 index 000000000000..1a1b75f7f1c6 --- /dev/null +++ b/ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.scss @@ -0,0 +1,33 @@ +.unconnected-account-alert { + &__footer { + flex-direction: column; + + :only-child { + margin: 0; + } + } + + &__footer-buttons { + display: flex; + flex-direction: row; + + button:first-child { + margin-right: 24px; + } + + button { + font-size: 14px; + line-height: 20px; + padding: 8px; + } + } + + &__error { + margin-bottom: 16px; + padding: 16px; + font-size: 14px; + border: 1px solid #D73A49; + background: #F8EAE8; + border-radius: 3px; + } +} diff --git a/ui/app/components/app/index.scss b/ui/app/components/app/index.scss index 933d35ced891..49468037a3c8 100644 --- a/ui/app/components/app/index.scss +++ b/ui/app/components/app/index.scss @@ -4,6 +4,8 @@ @import 'add-token-button/index'; +@import 'alerts/alerts.scss'; + @import 'app-header/index'; @import 'asset-list/asset-list.scss'; diff --git a/ui/app/ducks/alerts/index.js b/ui/app/ducks/alerts/index.js new file mode 100644 index 000000000000..3a7cd3919a0c --- /dev/null +++ b/ui/app/ducks/alerts/index.js @@ -0,0 +1,5 @@ +import unconnectedAccount from './unconnected-account' + +export default { + unconnectedAccount, +} diff --git a/ui/app/ducks/alerts/unconnected-account.js b/ui/app/ducks/alerts/unconnected-account.js new file mode 100644 index 000000000000..92ed697984e0 --- /dev/null +++ b/ui/app/ducks/alerts/unconnected-account.js @@ -0,0 +1,90 @@ +import { createSlice } from '@reduxjs/toolkit' +import { captureException } from '@sentry/browser' + +import actionConstants from '../../store/actionConstants' +import { addPermittedAccount } from '../../store/actions' +import { getOriginOfCurrentTab, getSelectedAddress } from '../../selectors/selectors' + +// Constants + +export const ALERT_STATE = { + CLOSED: 'CLOSED', + ERROR: 'ERROR', + LOADING: 'LOADING', + OPEN: 'OPEN', +} + +const name = 'unconnectedAccount' + +const initialState = { + state: ALERT_STATE.CLOSED, +} + +// Slice (reducer plus auto-generated actions and action creators) + +const slice = createSlice({ + name, + initialState, + reducers: { + connectAccountFailed: (state) => { + state.state = ALERT_STATE.ERROR + }, + connectAccountRequested: (state) => { + state.state = ALERT_STATE.LOADING + }, + connectAccountSucceeded: (state) => { + state.state = ALERT_STATE.CLOSED + }, + dismissAlert: (state) => { + state.state = ALERT_STATE.CLOSED + }, + switchedToUnconnectedAccount: (state) => { + state.state = ALERT_STATE.OPEN + }, + }, + extraReducers: { + [actionConstants.SELECTED_ADDRESS_CHANGED]: (state) => { + // close the alert if the account is switched while it's open + if (state.state === ALERT_STATE.OPEN) { + state.state = ALERT_STATE.CLOSED + } + }, + }, +}) + +const { actions, reducer } = slice + +export default reducer + +// Selectors + +export const getAlertState = (state) => state[name].state + +export const alertIsOpen = (state) => state[name].state !== ALERT_STATE.CLOSED + +// Actions / action-creators + +export const { + connectAccountFailed, + connectAccountRequested, + connectAccountSucceeded, + dismissAlert, + switchedToUnconnectedAccount, +} = actions + +export const connectAccount = () => { + return async (dispatch, getState) => { + const state = getState() + const selectedAddress = getSelectedAddress(state) + const origin = getOriginOfCurrentTab(state) + try { + await dispatch(connectAccountRequested()) + await dispatch(addPermittedAccount(origin, selectedAddress)) + await dispatch(connectAccountSucceeded()) + } catch (error) { + console.error(error) + captureException(error) + await dispatch(connectAccountFailed()) + } + } +} diff --git a/ui/app/ducks/index.js b/ui/app/ducks/index.js index d0531dceedd8..a63fbe56ce49 100644 --- a/ui/app/ducks/index.js +++ b/ui/app/ducks/index.js @@ -5,8 +5,10 @@ import sendReducer from './send/send.duck' import appStateReducer from './app/app' import confirmTransactionReducer from './confirm-transaction/confirm-transaction.duck' import gasReducer from './gas/gas.duck' +import alerts from './alerts' export default combineReducers({ + ...alerts, activeTab: (s) => (s === undefined ? null : s), metamask: metamaskReducer, appState: appStateReducer, diff --git a/ui/app/pages/routes/routes.component.js b/ui/app/pages/routes/routes.component.js index 7a8d9bacf3f4..e02d2bb77073 100644 --- a/ui/app/pages/routes/routes.component.js +++ b/ui/app/pages/routes/routes.component.js @@ -29,6 +29,7 @@ import { Modal } from '../../components/app/modals' import Alert from '../../components/ui/alert' import AppHeader from '../../components/app/app-header' import UnlockPage from '../unlock-page' +import Alerts from '../../components/app/alerts' import { ADD_TOKEN_ROUTE, @@ -251,6 +252,7 @@ export default class Routes extends Component { { !isLoading && isLoadingNetwork && } { this.renderRoutes() } + ) } diff --git a/ui/app/store/actionConstants.js b/ui/app/store/actionConstants.js index 02396e2ee952..a1ec4488f212 100644 --- a/ui/app/store/actionConstants.js +++ b/ui/app/store/actionConstants.js @@ -18,6 +18,7 @@ export default { NETWORK_DROPDOWN_CLOSE: 'UI_NETWORK_DROPDOWN_CLOSE', // remote state UPDATE_METAMASK_STATE: 'UPDATE_METAMASK_STATE', + SELECTED_ADDRESS_CHANGED: 'SELECTED_ADDRESS_CHANGED', FORGOT_PASSWORD: 'FORGOT_PASSWORD', CLOSE_WELCOME_SCREEN: 'CLOSE_WELCOME_SCREEN', // unlock screen diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js index e44ec27948fa..7cba6e57336d 100644 --- a/ui/app/store/actions.js +++ b/ui/app/store/actions.js @@ -15,6 +15,11 @@ import { setCustomGasLimit } from '../ducks/gas/gas.duck' import txHelper from '../../lib/tx-helper' import { getEnvironmentType } from '../../../app/scripts/lib/util' import actionConstants from './actionConstants' +import { + getPermittedAccountsForCurrentTab, + getSelectedAddress, +} from '../selectors/selectors' +import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account' let background = null let promisifiedBackground = null @@ -1089,12 +1094,21 @@ export function updateMetamaskState (newState) { return (dispatch, getState) => { const { metamask: currentState } = getState() - const { currentLocale } = currentState - const { currentLocale: newLocale } = newState + const { + currentLocale, + selectedAddress, + } = currentState + const { + currentLocale: newLocale, + selectedAddress: newSelectedAddress, + } = newState if (currentLocale && newLocale && currentLocale !== newLocale) { dispatch(updateCurrentLocale(newLocale)) } + if (selectedAddress !== newSelectedAddress) { + dispatch({ type: actionConstants.SELECTED_ADDRESS_CHANGED }) + } dispatch({ type: actionConstants.UPDATE_METAMASK_STATE, @@ -1161,10 +1175,17 @@ export function setSelectedAddress (address) { } export function showAccountDetail (address) { - return async (dispatch) => { + return async (dispatch, getState) => { dispatch(showLoadingIndication()) log.debug(`background.setSelectedAddress`) + const state = getState() + const selectedAddress = getSelectedAddress(state) + const permittedAccountsForCurrentTab = getPermittedAccountsForCurrentTab(state) + const currentTabIsConnectedToPreviousAddress = permittedAccountsForCurrentTab.includes(selectedAddress) + const currentTabIsConnectedToNextAddress = permittedAccountsForCurrentTab.includes(address) + const switchingToUnconnectedAddress = currentTabIsConnectedToPreviousAddress && !currentTabIsConnectedToNextAddress + let tokens try { tokens = await promisifiedBackground.setSelectedAddress(address) @@ -1179,6 +1200,9 @@ export function showAccountDetail (address) { type: actionConstants.SHOW_ACCOUNT_DETAIL, value: address, }) + if (switchingToUnconnectedAddress) { + dispatch(switchedToUnconnectedAccount()) + } dispatch(setSelectedToken()) } } diff --git a/yarn.lock b/yarn.lock index 8bb1123d3c23..6c43c0403569 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1981,6 +1981,16 @@ react-lifecycles-compat "^3.0.4" warning "^3.0.0" +"@reduxjs/toolkit@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.3.2.tgz#cbd062f0b806eb4611afeb2b30240e5186d6dd27" + integrity sha512-IRI9Nx6Ys/u4NDqPvUC0+e8MH+e1VME9vn30xAmd+MBqDsClc0Dhrlv4Scw2qltRy/mrINarU6BqJp4/dcyyFg== + dependencies: + immer "^6.0.1" + redux "^4.0.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@sentry/browser@^5.11.1": version "5.11.1" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.11.1.tgz#337ffcb52711b23064c847a07629e966f54a5ebb" @@ -14209,6 +14219,11 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immer@^6.0.1: + version "6.0.3" + resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.3.tgz#94d5051cd724668160a900d66d85ec02816f29bd" + integrity sha512-12VvNrfSrXZdm/BJgi/KDW2soq5freVSf3I1+4CLunUM8mAGx2/0Njy0xBVzi5zewQZiwM7z1/1T+8VaI7NkmQ== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -24342,6 +24357,11 @@ reselect@^3.0.1: resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147" integrity sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc= +reselect@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" + integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== + resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"