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.
feature/default_network_editable
Mark Stacey 5 years ago committed by GitHub
parent c0489163b5
commit c4fb514f3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 18
      app/_locales/en/messages.json
  2. 54
      app/scripts/controllers/alert.js
  3. 9
      app/scripts/metamask-controller.js
  4. 4
      test/unit/ui/app/actions.spec.js
  5. 41
      ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.js
  6. 21
      ui/app/components/app/alerts/unconnected-account-alert/unconnected-account-alert.scss
  7. 35
      ui/app/ducks/alerts/unconnected-account.js
  8. 5
      ui/app/ducks/index.js
  9. 5
      ui/app/ducks/metamask/metamask.js
  10. 2
      ui/app/helpers/constants/routes.js
  11. 71
      ui/app/pages/settings/alerts-tab/alerts-tab.js
  12. 28
      ui/app/pages/settings/alerts-tab/alerts-tab.scss
  13. 1
      ui/app/pages/settings/alerts-tab/index.js
  14. 2
      ui/app/pages/settings/index.scss
  15. 8
      ui/app/pages/settings/settings.component.js
  16. 2
      ui/app/pages/settings/settings.container.js
  17. 10
      ui/app/store/actions.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save