1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Allow disabling alerts (#8550)

The unconnected account alert can now be disabled. A "don't show this
again" checkbox has been added to the alert, which prevents that alert
from being shown in the future.

An alert settings page has been added to the settings as well. This
page allows the user to disable or enable any alert.
This commit is contained in:
Mark Stacey 2020-05-08 16:45:52 -03:00 committed by GitHub
parent c0489163b5
commit c4fb514f3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 303 additions and 13 deletions

View File

@ -148,6 +148,18 @@
"addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask"
},
"alerts": {
"message": "Alerts"
},
"alertsSettingsDescription": {
"message": "Enable or disable each alert"
},
"alertSettingsUnconnectedAccount": {
"message": "Switching to an unconnected account"
},
"alertSettingsUnconnectedAccountDescription": {
"message": "This alert is shown in the popup when you switch from a connected account to an unconnected account."
},
"allowOriginSpendToken": {
"message": "Allow $1 to spend your $2?",
"description": "$1 is the url of the site and $2 is the symbol of the token they are requesting to spend"
@ -462,6 +474,9 @@
"done": {
"message": "Done"
},
"dontShowThisAgain": {
"message": "Don't show this again"
},
"downloadGoogleChrome": {
"message": "Download Google Chrome"
},
@ -1540,6 +1555,9 @@
"unconnectedAccountAlertDescription": {
"message": "This account is not connected to this site"
},
"unconnectedAccountAlertDisableTooltip": {
"message": "This can be changed in \"Settings > Alerts\""
},
"units": {
"message": "units"
},

View File

@ -0,0 +1,54 @@
import ObservableStore from 'obs-store'
/**
* @typedef {Object} AlertControllerInitState
* @property {Object} alertEnabledness - A map of any alerts that were suppressed keyed by alert ID, where the value
* is the timestamp of when the user suppressed the alert.
*/
/**
* @typedef {Object} AlertControllerOptions
* @property {AlertControllerInitState} initState - The initial controller state
*/
export const ALERT_TYPES = {
unconnectedAccount: 'unconnectedAccount',
}
const defaultState = {
alertEnabledness: Object.keys(ALERT_TYPES)
.reduce(
(alertEnabledness, alertType) => {
alertEnabledness[alertType] = true
return alertEnabledness
},
{}
),
}
/**
* Controller responsible for maintaining
* alert related state
*/
export default class AlertController {
/**
* @constructor
* @param {AlertControllerOptions} [opts] - Controller configuration parameters
*/
constructor (opts = {}) {
const { initState } = opts
const state = Object.assign(
{},
defaultState,
initState,
)
this.store = new ObservableStore(state)
}
setAlertEnabledness (alertId, enabledness) {
let { alertEnabledness } = this.store.getState()
alertEnabledness = { ...alertEnabledness }
alertEnabledness[alertId] = enabledness
this.store.updateState({ alertEnabledness })
}
}

View File

@ -31,6 +31,7 @@ import PreferencesController from './controllers/preferences'
import AppStateController from './controllers/app-state'
import InfuraController from './controllers/infura'
import CachedBalancesController from './controllers/cached-balances'
import AlertController from './controllers/alert'
import OnboardingController from './controllers/onboarding'
import ThreeBoxController from './controllers/threebox'
import RecentBlocksController from './controllers/recent-blocks'
@ -233,6 +234,8 @@ export default class MetamaskController extends EventEmitter {
this.addressBookController = new AddressBookController(undefined, initState.AddressBookController)
this.alertController = new AlertController({ initState: initState.AlertController })
this.threeBoxController = new ThreeBoxController({
preferencesController: this.preferencesController,
addressBookController: this.addressBookController,
@ -305,6 +308,7 @@ export default class MetamaskController extends EventEmitter {
NetworkController: this.networkController.store,
InfuraController: this.infuraController.store,
CachedBalancesController: this.cachedBalancesController.store,
AlertController: this.alertController.store,
OnboardingController: this.onboardingController.store,
IncomingTransactionsController: this.incomingTransactionsController.store,
ABTestController: this.abTestController.store,
@ -331,6 +335,7 @@ export default class MetamaskController extends EventEmitter {
AddressBookController: this.addressBookController,
CurrencyController: this.currencyRateController,
InfuraController: this.infuraController.store,
AlertController: this.alertController.store,
OnboardingController: this.onboardingController.store,
IncomingTransactionsController: this.incomingTransactionsController.store,
PermissionsController: this.permissionsController.permissions,
@ -440,6 +445,7 @@ export default class MetamaskController extends EventEmitter {
const keyringController = this.keyringController
const networkController = this.networkController
const onboardingController = this.onboardingController
const alertController = this.alertController
const permissionsController = this.permissionsController
const preferencesController = this.preferencesController
const threeBoxController = this.threeBoxController
@ -556,6 +562,9 @@ export default class MetamaskController extends EventEmitter {
// onboarding controller
setSeedPhraseBackedUp: nodeify(onboardingController.setSeedPhraseBackedUp, onboardingController),
// alert controller
setAlertEnabledness: nodeify(alertController.setAlertEnabledness, alertController),
// 3Box
setThreeBoxSyncingPermission: nodeify(threeBoxController.setThreeBoxSyncingPermission, threeBoxController),
restoreFromThreeBox: nodeify(threeBoxController.restoreFromThreeBox, threeBoxController),

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: { selectedAddress: '0x123' } })
const store = mockStore({ 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: { selectedAddress: '0x123' } })
const store = mockStore({ metamask: { alertEnabledness: {}, selectedAddress: '0x123' } })
const expectedActions = [
{ type: 'SHOW_LOADING_INDICATION', value: undefined },
{ type: 'HIDE_LOADING_INDICATION' },

View File

@ -1,15 +1,18 @@
import React, { useContext } from 'react'
import React, { useContext, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
ALERT_STATE,
connectAccount,
dismissAlert,
dismissAndDisableAlert,
getAlertState,
} from '../../../../ducks/alerts/unconnected-account'
import { I18nContext } from '../../../../contexts/i18n'
import Popover from '../../../ui/popover'
import Button from '../../../ui/button'
import Checkbox from '../../../ui/check-box'
import Tooltip from '../../../ui/tooltip-v2'
const {
ERROR,
@ -20,12 +23,20 @@ const SwitchToUnconnectedAccountAlert = () => {
const t = useContext(I18nContext)
const dispatch = useDispatch()
const alertState = useSelector(getAlertState)
const [dontShowThisAgain, setDontShowThisAgain] = useState(false)
const onClose = async () => {
return dontShowThisAgain
? await dispatch(dismissAndDisableAlert())
: dispatch(dismissAlert())
}
return (
<Popover
contentClassName="unconnected-account-alert__content"
title={t('unconnectedAccountAlertTitle')}
subtitle={t('unconnectedAccountAlertDescription')}
onClose={() => dispatch(dismissAlert())}
onClose={onClose}
footer={(
<>
{
@ -40,13 +51,13 @@ const SwitchToUnconnectedAccountAlert = () => {
<div className="unconnected-account-alert__footer-buttons">
<Button
disabled={alertState === LOADING}
onClick={() => dispatch(dismissAlert())}
onClick={onClose}
type="secondary"
>
{ t('dismiss') }
</Button>
<Button
disabled={alertState === LOADING || alertState === ERROR}
disabled={alertState === LOADING || alertState === ERROR || dontShowThisAgain }
onClick={() => dispatch(connectAccount())}
type="primary"
>
@ -56,7 +67,27 @@ const SwitchToUnconnectedAccountAlert = () => {
</>
)}
footerClassName="unconnected-account-alert__footer"
/>
>
<Checkbox
id="unconnectedAccount_dontShowThisAgain"
checked={dontShowThisAgain}
className="unconnected-account-alert__checkbox"
onClick={() => setDontShowThisAgain((checked) => !checked)}
/>
<label
className="unconnected-account-alert__checkbox-label"
htmlFor="unconnectedAccount_dontShowThisAgain"
>
{ t('dontShowThisAgain') }
<Tooltip
position="top"
title={t('unconnectedAccountAlertDisableTooltip')}
wrapperClassName="unconnected-account-alert__checkbox-label-tooltip"
>
<i className="fa fa-info-circle" />
</Tooltip>
</label>
</Popover>
)
}

View File

@ -30,4 +30,25 @@
background: #F8EAE8;
border-radius: 3px;
}
&__content {
flex-direction: row;
padding: 0 24px 24px 24px;
}
&__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

@ -1,8 +1,9 @@
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 { addPermittedAccount } from '../../store/actions'
import { addPermittedAccount, setAlertEnabledness } from '../../store/actions'
import {
getOriginOfCurrentTab,
getSelectedAddress,
@ -17,7 +18,7 @@ export const ALERT_STATE = {
OPEN: 'OPEN',
}
const name = 'unconnectedAccount'
const name = ALERT_TYPES.unconnectedAccount
const initialState = {
state: ALERT_STATE.CLOSED,
@ -41,6 +42,15 @@ const slice = createSlice({
dismissAlert: (state) => {
state.state = ALERT_STATE.CLOSED
},
disableAlertFailed: (state) => {
state.state = ALERT_STATE.ERROR
},
disableAlertRequested: (state) => {
state.state = ALERT_STATE.LOADING
},
disableAlertSucceeded: (state) => {
state.state = ALERT_STATE.CLOSED
},
switchedToUnconnectedAccount: (state) => {
state.state = ALERT_STATE.OPEN
},
@ -67,14 +77,33 @@ export const alertIsOpen = (state) => state[name].state !== ALERT_STATE.CLOSED
// Actions / action-creators
export const {
const {
connectAccountFailed,
connectAccountRequested,
connectAccountSucceeded,
dismissAlert,
disableAlertFailed,
disableAlertRequested,
disableAlertSucceeded,
switchedToUnconnectedAccount,
} = actions
export { dismissAlert, switchedToUnconnectedAccount }
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 connectAccount = () => {
return async (dispatch, getState) => {
const state = getState()

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 * as alerts from './alerts'
import { unconnectedAccount } from './alerts'
import { ALERT_TYPES } from '../../../app/scripts/controllers/alert'
export default combineReducers({
...alerts,
[ALERT_TYPES.unconnectedAccount]: unconnectedAccount,
activeTab: (s) => (s === undefined ? null : s),
metamask: metamaskReducer,
appState: appStateReducer,

View File

@ -1,4 +1,5 @@
import * as actionConstants from '../../store/actionConstants'
import { ALERT_TYPES } from '../../../../app/scripts/controllers/alert'
export default function reduceMetamask (state = {}, action) {
const metamaskState = Object.assign({
@ -367,3 +368,7 @@ export default function reduceMetamask (state = {}, action) {
}
export const getCurrentLocale = (state) => state.metamask.currentLocale
export const getAlertEnabledness = (state) => state.metamask.alertEnabledness
export const getUnconnectedAccountAlertEnabledness = (state) => getAlertEnabledness(state)[ALERT_TYPES.unconnectedAccount]

View File

@ -7,6 +7,7 @@ const CONNECTIONS_ROUTE = '/settings/connections'
const ADVANCED_ROUTE = '/settings/advanced'
const SECURITY_ROUTE = '/settings/security'
const ABOUT_US_ROUTE = '/settings/about-us'
const ALERTS_ROUTE = '/settings/alerts'
const NETWORKS_ROUTE = '/settings/networks'
const CONTACT_LIST_ROUTE = '/settings/contact-list'
const CONTACT_EDIT_ROUTE = '/settings/contact-list/edit-contact'
@ -54,6 +55,7 @@ const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request'
export {
DEFAULT_ROUTE,
ALERTS_ROUTE,
UNLOCK_ROUTE,
LOCK_ROUTE,
SETTINGS_ROUTE,

View File

@ -0,0 +1,71 @@
import React, { useContext } from 'react'
import PropTypes from 'prop-types'
import { useDispatch, useSelector } from 'react-redux'
import { ALERT_TYPES } from '../../../../../app/scripts/controllers/alert'
import { I18nContext } from '../../../contexts/i18n'
import Tooltip from '../../../components/ui/tooltip-v2'
import ToggleButton from '../../../components/ui/toggle-button'
import { setAlertEnabledness } from '../../../store/actions'
import { getAlertEnabledness } from '../../../ducks/metamask/metamask'
const AlertSettingsEntry = ({ alertId, description, title }) => {
const t = useContext(I18nContext)
const dispatch = useDispatch()
const isEnabled = useSelector((state) => getAlertEnabledness(state)[alertId])
return (
<>
<span>
{ title }
</span>
<Tooltip
position="top"
title={description}
wrapperClassName="alerts-tab__description"
>
<i className="fa fa-info-circle" />
</Tooltip>
<ToggleButton
offLabel={t('off')}
onLabel={t('on')}
onToggle={() => dispatch(setAlertEnabledness(alertId, !isEnabled))}
value={isEnabled}
/>
</>
)
}
AlertSettingsEntry.propTypes = {
alertId: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
}
const AlertsTab = () => {
const t = useContext(I18nContext)
const alertConfig = {
[ALERT_TYPES.unconnectedAccount]: {
title: t('alertSettingsUnconnectedAccount'),
description: t('alertSettingsUnconnectedAccountDescription'),
},
}
return (
<div className="alerts-tab__body">
{
Object.entries(alertConfig).map(([alertId, { title, description }]) => (
<AlertSettingsEntry
alertId={alertId}
description={description}
key={alertId}
title={title}
/>
))
}
</div>
)
}
export default AlertsTab

View File

@ -0,0 +1,28 @@
.alerts-tab {
&__body {
display: grid;
grid-template-columns: 8fr 30px max-content;
grid-template-rows: 1fr 1fr;
font-size: 14px;
align-items: center;
}
&__body > * {
border-bottom: 1px solid $Grey-100;
padding: 16px 8px;
height: 100%;
}
&__body > :nth-child(1n) {
padding-left: 32px;
}
&__body > :nth-child(3n) {
padding-right: 32px;
}
&__description {
display: flex;
align-items: center;
}
}

View File

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

View File

@ -1,5 +1,7 @@
@import 'info-tab/index';
@import 'alerts-tab/alerts-tab';
@import 'networks-tab/index';
@import 'settings-tab/index';

View File

@ -4,12 +4,14 @@ import { Switch, Route, matchPath } from 'react-router-dom'
import TabBar from '../../components/app/tab-bar'
import classnames from 'classnames'
import SettingsTab from './settings-tab'
import AlertsTab from './alerts-tab'
import NetworksTab from './networks-tab'
import AdvancedTab from './advanced-tab'
import InfoTab from './info-tab'
import SecurityTab from './security-tab'
import ContactListTab from './contact-list-tab'
import {
ALERTS_ROUTE,
DEFAULT_ROUTE,
ADVANCED_ROUTE,
SECURITY_ROUTE,
@ -159,6 +161,7 @@ class SettingsPage extends PureComponent {
{ content: t('advanced'), description: t('advancedSettingsDescription'), key: ADVANCED_ROUTE },
{ content: t('contacts'), description: t('contactsSettingsDescription'), key: CONTACT_LIST_ROUTE },
{ content: t('securityAndPrivacy'), description: t('securitySettingsDescription'), key: SECURITY_ROUTE },
{ content: t('alerts'), description: t('alertsSettingsDescription'), key: ALERTS_ROUTE },
{ content: t('networks'), description: t('networkSettingsDescription'), key: NETWORKS_ROUTE },
{ content: t('about'), description: t('aboutSettingsDescription'), key: ABOUT_US_ROUTE },
]}
@ -191,6 +194,11 @@ class SettingsPage extends PureComponent {
path={ADVANCED_ROUTE}
component={AdvancedTab}
/>
<Route
exact
path={ALERTS_ROUTE}
component={AlertsTab}
/>
<Route
exact
path={NETWORKS_ROUTE}

View File

@ -12,6 +12,7 @@ import {
ADVANCED_ROUTE,
SECURITY_ROUTE,
GENERAL_ROUTE,
ALERTS_ROUTE,
ABOUT_US_ROUTE,
SETTINGS_ROUTE,
CONTACT_LIST_ROUTE,
@ -29,6 +30,7 @@ const ROUTES_TO_I18N_KEYS = {
[ADVANCED_ROUTE]: 'advanced',
[SECURITY_ROUTE]: 'securityAndPrivacy',
[ABOUT_US_ROUTE]: 'about',
[ALERTS_ROUTE]: 'alerts',
[CONTACT_LIST_ROUTE]: 'contacts',
[CONTACT_ADD_ROUTE]: 'newContact',
[CONTACT_EDIT_ROUTE]: 'editContact',

View File

@ -20,6 +20,7 @@ import {
getSelectedAddress,
} from '../selectors'
import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'
import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'
let background = null
let promisifiedBackground = null
@ -1186,6 +1187,7 @@ export function showAccountDetail (address) {
log.debug(`background.setSelectedAddress`)
const state = getState()
const unconnectedAccountAlertIsEnabled = getUnconnectedAccountAlertEnabledness(state)
const selectedAddress = getSelectedAddress(state)
const permittedAccountsForCurrentTab = getPermittedAccountsForCurrentTab(state)
const currentTabIsConnectedToPreviousAddress = permittedAccountsForCurrentTab.includes(selectedAddress)
@ -1204,7 +1206,7 @@ export function showAccountDetail (address) {
type: actionConstants.SHOW_ACCOUNT_DETAIL,
value: address,
})
if (switchingToUnconnectedAddress) {
if (unconnectedAccountAlertIsEnabled && switchingToUnconnectedAddress) {
dispatch(switchedToUnconnectedAccount())
}
dispatch(setSelectedToken())
@ -2154,6 +2156,12 @@ export function setConnectedStatusPopoverHasBeenShown () {
}
}
export function setAlertEnabledness (alertId, enabledness) {
return async () => {
await promisifiedBackground.setAlertEnabledness(alertId, enabledness)
}
}
export function loadingMethodDataStarted () {
return {
type: actionConstants.LOADING_METHOD_DATA_STARTED,