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:
parent
95c230127c
commit
b599035a12
16
app/_locales/en/messages.json
generated
16
app/_locales/en/messages.json
generated
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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": {
|
||||
|
@ -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 });
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
ui/helpers/constants/notifications.js
Normal file
6
ui/helpers/constants/notifications.js
Normal 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
|
@ -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,
|
||||
|
@ -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 />
|
||||
|
@ -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,
|
||||
|
1
ui/pages/notifications/index.js
Normal file
1
ui/pages/notifications/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './notifications';
|
114
ui/pages/notifications/index.scss
Normal file
114
ui/pages/notifications/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
100
ui/pages/notifications/notification.test.js
Normal file
100
ui/pages/notifications/notification.test.js
Normal 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();
|
||||
});
|
||||
});
|
141
ui/pages/notifications/notifications.js
Normal file
141
ui/pages/notifications/notifications.js
Normal 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,
|
||||
};
|
@ -24,3 +24,4 @@
|
||||
@import 'token-details/index';
|
||||
@import 'unlock-page/index';
|
||||
@import 'onboarding-flow/index';
|
||||
@import 'notifications/index';
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
*/
|
||||
|
||||
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user