Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/notification-services-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `fetchMetamaskNotificationsCategories()` controller method that fetches the notification categories manifest from `GET /api/v4/notifications/categories`, stores the result in new `metamaskNotificationsCategories` state, and exposes it through the messenger as `NotificationServicesController:fetchMetamaskNotificationsCategories` ([#9984](https://github.com/MetaMask/core/pull/9984))
- Add `metamaskNotificationsCategories` state field (`NotificationsCategory[]`) for the server-driven notification settings taxonomy ([#9984](https://github.com/MetaMask/core/pull/9984))
- Add `isFetchingMetamaskNotificationsCategories` state flag that reflects whether the categories request is in flight ([#9984](https://github.com/MetaMask/core/pull/9984))
- Export `NotificationsCategory` type from the notification-api schema ([#9984](https://github.com/MetaMask/core/pull/9984))

### Changed

- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ export type NotificationServicesControllerSendPerpPlaceOrderNotificationAction =
handler: NotificationServicesController['sendPerpPlaceOrderNotification'];
};

/**
* Fetches the list of MetaMask notification categories from the notifications API,
* stores the result in controller state, and returns the categories.
*
* This method sets the categories-loading flag while the request is in flight
* so the UI can reflect the loading state. If the request fails, it logs the
* error and throws a generic failure error.
*
* @returns A promise that resolves to the fetched notification categories.
* @throws {Error} If the categories request fails.
*/
export type NotificationServicesControllerFetchMetamaskNotificationsCategoriesAction =
{
type: `NotificationServicesController:fetchMetamaskNotificationsCategories`;
handler: NotificationServicesController['fetchMetamaskNotificationsCategories'];
};

/**
* Union of all NotificationServicesController action types.
*/
Expand All @@ -243,4 +260,5 @@ export type NotificationServicesControllerMethodActions =
| NotificationServicesControllerDeleteNotificationsByIdAction
| NotificationServicesControllerMarkMetamaskNotificationsAsReadAction
| NotificationServicesControllerUpdateMetamaskNotificationsListAction
| NotificationServicesControllerSendPerpPlaceOrderNotificationAction;
| NotificationServicesControllerSendPerpPlaceOrderNotificationAction
| NotificationServicesControllerFetchMetamaskNotificationsCategoriesAction;
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
mockFetchFeatureAnnouncementNotifications,
mockMarkNotificationsAsRead,
mockCreatePerpNotification,
mockGetNotificationsCategories,
} from './__fixtures__/mockServices.js';
import { waitFor } from './__fixtures__/test-utils.js';
import { TRIGGER_TYPES } from './constants/index.js';
Expand All @@ -47,6 +48,7 @@ import {
createMockFeatureAnnouncementRaw,
} from './mocks/mock-feature-announcements.js';
import { createMockNotificationEthSent } from './mocks/mock-raw-notifications.js';
import { getMockNotificationsCategoriesResponse } from './mocks/mockResponses.js';
import {
DEFAULT_AGENTIC_CLI_PREFERENCES,
DEFAULT_PERPS_PREFERENCES,
Expand All @@ -63,6 +65,7 @@ import type {
import { processFeatureAnnouncement } from './processors/index.js';
import { processNotification } from './processors/process-notifications.js';
import { processSnapNotification } from './processors/process-snap-notifications.js';
import * as OnChainNotifications from './services/api-notifications.js';
import { notificationsConfigCache } from './services/notification-config-cache.js';
import type { INotification, OrderInput } from './types/index.js';

Expand Down Expand Up @@ -1259,6 +1262,81 @@ describe('NotificationServicesController', () => {
});
});

describe('fetchMetamaskNotificationsCategories', () => {
it('fetches notification categories and updates state', async () => {
const { messenger } = mockNotificationMessenger();
const mockCategoriesAPI = mockGetNotificationsCategories();
const expectedCategories =
getMockNotificationsCategoriesResponse().response;
const controller = new NotificationServicesController({
messenger,
env: { featureAnnouncements: featureAnnouncementsEnv },
});

const result = await controller.fetchMetamaskNotificationsCategories();

expect(mockCategoriesAPI.isDone()).toBe(true);
expect(result).toStrictEqual(expectedCategories);
expect(controller.state.metamaskNotificationsCategories).toStrictEqual(
expectedCategories,
);
expect(controller.state.isFetchingMetamaskNotificationsCategories).toBe(
false,
);
});

it('returns an empty array and resets loading state when the API fails', async () => {
const { messenger } = mockNotificationMessenger();
const mockCategoriesAPI = mockGetNotificationsCategories({
status: 500,
body: { error: 'mock api failure' },
});
const mockLogError = mockErrorLog();
const controller = new NotificationServicesController({
messenger,
env: { featureAnnouncements: featureAnnouncementsEnv },
});

const result = await controller.fetchMetamaskNotificationsCategories();

expect(mockCategoriesAPI.isDone()).toBe(true);
expect(result).toStrictEqual([]);
expect(controller.state.metamaskNotificationsCategories).toStrictEqual(
[],
);
expect(controller.state.isFetchingMetamaskNotificationsCategories).toBe(
false,
);
mockLogError.mockRestore();
});

it('throws and resets loading state when fetching categories throws', async () => {
const { messenger } = mockNotificationMessenger();
const mockLogError = mockErrorLog();
const getCategoriesSpy = jest
.spyOn(OnChainNotifications, 'getNotificationsCategories')
.mockRejectedValue(new Error('unexpected error'));
const controller = new NotificationServicesController({
messenger,
env: { featureAnnouncements: featureAnnouncementsEnv },
});

await expect(
controller.fetchMetamaskNotificationsCategories(),
).rejects.toThrow('Failed to fetch notifications categories');

expect(controller.state.metamaskNotificationsCategories).toStrictEqual(
[],
);
expect(controller.state.isFetchingMetamaskNotificationsCategories).toBe(
false,
);
expect(mockLogError).toHaveBeenCalled();
getCategoriesSpy.mockRestore();
mockLogError.mockRestore();
});
});

describe('getNotificationsByType', () => {
it('can fetch notifications by their type', async () => {
const { messenger } = mockNotificationMessenger();
Expand Down Expand Up @@ -1850,6 +1928,7 @@ describe('NotificationServicesController', () => {
),
).toMatchInlineSnapshot(`
{
"metamaskNotificationsCategories": [],
"metamaskNotificationsList": [],
"metamaskNotificationsReadList": [],
"subscriptionAccountsSeen": [],
Expand All @@ -1875,6 +1954,7 @@ describe('NotificationServicesController', () => {
"isFeatureAnnouncementsEnabled": false,
"isMetamaskNotificationsFeatureSeen": false,
"isNotificationServicesEnabled": false,
"metamaskNotificationsCategories": [],
"metamaskNotificationsList": [],
"subscriptionAccountsSeen": [],
}
Expand All @@ -1899,6 +1979,7 @@ describe('NotificationServicesController', () => {
"isFeatureAnnouncementsEnabled": false,
"isMetamaskNotificationsFeatureSeen": false,
"isNotificationServicesEnabled": false,
"metamaskNotificationsCategories": [],
"metamaskNotificationsList": [],
"metamaskNotificationsReadList": [],
"subscriptionAccountsSeen": [],
Expand All @@ -1924,10 +2005,12 @@ describe('NotificationServicesController', () => {
"isCheckingAccountsPresence": false,
"isFeatureAnnouncementsEnabled": false,
"isFetchingMetamaskNotifications": false,
"isFetchingMetamaskNotificationsCategories": false,
"isMetamaskNotificationsFeatureSeen": false,
"isNotificationServicesEnabled": false,
"isUpdatingMetamaskNotifications": false,
"isUpdatingMetamaskNotificationsAccount": [],
"metamaskNotificationsCategories": [],
"metamaskNotificationsList": [],
"metamaskNotificationsReadList": [],
"subscriptionAccountsSeen": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@metamask/authenticated-user-storage';
import type {
ControllerGetStateAction,
ControllerStateChangeEvent,
ControllerStateChangedEvent,
StateMetadata,
} from '@metamask/base-controller';
import { BaseController } from '@metamask/base-controller';
Expand Down Expand Up @@ -40,7 +40,10 @@ import type {
} from '../NotificationServicesPushController/index.js';
import type { NotificationServicesPushControllerMethodActions } from '../NotificationServicesPushController/NotificationServicesPushController-method-action-types.js';
import { TRIGGER_TYPES } from './constants/notification-schema.js';
import type { NormalisedAPINotification } from './index.js';
import type {
NormalisedAPINotification,
NotificationsCategory,
} from './index.js';
import type { NotificationServicesControllerMethodActions } from './NotificationServicesController-method-action-types.js';
import {
processAndFilterNotifications,
Expand All @@ -50,6 +53,7 @@ import type { ENV } from './services/api-notifications.js';
import {
getAPINotifications,
getNotificationsApiConfigCached,
getNotificationsCategories,
markNotificationsAsRead,
} from './services/api-notifications.js';
import { getFeatureAnnouncementNotifications } from './services/feature-announcements.js';
Expand Down Expand Up @@ -99,6 +103,10 @@ export type NotificationServicesControllerState = {
* List of read metamask notifications
*/
metamaskNotificationsReadList: string[];
/**
* List of notification categories
*/
metamaskNotificationsCategories: NotificationsCategory[];
/**
* Flag that indicates that the creating notifications is in progress
*/
Expand All @@ -109,6 +117,11 @@ export type NotificationServicesControllerState = {
* when fetching notifications
*/
isFetchingMetamaskNotifications: boolean;
/**
* Flag that indicates that fetching notification categories
* is in progress. Used for readiness checks for categories consumers
*/
isFetchingMetamaskNotificationsCategories: boolean;
/**
* Flag that indicates that the updating notifications for a specific address is in progress
*/
Expand Down Expand Up @@ -157,6 +170,12 @@ const metadata: StateMetadata<NotificationServicesControllerState> = {
includeInDebugSnapshot: true,
usedInUi: true,
},
metamaskNotificationsCategories: {
includeInStateLogs: true,
persist: true,
includeInDebugSnapshot: true,
usedInUi: true,
},
isUpdatingMetamaskNotifications: {
includeInStateLogs: false,
persist: false,
Expand All @@ -181,6 +200,12 @@ const metadata: StateMetadata<NotificationServicesControllerState> = {
includeInDebugSnapshot: false,
usedInUi: true,
},
isFetchingMetamaskNotificationsCategories: {
includeInStateLogs: false,
persist: false,
includeInDebugSnapshot: false,
usedInUi: true,
},
};
export const defaultState: NotificationServicesControllerState = {
subscriptionAccountsSeen: [],
Expand All @@ -189,10 +214,12 @@ export const defaultState: NotificationServicesControllerState = {
isFeatureAnnouncementsEnabled: false,
metamaskNotificationsList: [],
metamaskNotificationsReadList: [],
metamaskNotificationsCategories: [],
isUpdatingMetamaskNotifications: false,
isFetchingMetamaskNotifications: false,
isUpdatingMetamaskNotificationsAccount: [],
isCheckingAccountsPresence: false,
isFetchingMetamaskNotificationsCategories: false,
};

export type NotificationServicesControllerEnableNotificationsOptions = {
Expand Down Expand Up @@ -350,6 +377,7 @@ const MESSENGER_EXPOSED_METHODS = [
'disableAccounts',
'enableAccounts',
'fetchAndUpdateMetamaskNotifications',
'fetchMetamaskNotificationsCategories',
'getNotificationsByType',
'deleteNotificationById',
'deleteNotificationsById',
Expand Down Expand Up @@ -385,7 +413,7 @@ type AllowedActions =

// Events
export type NotificationServicesControllerStateChangeEvent =
ControllerStateChangeEvent<
ControllerStateChangedEvent<
typeof controllerName,
NotificationServicesControllerState
>;
Expand Down Expand Up @@ -973,6 +1001,23 @@ export class NotificationServicesController extends BaseController<
});
}

/**
* Updates the state to indicate whether fetching of MetaMask notification categories is in progress.
*
* This method is used to set the `isFetchingMetamaskNotificationsCategories` state, which can be utilized
* to show or hide loading indicators in the UI when notifications categories are being fetched.
*
* @param isFetchingNotificationCategories - A boolean value representing the fetching state.
*/
#setIsFetchingNotificationsCategories(
isFetchingNotificationCategories: boolean,
): void {
this.update((state) => {
state.isFetchingMetamaskNotificationsCategories =
isFetchingNotificationCategories;
});
}

/**
* Public method to expose enabling push notifications
*/
Expand Down Expand Up @@ -1617,4 +1662,36 @@ export class NotificationServicesController extends BaseController<
// Do Nothing
}
}

/**
* Fetches the list of MetaMask notification categories from the notifications API,
* stores the result in controller state, and returns the categories.
*
* This method sets the categories-loading flag while the request is in flight
* so the UI can reflect the loading state. If the request fails, it logs the
* error and throws a generic failure error.
*
* @returns A promise that resolves to the fetched notification categories.
* @throws {Error} If the categories request fails.
*/
public async fetchMetamaskNotificationsCategories(): Promise<
NotificationsCategory[]
> {
this.#setIsFetchingNotificationsCategories(true);

try {
const categories = await getNotificationsCategories();

this.update((state) => {
state.metamaskNotificationsCategories = categories;
});

return categories;
} catch (error) {
log.error('Failed to fetch notifications categories', error);
throw new Error('Failed to fetch notifications categories');
} finally {
this.#setIsFetchingNotificationsCategories(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMockListNotificationsResponse,
getMockMarkNotificationsAsReadResponse,
getMockCreatePerpOrderNotification,
getMockNotificationsCategoriesResponse,
} from '../mocks/mockResponses.js';

type MockReply = {
Expand Down Expand Up @@ -64,6 +65,19 @@ export const mockMarkNotificationsAsRead = (
return mockEndpoint;
};

export const mockGetNotificationsCategories = (
mockReply?: MockReply,
): nock.Scope => {
const mockResponse = getMockNotificationsCategoriesResponse();
const reply = mockReply ?? { status: 200, body: mockResponse.response };

const mockEndpoint = nock(mockResponse.url)
.get('')
.reply(reply.status, reply.body);

return mockEndpoint;
};

export const mockCreatePerpNotification = (
mockReply?: MockReply,
): nock.Scope => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type {
NotificationServicesControllerDisableAccountsAction,
NotificationServicesControllerEnableAccountsAction,
NotificationServicesControllerFetchAndUpdateMetamaskNotificationsAction,
NotificationServicesControllerFetchMetamaskNotificationsCategoriesAction,
NotificationServicesControllerGetNotificationsByTypeAction,
NotificationServicesControllerDeleteNotificationByIdAction,
NotificationServicesControllerDeleteNotificationsByIdAction,
Expand Down
Loading