From 9468e3831337aba49b144f62ce30f0b7b7b4287a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Sat, 19 Oct 2024 18:29:37 -0400 Subject: [PATCH] integrate snap notification services --- app/scripts/background.js | 3 +- app/scripts/constants/sentry-state.ts | 3 - app/scripts/metamask-controller.js | 82 +++++++++---------- app/scripts/metamask-controller.test.js | 28 ------- ...rs-after-init-opt-in-background-state.json | 1 - .../metamask-notifications/useCounter.tsx | 12 +-- .../notification-components/snap/snap.tsx | 24 ++++-- .../notifications-list-read-all-button.tsx | 6 +- .../notifications/notifications-list.test.tsx | 2 +- ui/pages/notifications/notifications-list.tsx | 8 +- ui/pages/notifications/notifications.test.tsx | 1 - ui/pages/notifications/notifications.tsx | 49 ++++++----- ui/pages/notifications/snap/types/types.ts | 17 ---- ui/pages/notifications/snap/utils/utils.ts | 17 ---- .../metamask-notifications.ts | 36 ++++++++ ui/selectors/selectors.js | 51 ------------ ui/selectors/selectors.test.js | 27 ------ ui/store/actions.ts | 52 ++++++++---- 18 files changed, 168 insertions(+), 251 deletions(-) delete mode 100644 ui/pages/notifications/snap/types/types.ts delete mode 100644 ui/pages/notifications/snap/utils/utils.ts diff --git a/app/scripts/background.js b/app/scripts/background.js index 9f203b35661d..9b37baa95fb7 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1078,7 +1078,8 @@ export function setupController( controller.notificationServicesController.state; const snapNotificationCount = Object.values( - controller.notificationController.state.notifications, + controller.notificationServicesController.state + .metamaskNotificationsList, ).filter((notification) => notification.readDate === null).length; const featureAnnouncementCount = isFeatureAnnouncementsEnabled diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 76fb2386f1f6..c2fb44bfb368 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -184,9 +184,6 @@ export const SENTRY_BACKGROUND_STATE = { allNfts: false, ignoredNfts: false, }, - NotificationController: { - notifications: false, - }, OnboardingController: { completedOnboarding: true, firstTimeFlowType: true, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index f7832f5893ec..5888a049247f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -79,7 +79,6 @@ import { LoggingController, LogType } from '@metamask/logging-controller'; import { PermissionLogController } from '@metamask/permission-log-controller'; import { RateLimitController } from '@metamask/rate-limit-controller'; -import { NotificationController } from '@metamask/notification-controller'; import { CronjobController, JsonSnapsRegistry, @@ -369,6 +368,7 @@ import createTracingMiddleware from './lib/createTracingMiddleware'; import { PatchStore } from './lib/PatchStore'; import { sanitizeUIState } from './lib/state-utils'; +const { TRIGGER_TYPES } = NotificationServicesController.Constants; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) // The process of updating the badge happens in app/scripts/background.js. @@ -1392,13 +1392,6 @@ export default class MetamaskController extends EventEmitter { }, }); - this.notificationController = new NotificationController({ - messenger: this.controllerMessenger.getRestricted({ - name: 'NotificationController', - }), - state: initState.NotificationController, - }); - this.rateLimitController = new RateLimitController({ state: initState.RateLimitController, messenger: this.controllerMessenger.getRestricted({ @@ -1426,11 +1419,28 @@ export default class MetamaskController extends EventEmitter { rateLimitTimeout: 300000, }, showInAppNotification: { - method: (origin, message) => { + method: (origin, args) => { + const { message, title, footerLink } = args; + + const detailedView = { + title, + ...(footerLink ? { footerLink } : {}), + interfaceId: args.content, + }; + + const notification = { + data: { + message, + origin, + ...(args.content ? { detailedView } : {}), + }, + type: 'snap', + readDate: null, + }; + this.controllerMessenger.call( - 'NotificationController:show', - origin, - message, + 'NotificationServicesController:updateMetamaskNotificationsList', + notification, ); return null; @@ -1488,6 +1498,7 @@ export default class MetamaskController extends EventEmitter { `${this.approvalController.name}:hasRequest`, `${this.approvalController.name}:acceptRequest`, `${this.snapController.name}:get`, + 'NotificationServicesController:getNotificationsByType', ], }); @@ -2398,7 +2409,6 @@ export default class MetamaskController extends EventEmitter { SnapController: this.snapController, CronjobController: this.cronjobController, SnapsRegistry: this.snapsRegistry, - NotificationController: this.notificationController, SnapInterfaceController: this.snapInterfaceController, SnapInsightsController: this.snapInsightsController, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -2453,7 +2463,6 @@ export default class MetamaskController extends EventEmitter { SnapController: this.snapController, CronjobController: this.cronjobController, SnapsRegistry: this.snapsRegistry, - NotificationController: this.notificationController, SnapInterfaceController: this.snapInterfaceController, SnapInsightsController: this.snapInsightsController, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -2773,14 +2782,15 @@ export default class MetamaskController extends EventEmitter { origin, args.message, ), - showInAppNotification: (origin, args) => - this.controllerMessenger.call( + showInAppNotification: (origin, args) => { + return this.controllerMessenger.call( 'RateLimitController:call', origin, 'showInAppNotification', origin, - args.message, - ), + args, + ); + }, updateSnapState: this.controllerMessenger.call.bind( this.controllerMessenger, 'SnapController:updateSnapState', @@ -2825,24 +2835,6 @@ 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); - } - /** * Sets up BaseController V2 event subscriptions. Currently, this includes * the subscriptions necessary to notify permission subjects of account @@ -3042,8 +3034,8 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( `${this.snapController.name}:snapUninstalled`, (truncatedSnap) => { - const notificationIds = Object.values( - this.notificationController.state.notifications, + const notificationIds = this.getNotificationsByType( + TRIGGER_TYPES.SNAP, ).reduce((idList, notification) => { if (notification.origin === truncatedSnap.id) { idList.push(notification.id); @@ -3051,7 +3043,7 @@ export default class MetamaskController extends EventEmitter { return idList; }, []); - this.dismissNotifications(notificationIds); + this.deleteNotificationsById(notificationIds); const snapId = truncatedSnap.id; const snapCategory = this._getSnapMetadata(snapId)?.category; @@ -3809,8 +3801,6 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger, 'SnapController:revokeDynamicPermissions', ), - dismissNotifications: this.dismissNotifications.bind(this), - markNotificationsAsRead: this.markNotificationsAsRead.bind(this), disconnectOriginFromSnap: this.controllerMessenger.call.bind( this.controllerMessenger, 'SnapController:disconnectOrigin', @@ -4094,6 +4084,14 @@ export default class MetamaskController extends EventEmitter { notificationServicesController.fetchAndUpdateMetamaskNotifications.bind( notificationServicesController, ), + deleteNotificationsById: + notificationServicesController.deleteNotificationsById.bind( + notificationServicesController, + ), + getNotificationsByType: + notificationServicesController.getNotificationsByType.bind( + notificationServicesController, + ), markMetamaskNotificationsAsRead: notificationServicesController.markMetamaskNotificationsAsRead.bind( notificationServicesController, @@ -4322,8 +4320,6 @@ export default class MetamaskController extends EventEmitter { // Clear snap state this.snapController.clearState(); - // Clear notification state - this.notificationController.clear(); // clear accounts in AccountTrackerController this.accountTrackerController.clearAccounts(); diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 77b062bcfdc7..52bc9fb9c8e2 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -245,17 +245,6 @@ const firstTimeState = { }, ), }, - NotificationController: { - notifications: { - [NOTIFICATION_ID]: { - id: NOTIFICATION_ID, - origin: 'local:http://localhost:8086/', - createdDate: 1652967897732, - readDate: null, - message: 'Hello, http://localhost:8086!', - }, - }, - }, PhishingController: { phishingLists: [ { @@ -1812,23 +1801,6 @@ describe('MetaMaskController', () => { }); }); - describe('markNotificationsAsRead', () => { - it('marks the notification as read', () => { - metamaskController.markNotificationsAsRead([NOTIFICATION_ID]); - const readNotification = - metamaskController.getState().notifications[NOTIFICATION_ID]; - expect(readNotification.readDate).not.toBeNull(); - }); - }); - - describe('dismissNotifications', () => { - it('deletes the notification from state', () => { - metamaskController.dismissNotifications([NOTIFICATION_ID]); - const state = metamaskController.getState().notifications; - expect(Object.values(state)).not.toContain(NOTIFICATION_ID); - }); - }); - describe('getTokenStandardAndDetails', () => { it('gets token data from the token list if available, and with a balance retrieved by fetchTokenBalance', async () => { const providerResultStub = { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 78988fc87cc8..376319cbb4bc 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -150,7 +150,6 @@ "allNfts": "object", "ignoredNfts": "object" }, - "NotificationController": { "notifications": "object" }, "NotificationServicesController": { "subscriptionAccountsSeen": "object", "isMetamaskNotificationsFeatureSeen": "boolean", diff --git a/ui/hooks/metamask-notifications/useCounter.tsx b/ui/hooks/metamask-notifications/useCounter.tsx index 9d0303e3576a..75b8c44012eb 100644 --- a/ui/hooks/metamask-notifications/useCounter.tsx +++ b/ui/hooks/metamask-notifications/useCounter.tsx @@ -6,15 +6,15 @@ import { getFeatureAnnouncementsUnreadCount, getOnChainMetamaskNotificationsReadCount, getOnChainMetamaskNotificationsUnreadCount, + getSnapNotificationsReadCount, + getSnapNotificationsUnreadCount, } from '../../selectors/metamask-notifications/metamask-notifications'; -import { - getReadNotificationsCount, - getUnreadNotificationsCount, -} from '../../selectors'; const useSnapNotificationdCount = () => { - const unreadSnapNotificationsCount = useSelector(getUnreadNotificationsCount); - const readSnapNotificationsCount = useSelector(getReadNotificationsCount); + const unreadSnapNotificationsCount = useSelector( + getSnapNotificationsUnreadCount, + ); + const readSnapNotificationsCount = useSelector(getSnapNotificationsReadCount); return { unreadSnapNotificationsCount, readSnapNotificationsCount }; }; diff --git a/ui/pages/notifications/notification-components/snap/snap.tsx b/ui/pages/notifications/notification-components/snap/snap.tsx index 95ccb16e416f..00ccecd6d71f 100644 --- a/ui/pages/notifications/notification-components/snap/snap.tsx +++ b/ui/pages/notifications/notification-components/snap/snap.tsx @@ -1,32 +1,38 @@ import React, { useContext } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { NotificationServicesController } from '@metamask/notification-services-controller'; } from '@metamask/notification-services-controller'; import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { NotificationListItemSnap } from '../../../../components/multichain'; -import type { SnapNotification } from '../../snap/types/types'; import { getSnapsMetadata } from '../../../../selectors'; -import { markNotificationsAsRead } from '../../../../store/actions'; import { getSnapRoute, getSnapName } from '../../../../helpers/utils/util'; +import { useMarkNotificationAsRead } from '../../../../hooks/metamask-notifications/useNotifications'; type SnapComponentProps = { - snapNotification: SnapNotification; + snapNotification: NotificationServicesController.Types.INotification; }; export const SnapComponent = ({ snapNotification }: SnapComponentProps) => { - const dispatch = useDispatch(); const history = useHistory(); const trackEvent = useContext(MetaMetricsContext); + const { markNotificationAsRead } = useMarkNotificationAsRead(); const snapsMetadata = useSelector(getSnapsMetadata); const snapsNameGetter = getSnapName(snapsMetadata); const handleSnapClick = () => { - dispatch(markNotificationsAsRead([snapNotification.id])); + markNotificationAsRead([ + { + id: snapNotification.id, + type: snapNotification.type, + isRead: snapNotification.isRead, + }, + ]); trackEvent({ category: MetaMetricsEventCategory.NotificationInteraction, event: MetaMetricsEventName.NotificationClicked, @@ -39,7 +45,13 @@ export const SnapComponent = ({ snapNotification }: SnapComponentProps) => { }; const handleSnapButton = () => { - dispatch(markNotificationsAsRead([snapNotification.id])); + markNotificationAsRead([ + { + id: snapNotification.id, + type: snapNotification.type, + isRead: snapNotification.isRead, + }, + ]); trackEvent({ category: MetaMetricsEventCategory.NotificationInteraction, event: MetaMetricsEventName.NotificationClicked, diff --git a/ui/pages/notifications/notifications-list-read-all-button.tsx b/ui/pages/notifications/notifications-list-read-all-button.tsx index 7efff2cd1012..a6ca8589ebed 100644 --- a/ui/pages/notifications/notifications-list-read-all-button.tsx +++ b/ui/pages/notifications/notifications-list-read-all-button.tsx @@ -12,15 +12,13 @@ import { getUnreadNotifications } from '../../selectors'; import { markNotificationsAsRead } from '../../store/actions'; import { Box, Button, ButtonVariant } from '../../components/component-library'; import { BlockSize } from '../../helpers/constants/design-system'; -import type { NotificationType } from './notifications'; -import { SNAP } from './snap/types/types'; type Notification = NotificationServicesController.Types.INotification; type MarkAsReadNotificationsParam = NotificationServicesController.Types.MarkAsReadNotificationsParam; export type NotificationsListReadAllButtonProps = { - notifications: NotificationType[]; + notifications: Notification[]; }; export const NotificationsListReadAllButton = ({ @@ -40,7 +38,7 @@ export const NotificationsListReadAllButton = ({ .filter( (notification): notification is Notification => (notification as Notification).id !== undefined && - notification.type !== SNAP, + notification.type !== TRIGGER_TYPES.SNAP, ) .map((notification: Notification) => ({ id: notification.id, diff --git a/ui/pages/notifications/notifications-list.test.tsx b/ui/pages/notifications/notifications-list.test.tsx index a48e5be37845..d5a7ef7ca41c 100644 --- a/ui/pages/notifications/notifications-list.test.tsx +++ b/ui/pages/notifications/notifications-list.test.tsx @@ -9,7 +9,7 @@ import { NotificationsList } from './notifications-list'; import { TAB_KEYS } from './notifications'; jest.mock('../../store/actions', () => ({ - deleteExpiredNotifications: jest.fn(() => () => Promise.resolve()), + deleteExpiredSnapNotifications: jest.fn(() => () => Promise.resolve()), fetchAndUpdateMetamaskNotifications: jest.fn(() => () => Promise.resolve()), })); diff --git a/ui/pages/notifications/notifications-list.tsx b/ui/pages/notifications/notifications-list.tsx index 500b7ef210aa..deba45876822 100644 --- a/ui/pages/notifications/notifications-list.tsx +++ b/ui/pages/notifications/notifications-list.tsx @@ -15,12 +15,12 @@ import { SnapComponent } from './notification-components/snap/snap'; import { NotificationsPlaceholder } from './notifications-list-placeholder'; import { NotificationsListTurnOnNotifications } from './notifications-list-turn-on-notifications'; import { NotificationsListItem } from './notifications-list-item'; -import { NotificationType, TAB_KEYS } from './notifications'; +import { type Notification, TAB_KEYS } from './notifications'; import { NotificationsListReadAllButton } from './notifications-list-read-all-button'; export type NotificationsListProps = { activeTab: TAB_KEYS; - notifications: NotificationType[]; + notifications: Notification[]; isLoading: boolean; isError: boolean; notificationsCount: number; @@ -62,9 +62,9 @@ function ErrorContent() { ); } -function NotificationItem(props: { notification: NotificationType }) { +function NotificationItem(props: { notification: Notification }) { const { notification } = props; - if (notification.type === 'SNAP') { + if (notification.type === TRIGGER_TYPES.SNAP) { return ; } diff --git a/ui/pages/notifications/notifications.test.tsx b/ui/pages/notifications/notifications.test.tsx index fc7675a85dea..355a92f122d0 100644 --- a/ui/pages/notifications/notifications.test.tsx +++ b/ui/pages/notifications/notifications.test.tsx @@ -27,7 +27,6 @@ jest.mock( jest.mock('../../store/actions', () => ({ ...jest.requireActual('../../store/actions'), - markNotificationsAsRead: jest.fn(), markMetamaskNotificationsAsRead: jest.fn(), })); diff --git a/ui/pages/notifications/notifications.tsx b/ui/pages/notifications/notifications.tsx index 3ad04ceda401..98e5dfd886cf 100644 --- a/ui/pages/notifications/notifications.tsx +++ b/ui/pages/notifications/notifications.tsx @@ -18,30 +18,26 @@ import { NotificationsPage } from '../../components/multichain'; import { Content, Header } from '../../components/multichain/pages/page'; import { useMetamaskNotificationsContext } from '../../contexts/metamask-notifications/metamask-notifications'; import { useUnreadNotificationsCounter } from '../../hooks/metamask-notifications/useCounter'; -import { getNotifications, getNotifySnaps } from '../../selectors'; +import { getNotifySnaps } from '../../selectors'; import { selectIsFeatureAnnouncementsEnabled, selectIsMetamaskNotificationsEnabled, getMetamaskNotifications, } from '../../selectors/metamask-notifications/metamask-notifications'; -import { deleteExpiredNotifications } from '../../store/actions'; +import { deleteExpiredSnapNotifications } from '../../store/actions'; import { AlignItems, Display, JustifyContent, } from '../../helpers/constants/design-system'; import { NotificationsList } from './notifications-list'; -import { processSnapNotifications } from './snap/utils/utils'; -import { SnapNotification } from './snap/types/types'; import { NewFeatureTag } from './NewFeatureTag'; -type Notification = NotificationServicesController.Types.INotification; +export type Notification = NotificationServicesController.Types.INotification; const { TRIGGER_TYPES, TRIGGER_TYPES_WALLET_SET } = NotificationServicesController.Constants; -export type NotificationType = Notification | SnapNotification; - // NOTE - Tab filters could change once we support more notifications. export const enum TAB_KEYS { // Shows all notifications @@ -56,28 +52,18 @@ export const enum TAB_KEYS { // Cleanup method to ensure we aren't keeping really old notifications. // See internals to tweak expiry date -const useEffectDeleteExpiredNotifications = () => { +const useEffectDeleteExpiredSnapNotifications = () => { const dispatch = useDispatch(); useEffect(() => { return () => { - dispatch(deleteExpiredNotifications()); + dispatch(deleteExpiredSnapNotifications()); }; }, [dispatch]); }; -const useSnapNotifications = () => { - const snapNotifications = useSelector(getNotifications); - - const processedSnapNotifications: SnapNotification[] = useMemo(() => { - return processSnapNotifications(snapNotifications); - }, [snapNotifications]); - - return processedSnapNotifications; -}; - // NOTE - these 2 data sources are combined in our controller. // FUTURE - we could separate these data sources into separate methods. -const useFeatureAnnouncementAndWalletNotifications = () => { +const useMetamaskNotifications = () => { const isFeatureAnnouncementsEnabled = useSelector( selectIsFeatureAnnouncementsEnabled, ); @@ -104,16 +90,25 @@ const useFeatureAnnouncementAndWalletNotifications = () => { : []; }, [isMetamaskNotificationsEnabled, notificationsData]); + const snapNotifications = useMemo(() => { + return (notificationsData ?? []).filter( + (n) => n.type === TRIGGER_TYPES.SNAP, + ); + }, [notificationsData]); + return { featureAnnouncementNotifications, walletNotifications, + snapNotifications, }; }; const useCombinedNotifications = () => { - const snapNotifications = useSnapNotifications(); - const { featureAnnouncementNotifications, walletNotifications } = - useFeatureAnnouncementAndWalletNotifications(); + const { + featureAnnouncementNotifications, + walletNotifications, + snapNotifications, + } = useMetamaskNotifications(); const combinedNotifications = useMemo(() => { const notifications = [ @@ -137,7 +132,7 @@ const useCombinedNotifications = () => { const filterNotifications = ( activeTab: TAB_KEYS, - notifications: NotificationType[], + notifications: Notification[], ) => { if (activeTab === TAB_KEYS.ALL) { return notifications; @@ -152,7 +147,9 @@ const filterNotifications = ( } if (activeTab === TAB_KEYS.WEB3) { - return notifications.filter((notification) => notification.type === 'SNAP'); + return notifications.filter( + (notification) => notification.type === TRIGGER_TYPES.SNAP, + ); } return notifications; @@ -162,7 +159,7 @@ export default function Notifications() { const history = useHistory(); const t = useI18nContext(); - useEffectDeleteExpiredNotifications(); + useEffectDeleteExpiredSnapNotifications(); const { isLoading, error } = useMetamaskNotificationsContext(); const [activeTab, setActiveTab] = useState(TAB_KEYS.ALL); diff --git a/ui/pages/notifications/snap/types/types.ts b/ui/pages/notifications/snap/types/types.ts deleted file mode 100644 index 20a675b1e6d7..000000000000 --- a/ui/pages/notifications/snap/types/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const SNAP = 'SNAP' as const; - -export type RawSnapNotification = { - id: string; - message: string; - origin: string; - createdDate: number; - readDate?: number; -}; - -export type SnapNotification = { - id: string; - createdAt: string; - isRead: boolean; - type: typeof SNAP; - data: RawSnapNotification; -}; diff --git a/ui/pages/notifications/snap/utils/utils.ts b/ui/pages/notifications/snap/utils/utils.ts deleted file mode 100644 index 0be610ec51f4..000000000000 --- a/ui/pages/notifications/snap/utils/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { RawSnapNotification, SnapNotification } from '../types/types'; -import { SNAP } from '../types/types'; - -export const processSnapNotifications = ( - snapNotifications: RawSnapNotification[], -): SnapNotification[] => { - const snaps = snapNotifications.map((snapNotification): SnapNotification => { - return { - id: snapNotification.id, - createdAt: new Date(snapNotification.createdDate).toISOString(), - isRead: Boolean(snapNotification.readDate), - type: SNAP, - data: snapNotification, - }; - }); - return snaps; -}; diff --git a/ui/selectors/metamask-notifications/metamask-notifications.ts b/ui/selectors/metamask-notifications/metamask-notifications.ts index ae71adaa8d36..e7634330574b 100644 --- a/ui/selectors/metamask-notifications/metamask-notifications.ts +++ b/ui/selectors/metamask-notifications/metamask-notifications.ts @@ -104,6 +104,42 @@ export const getFeatureAnnouncementsReadCount = createSelector( }, ); +/** + * Selector to get the count of unread snap notifications. + * + * @param {AppState} state - The current state of the Redux store. + * @returns {number} The count of unread snap notifications. + */ +export const getSnapNotificationsUnreadCount = createSelector( + [getMetamaskNotifications], + (notifications: Notification[]) => { + return notifications + ? notifications.filter( + (notification) => + !notification.isRead && notification.type === TRIGGER_TYPES.SNAP, + ).length + : 0; + }, +); + +/** + * Selector to get the count of read snap notifications. + * + * @param {AppState} state - The current state of the Redux store. + * @returns {number} The count of read snap notifications. + */ +export const getSnapNotificationsReadCount = createSelector( + [getMetamaskNotifications], + (notifications: Notification[]) => { + return notifications + ? notifications.filter( + (notification) => + notification.isRead && notification.type === TRIGGER_TYPES.SNAP, + ).length + : 0; + }, +); + /** * Selector to get the count of unread non-feature announcement notifications. * diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 09c062012731..8a5fe5a53138 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -1719,57 +1719,6 @@ export const getSnapInsights = createDeepEqualSelector( (insights, id) => insights?.[id], ); -/** - * @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 getReadNotificationsCount = createSelector( - getNotifications, - (notifications) => - notifications.filter((notification) => notification.readDate !== null) - .length, -); - -export const getUnreadNotificationsCount = createSelector( - getUnreadNotifications, - (notifications) => notifications.length, -); - /** * Get an object of announcement IDs and if they are allowed or not. * diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index 8d71048e0924..f97f653da684 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1177,33 +1177,6 @@ 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('#getReadNotificationsCount', () => { - const readNotificationsCount = - selectors.getReadNotificationsCount(mockState); - expect(readNotificationsCount).toStrictEqual(1); - }); - 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, - ]); - }); it('#getUseCurrencyRateCheck', () => { const useCurrencyRateCheck = selectors.getUseCurrencyRateCheck(mockState); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 3433a798a9d9..f97f07bf80a3 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -41,7 +41,7 @@ import { } from '@metamask/network-controller'; import { InterfaceState } from '@metamask/snaps-sdk'; import { KeyringTypes } from '@metamask/keyring-controller'; -import type { NotificationServicesController } from '@metamask/notification-services-controller'; +import { NotificationServicesController } from '@metamask/notification-services-controller'; import { Patch } from 'immer'; import switchDirection from '../../shared/lib/switch-direction'; import { @@ -59,7 +59,6 @@ import { getApprovalFlows, getCurrentNetworkTransactions, getIsSigningQRHardwareTransaction, - getNotifications, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) getPermissionSubjects, getFirstSnapInstallOrUpdateRequest, @@ -127,6 +126,7 @@ import { CaveatTypes, EndowmentTypes, } from '../../shared/constants/permissions'; +import { getMetamaskNotifications } from '../selectors/metamask-notifications/metamask-notifications'; import * as actionConstants from './actionConstants'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import { updateCustodyState } from './institutional/institution-actions'; @@ -1238,7 +1238,7 @@ export function dismissNotifications( }; } -export function deleteExpiredNotifications(): ThunkAction< +export function deleteExpiredSnapNotifications(): ThunkAction< void, MetaMaskReduxState, unknown, @@ -1246,9 +1246,14 @@ export function deleteExpiredNotifications(): ThunkAction< > { return async (dispatch, getState) => { const state = getState(); - const notifications = getNotifications(state); + const notifications = getMetamaskNotifications(state); + const snapNotifications = notifications.filter( + (notification) => + notification.type === + NotificationServicesController.Constants.TRIGGER_TYPES.SNAP, + ); - const notificationIdsToDelete = notifications + const notificationIdsToDelete = snapNotifications .filter((notification) => { const expirationTime = new Date( Date.now() - NOTIFICATIONS_EXPIRATION_DELAY, @@ -1261,7 +1266,7 @@ export function deleteExpiredNotifications(): ThunkAction< }) .map(({ id }) => id); if (notificationIdsToDelete.length) { - await submitRequestToBackground('dismissNotifications', [ + await submitRequestToBackground('deleteNotificationsById', [ notificationIdsToDelete, ]); await forceUpdateMetamaskState(dispatch); @@ -1269,15 +1274,6 @@ export function deleteExpiredNotifications(): ThunkAction< }; } -export function markNotificationsAsRead( - ids: string[], -): ThunkAction { - return async (dispatch: MetaMaskReduxDispatch) => { - await submitRequestToBackground('markNotificationsAsRead', [ids]); - await forceUpdateMetamaskState(dispatch); - }; -} - export function revokeDynamicSnapPermissions( snapId: string, permissionNames: string[], @@ -5449,6 +5445,32 @@ export function fetchAndUpdateMetamaskNotifications(): ThunkAction< }; } +/** + * Deletes a notification by its id. + * + * This function sends a request to the background script to delete a notification by the passed in id and update the state accordingly. + * If the operation encounters an error, it logs the error message and rethrows the error to ensure it is handled appropriately. + * + * @param ids - The ids of the notifications to delete. + * @returns A thunk action that, when dispatched, attempts to delete a notification by its id. + */ +export function deleteNotificationsById( + ids: string[], +): ThunkAction { + return async () => { + try { + const response = await submitRequestToBackground( + 'deleteNotificationsById', + [ids], + ); + return response; + } catch (error) { + logErrorWithMessage(error); + throw error; + } + }; +} + /** * Synchronizes accounts data with user storage between devices. *