mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 09:57:02 +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": {
|
"notEnoughGas": {
|
||||||
"message": "Not Enough Gas"
|
"message": "Not Enough Gas"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"message": "Notifications"
|
||||||
|
},
|
||||||
"notifications10ActionText": {
|
"notifications10ActionText": {
|
||||||
"message": "Visit in settings",
|
"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."
|
"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": {
|
"notifications9Title": {
|
||||||
"message": "👓 We are making transactions easier to read."
|
"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": {
|
"numberOfNewTokensDetected": {
|
||||||
"message": "$1 new tokens found in this account",
|
"message": "$1 new tokens found in this account",
|
||||||
"description": "$1 is the number of new tokens detected"
|
"description": "$1 is the number of new tokens detected"
|
||||||
|
@ -42,6 +42,7 @@ import {
|
|||||||
SubjectMetadataController,
|
SubjectMetadataController,
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
RateLimitController,
|
RateLimitController,
|
||||||
|
NotificationController,
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
} from '@metamask/controllers';
|
} from '@metamask/controllers';
|
||||||
import SmartTransactionsController from '@metamask/smart-transactions-controller';
|
import SmartTransactionsController from '@metamask/smart-transactions-controller';
|
||||||
@ -682,6 +683,13 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
messenger: snapControllerMessenger,
|
messenger: snapControllerMessenger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.notificationController = new NotificationController({
|
||||||
|
messenger: this.controllerMessenger.getRestricted({
|
||||||
|
name: 'NotificationController',
|
||||||
|
}),
|
||||||
|
state: initState.NotificationController,
|
||||||
|
});
|
||||||
|
|
||||||
this.rateLimitController = new RateLimitController({
|
this.rateLimitController = new RateLimitController({
|
||||||
messenger: this.controllerMessenger.getRestricted({
|
messenger: this.controllerMessenger.getRestricted({
|
||||||
name: 'RateLimitController',
|
name: 'RateLimitController',
|
||||||
@ -700,6 +708,15 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
showInAppNotification: (origin, message) => {
|
||||||
|
this.controllerMessenger.call(
|
||||||
|
'NotificationController:show',
|
||||||
|
origin,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
@ -1001,6 +1018,7 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
CollectiblesController: this.collectiblesController,
|
CollectiblesController: this.collectiblesController,
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
SnapController: this.snapController,
|
SnapController: this.snapController,
|
||||||
|
NotificationController: this.notificationController,
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1041,6 +1059,7 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
CollectiblesController: this.collectiblesController,
|
CollectiblesController: this.collectiblesController,
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
SnapController: this.snapController,
|
SnapController: this.snapController,
|
||||||
|
NotificationController: this.notificationController,
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
},
|
},
|
||||||
controllerMessenger: this.controllerMessenger,
|
controllerMessenger: this.controllerMessenger,
|
||||||
@ -1116,7 +1135,7 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
type: MESSAGE_TYPE.SNAP_CONFIRM,
|
type: MESSAGE_TYPE.SNAP_CONFIRM,
|
||||||
requestData: confirmationData,
|
requestData: confirmationData,
|
||||||
}),
|
}),
|
||||||
showNotification: (origin, args) =>
|
showNativeNotification: (origin, args) =>
|
||||||
this.controllerMessenger.call(
|
this.controllerMessenger.call(
|
||||||
'RateLimitController:call',
|
'RateLimitController:call',
|
||||||
origin,
|
origin,
|
||||||
@ -1124,6 +1143,14 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
origin,
|
origin,
|
||||||
args.message,
|
args.message,
|
||||||
),
|
),
|
||||||
|
showInAppNotification: (origin, args) =>
|
||||||
|
this.controllerMessenger.call(
|
||||||
|
'RateLimitController:call',
|
||||||
|
origin,
|
||||||
|
'showInAppNotification',
|
||||||
|
origin,
|
||||||
|
args.message,
|
||||||
|
),
|
||||||
updateSnapState: this.controllerMessenger.call.bind(
|
updateSnapState: this.controllerMessenger.call.bind(
|
||||||
this.controllerMessenger,
|
this.controllerMessenger,
|
||||||
'SnapController:updateSnapState',
|
'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
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1756,6 +1802,8 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
disableSnap: this.snapController.disableSnap.bind(this.snapController),
|
disableSnap: this.snapController.disableSnap.bind(this.snapController),
|
||||||
enableSnap: this.snapController.enableSnap.bind(this.snapController),
|
enableSnap: this.snapController.enableSnap.bind(this.snapController),
|
||||||
removeSnap: this.snapController.removeSnap.bind(this.snapController),
|
removeSnap: this.snapController.removeSnap.bind(this.snapController),
|
||||||
|
dismissNotifications: this.dismissNotifications.bind(this),
|
||||||
|
markNotificationsAsRead: this.markNotificationsAsRead.bind(this),
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
// swaps
|
// swaps
|
||||||
|
@ -18,6 +18,8 @@ import { addHexPrefix } from './lib/util';
|
|||||||
|
|
||||||
const Ganache = require('../../test/e2e/ganache');
|
const Ganache = require('../../test/e2e/ganache');
|
||||||
|
|
||||||
|
const NOTIFICATION_ID = 'NHL8f2eSSTn9TKBamRLiU';
|
||||||
|
|
||||||
const firstTimeState = {
|
const firstTimeState = {
|
||||||
config: {},
|
config: {},
|
||||||
NetworkController: {
|
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();
|
const ganacheServer = new Ganache();
|
||||||
@ -1212,6 +1225,27 @@ describe('MetaMaskController', function () {
|
|||||||
assert.deepEqual(metamaskController.getState(), oldState);
|
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() {
|
function deferredPromise() {
|
||||||
|
@ -37,6 +37,22 @@
|
|||||||
"1559": true
|
"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": {},
|
"cachedBalances": {},
|
||||||
"incomingTransactions": {},
|
"incomingTransactions": {},
|
||||||
"unapprovedTxs": {
|
"unapprovedTxs": {
|
||||||
|
@ -23,6 +23,9 @@ import {
|
|||||||
IMPORT_ACCOUNT_ROUTE,
|
IMPORT_ACCOUNT_ROUTE,
|
||||||
CONNECT_HARDWARE_ROUTE,
|
CONNECT_HARDWARE_ROUTE,
|
||||||
DEFAULT_ROUTE,
|
DEFAULT_ROUTE,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
NOTIFICATIONS_ROUTE,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
} from '../../../helpers/constants/routes';
|
} from '../../../helpers/constants/routes';
|
||||||
import TextField from '../../ui/text-field';
|
import TextField from '../../ui/text-field';
|
||||||
import SearchIcon from '../../ui/search-icon';
|
import SearchIcon from '../../ui/search-icon';
|
||||||
@ -84,6 +87,9 @@ export default class AccountMenu extends Component {
|
|||||||
toggleAccountMenu: PropTypes.func,
|
toggleAccountMenu: PropTypes.func,
|
||||||
addressConnectedSubjectMap: PropTypes.object,
|
addressConnectedSubjectMap: PropTypes.object,
|
||||||
originOfCurrentTab: PropTypes.string,
|
originOfCurrentTab: PropTypes.string,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
unreadNotificationsCount: PropTypes.number,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
};
|
};
|
||||||
|
|
||||||
accountsRef;
|
accountsRef;
|
||||||
@ -293,6 +299,9 @@ export default class AccountMenu extends Component {
|
|||||||
toggleAccountMenu,
|
toggleAccountMenu,
|
||||||
lockMetamask,
|
lockMetamask,
|
||||||
history,
|
history,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
unreadNotificationsCount,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!isAccountMenuOpen) {
|
if (!isAccountMenuOpen) {
|
||||||
@ -400,6 +409,33 @@ export default class AccountMenu extends Component {
|
|||||||
text={t('connectHardwareWallet')}
|
text={t('connectHardwareWallet')}
|
||||||
/>
|
/>
|
||||||
<div className="account-menu__divider" />
|
<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
|
<AccountMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
global.platform.openTab({ url: supportLink });
|
global.platform.openTab({ url: supportLink });
|
||||||
|
@ -13,6 +13,9 @@ import {
|
|||||||
getMetaMaskKeyrings,
|
getMetaMaskKeyrings,
|
||||||
getOriginOfCurrentTab,
|
getOriginOfCurrentTab,
|
||||||
getSelectedAddress,
|
getSelectedAddress,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
getUnreadNotificationsCount,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
} from '../../../selectors';
|
} from '../../../selectors';
|
||||||
import AccountMenu from './account-menu.component';
|
import AccountMenu from './account-menu.component';
|
||||||
|
|
||||||
@ -28,7 +31,9 @@ function mapStateToProps(state) {
|
|||||||
const accounts = getMetaMaskAccountsOrdered(state);
|
const accounts = getMetaMaskAccountsOrdered(state);
|
||||||
const origin = getOriginOfCurrentTab(state);
|
const origin = getOriginOfCurrentTab(state);
|
||||||
const selectedAddress = getSelectedAddress(state);
|
const selectedAddress = getSelectedAddress(state);
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
const unreadNotificationsCount = getUnreadNotificationsCount(state);
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
return {
|
return {
|
||||||
isAccountMenuOpen,
|
isAccountMenuOpen,
|
||||||
addressConnectedSubjectMap: getAddressConnectedSubjectMap(state),
|
addressConnectedSubjectMap: getAddressConnectedSubjectMap(state),
|
||||||
@ -37,6 +42,9 @@ function mapStateToProps(state) {
|
|||||||
keyrings: getMetaMaskKeyrings(state),
|
keyrings: getMetaMaskKeyrings(state),
|
||||||
accounts,
|
accounts,
|
||||||
shouldShowAccountsSearch: accounts.length >= SHOW_SEARCH_ACCOUNTS_MIN_COUNT,
|
shouldShowAccountsSearch: accounts.length >= SHOW_SEARCH_ACCOUNTS_MIN_COUNT,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
unreadNotificationsCount,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,6 +53,12 @@
|
|||||||
&__icon {
|
&__icon {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
color: var(--color-icon-default);
|
||||||
|
|
||||||
|
i {
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
@ -122,7 +128,7 @@
|
|||||||
scrollbar-width: auto;
|
scrollbar-width: auto;
|
||||||
|
|
||||||
@media screen and (max-width: $break-small) {
|
@media screen and (max-width: $break-small) {
|
||||||
max-height: 228px;
|
max-height: 156px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyring-label {
|
.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 {
|
&__no-accounts {
|
||||||
@include H6;
|
@include H6;
|
||||||
|
|
||||||
|
@ -20,6 +20,9 @@ export default class AppHeader extends PureComponent {
|
|||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
disableNetworkIndicator: PropTypes.bool,
|
disableNetworkIndicator: PropTypes.bool,
|
||||||
isAccountMenuOpen: PropTypes.bool,
|
isAccountMenuOpen: PropTypes.bool,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
unreadNotificationsCount: PropTypes.number,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,6 +69,9 @@ export default class AppHeader extends PureComponent {
|
|||||||
selectedAddress,
|
selectedAddress,
|
||||||
disabled,
|
disabled,
|
||||||
isAccountMenuOpen,
|
isAccountMenuOpen,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
unreadNotificationsCount,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -90,6 +96,15 @@ export default class AppHeader extends PureComponent {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Identicon address={selectedAddress} diameter={32} addBorder />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { compose } from 'redux';
|
import { compose } from 'redux';
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
import { getUnreadNotificationsCount } from '../../../selectors';
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
import * as actions from '../../../store/actions';
|
import * as actions from '../../../store/actions';
|
||||||
import AppHeader from './app-header.component';
|
import AppHeader from './app-header.component';
|
||||||
@ -10,11 +13,18 @@ const mapStateToProps = (state) => {
|
|||||||
const { networkDropdownOpen } = appState;
|
const { networkDropdownOpen } = appState;
|
||||||
const { selectedAddress, isUnlocked, isAccountMenuOpen } = metamask;
|
const { selectedAddress, isUnlocked, isAccountMenuOpen } = metamask;
|
||||||
|
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
const unreadNotificationsCount = getUnreadNotificationsCount(state);
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
return {
|
return {
|
||||||
networkDropdownOpen,
|
networkDropdownOpen,
|
||||||
selectedAddress,
|
selectedAddress,
|
||||||
isUnlocked,
|
isUnlocked,
|
||||||
isAccountMenuOpen,
|
isAccountMenuOpen,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
unreadNotificationsCount,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -86,4 +86,23 @@
|
|||||||
width: 0;
|
width: 0;
|
||||||
justify-content: flex-end;
|
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';
|
const CONNECT_CONFIRM_PERMISSIONS_ROUTE = '/confirm-permissions';
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
const CONNECT_SNAP_INSTALL_ROUTE = '/snap-install';
|
const CONNECT_SNAP_INSTALL_ROUTE = '/snap-install';
|
||||||
|
const NOTIFICATIONS_ROUTE = '/notifications';
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
const CONNECTED_ROUTE = '/connected';
|
const CONNECTED_ROUTE = '/connected';
|
||||||
const CONNECTED_ACCOUNTS_ROUTE = '/connected/accounts';
|
const CONNECTED_ACCOUNTS_ROUTE = '/connected/accounts';
|
||||||
@ -229,6 +230,7 @@ export {
|
|||||||
CONNECT_CONFIRM_PERMISSIONS_ROUTE,
|
CONNECT_CONFIRM_PERMISSIONS_ROUTE,
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
CONNECT_SNAP_INSTALL_ROUTE,
|
CONNECT_SNAP_INSTALL_ROUTE,
|
||||||
|
NOTIFICATIONS_ROUTE,
|
||||||
///: END:ONLY_INCLUDE_IN
|
///: END:ONLY_INCLUDE_IN
|
||||||
CONNECTED_ROUTE,
|
CONNECTED_ROUTE,
|
||||||
CONNECTED_ACCOUNTS_ROUTE,
|
CONNECTED_ACCOUNTS_ROUTE,
|
||||||
|
@ -114,7 +114,7 @@ export default class Home extends PureComponent {
|
|||||||
infuraBlocked: PropTypes.bool.isRequired,
|
infuraBlocked: PropTypes.bool.isRequired,
|
||||||
showWhatsNewPopup: PropTypes.bool.isRequired,
|
showWhatsNewPopup: PropTypes.bool.isRequired,
|
||||||
hideWhatsNewPopup: PropTypes.func.isRequired,
|
hideWhatsNewPopup: PropTypes.func.isRequired,
|
||||||
notificationsToShow: PropTypes.bool.isRequired,
|
announcementsToShow: PropTypes.bool.isRequired,
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
errorsToShow: PropTypes.object.isRequired,
|
errorsToShow: PropTypes.object.isRequired,
|
||||||
shouldShowErrors: PropTypes.bool.isRequired,
|
shouldShowErrors: PropTypes.bool.isRequired,
|
||||||
@ -525,7 +525,7 @@ export default class Home extends PureComponent {
|
|||||||
history,
|
history,
|
||||||
connectedStatusPopoverHasBeenShown,
|
connectedStatusPopoverHasBeenShown,
|
||||||
isPopup,
|
isPopup,
|
||||||
notificationsToShow,
|
announcementsToShow,
|
||||||
showWhatsNewPopup,
|
showWhatsNewPopup,
|
||||||
hideWhatsNewPopup,
|
hideWhatsNewPopup,
|
||||||
seedPhraseBackedUp,
|
seedPhraseBackedUp,
|
||||||
@ -543,9 +543,8 @@ export default class Home extends PureComponent {
|
|||||||
const showWhatsNew =
|
const showWhatsNew =
|
||||||
((completedOnboarding && firstTimeFlowType === 'import') ||
|
((completedOnboarding && firstTimeFlowType === 'import') ||
|
||||||
!completedOnboarding) &&
|
!completedOnboarding) &&
|
||||||
notificationsToShow &&
|
announcementsToShow &&
|
||||||
showWhatsNewPopup;
|
showWhatsNewPopup;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
<Route path={CONNECTED_ROUTE} component={ConnectedSites} exact />
|
<Route path={CONNECTED_ROUTE} component={ConnectedSites} exact />
|
||||||
|
@ -126,7 +126,7 @@ const mapStateToProps = (state) => {
|
|||||||
shouldShowWeb3ShimUsageNotification,
|
shouldShowWeb3ShimUsageNotification,
|
||||||
pendingConfirmations,
|
pendingConfirmations,
|
||||||
infuraBlocked: getInfuraBlocked(state),
|
infuraBlocked: getInfuraBlocked(state),
|
||||||
notificationsToShow: getSortedAnnouncementsToShow(state).length > 0,
|
announcementsToShow: getSortedAnnouncementsToShow(state).length > 0,
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
errorsToShow: metamask.snapErrors,
|
errorsToShow: metamask.snapErrors,
|
||||||
shouldShowErrors: Object.entries(metamask.snapErrors || []).length > 0,
|
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 'token-details/index';
|
||||||
@import 'unlock-page/index';
|
@import 'unlock-page/index';
|
||||||
@import 'onboarding-flow/index';
|
@import 'onboarding-flow/index';
|
||||||
|
@import 'notifications/index';
|
||||||
|
@ -34,6 +34,9 @@ import Alerts from '../../components/app/alerts';
|
|||||||
import Asset from '../asset';
|
import Asset from '../asset';
|
||||||
import OnboardingAppHeader from '../onboarding-flow/onboarding-app-header/onboarding-app-header';
|
import OnboardingAppHeader from '../onboarding-flow/onboarding-app-header/onboarding-app-header';
|
||||||
import TokenDetailsPage from '../token-details';
|
import TokenDetailsPage from '../token-details';
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
import Notifications from '../notifications';
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IMPORT_TOKEN_ROUTE,
|
IMPORT_TOKEN_ROUTE,
|
||||||
@ -59,6 +62,9 @@ import {
|
|||||||
ONBOARDING_ROUTE,
|
ONBOARDING_ROUTE,
|
||||||
ADD_COLLECTIBLE_ROUTE,
|
ADD_COLLECTIBLE_ROUTE,
|
||||||
TOKEN_DETAILS,
|
TOKEN_DETAILS,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
NOTIFICATIONS_ROUTE,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
} from '../../helpers/constants/routes';
|
} from '../../helpers/constants/routes';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -175,6 +181,11 @@ export default class Routes extends Component {
|
|||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
<Authenticated path={SETTINGS_ROUTE} component={Settings} />
|
<Authenticated path={SETTINGS_ROUTE} component={Settings} />
|
||||||
|
{
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
<Authenticated path={NOTIFICATIONS_ROUTE} component={Notifications} />
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
}
|
||||||
<Authenticated
|
<Authenticated
|
||||||
path={`${CONFIRM_TRANSACTION_ROUTE}/:id?`}
|
path={`${CONFIRM_TRANSACTION_ROUTE}/:id?`}
|
||||||
component={ConfirmTransaction}
|
component={ConfirmTransaction}
|
||||||
|
@ -741,6 +741,7 @@ export function getSnaps(state) {
|
|||||||
export const getSnapsRouteObjects = createSelector(getSnaps, (snaps) => {
|
export const getSnapsRouteObjects = createSelector(getSnaps, (snaps) => {
|
||||||
return Object.values(snaps).map((snap) => {
|
return Object.values(snaps).map((snap) => {
|
||||||
return {
|
return {
|
||||||
|
id: snap.id,
|
||||||
tabMessage: () => snap.manifest.proposedName,
|
tabMessage: () => snap.manifest.proposedName,
|
||||||
descriptionMessage: () => snap.manifest.description,
|
descriptionMessage: () => snap.manifest.description,
|
||||||
sectionMessage: () => 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
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -781,8 +826,8 @@ function getAllowedAnnouncementIds(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Notification
|
* @typedef {Object} Announcement
|
||||||
* @property {number} id - A unique identifier for the notification
|
* @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
|
* @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);
|
const appIsLoading = selectors.getAppIsLoading(mockState);
|
||||||
expect(appIsLoading).toStrictEqual(false);
|
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,
|
getMetaMaskAccounts,
|
||||||
getPermittedAccountsForCurrentTab,
|
getPermittedAccountsForCurrentTab,
|
||||||
getSelectedAddress,
|
getSelectedAddress,
|
||||||
|
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||||
|
getNotifications,
|
||||||
|
///: END:ONLY_INCLUDE_IN
|
||||||
} from '../selectors';
|
} from '../selectors';
|
||||||
import { computeEstimatedGasLimit, resetSendState } from '../ducks/send';
|
import { computeEstimatedGasLimit, resetSendState } from '../ducks/send';
|
||||||
import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account';
|
import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account';
|
||||||
@ -36,6 +39,9 @@ import {
|
|||||||
import { EVENT } from '../../shared/constants/metametrics';
|
import { EVENT } from '../../shared/constants/metametrics';
|
||||||
import { parseSmartTransactionsError } from '../pages/swaps/swaps.util';
|
import { parseSmartTransactionsError } from '../pages/swaps/swaps.util';
|
||||||
import { isEqualCaseInsensitive } from '../../shared/modules/string-utils';
|
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';
|
import * as actionConstants from './actionConstants';
|
||||||
|
|
||||||
let background = null;
|
let background = null;
|
||||||
@ -1002,6 +1008,44 @@ export function removeSnap(snapId) {
|
|||||||
export async function removeSnapError(msgData) {
|
export async function removeSnapError(msgData) {
|
||||||
return promisifiedBackground.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
|
///: END:ONLY_INCLUDE_IN
|
||||||
|
|
||||||
export function cancelMsg(msgData) {
|
export function cancelMsg(msgData) {
|
||||||
|
Loading…
Reference in New Issue
Block a user