From b599035a12010e31b85c3733fb573ba05b840b3c Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 1 Jun 2022 19:09:13 +0200 Subject: [PATCH] Snap notifications integration (#14605) * begin controller implentation * add NotificationController * create selectors and actions * update actions tu use forceUpdateMetamaskState * Basic notification UI * fix typo and remove console.log * lint * more css * add notifications scroll * add translations and fix some css * Fix rebase and edit colors * add flask tags * add flask tag to routes component * add missing flask tags * add tests * fix tests * store notification expiration delay in constant * address requested changes * rename to unreadNotificationsCount * add missing flask tag --- app/_locales/en/messages.json | 16 ++ app/scripts/metamask-controller.js | 50 ++++++- app/scripts/metamask-controller.test.js | 34 +++++ test/data/mock-state.json | 16 ++ .../account-menu/account-menu.component.js | 36 +++++ .../account-menu/account-menu.container.js | 10 +- ui/components/app/account-menu/index.scss | 27 +++- .../app/app-header/app-header.component.js | 15 ++ .../app/app-header/app-header.container.js | 10 ++ ui/components/app/app-header/index.scss | 19 +++ ui/helpers/constants/notifications.js | 6 + ui/helpers/constants/routes.js | 2 + ui/pages/home/home.component.js | 7 +- ui/pages/home/home.container.js | 2 +- ui/pages/notifications/index.js | 1 + ui/pages/notifications/index.scss | 114 ++++++++++++++ ui/pages/notifications/notification.test.js | 100 +++++++++++++ ui/pages/notifications/notifications.js | 141 ++++++++++++++++++ ui/pages/pages.scss | 1 + ui/pages/routes/routes.component.js | 11 ++ ui/selectors/selectors.js | 49 +++++- ui/selectors/selectors.test.js | 23 +++ ui/store/actions.js | 44 ++++++ 23 files changed, 724 insertions(+), 10 deletions(-) create mode 100644 ui/helpers/constants/notifications.js create mode 100644 ui/pages/notifications/index.js create mode 100644 ui/pages/notifications/index.scss create mode 100644 ui/pages/notifications/notification.test.js create mode 100644 ui/pages/notifications/notifications.js diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 4f1977a48..2542f85d9 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2132,6 +2132,9 @@ "notEnoughGas": { "message": "Not Enough Gas" }, + "notifications": { + "message": "Notifications" + }, "notifications10ActionText": { "message": "Visit in settings", "description": "The 'call to action' on the button, or link, of the 'Visit in settings' notification. Upon clicking, users will be taken to settings page." @@ -2252,6 +2255,19 @@ "notifications9Title": { "message": "👓 We are making transactions easier to read." }, + "notificationsEmptyText": { + "message": "Nothing to see here." + }, + "notificationsHeader": { + "message": "Notifications" + }, + "notificationsInfos": { + "message": "$1 from $2", + "description": "$1 is the date at which the notification has been dispatched and $2 is the link to the snap that dispatched the notification." + }, + "notificationsMarkAllAsRead": { + "message": "Mark all as read" + }, "numberOfNewTokensDetected": { "message": "$1 new tokens found in this account", "description": "$1 is the number of new tokens detected" diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e268f2a2e..10b32b19d 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -42,6 +42,7 @@ import { SubjectMetadataController, ///: BEGIN:ONLY_INCLUDE_IN(flask) RateLimitController, + NotificationController, ///: END:ONLY_INCLUDE_IN } from '@metamask/controllers'; import SmartTransactionsController from '@metamask/smart-transactions-controller'; @@ -682,6 +683,13 @@ export default class MetamaskController extends EventEmitter { messenger: snapControllerMessenger, }); + this.notificationController = new NotificationController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'NotificationController', + }), + state: initState.NotificationController, + }); + this.rateLimitController = new RateLimitController({ messenger: this.controllerMessenger.getRestricted({ name: 'RateLimitController', @@ -700,6 +708,15 @@ export default class MetamaskController extends EventEmitter { ); return null; }, + showInAppNotification: (origin, message) => { + this.controllerMessenger.call( + 'NotificationController:show', + origin, + message, + ); + + return null; + }, }, }); ///: END:ONLY_INCLUDE_IN @@ -1001,6 +1018,7 @@ export default class MetamaskController extends EventEmitter { CollectiblesController: this.collectiblesController, ///: BEGIN:ONLY_INCLUDE_IN(flask) SnapController: this.snapController, + NotificationController: this.notificationController, ///: END:ONLY_INCLUDE_IN }); @@ -1041,6 +1059,7 @@ export default class MetamaskController extends EventEmitter { CollectiblesController: this.collectiblesController, ///: BEGIN:ONLY_INCLUDE_IN(flask) SnapController: this.snapController, + NotificationController: this.notificationController, ///: END:ONLY_INCLUDE_IN }, controllerMessenger: this.controllerMessenger, @@ -1116,7 +1135,7 @@ export default class MetamaskController extends EventEmitter { type: MESSAGE_TYPE.SNAP_CONFIRM, requestData: confirmationData, }), - showNotification: (origin, args) => + showNativeNotification: (origin, args) => this.controllerMessenger.call( 'RateLimitController:call', origin, @@ -1124,6 +1143,14 @@ export default class MetamaskController extends EventEmitter { origin, args.message, ), + showInAppNotification: (origin, args) => + this.controllerMessenger.call( + 'RateLimitController:call', + origin, + 'showInAppNotification', + origin, + args.message, + ), updateSnapState: this.controllerMessenger.call.bind( this.controllerMessenger, 'SnapController:updateSnapState', @@ -1131,6 +1158,25 @@ export default class MetamaskController extends EventEmitter { }), }; } + + /** + * Deletes the specified notifications from state. + * + * @param {string[]} ids - The notifications ids to delete. + */ + dismissNotifications(ids) { + this.notificationController.dismiss(ids); + } + + /** + * Updates the readDate attribute of the specified notifications. + * + * @param {string[]} ids - The notifications ids to mark as read. + */ + markNotificationsAsRead(ids) { + this.notificationController.markRead(ids); + } + ///: END:ONLY_INCLUDE_IN /** @@ -1756,6 +1802,8 @@ export default class MetamaskController extends EventEmitter { disableSnap: this.snapController.disableSnap.bind(this.snapController), enableSnap: this.snapController.enableSnap.bind(this.snapController), removeSnap: this.snapController.removeSnap.bind(this.snapController), + dismissNotifications: this.dismissNotifications.bind(this), + markNotificationsAsRead: this.markNotificationsAsRead.bind(this), ///: END:ONLY_INCLUDE_IN // swaps diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 6baf1b113..be4aa390b 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -18,6 +18,8 @@ import { addHexPrefix } from './lib/util'; const Ganache = require('../../test/e2e/ganache'); +const NOTIFICATION_ID = 'NHL8f2eSSTn9TKBamRLiU'; + const firstTimeState = { config: {}, NetworkController: { @@ -32,6 +34,17 @@ const firstTimeState = { }, }, }, + NotificationController: { + notifications: { + [NOTIFICATION_ID]: { + id: NOTIFICATION_ID, + origin: 'local:http://localhost:8086/', + createdDate: 1652967897732, + readDate: null, + message: 'Hello, http://localhost:8086!', + }, + }, + }, }; const ganacheServer = new Ganache(); @@ -1212,6 +1225,27 @@ describe('MetaMaskController', function () { assert.deepEqual(metamaskController.getState(), oldState); }); }); + + describe('markNotificationsAsRead', function () { + it('marks the notification as read', function () { + metamaskController.markNotificationsAsRead([NOTIFICATION_ID]); + const readNotification = metamaskController.getState().notifications[ + NOTIFICATION_ID + ]; + assert.notEqual(readNotification.readDate, null); + }); + }); + + describe('dismissNotifications', function () { + it('deletes the notification from state', function () { + metamaskController.dismissNotifications([NOTIFICATION_ID]); + const state = metamaskController.getState().notifications; + assert.ok( + !Object.values(state).includes(NOTIFICATION_ID), + 'Object should not include the deleted notification', + ); + }); + }); }); function deferredPromise() { diff --git a/test/data/mock-state.json b/test/data/mock-state.json index d856bb3b6..231f05069 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -37,6 +37,22 @@ "1559": true } }, + "notifications": { + "test": { + "id": "test", + "origin": "local:http://localhost:8086/", + "createdDate": 1652967897732, + "readDate": null, + "message": "Hello, http://localhost:8086!" + }, + "test2": { + "id": "test2", + "origin": "local:http://localhost:8086/", + "createdDate": 1652967897732, + "readDate": 1652967897732, + "message": "Hello, http://localhost:8086!" + } + }, "cachedBalances": {}, "incomingTransactions": {}, "unapprovedTxs": { diff --git a/ui/components/app/account-menu/account-menu.component.js b/ui/components/app/account-menu/account-menu.component.js index 85cf0bf6c..ada5e5577 100644 --- a/ui/components/app/account-menu/account-menu.component.js +++ b/ui/components/app/account-menu/account-menu.component.js @@ -23,6 +23,9 @@ import { IMPORT_ACCOUNT_ROUTE, CONNECT_HARDWARE_ROUTE, DEFAULT_ROUTE, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + NOTIFICATIONS_ROUTE, + ///: END:ONLY_INCLUDE_IN } from '../../../helpers/constants/routes'; import TextField from '../../ui/text-field'; import SearchIcon from '../../ui/search-icon'; @@ -84,6 +87,9 @@ export default class AccountMenu extends Component { toggleAccountMenu: PropTypes.func, addressConnectedSubjectMap: PropTypes.object, originOfCurrentTab: PropTypes.string, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount: PropTypes.number, + ///: END:ONLY_INCLUDE_IN }; accountsRef; @@ -293,6 +299,9 @@ export default class AccountMenu extends Component { toggleAccountMenu, lockMetamask, history, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount, + ///: END:ONLY_INCLUDE_IN } = this.props; if (!isAccountMenuOpen) { @@ -400,6 +409,33 @@ export default class AccountMenu extends Component { text={t('connectHardwareWallet')} />
+ { + ///: BEGIN:ONLY_INCLUDE_IN(flask) + <> + { + toggleAccountMenu(); + history.push(NOTIFICATIONS_ROUTE); + }} + icon={ +
+ + {unreadNotificationsCount > 0 && ( +
+ {unreadNotificationsCount} +
+ )} +
+ } + text={t('notifications')} + /> +
+ + ///: END:ONLY_INCLUDE_IN + } { global.platform.openTab({ url: supportLink }); diff --git a/ui/components/app/account-menu/account-menu.container.js b/ui/components/app/account-menu/account-menu.container.js index 936c05eb6..686270920 100644 --- a/ui/components/app/account-menu/account-menu.container.js +++ b/ui/components/app/account-menu/account-menu.container.js @@ -13,6 +13,9 @@ import { getMetaMaskKeyrings, getOriginOfCurrentTab, getSelectedAddress, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + getUnreadNotificationsCount, + ///: END:ONLY_INCLUDE_IN } from '../../../selectors'; import AccountMenu from './account-menu.component'; @@ -28,7 +31,9 @@ function mapStateToProps(state) { const accounts = getMetaMaskAccountsOrdered(state); const origin = getOriginOfCurrentTab(state); const selectedAddress = getSelectedAddress(state); - + ///: BEGIN:ONLY_INCLUDE_IN(flask) + const unreadNotificationsCount = getUnreadNotificationsCount(state); + ///: END:ONLY_INCLUDE_IN return { isAccountMenuOpen, addressConnectedSubjectMap: getAddressConnectedSubjectMap(state), @@ -37,6 +42,9 @@ function mapStateToProps(state) { keyrings: getMetaMaskKeyrings(state), accounts, shouldShowAccountsSearch: accounts.length >= SHOW_SEARCH_ACCOUNTS_MIN_COUNT, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount, + ///: END:ONLY_INCLUDE_IN }; } diff --git a/ui/components/app/account-menu/index.scss b/ui/components/app/account-menu/index.scss index bbc5a6fec..c8b512ddf 100644 --- a/ui/components/app/account-menu/index.scss +++ b/ui/components/app/account-menu/index.scss @@ -53,6 +53,12 @@ &__icon { margin-right: 8px; display: flex; + color: var(--color-icon-default); + + i { + text-align: center; + width: 24px; + } } &__text { @@ -122,7 +128,7 @@ scrollbar-width: auto; @media screen and (max-width: $break-small) { - max-height: 228px; + max-height: 156px; } .keyring-label { @@ -145,6 +151,25 @@ } } + &__notifications { + position: relative; + + &__count { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + right: 0; + bottom: 0; + min-width: 12px; + min-height: 12px; + font-size: 8px; + border-radius: 50%; + background-color: var(--color-primary-default); + color: var(--color-primary-inverse); + } + } + &__no-accounts { @include H6; diff --git a/ui/components/app/app-header/app-header.component.js b/ui/components/app/app-header/app-header.component.js index c2a6a0926..8a97bddc5 100644 --- a/ui/components/app/app-header/app-header.component.js +++ b/ui/components/app/app-header/app-header.component.js @@ -20,6 +20,9 @@ export default class AppHeader extends PureComponent { disabled: PropTypes.bool, disableNetworkIndicator: PropTypes.bool, isAccountMenuOpen: PropTypes.bool, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount: PropTypes.number, + ///: END:ONLY_INCLUDE_IN onClick: PropTypes.func, }; @@ -66,6 +69,9 @@ export default class AppHeader extends PureComponent { selectedAddress, disabled, isAccountMenuOpen, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount, + ///: END:ONLY_INCLUDE_IN } = this.props; return ( @@ -90,6 +96,15 @@ export default class AppHeader extends PureComponent { }} > + { + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount > 0 && ( +
+ {unreadNotificationsCount} +
+ ) + ///: END:ONLY_INCLUDE_IN + }
) ); diff --git a/ui/components/app/app-header/app-header.container.js b/ui/components/app/app-header/app-header.container.js index a7d632b6d..e30049bc3 100644 --- a/ui/components/app/app-header/app-header.container.js +++ b/ui/components/app/app-header/app-header.container.js @@ -1,6 +1,9 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { compose } from 'redux'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +import { getUnreadNotificationsCount } from '../../../selectors'; +///: END:ONLY_INCLUDE_IN import * as actions from '../../../store/actions'; import AppHeader from './app-header.component'; @@ -10,11 +13,18 @@ const mapStateToProps = (state) => { const { networkDropdownOpen } = appState; const { selectedAddress, isUnlocked, isAccountMenuOpen } = metamask; + ///: BEGIN:ONLY_INCLUDE_IN(flask) + const unreadNotificationsCount = getUnreadNotificationsCount(state); + ///: END:ONLY_INCLUDE_IN + return { networkDropdownOpen, selectedAddress, isUnlocked, isAccountMenuOpen, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + unreadNotificationsCount, + ///: END:ONLY_INCLUDE_IN }; }; diff --git a/ui/components/app/app-header/index.scss b/ui/components/app/app-header/index.scss index a5906af7f..8d5eb8c30 100644 --- a/ui/components/app/app-header/index.scss +++ b/ui/components/app/app-header/index.scss @@ -86,4 +86,23 @@ width: 0; justify-content: flex-end; } + + .account-menu__icon { + position: relative; + + &__notification-count { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + right: 0; + bottom: 0; + min-width: 16px; + min-height: 16px; + font-size: 10px; + border-radius: 50%; + background-color: var(--color-primary-default); + color: var(--color-primary-inverse); + } + } } diff --git a/ui/helpers/constants/notifications.js b/ui/helpers/constants/notifications.js new file mode 100644 index 000000000..1acc32dd0 --- /dev/null +++ b/ui/helpers/constants/notifications.js @@ -0,0 +1,6 @@ +///: BEGIN:ONLY_INCLUDE_IN(flask) + +// The time after which a notification should be deleted. +export const NOTIFICATIONS_EXPIRATION_DELAY = 10000; + +///: END:ONLY_INCLUDE_IN diff --git a/ui/helpers/constants/routes.js b/ui/helpers/constants/routes.js index 519e0a098..9f5f028e1 100644 --- a/ui/helpers/constants/routes.js +++ b/ui/helpers/constants/routes.js @@ -33,6 +33,7 @@ const CONNECT_ROUTE = '/connect'; const CONNECT_CONFIRM_PERMISSIONS_ROUTE = '/confirm-permissions'; ///: BEGIN:ONLY_INCLUDE_IN(flask) const CONNECT_SNAP_INSTALL_ROUTE = '/snap-install'; +const NOTIFICATIONS_ROUTE = '/notifications'; ///: END:ONLY_INCLUDE_IN const CONNECTED_ROUTE = '/connected'; const CONNECTED_ACCOUNTS_ROUTE = '/connected/accounts'; @@ -229,6 +230,7 @@ export { CONNECT_CONFIRM_PERMISSIONS_ROUTE, ///: BEGIN:ONLY_INCLUDE_IN(flask) CONNECT_SNAP_INSTALL_ROUTE, + NOTIFICATIONS_ROUTE, ///: END:ONLY_INCLUDE_IN CONNECTED_ROUTE, CONNECTED_ACCOUNTS_ROUTE, diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index ab4fda2e8..64f111f07 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -114,7 +114,7 @@ export default class Home extends PureComponent { infuraBlocked: PropTypes.bool.isRequired, showWhatsNewPopup: PropTypes.bool.isRequired, hideWhatsNewPopup: PropTypes.func.isRequired, - notificationsToShow: PropTypes.bool.isRequired, + announcementsToShow: PropTypes.bool.isRequired, ///: BEGIN:ONLY_INCLUDE_IN(flask) errorsToShow: PropTypes.object.isRequired, shouldShowErrors: PropTypes.bool.isRequired, @@ -525,7 +525,7 @@ export default class Home extends PureComponent { history, connectedStatusPopoverHasBeenShown, isPopup, - notificationsToShow, + announcementsToShow, showWhatsNewPopup, hideWhatsNewPopup, seedPhraseBackedUp, @@ -543,9 +543,8 @@ export default class Home extends PureComponent { const showWhatsNew = ((completedOnboarding && firstTimeFlowType === 'import') || !completedOnboarding) && - notificationsToShow && + announcementsToShow && showWhatsNewPopup; - return (
diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index 13229b199..09254e101 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -126,7 +126,7 @@ const mapStateToProps = (state) => { shouldShowWeb3ShimUsageNotification, pendingConfirmations, infuraBlocked: getInfuraBlocked(state), - notificationsToShow: getSortedAnnouncementsToShow(state).length > 0, + announcementsToShow: getSortedAnnouncementsToShow(state).length > 0, ///: BEGIN:ONLY_INCLUDE_IN(flask) errorsToShow: metamask.snapErrors, shouldShowErrors: Object.entries(metamask.snapErrors || []).length > 0, diff --git a/ui/pages/notifications/index.js b/ui/pages/notifications/index.js new file mode 100644 index 000000000..d9f22168e --- /dev/null +++ b/ui/pages/notifications/index.js @@ -0,0 +1 @@ +export { default } from './notifications'; diff --git a/ui/pages/notifications/index.scss b/ui/pages/notifications/index.scss new file mode 100644 index 000000000..a76a4cb87 --- /dev/null +++ b/ui/pages/notifications/index.scss @@ -0,0 +1,114 @@ +.notifications { + position: relative; + display: flex; + flex-direction: column; + background-color: var(--color-background-default); + + &__header { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + padding: 65px 24px 15px 24px; + border-bottom: 1px solid var(--color-border-muted); + + @media screen and (max-width: $break-small) { + padding: 10px 20px; + } + + &__title-container { + display: flex; + flex-flow: row; + align-items: center; + flex: 0 0 auto; + + &__title { + @include H3; + + margin-left: 26px; + + @media screen and (max-width: $break-small) { + @include H6; + + margin-left: 16px; + font-weight: bold; + } + } + } + + &_button { + width: auto; + + @media screen and (max-width: $break-small) { + font-size: 0.75rem; + padding: 3.5px 1rem; + } + } + } + + &__container { + display: flex; + overflow: auto; + flex-direction: column; + flex: 1 1 auto; + + @media screen and (max-width: $break-small) { + height: 100%; + } + + &__text { + @include H3; + + color: var(--color-text-muted); + text-align: center; + + @media screen and (max-width: $break-small) { + @include H6; + } + } + } + + .empty { + justify-content: center; + align-items: center; + } + + &__item { + display: flex; + flex-direction: row; + align-items: center; + border-bottom: 1px solid var(--color-border-muted); + padding: 16px; + cursor: pointer; + + &:hover { + background-color: var(--color-background-alternative); + } + + &__unread-dot { + content: ' '; + align-self: flex-start; + margin-top: 6px; + width: 8px; + height: 8px; + background-color: transparent; + border-radius: 50%; + } + + .unread { + background-color: var(--color-primary-default); + } + + &__details { + display: flex; + flex-direction: column; + margin-left: 12px; + + &__infos { + color: var(--color-text-muted); + font-size: 12px; + margin-top: 6px; + } + } + } +} diff --git a/ui/pages/notifications/notification.test.js b/ui/pages/notifications/notification.test.js new file mode 100644 index 000000000..b66a9113e --- /dev/null +++ b/ui/pages/notifications/notification.test.js @@ -0,0 +1,100 @@ +import React from 'react'; + +import { renderWithProvider } from '../../../test/lib/render-helpers'; +import configureStore from '../../store/store'; +import Notifications, { NotificationItem } from './notifications'; + +describe('Notifications', () => { + const render = (params) => { + const store = configureStore({ + ...params, + }); + + return renderWithProvider(, store); + }; + + it('can render a list of notifications', () => { + const mockStore = { + metamask: { + notifications: { + test: { + id: 'test', + origin: 'test', + createdDate: 1652967897732, + readDate: null, + message: 'foo', + }, + test2: { + id: 'test2', + origin: 'test', + createdDate: 1652967897732, + readDate: null, + message: 'bar', + }, + }, + snaps: { + test: { + enabled: true, + id: 'test', + manifest: { + proposedName: 'Notification Example Snap', + description: 'A notification example snap.', + }, + }, + }, + }, + }; + + const { getByText } = render(mockStore); + + expect( + getByText(mockStore.metamask.notifications.test.message), + ).toBeDefined(); + + expect( + getByText(mockStore.metamask.notifications.test2.message), + ).toBeDefined(); + }); + + it('can render an empty list of notifications', () => { + const mockStore = { + metamask: { + notifications: {}, + snaps: {}, + }, + }; + + const { getByText } = render(mockStore); + + expect(getByText('Nothing to see here.')).toBeDefined(); + }); +}); + +describe('NotificationItem', () => { + const render = (props) => renderWithProvider(); + it('can render notification item', () => { + const props = { + notification: { + id: 'test', + origin: 'test', + createdDate: 1652967897732, + readDate: null, + message: 'Hello, http://localhost:8086!', + }, + snaps: [ + { + id: 'test', + tabMessage: () => 'test snap name', + descriptionMessage: () => 'test description', + sectionMessage: () => 'test section Message', + route: '/test', + icon: 'test', + }, + ], + onItemClick: jest.fn(), + }; + const { getByText } = render(props); + + expect(getByText(props.notification.message)).toBeDefined(); + }); +}); diff --git a/ui/pages/notifications/notifications.js b/ui/pages/notifications/notifications.js new file mode 100644 index 000000000..3c485137a --- /dev/null +++ b/ui/pages/notifications/notifications.js @@ -0,0 +1,141 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { formatDate } from '../../helpers/utils/util'; +import { + getNotifications, + getSnapsRouteObjects, + getUnreadNotifications, +} from '../../selectors'; +import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; +import { + deleteExpiredNotifications, + markNotificationsAsRead, +} from '../../store/actions'; +import IconCaretLeft from '../../components/ui/icon/icon-caret-left'; +import Button from '../../components/ui/button'; +import { useI18nContext } from '../../hooks/useI18nContext'; + +export function NotificationItem({ notification, snaps, onItemClick }) { + const { message, origin, createdDate, readDate } = notification; + const history = useHistory(); + const t = useI18nContext(); + + const snap = snaps.find(({ id: snapId }) => { + return snapId === origin; + }); + + const handleNameClick = (e) => { + e.stopPropagation(); + history.push(snap.route); + }; + + const handleItemClick = () => onItemClick(notification); + + return ( +
+
+
+

{message}

+

+ {t('notificationsInfos', [ + formatDate(createdDate, "LLLL d',' yyyy 'at' t"), + , + ])} +

+
+
+ ); +} + +export default function Notifications() { + const history = useHistory(); + const dispatch = useDispatch(); + const t = useI18nContext(); + const notifications = useSelector(getNotifications); + const snapsRouteObject = useSelector(getSnapsRouteObjects); + const unreadNotifications = useSelector(getUnreadNotifications); + + const markAllAsRead = () => { + const unreadNotificationIds = unreadNotifications.map(({ id }) => id); + + dispatch(markNotificationsAsRead(unreadNotificationIds)); + }; + + const markAsRead = (notificationToMark) => { + if (!notificationToMark.readDate) { + dispatch(markNotificationsAsRead([notificationToMark.id])); + } + }; + + useEffect(() => { + return () => dispatch(deleteExpiredNotifications()); + }, [dispatch]); + + return ( +
+
+
+ history.push(DEFAULT_ROUTE)} + /> +
+ {t('notificationsHeader')} +
+
+ +
+
+ {notifications.length > 0 ? ( + notifications.map((notification, id) => ( + + )) + ) : ( +
+ {t('notificationsEmptyText')} +
+ )} +
+
+ ); +} + +NotificationItem.propTypes = { + notification: { + id: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + origin: PropTypes.string.isRequired, + createdDate: PropTypes.number.isRequired, + readDate: PropTypes.number.isRequired, + }, + snaps: PropTypes.array.isRequired, + onItemClick: PropTypes.func.isRequired, +}; diff --git a/ui/pages/pages.scss b/ui/pages/pages.scss index a2a6c56c2..d6160c24f 100644 --- a/ui/pages/pages.scss +++ b/ui/pages/pages.scss @@ -24,3 +24,4 @@ @import 'token-details/index'; @import 'unlock-page/index'; @import 'onboarding-flow/index'; +@import 'notifications/index'; diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index 851111fa7..a4d3d8c5a 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -34,6 +34,9 @@ import Alerts from '../../components/app/alerts'; import Asset from '../asset'; import OnboardingAppHeader from '../onboarding-flow/onboarding-app-header/onboarding-app-header'; import TokenDetailsPage from '../token-details'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +import Notifications from '../notifications'; +///: END:ONLY_INCLUDE_IN import { IMPORT_TOKEN_ROUTE, @@ -59,6 +62,9 @@ import { ONBOARDING_ROUTE, ADD_COLLECTIBLE_ROUTE, TOKEN_DETAILS, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + NOTIFICATIONS_ROUTE, + ///: END:ONLY_INCLUDE_IN } from '../../helpers/constants/routes'; import { @@ -175,6 +181,11 @@ export default class Routes extends Component { exact /> + { + ///: BEGIN:ONLY_INCLUDE_IN(flask) + + ///: END:ONLY_INCLUDE_IN + } { return Object.values(snaps).map((snap) => { return { + id: snap.id, tabMessage: () => snap.manifest.proposedName, descriptionMessage: () => snap.manifest.description, sectionMessage: () => snap.manifest.description, @@ -749,6 +750,50 @@ export const getSnapsRouteObjects = createSelector(getSnaps, (snaps) => { }; }); }); + +/** + * @typedef {Object} Notification + * @property {string} id - A unique identifier for the notification + * @property {string} origin - A string identifing the snap origin + * @property {EpochTimeStamp} createdDate - A date in epochTimeStramps, identifying when the notification was first committed + * @property {EpochTimeStamp} readDate - A date in epochTimeStramps, identifying when the notification was read by the user + * @property {string} message - A string containing the notification message + */ + +/** + * Notifications are managed by the notification controller and referenced by + * `state.metamask.notifications`. This function returns a list of notifications + * the can be shown to the user. + * + * The returned notifications are sorted by date. + * + * @param {Object} state - the redux state object + * @returns {Notification[]} An array of notifications that can be shown to the user + */ + +export function getNotifications(state) { + const notifications = Object.values(state.metamask.notifications); + + const notificationsSortedByDate = notifications.sort( + (a, b) => new Date(b.createdDate) - new Date(a.createdDate), + ); + return notificationsSortedByDate; +} + +export function getUnreadNotifications(state) { + const notifications = getNotifications(state); + + const unreadNotificationCount = notifications.filter( + (notification) => notification.readDate === null, + ); + + return unreadNotificationCount; +} + +export const getUnreadNotificationsCount = createSelector( + getUnreadNotifications, + (notifications) => notifications.length, +); ///: END:ONLY_INCLUDE_IN /** @@ -781,8 +826,8 @@ function getAllowedAnnouncementIds(state) { } /** - * @typedef {Object} Notification - * @property {number} id - A unique identifier for the notification + * @typedef {Object} Announcement + * @property {number} id - A unique identifier for the announcement * @property {string} date - A date in YYYY-MM-DD format, identifying when the notification was first committed */ diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index ca3f9c92f..385bd2a56 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -244,4 +244,27 @@ describe('Selectors', () => { const appIsLoading = selectors.getAppIsLoading(mockState); expect(appIsLoading).toStrictEqual(false); }); + it('#getNotifications', () => { + const notifications = selectors.getNotifications(mockState); + + expect(notifications).toStrictEqual([ + mockState.metamask.notifications.test, + mockState.metamask.notifications.test2, + ]); + }); + it('#getUnreadNotificationsCount', () => { + const unreadNotificationCount = selectors.getUnreadNotificationsCount( + mockState, + ); + + expect(unreadNotificationCount).toStrictEqual(1); + }); + + it('#getUnreadNotifications', () => { + const unreadNotifications = selectors.getUnreadNotifications(mockState); + + expect(unreadNotifications).toStrictEqual([ + mockState.metamask.notifications.test, + ]); + }); }); diff --git a/ui/store/actions.js b/ui/store/actions.js index 95243f081..b8f738882 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -23,6 +23,9 @@ import { getMetaMaskAccounts, getPermittedAccountsForCurrentTab, getSelectedAddress, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + getNotifications, + ///: END:ONLY_INCLUDE_IN } from '../selectors'; import { computeEstimatedGasLimit, resetSendState } from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; @@ -36,6 +39,9 @@ import { import { EVENT } from '../../shared/constants/metametrics'; import { parseSmartTransactionsError } from '../pages/swaps/swaps.util'; import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notifications'; +///: END:ONLY_INCLUDE_IN import * as actionConstants from './actionConstants'; let background = null; @@ -1002,6 +1008,44 @@ export function removeSnap(snapId) { export async function removeSnapError(msgData) { return promisifiedBackground.removeSnapError(msgData); } + +export function dismissNotifications(ids) { + return async (dispatch) => { + await promisifiedBackground.dismissNotifications(ids); + await forceUpdateMetamaskState(dispatch); + }; +} + +export function deleteExpiredNotifications() { + return async (dispatch, getState) => { + const state = getState(); + const notifications = getNotifications(state); + + const notificationIdsToDelete = notifications + .filter((notification) => { + const expirationTime = new Date( + Date.now() - NOTIFICATIONS_EXPIRATION_DELAY, + ); + + return Boolean( + notification.readDate && + new Date(notification.readDate) < expirationTime, + ); + }) + .map(({ id }) => id); + if (notificationIdsToDelete.length) { + await promisifiedBackground.dismissNotifications(notificationIdsToDelete); + await forceUpdateMetamaskState(dispatch); + } + }; +} + +export function markNotificationsAsRead(ids) { + return async (dispatch) => { + await promisifiedBackground.markNotificationsAsRead(ids); + await forceUpdateMetamaskState(dispatch); + }; +} ///: END:ONLY_INCLUDE_IN export function cancelMsg(msgData) {