1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 17:33:23 +01:00

Add switch to connected account alert (#8532)

Add alert suggesting that the user switch to a connected account. This
alert is displayed when the popup is opened over an active tab that is
connected to some account, but not the current selected account. The
user can choose to switch to a connected account, or dismiss the alert.

This alert is only shown once per account switch. So if the user
repeatedly opens the popup on a dapp without switching accounts, it'll
only be shown the first time. The alert also won't be shown if the user
has just dismissed an "Unconnected account" alert on this same dapp
and account, as that would be redundant.

The alert has a "Don't show me this again" checkbox that allows the
user to disable the alert. It can be re-enabled again on the Alerts
settings page.
This commit is contained in:
Mark Stacey 2020-05-12 10:01:52 -03:00 committed by GitHub
parent 6868688a03
commit 53ec42d95f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 437 additions and 14 deletions

View File

@ -154,6 +154,12 @@
"alertsSettingsDescription": {
"message": "Enable or disable each alert"
},
"alertSettingsSwitchToConnected": {
"message": "Opening popup with an unconnected account selected"
},
"alertSettingsSwitchToConnectedDescription": {
"message": "This alert is shown when you open the popup with an unconnected account selected."
},
"alertSettingsUnconnectedAccount": {
"message": "Switching to an unconnected account"
},
@ -953,6 +959,9 @@
"noAlreadyHaveSeed": {
"message": "No, I already have a seed phrase"
},
"notConnected": {
"message": "Not connected"
},
"protectYourKeys": {
"message": "Protect Your Keys!"
},
@ -1412,6 +1421,16 @@
"supportCenter": {
"message": "Visit our Support Center"
},
"switchAccounts": {
"message": "Switch accounts"
},
"switchToConnectedAlertMultipleAccountsDescription": {
"message": "This account is not connected. Switch to a connected account?"
},
"switchToConnectedAlertSingleAccountDescription": {
"message": "This account is not connected. Switch to a connected account ($1)?",
"description": "$1 will be replaced by the name of the connected account"
},
"symbol": {
"message": "Symbol"
},
@ -1549,9 +1568,6 @@
"unapproved": {
"message": "Unapproved"
},
"unconnectedAccountAlertTitle": {
"message": "Not connected"
},
"unconnectedAccountAlertDescription": {
"message": "This account is not connected to this site."
},

View File

@ -12,6 +12,7 @@ import ObservableStore from 'obs-store'
*/
export const ALERT_TYPES = {
switchToConnected: 'switchToConnected',
unconnectedAccount: 'unconnectedAccount',
}
@ -24,6 +25,7 @@ const defaultState = {
},
{}
),
switchToConnectedAlertShown: {},
}
/**
@ -36,13 +38,27 @@ export default class AlertController {
* @param {AlertControllerOptions} [opts] - Controller configuration parameters
*/
constructor (opts = {}) {
const { initState } = opts
const { initState, preferencesStore } = opts
const state = Object.assign(
{},
defaultState,
initState,
{
switchToConnectedAlertShown: {},
}
)
this.store = new ObservableStore(state)
const { selectedAddress } = preferencesStore.getState()
this.selectedAddress = selectedAddress
preferencesStore.subscribe(({ selectedAddress }) => {
const currentState = this.store.getState()
if (currentState.switchToConnectedAlertShown && this.selectedAddress !== selectedAddress) {
this.selectedAddress = selectedAddress
this.store.updateState({ switchToConnectedAlertShown: {} })
}
})
}
setAlertEnabledness (alertId, enabledness) {
@ -51,4 +67,15 @@ export default class AlertController {
alertEnabledness[alertId] = enabledness
this.store.updateState({ alertEnabledness })
}
/**
* Sets the "switch to connected" alert as shown for the given origin
* @param {string} origin - The origin the alert has been shown for
*/
setSwitchToConnectedAlertShown (origin) {
let { switchToConnectedAlertShown } = this.store.getState()
switchToConnectedAlertShown = { ...switchToConnectedAlertShown }
switchToConnectedAlertShown[origin] = true
this.store.updateState({ switchToConnectedAlertShown })
}
}

View File

@ -234,7 +234,10 @@ export default class MetamaskController extends EventEmitter {
this.addressBookController = new AddressBookController(undefined, initState.AddressBookController)
this.alertController = new AlertController({ initState: initState.AlertController })
this.alertController = new AlertController({
initState: initState.AlertController,
preferencesStore: this.preferencesController.store,
})
this.threeBoxController = new ThreeBoxController({
preferencesController: this.preferencesController,
@ -564,6 +567,7 @@ export default class MetamaskController extends EventEmitter {
// alert controller
setAlertEnabledness: nodeify(alertController.setAlertEnabledness, alertController),
setSwitchToConnectedAlertShown: nodeify(this.alertController.setSwitchToConnectedAlertShown, this.alertController),
// 3Box
setThreeBoxSyncingPermission: nodeify(threeBoxController.setThreeBoxSyncingPermission, threeBoxController),

View File

@ -522,6 +522,9 @@
"priceAndTimeEstimatesLastRetrieved": 1541527901281,
"errors": {}
},
"switchToConnected": {
"state": "CLOSED"
},
"unconnectedAccount": {
"state": "CLOSED"
}

View File

@ -473,6 +473,9 @@
"priceAndTimeEstimatesLastRetrieved": 1541527901281,
"errors": {}
},
"switchToConnected": {
"state": "CLOSED"
},
"unconnectedAccount": {
"state": "CLOSED"
}

View File

@ -1278,6 +1278,9 @@
"errors": {}
},
"confirmTransaction": {},
"switchToConnected": {
"state": "CLOSED"
},
"unconnectedAccount": {
"state": "CLOSED"
}

View File

@ -980,7 +980,7 @@ describe('Actions', function () {
it('#showAccountDetail', async function () {
setSelectedAddressSpy = sinon.stub(background, 'setSelectedAddress')
.callsArgWith(1, null)
const store = mockStore({ metamask: { alertEnabledness: {}, selectedAddress: '0x123' } })
const store = mockStore({ activeTab: {}, metamask: { alertEnabledness: {}, selectedAddress: '0x123' } })
await store.dispatch(actions.showAccountDetail())
assert(setSelectedAddressSpy.calledOnce)
@ -989,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({ metamask: { alertEnabledness: {}, selectedAddress: '0x123' } })
const store = mockStore({ activeTab: {}, metamask: { alertEnabledness: {}, selectedAddress: '0x123' } })
const expectedActions = [
{ type: 'SHOW_LOADING_INDICATION', value: undefined },
{ type: 'HIDE_LOADING_INDICATION' },

View File

@ -2,15 +2,22 @@ import React from 'react'
import { useSelector } from 'react-redux'
import UnconnectedAccountAlert from './unconnected-account-alert'
import SwitchToConnectedAlert from './switch-to-connected-alert'
import { alertIsOpen as unconnectedAccountAlertIsOpen } from '../../../ducks/alerts/unconnected-account'
import { alertIsOpen as switchToConnectedAlertIsOpen } from '../../../ducks/alerts/switch-to-connected'
const Alerts = () => {
const _unconnectedAccountAlertIsOpen = useSelector(unconnectedAccountAlertIsOpen)
const _switchToConnectedAlertIsOpen = useSelector(switchToConnectedAlertIsOpen)
if (_unconnectedAccountAlertIsOpen) {
return (
<UnconnectedAccountAlert />
)
} else if (_switchToConnectedAlertIsOpen) {
return (
<SwitchToConnectedAlert />
)
}
return null

View File

@ -1 +1,3 @@
@import './unconnected-account-alert/unconnected-account-alert.scss';
@import './switch-to-connected-alert/switch-to-connected-alert.scss';

View File

@ -0,0 +1 @@
export { default } from './switch-to-connected-alert'

View File

@ -0,0 +1,121 @@
import React, { useContext, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
ALERT_STATE,
switchToAccount,
dismissAlert,
dismissAndDisableAlert,
getAlertState,
} from '../../../../ducks/alerts/switch-to-connected'
import { getPermittedIdentitiesForCurrentTab } from '../../../../selectors'
import { I18nContext } from '../../../../contexts/i18n'
import Popover from '../../../ui/popover'
import Button from '../../../ui/button'
import Dropdown from '../../../ui/dropdown'
import Checkbox from '../../../ui/check-box'
import Tooltip from '../../../ui/tooltip-v2'
const {
ERROR,
LOADING,
} = ALERT_STATE
const SwitchToUnconnectedAccountAlert = () => {
const t = useContext(I18nContext)
const dispatch = useDispatch()
const alertState = useSelector(getAlertState)
const connectedAccounts = useSelector(getPermittedIdentitiesForCurrentTab)
const [accountToSwitchTo, setAccountToSwitchTo] = useState(connectedAccounts[0].address)
const [dontShowThisAgain, setDontShowThisAgain] = useState(false)
const onClose = async () => {
return dontShowThisAgain
? await dispatch(dismissAndDisableAlert())
: dispatch(dismissAlert())
}
const options = connectedAccounts.map((account) => {
return { name: account.name, value: account.address }
})
return (
<Popover
contentClassName="switch-to-connected-alert__content"
footer={(
<>
{
alertState === ERROR
? (
<div className="switch-to-connected-alert__error">
{ t('failureMessage') }
</div>
)
: null
}
<div className="switch-to-connected-alert__footer-buttons">
<Button
disabled={alertState === LOADING}
onClick={onClose}
type="secondary"
>
{ t('dismiss') }
</Button>
<Button
disabled={alertState === LOADING || alertState === ERROR || dontShowThisAgain}
onClick={() => dispatch(switchToAccount(accountToSwitchTo))}
type="primary"
>
{ t('switchAccounts') }
</Button>
</div>
</>
)}
footerClassName="switch-to-connected-alert__footer"
onClose={onClose}
subtitle={
connectedAccounts.length > 1
? t('switchToConnectedAlertMultipleAccountsDescription')
: t('switchToConnectedAlertSingleAccountDescription', [connectedAccounts[0].name])
}
title={t('notConnected')}
>
{
connectedAccounts.length > 1
? (
<Dropdown
className="switch-to-connected-alert__dropdown"
title="Switch to account"
onChange={(address) => setAccountToSwitchTo(address)}
options={options}
selectedOption={accountToSwitchTo}
/>
)
: null
}
<div className="switch-to-connected-alert__checkbox-wrapper">
<Checkbox
id="switchToConnected_dontShowThisAgain"
checked={dontShowThisAgain}
className="switch-to-connected-alert__checkbox"
onClick={() => setDontShowThisAgain((checked) => !checked)}
/>
<label
className="switch-to-connected-alert__checkbox-label"
htmlFor="switchToConnected_dontShowThisAgain"
>
{ t('dontShowThisAgain') }
<Tooltip
position="top"
title={t('unconnectedAccountAlertDisableTooltip')}
wrapperClassName="switch-to-connected-alert__checkbox-label-tooltip"
>
<i className="fa fa-info-circle" />
</Tooltip>
</label>
</div>
</Popover>
)
}
export default SwitchToUnconnectedAccountAlert

View File

@ -0,0 +1,66 @@
.switch-to-connected-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;
}
&__content {
align-items: center;
padding: 0 24px 24px 24px;
}
&__dropdown {
background-color: white;
width: 100%;
margin-bottom: 24px;
}
&__checkbox-wrapper {
display: flex;
flex-direction: row;
width: 100%;
}
&__checkbox {
margin-right: 8px;
}
&__checkbox-label {
font-size: 14px;
margin-top: auto;
margin-bottom: auto;
color: $Grey-500;
display: flex;
}
&__checkbox-label-tooltip {
margin-left: 8px;
}
}

View File

@ -34,7 +34,7 @@ const SwitchToUnconnectedAccountAlert = () => {
return (
<Popover
contentClassName="unconnected-account-alert__content"
title={t('unconnectedAccountAlertTitle')}
title={t('notConnected')}
subtitle={t('unconnectedAccountAlertDescription')}
onClose={onClose}
footer={(

View File

@ -1 +1,2 @@
export { default as switchToConnected } from './switch-to-connected'
export { default as unconnectedAccount } from './unconnected-account'

View File

@ -0,0 +1,111 @@
import { createSlice } from '@reduxjs/toolkit'
import { captureException } from '@sentry/browser'
import { ALERT_TYPES } from '../../../../app/scripts/controllers/alert'
import * as actionConstants from '../../store/actionConstants'
import { setAlertEnabledness, setSelectedAddress } from '../../store/actions'
// Constants
export const ALERT_STATE = {
CLOSED: 'CLOSED',
ERROR: 'ERROR',
LOADING: 'LOADING',
OPEN: 'OPEN',
}
const name = ALERT_TYPES.switchToConnected
const initialState = {
state: ALERT_STATE.CLOSED,
}
// Slice (reducer plus auto-generated actions and action creators)
const slice = createSlice({
name,
initialState,
reducers: {
disableAlertFailed: (state) => {
state.state = ALERT_STATE.ERROR
},
disableAlertRequested: (state) => {
state.state = ALERT_STATE.LOADING
},
disableAlertSucceeded: (state) => {
state.state = ALERT_STATE.CLOSED
},
dismissAlert: (state) => {
state.state = ALERT_STATE.CLOSED
},
switchAccountFailed: (state) => {
state.state = ALERT_STATE.ERROR
},
switchAccountRequested: (state) => {
state.state = ALERT_STATE.LOADING
},
switchAccountSucceeded: (state) => {
state.state = ALERT_STATE.CLOSED
},
},
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
const {
disableAlertFailed,
disableAlertRequested,
disableAlertSucceeded,
dismissAlert,
switchAccountFailed,
switchAccountRequested,
switchAccountSucceeded,
} = actions
export { dismissAlert }
export const dismissAndDisableAlert = () => {
return async (dispatch) => {
try {
await dispatch(disableAlertRequested())
await dispatch(setAlertEnabledness(name, false))
await dispatch(disableAlertSucceeded())
} catch (error) {
console.error(error)
captureException(error)
await dispatch(disableAlertFailed())
}
}
}
export const switchToAccount = (address) => {
return async (dispatch) => {
try {
await dispatch(switchAccountRequested())
await dispatch(setSelectedAddress(address))
await dispatch(switchAccountSucceeded())
} catch (error) {
console.error(error)
captureException(error)
await dispatch(switchAccountFailed())
}
}
}

View File

@ -5,10 +5,11 @@ 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 { unconnectedAccount } from './alerts'
import { switchToConnected, unconnectedAccount } from './alerts'
import { ALERT_TYPES } from '../../../app/scripts/controllers/alert'
export default combineReducers({
[ALERT_TYPES.switchToConnected]: switchToConnected,
[ALERT_TYPES.unconnectedAccount]: unconnectedAccount,
activeTab: (s) => (s === undefined ? null : s),
metamask: metamaskReducer,

View File

@ -371,4 +371,8 @@ export const getCurrentLocale = (state) => state.metamask.currentLocale
export const getAlertEnabledness = (state) => state.metamask.alertEnabledness
export const getSwitchToConnectedAlertEnabledness = (state) => getAlertEnabledness(state)[ALERT_TYPES.switchToConnected]
export const getUnconnectedAccountAlertEnabledness = (state) => getAlertEnabledness(state)[ALERT_TYPES.unconnectedAccount]
export const getSwitchToConnectedAlertShown = (state) => state.metamask.switchToConnectedAlertShown

View File

@ -184,6 +184,7 @@ export default class Routes extends Component {
render () {
const {
isLoading,
isUnlocked,
alertMessage,
textDirection,
loadingMessage,
@ -252,7 +253,13 @@ export default class Routes extends Component {
{ !isLoading && isLoadingNetwork && <LoadingNetwork /> }
{ this.renderRoutes() }
</div>
<Alerts />
{
isUnlocked
? (
<Alerts />
)
: null
}
</div>
)
}

View File

@ -46,6 +46,10 @@ const AlertsTab = () => {
const t = useContext(I18nContext)
const alertConfig = {
[ALERT_TYPES.switchToConnected]: {
title: t('alertSettingsSwitchToConnected'),
description: t('alertSettingsSwitchToConnectedDescription'),
},
[ALERT_TYPES.unconnectedAccount]: {
title: t('alertSettingsUnconnectedAccount'),
description: t('alertSettingsUnconnectedAccountDescription'),

View File

@ -1,5 +1,5 @@
import { forOwn } from 'lodash'
import { getOriginOfCurrentTab } from './selectors'
import { getMetaMaskIdentities, getOriginOfCurrentTab } from './selectors'
import {
CAVEAT_NAMES,
} from '../../../app/scripts/controllers/permissions/enums'
@ -117,6 +117,12 @@ export function getConnectedDomainsForSelectedAddress (state) {
return connectedDomains
}
export function getPermittedIdentitiesForCurrentTab (state) {
const permittedAccounts = getPermittedAccountsForCurrentTab(state)
const identities = getMetaMaskIdentities(state)
return permittedAccounts.map((address) => identities[address])
}
/**
* Returns an object mapping addresses to objects mapping origins to connected
* domain info. Domain info objects have the following properties:

View File

@ -1188,6 +1188,7 @@ export function showAccountDetail (address) {
const state = getState()
const unconnectedAccountAlertIsEnabled = getUnconnectedAccountAlertEnabledness(state)
const activeTabOrigin = state.activeTab.origin
const selectedAddress = getSelectedAddress(state)
const permittedAccountsForCurrentTab = getPermittedAccountsForCurrentTab(state)
const currentTabIsConnectedToPreviousAddress = permittedAccountsForCurrentTab.includes(selectedAddress)
@ -1206,10 +1207,11 @@ export function showAccountDetail (address) {
type: actionConstants.SHOW_ACCOUNT_DETAIL,
value: address,
})
dispatch(setSelectedToken())
if (unconnectedAccountAlertIsEnabled && switchingToUnconnectedAddress) {
dispatch(switchedToUnconnectedAccount())
await setSwitchToConnectedAlertShown(activeTabOrigin)
}
dispatch(setSelectedToken())
}
}
@ -2162,6 +2164,10 @@ export function setAlertEnabledness (alertId, enabledness) {
}
}
export async function setSwitchToConnectedAlertShown (origin) {
await promisifiedBackground.setSwitchToConnectedAlertShown(origin)
}
export function loadingMethodDataStarted () {
return {
type: actionConstants.LOADING_METHOD_DATA_STARTED,

View File

@ -7,8 +7,17 @@ import Root from './app/pages'
import * as actions from './app/store/actions'
import configureStore from './app/store/store'
import txHelper from './lib/tx-helper'
import { getEnvironmentType } from '../app/scripts/lib/util'
import { ALERT_TYPES } from '../app/scripts/controllers/alert'
import { ENVIRONMENT_TYPE_POPUP } from '../app/scripts/lib/enums'
import { fetchLocale } from './app/helpers/utils/i18n-helper'
import switchDirection from './app/helpers/utils/switch-direction'
import { getPermittedAccountsForCurrentTab, getSelectedAddress } from './app/selectors'
import { ALERT_STATE } from './app/ducks/alerts/switch-to-connected'
import {
getSwitchToConnectedAlertEnabledness,
getSwitchToConnectedAlertShown,
} from './app/ducks/metamask/metamask'
log.setLevel(global.METAMASK_DEBUG ? 'debug' : 'warn')
@ -43,7 +52,7 @@ async function startApp (metamaskState, backgroundConnection, opts) {
await switchDirection('rtl')
}
const store = configureStore({
const draftInitialState = {
activeTab: opts.activeTab,
// metamaskState represents the cross-tab state
@ -56,7 +65,28 @@ async function startApp (metamaskState, backgroundConnection, opts) {
current: currentLocaleMessages,
en: enLocaleMessages,
},
})
}
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
const origin = draftInitialState.activeTab.origin
const permittedAccountsForCurrentTab = getPermittedAccountsForCurrentTab(draftInitialState)
const selectedAddress = getSelectedAddress(draftInitialState)
const switchToConnectedAlertShown = getSwitchToConnectedAlertShown(draftInitialState)
const switchToConnectedAlertIsEnabled = getSwitchToConnectedAlertEnabledness(draftInitialState)
if (
origin &&
switchToConnectedAlertIsEnabled &&
!switchToConnectedAlertShown[origin] &&
permittedAccountsForCurrentTab.length > 0 &&
!permittedAccountsForCurrentTab.includes(selectedAddress)
) {
draftInitialState[ALERT_TYPES.switchToConnected] = { state: ALERT_STATE.OPEN }
actions.setSwitchToConnectedAlertShown(origin)
}
}
const store = configureStore(draftInitialState)
// if unconfirmed txs, start on txConf page
const unapprovedTxsAll = txHelper(