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

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
This commit is contained in:
Guillaume Roux 2022-06-01 19:09:13 +02:00 committed by GitHub
parent 95c230127c
commit b599035a12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 724 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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')}
/>
<div className="account-menu__divider" />
{
///: BEGIN:ONLY_INCLUDE_IN(flask)
<>
<AccountMenuItem
onClick={() => {
toggleAccountMenu();
history.push(NOTIFICATIONS_ROUTE);
}}
icon={
<div className="account-menu__notifications">
<i
className="fa fa-bell fa-xl"
color="var(--color-icon-default)"
/>
{unreadNotificationsCount > 0 && (
<div className="account-menu__notifications__count">
{unreadNotificationsCount}
</div>
)}
</div>
}
text={t('notifications')}
/>
<div className="account-menu__divider" />
</>
///: END:ONLY_INCLUDE_IN
}
<AccountMenuItem
onClick={() => {
global.platform.openTab({ url: supportLink });

View File

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

View File

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

View File

@ -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 {
}}
>
<Identicon address={selectedAddress} diameter={32} addBorder />
{
///: BEGIN:ONLY_INCLUDE_IN(flask)
unreadNotificationsCount > 0 && (
<div className="account-menu__icon__notification-count">
{unreadNotificationsCount}
</div>
)
///: END:ONLY_INCLUDE_IN
}
</div>
)
);

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="main-container">
<Route path={CONNECTED_ROUTE} component={ConnectedSites} exact />

View File

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

View File

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

View File

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

View File

@ -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(<Notifications />, 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(<NotificationItem {...props} />);
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();
});
});

View File

@ -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 (
<div className="notifications__item" onClick={handleItemClick}>
<div
className={classnames(
'notifications__item__unread-dot',
!readDate && 'unread',
)}
/>
<div className="notifications__item__details">
<p className="notifications__item__details__message">{message}</p>
<p className="notifications__item__details__infos">
{t('notificationsInfos', [
formatDate(createdDate, "LLLL d',' yyyy 'at' t"),
<Button type="inline" onClick={handleNameClick} key="button">
{snap.tabMessage()}
</Button>,
])}
</p>
</div>
</div>
);
}
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 (
<div className="main-container notifications">
<div className="notifications__header">
<div className="notifications__header__title-container">
<IconCaretLeft
className="notifications__header__title-container__back-button"
color="var(--color-text-default)"
size={23}
onClick={() => history.push(DEFAULT_ROUTE)}
/>
<div className="notifications__header__title-container__title">
{t('notificationsHeader')}
</div>
</div>
<Button
type="secondary"
className="notifications__header_button"
onClick={markAllAsRead}
>
{t('notificationsMarkAllAsRead')}
</Button>
</div>
<div
className={classnames(
'notifications__container',
notifications.length === 0 && 'empty',
)}
>
{notifications.length > 0 ? (
notifications.map((notification, id) => (
<NotificationItem
notification={notification}
snaps={snapsRouteObject}
key={id}
onItemClick={markAsRead}
/>
))
) : (
<div className="notifications__container__text">
{t('notificationsEmptyText')}
</div>
)}
</div>
</div>
);
}
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,
};

View File

@ -24,3 +24,4 @@
@import 'token-details/index';
@import 'unlock-page/index';
@import 'onboarding-flow/index';
@import 'notifications/index';

View File

@ -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
/>
<Authenticated path={SETTINGS_ROUTE} component={Settings} />
{
///: BEGIN:ONLY_INCLUDE_IN(flask)
<Authenticated path={NOTIFICATIONS_ROUTE} component={Notifications} />
///: END:ONLY_INCLUDE_IN
}
<Authenticated
path={`${CONFIRM_TRANSACTION_ROUTE}/:id?`}
component={ConfirmTransaction}

View File

@ -741,6 +741,7 @@ export function getSnaps(state) {
export const getSnapsRouteObjects = createSelector(getSnaps, (snaps) => {
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
*/

View File

@ -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,
]);
});
});

View File

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