Skip to content

Commit d75a971

Browse files
authored
Update NotificationServicesController to accommodate for Snaps Notifications (#4809)
## Explanation * What is the current state of things and why does it need to change? We currently have snap types living in the extension, which should exist in this repo instead. Also, the extension is using two controllers to handle notifications, the `NotificationController` solely exists for the purpose of snap notifications. With the revamping of the notifications system, it is best to house all notifications in one place. * What is the solution your changes offer and how does it work? Add snap logic to the `NotificationServicesController` * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? The `updateMetamaskNotificationsList` function will be the gateway for snaps to add notifications to the controller. ## Changelog ### `@metamask/notification-services-controller` - **ADDED**: `getNotificationsByType` to grab a list of notifications by type. - **ADDED**: `deleteNotificationById` to delete a notification in the controller's state (to be only used by notifications that live in this controller which currently is just snaps). - **CHANGED**: `fetchAndUpdateMetamaskNotifications` to grab snaps from state before repopulating with a new list of other notifications. - **CHANGED**: `markNotificaftionsAsRead` to account for snaps notifications. - **CHANGED**: `updateMetamaskNotificationsList` to assign a processed notification to state instead of the originally passed in notification as the `processSnapNotification` function adds on extra properties to the raw notification. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent c3ae77e commit d75a971

File tree

14 files changed

+507
-37
lines changed

14 files changed

+507
-37
lines changed

packages/notification-services-controller/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"@contentful/rich-text-html-renderer": "^16.5.2",
103103
"@metamask/base-controller": "^7.0.1",
104104
"@metamask/controller-utils": "^11.3.0",
105+
"@metamask/utils": "^9.1.0",
105106
"bignumber.js": "^4.1.0",
106107
"firebase": "^10.11.0",
107108
"loglevel": "^1.8.1",

packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts

Lines changed: 226 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
import type { UserStorageController } from '@metamask/profile-sync-controller';
88
import { AuthenticationController } from '@metamask/profile-sync-controller';
99

10+
import { createMockSnapNotification } from './__fixtures__';
1011
import {
1112
createMockFeatureAnnouncementAPIResult,
1213
createMockFeatureAnnouncementRaw,
@@ -25,6 +26,7 @@ import {
2526
mockMarkNotificationsAsRead,
2627
} from './__fixtures__/mockServices';
2728
import { waitFor } from './__fixtures__/test-utils';
29+
import { TRIGGER_TYPES } from './constants';
2830
import NotificationServicesController, {
2931
defaultState,
3032
} from './NotificationServicesController';
@@ -35,7 +37,9 @@ import type {
3537
NotificationServicesPushControllerDisablePushNotifications,
3638
NotificationServicesPushControllerUpdateTriggerPushNotifications,
3739
} from './NotificationServicesController';
40+
import { processFeatureAnnouncement } from './processors';
3841
import { processNotification } from './processors/process-notifications';
42+
import { processSnapNotification } from './processors/process-snap-notifications';
3943
import * as OnChainNotifications from './services/onchain-notifications';
4044
import type { UserStorage } from './types/user-storage/user-storage';
4145
import * as Utils from './utils/utils';
@@ -472,23 +476,30 @@ describe('metamask-notifications - fetchAndUpdateMetamaskNotifications()', () =>
472476
};
473477
};
474478

475-
it('processes and shows feature announcements and wallet notifications', async () => {
479+
it('processes and shows feature announcements, wallet and snap notifications', async () => {
476480
const {
477481
messenger,
478482
mockFeatureAnnouncementAPIResult,
479483
mockListNotificationsAPIResult,
480484
} = arrangeMocks();
481485

486+
const snapNotification = createMockSnapNotification();
487+
const processedSnapNotification = processSnapNotification(snapNotification);
488+
482489
const controller = new NotificationServicesController({
483490
messenger,
484491
env: { featureAnnouncements: featureAnnouncementsEnv },
485-
state: { ...defaultState, isFeatureAnnouncementsEnabled: true },
492+
state: {
493+
...defaultState,
494+
isFeatureAnnouncementsEnabled: true,
495+
metamaskNotificationsList: [processedSnapNotification],
496+
},
486497
});
487498

488499
const result = await controller.fetchAndUpdateMetamaskNotifications();
489500

490501
// Should have 1 feature announcement and 1 wallet notification
491-
expect(result).toHaveLength(2);
502+
expect(result).toHaveLength(3);
492503
expect(
493504
result.find(
494505
(n) => n.id === mockFeatureAnnouncementAPIResult.items?.[0].fields.id,
@@ -497,9 +508,10 @@ describe('metamask-notifications - fetchAndUpdateMetamaskNotifications()', () =>
497508
expect(
498509
result.find((n) => n.id === mockListNotificationsAPIResult[0].id),
499510
).toBeDefined();
511+
expect(result.find((n) => n.type === TRIGGER_TYPES.SNAP)).toBeDefined();
500512

501513
// State is also updated
502-
expect(controller.state.metamaskNotificationsList).toHaveLength(2);
514+
expect(controller.state.metamaskNotificationsList).toHaveLength(3);
503515
});
504516

505517
it('only fetches and processes feature announcements if not authenticated', async () => {
@@ -529,6 +541,148 @@ describe('metamask-notifications - fetchAndUpdateMetamaskNotifications()', () =>
529541
});
530542
});
531543

544+
describe('metamask-notifications - getNotificationsByType', () => {
545+
it('can fetch notifications by their type', async () => {
546+
const { messenger } = mockNotificationMessenger();
547+
const controller = new NotificationServicesController({
548+
messenger,
549+
env: { featureAnnouncements: featureAnnouncementsEnv },
550+
});
551+
552+
const processedSnapNotification = processSnapNotification(
553+
createMockSnapNotification(),
554+
);
555+
const processedFeatureAnnouncement = processFeatureAnnouncement(
556+
createMockFeatureAnnouncementRaw(),
557+
);
558+
559+
await controller.updateMetamaskNotificationsList(processedSnapNotification);
560+
await controller.updateMetamaskNotificationsList(
561+
processedFeatureAnnouncement,
562+
);
563+
564+
expect(controller.state.metamaskNotificationsList).toHaveLength(2);
565+
566+
const filteredNotifications = controller.getNotificationsByType(
567+
TRIGGER_TYPES.SNAP,
568+
);
569+
570+
expect(filteredNotifications).toHaveLength(1);
571+
expect(filteredNotifications).toStrictEqual([
572+
{
573+
type: TRIGGER_TYPES.SNAP,
574+
id: expect.any(String),
575+
createdAt: expect.any(String),
576+
isRead: false,
577+
readDate: null,
578+
data: {
579+
message: 'fooBar',
580+
origin: '@metamask/example-snap',
581+
detailedView: {
582+
title: 'Detailed View',
583+
interfaceId: '1',
584+
footerLink: {
585+
text: 'Go Home',
586+
href: 'metamask://client/',
587+
},
588+
},
589+
},
590+
},
591+
]);
592+
});
593+
});
594+
595+
describe('metamask-notifications - deleteNotificationsById', () => {
596+
it('will delete a notification by its id', async () => {
597+
const { messenger } = mockNotificationMessenger();
598+
const processedSnapNotification = processSnapNotification(
599+
createMockSnapNotification(),
600+
);
601+
const controller = new NotificationServicesController({
602+
messenger,
603+
env: { featureAnnouncements: featureAnnouncementsEnv },
604+
state: { metamaskNotificationsList: [processedSnapNotification] },
605+
});
606+
607+
await controller.deleteNotificationsById([processedSnapNotification.id]);
608+
609+
expect(controller.state.metamaskNotificationsList).toHaveLength(0);
610+
});
611+
612+
it('will batch delete notifications', async () => {
613+
const { messenger } = mockNotificationMessenger();
614+
const processedSnapNotification1 = processSnapNotification(
615+
createMockSnapNotification(),
616+
);
617+
const processedSnapNotification2 = processSnapNotification(
618+
createMockSnapNotification(),
619+
);
620+
const controller = new NotificationServicesController({
621+
messenger,
622+
env: { featureAnnouncements: featureAnnouncementsEnv },
623+
state: {
624+
metamaskNotificationsList: [
625+
processedSnapNotification1,
626+
processedSnapNotification2,
627+
],
628+
},
629+
});
630+
631+
await controller.deleteNotificationsById([
632+
processedSnapNotification1.id,
633+
processedSnapNotification2.id,
634+
]);
635+
636+
expect(controller.state.metamaskNotificationsList).toHaveLength(0);
637+
});
638+
639+
it('will throw if a notification is not found', async () => {
640+
const { messenger } = mockNotificationMessenger();
641+
const processedSnapNotification = processSnapNotification(
642+
createMockSnapNotification(),
643+
);
644+
const controller = new NotificationServicesController({
645+
messenger,
646+
env: { featureAnnouncements: featureAnnouncementsEnv },
647+
state: { metamaskNotificationsList: [processedSnapNotification] },
648+
});
649+
650+
await expect(controller.deleteNotificationsById(['foo'])).rejects.toThrow(
651+
'The notification to be deleted does not exist.',
652+
);
653+
654+
expect(controller.state.metamaskNotificationsList).toHaveLength(1);
655+
});
656+
657+
it('will throw if the notification to be deleted is not locally persisted', async () => {
658+
const { messenger } = mockNotificationMessenger();
659+
const processedSnapNotification = processSnapNotification(
660+
createMockSnapNotification(),
661+
);
662+
const processedFeatureAnnouncement = processFeatureAnnouncement(
663+
createMockFeatureAnnouncementRaw(),
664+
);
665+
const controller = new NotificationServicesController({
666+
messenger,
667+
env: { featureAnnouncements: featureAnnouncementsEnv },
668+
state: {
669+
metamaskNotificationsList: [
670+
processedFeatureAnnouncement,
671+
processedSnapNotification,
672+
],
673+
},
674+
});
675+
676+
await expect(
677+
controller.deleteNotificationsById([processedFeatureAnnouncement.id]),
678+
).rejects.toThrow(
679+
'The notification type of "features_announcement" is not locally persisted, only the following types can use this function: snap.',
680+
);
681+
682+
expect(controller.state.metamaskNotificationsList).toHaveLength(2);
683+
});
684+
});
685+
532686
describe('metamask-notifications - markMetamaskNotificationsAsRead()', () => {
533687
const arrangeMocks = (options?: { onChainMarkAsReadFails: boolean }) => {
534688
const messengerMocks = mockNotificationMessenger();
@@ -576,6 +730,38 @@ describe('metamask-notifications - markMetamaskNotificationsAsRead()', () => {
576730
// We can debate & change implementation if it makes sense to mark as read locally if external APIs fail.
577731
expect(controller.state.metamaskNotificationsReadList).toHaveLength(1);
578732
});
733+
734+
it('updates snap notifications as read', async () => {
735+
const { messenger } = arrangeMocks();
736+
const processedSnapNotification = processSnapNotification(
737+
createMockSnapNotification(),
738+
);
739+
const controller = new NotificationServicesController({
740+
messenger,
741+
env: { featureAnnouncements: featureAnnouncementsEnv },
742+
state: {
743+
metamaskNotificationsList: [processedSnapNotification],
744+
},
745+
});
746+
747+
await controller.markMetamaskNotificationsAsRead([
748+
{
749+
type: TRIGGER_TYPES.SNAP,
750+
id: processedSnapNotification.id,
751+
isRead: false,
752+
},
753+
]);
754+
755+
// Should see 1 item in controller read state
756+
expect(controller.state.metamaskNotificationsReadList).toHaveLength(1);
757+
758+
// The notification should have a read date
759+
expect(
760+
// @ts-expect-error readDate property is guaranteed to exist
761+
// as we're dealing with a snap notification
762+
controller.state.metamaskNotificationsList[0].readDate,
763+
).not.toBeNull();
764+
});
579765
});
580766

581767
describe('metamask-notifications - enableMetamaskNotifications()', () => {
@@ -670,6 +856,42 @@ describe('metamask-notifications - disableMetamaskNotifications()', () => {
670856
});
671857
});
672858

859+
describe('metamask-notifications - updateMetamaskNotificationsList', () => {
860+
it('can add and process a new notification to the notifications list', async () => {
861+
const { messenger } = mockNotificationMessenger();
862+
const controller = new NotificationServicesController({
863+
messenger,
864+
env: { featureAnnouncements: featureAnnouncementsEnv },
865+
state: { isNotificationServicesEnabled: true },
866+
});
867+
const processedSnapNotification = processSnapNotification(
868+
createMockSnapNotification(),
869+
);
870+
await controller.updateMetamaskNotificationsList(processedSnapNotification);
871+
expect(controller.state.metamaskNotificationsList).toStrictEqual([
872+
{
873+
type: TRIGGER_TYPES.SNAP,
874+
id: expect.any(String),
875+
createdAt: expect.any(String),
876+
readDate: null,
877+
isRead: false,
878+
data: {
879+
message: 'fooBar',
880+
origin: '@metamask/example-snap',
881+
detailedView: {
882+
title: 'Detailed View',
883+
interfaceId: '1',
884+
footerLink: {
885+
text: 'Go Home',
886+
href: 'metamask://client/',
887+
},
888+
},
889+
},
890+
},
891+
]);
892+
});
893+
});
894+
673895
// Type-Computation - we are extracting args and parameters from a generic type utility
674896
// Thus this `AnyFunc` can be used to help constrain the generic parameters correctly
675897
// eslint-disable-next-line @typescript-eslint/no-explicit-any

0 commit comments

Comments
 (0)