1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 11:22:43 +02:00

Alert user upon switching to unconnected account (#8312)

An alert is now shown when the user switches from an account that is
connected to the active tab to an account that is not connected. The
alert prompts the user to dismiss the alert or connect the account
they're switching to.

The "loading" state is handled by disabling the buttons, and the error
state is handled by displaying a generic error message and disabling
the connect button.

The new reducer for this alert has been created with `createSlice` from
the Redux Toolkit. This utility is recommended by the Redux team, and
represents a new style of writing reducers that I hope we will use more
in the future (or at least something similar). `createSlice` constructs
a reducer, actions, and action creators automatically. The reducer is
constructed using their `createReducer` helper, which uses Immer to
allow directly mutating the state in the reducer but exposing these
changes as immutable.
This commit is contained in:
Mark Stacey 2020-04-29 14:10:51 -03:00 committed by GitHub
parent 7302a14341
commit 53feb20803
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 290 additions and 6 deletions

View File

@ -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"
},

View File

@ -521,5 +521,8 @@
],
"priceAndTimeEstimatesLastRetrieved": 1541527901281,
"errors": {}
},
"unconnectedAccount": {
"state": "CLOSED"
}
}

View File

@ -472,5 +472,8 @@
],
"priceAndTimeEstimatesLastRetrieved": 1541527901281,
"errors": {}
},
"unconnectedAccount": {
"state": "CLOSED"
}
}

View File

@ -1403,5 +1403,8 @@
"priceAndTimeEstimatesLastRetrieved": 1541527901281,
"errors": {}
},
"confirmTransaction": {}
"confirmTransaction": {},
"unconnectedAccount": {
"state": "CLOSED"
}
}

View File

@ -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",

View File

@ -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' },

View File

@ -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 (
<UnconnectedAccountAlert />
)
}
return null
}
export default Alerts

View File

@ -0,0 +1 @@
@import './unconnected-account-alert/unconnected-account-alert.scss';

View File

@ -0,0 +1 @@
export { default } from './alerts'

View File

@ -0,0 +1 @@
export { default } from './unconnected-account-alert'

View File

@ -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 (
<Popover
title={t('unconnectedAccountAlertTitle')}
subtitle={t('unconnectedAccountAlertDescription')}
onClose={() => dispatch(dismissAlert())}
footer={(
<>
{
alertState === ERROR
? (
<div className="unconnected-account-alert__error">
{ t('failureMessage') }
</div>
)
: null
}
<div className="unconnected-account-alert__footer-buttons">
<Button
disabled={alertState === LOADING}
onClick={() => dispatch(dismissAlert())}
type="secondary"
>
{ t('dismiss') }
</Button>
<Button
disabled={alertState === LOADING || alertState === ERROR}
onClick={() => dispatch(connectAccount())}
type="primary"
>
{ t('connect') }
</Button>
</div>
</>
)}
footerClassName="unconnected-account-alert__footer"
/>
)
}
export default SwitchToUnconnectedAccountAlert

View File

@ -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;
}
}

View File

@ -4,6 +4,8 @@
@import 'add-token-button/index';
@import 'alerts/alerts.scss';
@import 'app-header/index';
@import 'asset-list/asset-list.scss';

View File

@ -0,0 +1,5 @@
import unconnectedAccount from './unconnected-account'
export default {
unconnectedAccount,
}

View File

@ -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())
}
}
}

View File

@ -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,

View File

@ -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 && <LoadingNetwork /> }
{ this.renderRoutes() }
</div>
<Alerts />
</div>
)
}

View File

@ -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

View File

@ -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())
}
}

View File

@ -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"