diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 4d4d8a26b70..8d96219c2ef 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `updateNotificationPreferences` method and `NotificationServicesController:updateNotificationPreferences` action, which save a change to notification preferences in Authenticated User Storage ([#10861](https://github.com/MetaMask/core/pull/10861)) + - Takes a `NotificationPreferencesUpdate`: any sections, and any fields within a section. Fields left out keep their stored value; arrays and maps are replaced whole. + - Merges over a fresh read of the stored preferences, runs updates one at a time, and returns the saved preferences. + - Rejects updates that turn a marketing channel on without marketing consent with `MarketingConsentRequiredError`. Marketing channels already on in storage don't block other updates. + - Throws if notification preferences have not been set up yet, instead of creating them. +- Add `MarketingConsentRequiredError` and its `code`, `MARKETING_CONSENT_REQUIRED_ERROR_CODE` ([#10861](https://github.com/MetaMask/core/pull/10861)) + ### Changed +- **BREAKING:** `NotificationServicesControllerMessenger` requires `AnalyticsController:getState` and `AuthenticatedUserStorageService:invalidateQueries` in its allowed actions ([#10861](https://github.com/MetaMask/core/pull/10861)) +- **BREAKING:** When notification preferences are created for the first time, both marketing channels (push, and in-app feature announcements) now follow `AnalyticsController` marketing consent; the `hasMarketingConsent` and `productAnnouncementEnabled` options of `createOnChainTriggers` and `enableMetamaskNotifications` are ignored. Users without marketing consent no longer start with feature announcements on ([#10861](https://github.com/MetaMask/core/pull/10861)) - Bump `@metamask/profile-sync-controller` from `^34.0.0` to `^34.0.3` ([#10752](https://github.com/MetaMask/core/pull/10752), [#10756](https://github.com/MetaMask/core/pull/10756), [#10761](https://github.com/MetaMask/core/pull/10761)) - Bump `loglevel` from `^1.8.1` to `^1.9.2` ([#10724](https://github.com/MetaMask/core/pull/10724)) - Bump `firebase` from `^11.10.0` to `^12.19.0` ([#10600](https://github.com/MetaMask/core/pull/10600)) @@ -16,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-controller` from `^28.1.0` to `^28.2.0` ([#10851](https://github.com/MetaMask/core/pull/10851)) - Bump `@metamask/base-controller` from `^10.0.0` to `^10.1.0` ([#10864](https://github.com/MetaMask/core/pull/10864)) +### Deprecated + +- Deprecate the `isFeatureAnnouncementsEnabled` state property, the `setFeatureAnnouncementsEnabled` method and its `NotificationServicesController:setFeatureAnnouncementsEnabled` action, and the `hasMarketingConsent` and `productAnnouncementEnabled` options. Feature announcements follow marketing consent through the Authenticated User Storage `marketing.inAppNotificationsEnabled` preference ([#10861](https://github.com/MetaMask/core/pull/10861)) + ## [29.0.3] ### Changed diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index 624f7d8f93c..5bddde1f30c 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -73,6 +73,7 @@ }, "dependencies": { "@contentful/rich-text-html-renderer": "^16.6.10", + "@metamask/analytics-controller": "^4.0.0", "@metamask/authenticated-user-storage": "^4.1.0", "@metamask/base-controller": "^10.1.0", "@metamask/controller-utils": "^13.0.0", diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts index 9f7a0d8caf2..f485b773ce9 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts @@ -36,6 +36,10 @@ export type NotificationServicesControllerCheckAccountsPresenceAction = { * * **Action** - used in the notification settings to enable/disable feature announcements. * + * @deprecated Feature announcements follow marketing consent (the AUS + * `marketing.inAppNotificationsEnabled` preference). Nothing in this + * controller reads the flag this sets, and it will be removed. + * * @param featureAnnouncementsEnabled - A boolean value indicating the desired enabled state of the feature announcements. * @async * @throws {Error} If fails to update @@ -55,11 +59,13 @@ export type NotificationServicesControllerSetFeatureAnnouncementsEnabledAction = * {@link AuthenticatedUserStorageService} has no stored preferences yet. * Existing preferences are left as-is. * + * When preferences are initialized, both marketing channels (push, and + * in-app feature announcements) follow `AnalyticsController` marketing + * consent. + * * @param opts - optional options to mutate this functionality - * @param opts.hasMarketingConsent - The user's marketing-consent flag. - * Used only during initialization to seed marketing push notifications. - * @param opts.productAnnouncementEnabled - The user's product-announcement flag. - * Used only during initialization to seed marketing in-app notifications. + * @param opts.hasMarketingConsent - Deprecated and ignored. + * @param opts.productAnnouncementEnabled - Deprecated and ignored. * @param opts.registerPushNotifications - Whether to attempt FCM/device push registration. * @returns The updated or newly created user storage. * @throws {Error} Throws an error if unauthenticated or from other operations. @@ -217,6 +223,35 @@ export type NotificationServicesControllerSendPerpPlaceOrderNotificationAction = handler: NotificationServicesController['sendPerpPlaceOrderNotification']; }; +/** + * Saves a change to the user's notification preferences in Authenticated + * User Storage. Sections and fields left out keep their stored value, so + * callers send only what they change. + * + * Marketing notifications require marketing consent, so an update that + * turns a marketing channel on while the user has not consented is + * rejected. Only the fields in the update are checked: a marketing channel + * already on in storage doesn't block updates to other preferences. Turning + * consent off turns marketing channels off on its own (see + * `MarketingConsentController`). + * + * Read preferences from `AuthenticatedUserStorageService:getNotificationPreferences` + * (e.g. through `@metamask/react-data-query`), whose cache keeps UIs up to + * date; write them through this method so the consent rule is enforced. + * + * @param update - The sections and fields to change. + * @returns The saved preferences. + * @throws {MarketingConsentRequiredError} If the update turns a marketing + * channel on without marketing consent. + * @throws If notification preferences have not been set up yet; they are + * created when notifications are first enabled. + */ +export type NotificationServicesControllerUpdateNotificationPreferencesAction = + { + type: `NotificationServicesController:updateNotificationPreferences`; + handler: NotificationServicesController['updateNotificationPreferences']; + }; + /** * Union of all NotificationServicesController action types. */ @@ -237,4 +272,5 @@ export type NotificationServicesControllerMethodActions = | NotificationServicesControllerDeleteNotificationsByIdAction | NotificationServicesControllerMarkMetamaskNotificationsAsReadAction | NotificationServicesControllerUpdateMetamaskNotificationsListAction - | NotificationServicesControllerSendPerpPlaceOrderNotificationAction; + | NotificationServicesControllerSendPerpPlaceOrderNotificationAction + | NotificationServicesControllerUpdateNotificationPreferencesAction; diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 100c939447f..787e2d61dee 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -1,4 +1,6 @@ +import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; import type { + AuthenticatedUserStorageInvalidateQueriesAction, AuthenticatedUserStorageServiceGetNotificationPreferencesAction, AuthenticatedUserStorageServicePutNotificationPreferencesAction, NotificationPreferences, @@ -53,6 +55,8 @@ import { DEFAULT_PERPS_PREFERENCES, DEFAULT_PRICE_ALERT_PREFERENCES, DEFAULT_SOCIAL_AI_PREFERENCES, + MARKETING_CONSENT_REQUIRED_ERROR_CODE, + MarketingConsentRequiredError, NotificationServicesController, ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS, defaultState, @@ -639,13 +643,14 @@ describe('NotificationServicesController', () => { }; describe('when AUS preferences are not initialized (preferences are null)', () => { - it('writes a fresh preferences blob using hardcoded defaults, no wallet-activity addresses, and supplied marketing flags', async () => { + it('writes a fresh preferences blob using hardcoded defaults, no wallet-activity addresses, and marketing notifications from marketing consent', async () => { const { messenger, mockEnablePushNotifications, mockGetConfig, mockUpdateNotifications, mockKeyringControllerGetState, + mockAnalyticsControllerGetState, } = arrangeMocks({ configurePrefs: (mock) => mock.mockResolvedValueOnce(null), }); @@ -668,16 +673,16 @@ describe('NotificationServicesController', () => { ], }); + mockAnalyticsControllerGetState.mockReturnValue({ + optedInToMarketing: true, + }); const controller = new NotificationServicesController({ messenger, env: { featureAnnouncements: featureAnnouncementsEnv }, }); controller.init(); - await controller.createOnChainTriggers({ - hasMarketingConsent: true, - productAnnouncementEnabled: false, - }); + await controller.createOnChainTriggers(); expect(mockGetConfig).toHaveBeenCalled(); expect(mockTriggerQuery.isDone()).toBe(true); @@ -692,7 +697,7 @@ describe('NotificationServicesController', () => { accounts: [], }, marketing: { - inAppNotificationsEnabled: false, + inAppNotificationsEnabled: true, pushNotificationsEnabled: true, }, perps: { ...DEFAULT_PERPS_PREFERENCES }, @@ -911,7 +916,7 @@ describe('NotificationServicesController', () => { }); }); - it('enables marketing in-app notifications when product announcements are enabled', async () => { + it('keeps marketing notifications off without marketing consent, ignoring the deprecated options', async () => { const { messenger, mockUpdateNotifications } = arrangeMocks({ configurePrefs: (mock) => mock.mockResolvedValueOnce(null), }); @@ -924,12 +929,13 @@ describe('NotificationServicesController', () => { controller.init(); await controller.createOnChainTriggers({ + hasMarketingConsent: true, productAnnouncementEnabled: true, }); const [writtenPrefs] = mockUpdateNotifications.mock.calls[0]; expect(writtenPrefs.marketing).toStrictEqual({ - inAppNotificationsEnabled: true, + inAppNotificationsEnabled: false, pushNotificationsEnabled: false, }); }); @@ -2509,6 +2515,351 @@ describe('NotificationServicesController', () => { }); }); + describe('updateNotificationPreferences', () => { + const arrangeController = ( + optedInToMarketing: boolean, + stored: NotificationPreferences | null = mockPreferences(), + ): ReturnType & { + controller: NotificationServicesController; + } => { + const mocks = mockNotificationMessenger(); + mocks.mockAnalyticsControllerGetState.mockReturnValue({ + optedInToMarketing, + }); + mocks.mockGetNotificationPreferences.mockResolvedValue(stored); + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + return { ...mocks, controller }; + }; + + it('merges the update over freshly read preferences and saves it with the client platform', async () => { + const order: string[] = []; + const { + controller, + mockInvalidateQueries, + mockGetNotificationPreferences, + mockPutNotificationPreferences, + } = arrangeController(false); + mockInvalidateQueries.mockImplementation(async () => { + order.push('invalidate'); + }); + mockGetNotificationPreferences.mockImplementation(async () => { + order.push('get'); + return mockPreferences(); + }); + mockPutNotificationPreferences.mockImplementation(async () => { + order.push('put'); + }); + + const saved = await controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }); + + const expected = mockPreferences({ + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: false, + }, + }); + expect(saved).toStrictEqual(expected); + expect(mockPutNotificationPreferences).toHaveBeenCalledWith( + expected, + 'extension', + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [ + 'AuthenticatedUserStorageService:getNotificationPreferences', + ], + }); + expect(order).toStrictEqual(['invalidate', 'get', 'put', 'get']); + }); + + it('keeps stored fields that the update leaves out or sets to undefined', async () => { + const stored = mockPreferences({ + socialAI: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + mutedTraderProfileIds: ['trader'], + }, + }); + const { controller, mockPutNotificationPreferences } = arrangeController( + false, + stored, + ); + + await controller.updateNotificationPreferences({ + socialAI: { + pushNotificationsEnabled: true, + inAppNotificationsEnabled: undefined, + }, + walletActivity: undefined, + }); + + expect(mockPutNotificationPreferences).toHaveBeenCalledWith( + { + ...stored, + socialAI: { ...stored.socialAI, pushNotificationsEnabled: true }, + }, + 'extension', + ); + }); + + it('saves updates to other preferences when a marketing channel is already on without consent', async () => { + const stored = mockPreferencesWithMarketingInApp(true); + const { controller, mockPutNotificationPreferences } = arrangeController( + false, + stored, + ); + + await controller.updateNotificationPreferences({ + perps: { inAppNotificationsEnabled: false }, + }); + + expect(mockPutNotificationPreferences).toHaveBeenCalledWith( + { + ...stored, + perps: { ...stored.perps, inAppNotificationsEnabled: false }, + }, + 'extension', + ); + }); + + it('saves marketing channels turned off without consent', async () => { + const { controller, mockPutNotificationPreferences } = arrangeController( + false, + mockPreferencesWithMarketingInApp(true), + ); + + await controller.updateNotificationPreferences({ + marketing: { inAppNotificationsEnabled: false }, + }); + + expect(mockPutNotificationPreferences).toHaveBeenCalledWith( + mockPreferences(), + 'extension', + ); + }); + + it('saves marketing channels turned on when the user has consented', async () => { + const { controller, mockPutNotificationPreferences } = + arrangeController(true); + + await controller.updateNotificationPreferences({ + marketing: { inAppNotificationsEnabled: true }, + }); + + expect(mockPutNotificationPreferences).toHaveBeenCalledWith( + mockPreferencesWithMarketingInApp(true), + 'extension', + ); + }); + + it.each([ + { pushNotificationsEnabled: true }, + { inAppNotificationsEnabled: true }, + ])( + 'rejects turning a marketing channel on without consent (%o)', + async (marketing) => { + const { + controller, + mockGetNotificationPreferences, + mockPutNotificationPreferences, + } = arrangeController(false); + + await expect( + controller.updateNotificationPreferences({ marketing }), + ).rejects.toThrow(MarketingConsentRequiredError); + await expect( + controller.updateNotificationPreferences({ marketing }), + ).rejects.toMatchObject({ + code: MARKETING_CONSENT_REQUIRED_ERROR_CODE, + }); + expect(mockGetNotificationPreferences).not.toHaveBeenCalled(); + expect(mockPutNotificationPreferences).not.toHaveBeenCalled(); + }, + ); + + it('throws without writing when preferences have not been set up yet', async () => { + const { controller, mockPutNotificationPreferences } = arrangeController( + false, + null, + ); + + await expect( + controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }), + ).rejects.toThrow('Notification preferences have not been set up yet'); + expect(mockPutNotificationPreferences).not.toHaveBeenCalled(); + }); + + it('runs updates one at a time, each merging over the previous one', async () => { + let stored = mockPreferences(); + const { + controller, + mockGetNotificationPreferences, + mockPutNotificationPreferences, + } = arrangeController(false); + mockGetNotificationPreferences.mockImplementation(async () => stored); + mockPutNotificationPreferences.mockImplementation( + async (preferences: NotificationPreferences) => { + stored = preferences; + }, + ); + + await Promise.all([ + controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }), + controller.updateNotificationPreferences({ + socialAI: { pushNotificationsEnabled: false }, + }), + ]); + + expect(stored.perps.pushNotificationsEnabled).toBe(false); + expect(stored.socialAI.pushNotificationsEnabled).toBe(false); + }); + + it('runs the next update after a failed one', async () => { + const { controller, mockPutNotificationPreferences } = + arrangeController(false); + mockPutNotificationPreferences.mockRejectedValueOnce( + new Error('AUS unavailable'), + ); + + const first = controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }); + const second = controller.updateNotificationPreferences({ + perps: { inAppNotificationsEnabled: false }, + }); + + await expect(first).rejects.toThrow('AUS unavailable'); + expect(await second).toStrictEqual( + mockPreferences({ + perps: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: true, + }, + }), + ); + }); + + it('re-reads preferences once, after the last queued update', async () => { + const order: string[] = []; + const { + controller, + mockInvalidateQueries, + mockGetNotificationPreferences, + mockPutNotificationPreferences, + } = arrangeController(false); + mockInvalidateQueries.mockImplementation(async () => { + order.push('invalidate'); + }); + mockGetNotificationPreferences.mockImplementation(async () => { + order.push('get'); + return mockPreferences(); + }); + mockPutNotificationPreferences.mockImplementation(async () => { + order.push('put'); + }); + + await Promise.all([ + controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }), + controller.updateNotificationPreferences({ + socialAI: { pushNotificationsEnabled: false }, + }), + ]); + + expect(order).toStrictEqual([ + 'invalidate', + 'get', + 'put', + 'invalidate', + 'get', + 'put', + 'get', + ]); + }); + + it('re-reads preferences when the last queued update is rejected', async () => { + const order: string[] = []; + const { + controller, + mockGetNotificationPreferences, + mockPutNotificationPreferences, + } = arrangeController(false); + mockGetNotificationPreferences.mockImplementation(async () => { + order.push('get'); + return mockPreferences(); + }); + mockPutNotificationPreferences.mockImplementation(async () => { + order.push('put'); + }); + + const saved = controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }); + const rejected = controller.updateNotificationPreferences({ + marketing: { pushNotificationsEnabled: true }, + }); + + expect(await saved).toBeDefined(); + await expect(rejected).rejects.toThrow(MarketingConsentRequiredError); + expect(order).toStrictEqual(['get', 'put', 'get']); + }); + + it('does not re-read preferences when nothing was written', async () => { + const { controller, mockGetNotificationPreferences } = + arrangeController(false); + + await expect( + controller.updateNotificationPreferences({ + marketing: { inAppNotificationsEnabled: true }, + }), + ).rejects.toThrow(MarketingConsentRequiredError); + + expect(mockGetNotificationPreferences).not.toHaveBeenCalled(); + }); + + it('returns the saved preferences when re-reading them fails', async () => { + const { controller, mockGetNotificationPreferences } = + arrangeController(false); + mockGetNotificationPreferences + .mockResolvedValueOnce(mockPreferences()) + .mockRejectedValueOnce(new Error('AUS unavailable')); + + expect( + await controller.updateNotificationPreferences({ + perps: { pushNotificationsEnabled: false }, + }), + ).toStrictEqual( + mockPreferences({ + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: false, + }, + }), + ); + }); + + it('is callable through the messenger', async () => { + const { globalMessenger, mockPutNotificationPreferences } = + arrangeController(false); + + await globalMessenger.call( + 'NotificationServicesController:updateNotificationPreferences', + { perps: { pushNotificationsEnabled: false } }, + ); + + expect(mockPutNotificationPreferences).toHaveBeenCalledTimes(1); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const { messenger } = mockNotificationMessenger(); @@ -2665,6 +3016,8 @@ function mockNotificationMessenger(): { mockKeyringControllerGetState: jest.Mock; mockGetNotificationPreferences: jest.Mock; mockPutNotificationPreferences: jest.Mock; + mockInvalidateQueries: jest.Mock; + mockAnalyticsControllerGetState: jest.Mock; } { const globalMessenger = getRootMessenger(); @@ -2690,8 +3043,10 @@ function mockNotificationMessenger(): { 'NotificationServicesPushController:deletePushNotificationLinks', 'NotificationServicesPushController:enablePushNotifications', 'NotificationServicesPushController:subscribeToPushNotifications', + 'AuthenticatedUserStorageService:invalidateQueries', 'AuthenticatedUserStorageService:getNotificationPreferences', 'AuthenticatedUserStorageService:putNotificationPreferences', + 'AnalyticsController:getState', ], events: [ 'KeyringController:stateChange', @@ -2757,6 +3112,16 @@ function mockNotificationMessenger(): { undefined, ); + const mockInvalidateQueries = + typedMockAction().mockResolvedValue( + undefined, + ); + + const mockAnalyticsControllerGetState = + typedMockAction().mockReturnValue({ + optedInToMarketing: false, + } as ReturnType); + jest.spyOn(messenger, 'call').mockImplementation((...args) => { const [actionType] = args; @@ -2830,6 +3195,18 @@ function mockNotificationMessenger(): { return mockPutNotificationPreferences(params[0], params[1]); } + if (actionType === 'AuthenticatedUserStorageService:invalidateQueries') { + return mockInvalidateQueries( + params[0] as Parameters< + AuthenticatedUserStorageInvalidateQueriesAction['handler'] + >[0], + ); + } + + if (actionType === 'AnalyticsController:getState') { + return mockAnalyticsControllerGetState(); + } + throw new Error( `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, ); @@ -2849,6 +3226,8 @@ function mockNotificationMessenger(): { mockKeyringControllerGetState, mockGetNotificationPreferences, mockPutNotificationPreferences, + mockInvalidateQueries, + mockAnalyticsControllerGetState, }; } diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index 888274eb6ce..e9bbc37e67a 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -1,4 +1,6 @@ +import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; import type { + AuthenticatedUserStorageInvalidateQueriesAction, AuthenticatedUserStorageServiceGetNotificationPreferencesAction, AuthenticatedUserStorageServicePutNotificationPreferencesAction, NotificationPreferences, @@ -86,6 +88,11 @@ export type NotificationServicesControllerState = { /** * Flag that indicates if the feature announcements are enabled + * + * @deprecated Feature announcements follow marketing consent: they are the + * AUS `marketing.inAppNotificationsEnabled` preference, seeded from + * `AnalyticsController` consent. Nothing in this controller reads this flag, + * and it will be removed. */ isFeatureAnnouncementsEnabled: boolean; @@ -196,15 +203,14 @@ export const defaultState: NotificationServicesControllerState = { export type NotificationServicesControllerEnableNotificationsOptions = { /** - * Whether the user has consented to marketing notifications. Used only when - * notification preferences are being initialized for the first time to seed - * marketing push notifications. + * @deprecated Ignored. Marketing notifications are seeded from + * `AnalyticsController` marketing consent. This option will be removed. */ hasMarketingConsent?: boolean; /** - * Whether product announcements are enabled. Used only when notification - * preferences are being initialized for the first time to seed marketing - * in-app notifications. + * @deprecated Ignored. Marketing in-app notifications (feature + * announcements) are seeded from `AnalyticsController` marketing consent. + * This option will be removed. */ productAnnouncementEnabled?: boolean; /** @@ -287,8 +293,7 @@ const getEnabledAccounts = async ( /** * Builds a fresh `NotificationPreferences` blob using hardcoded defaults for - * Perps, Social AI, and Agentic CLI and the user's - * marketing/product-announcement flags. + * Perps, Social AI, and Agentic CLI and the user's marketing consent. * * `walletActivity` is written only because the blob schema requires the field; * nothing reads it back. Both channels are always on, and subscriptions are @@ -296,13 +301,12 @@ const getEnabledAccounts = async ( * to a keyring, but this blob is keyed by canonical profile ID, which pairing * shares across every SRP belonging to the same user. * - * @param hasMarketingConsent - Whether marketing push notifications should be enabled. - * @param productAnnouncementEnabled - Whether marketing in-app notifications should be enabled. + * @param hasMarketingConsent - Whether the user consents to marketing. Both + * marketing channels (push, and in-app feature announcements) follow it. * @returns A complete `NotificationPreferences` object. */ const buildFreshPreferences = ( hasMarketingConsent: boolean, - productAnnouncementEnabled: boolean, ): NotificationPreferences => ({ walletActivity: { inAppNotificationsEnabled: true, @@ -310,7 +314,7 @@ const buildFreshPreferences = ( accounts: [], }, marketing: { - inAppNotificationsEnabled: productAnnouncementEnabled, + inAppNotificationsEnabled: hasMarketingConsent, pushNotificationsEnabled: hasMarketingConsent, }, perps: { ...DEFAULT_PERPS_PREFERENCES }, @@ -337,6 +341,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markMetamaskNotificationsAsRead', 'updateMetamaskNotificationsList', 'sendPerpPlaceOrderNotification', + 'updateNotificationPreferences', ] as const; export type NotificationServicesControllerGetStateAction = @@ -359,8 +364,11 @@ type AllowedActions = | AuthenticationController.AuthenticationControllerIsSignedInAction | AuthenticationController.AuthenticationControllerPerformSignInAction // Authenticated User Storage Requests + | AuthenticatedUserStorageInvalidateQueriesAction | AuthenticatedUserStorageServiceGetNotificationPreferencesAction | AuthenticatedUserStorageServicePutNotificationPreferencesAction + // Analytics Controller Requests + | AnalyticsControllerGetStateAction // Push Notifications Controller Requests | NotificationServicesPushControllerMethodActions; @@ -404,6 +412,66 @@ export type NotificationServicesControllerMessenger = Messenger< Events | AllowedEvents >; +/** + * The `code` of {@link MarketingConsentRequiredError}. + */ +export const MARKETING_CONSENT_REQUIRED_ERROR_CODE = + 'MARKETING_CONSENT_REQUIRED'; + +/** + * Thrown when marketing notifications are turned on without marketing + * consent. Ask the user for consent first, then retry. Match on `code`. + */ +export class MarketingConsentRequiredError extends Error { + readonly code = MARKETING_CONSENT_REQUIRED_ERROR_CODE; + + constructor() { + super('Marketing notifications require marketing consent'); + this.name = 'MarketingConsentRequiredError'; + } +} + +/** + * A change to notification preferences: any sections, and any fields within a + * section. Fields left out keep their stored value. Arrays and maps are + * replaced whole. + */ +export type NotificationPreferencesUpdate = { + [Section in keyof NotificationPreferences]?: Partial< + NotificationPreferences[Section] + >; +}; + +/** + * Applies an update over stored preferences. Sections and fields the update + * leaves out keep their stored value, and fields set to `undefined` are + * ignored, so a field can never be reset by leaving it out. + * + * @param storedPreferences - The preferences stored in AUS. + * @param update - The sections and fields the caller sent. + * @returns The preferences to write. + */ +const mergeNotificationPreferences = ( + storedPreferences: NotificationPreferences, + update: NotificationPreferencesUpdate, +): NotificationPreferences => { + const preferences = { ...storedPreferences }; + for (const [section, requestedFields] of Object.entries(update)) { + const changes = Object.fromEntries( + Object.entries(requestedFields ?? {}).filter( + ([, value]) => value !== undefined, + ), + ); + Object.assign(preferences, { + [section]: { + ...storedPreferences[section as keyof NotificationPreferences], + ...changes, + }, + }); + } + return preferences; +}; + type FeatureAnnouncementEnv = { spaceId: string; accessToken: string; @@ -722,6 +790,23 @@ export class NotificationServicesController extends BaseController< readonly #featureAnnouncementEnv: FeatureAnnouncementEnv; + /** + * The last queued preferences update. Updates run one at a time so each + * one merges over the result of the one before. + */ + #preferencesUpdate: Promise = Promise.resolve(); + + /** + * Preferences updates queued and not finished yet. + */ + #queuedPreferencesUpdates = 0; + + /** + * Whether a queued update has written preferences since they were last + * re-read. + */ + #preferencesWrittenSinceRefresh = false; + readonly #env: ENV; /** @@ -983,6 +1068,10 @@ export class NotificationServicesController extends BaseController< * * **Action** - used in the notification settings to enable/disable feature announcements. * + * @deprecated Feature announcements follow marketing consent (the AUS + * `marketing.inAppNotificationsEnabled` preference). Nothing in this + * controller reads the flag this sets, and it will be removed. + * * @param featureAnnouncementsEnabled - A boolean value indicating the desired enabled state of the feature announcements. * @async * @throws {Error} If fails to update @@ -1009,11 +1098,13 @@ export class NotificationServicesController extends BaseController< * {@link AuthenticatedUserStorageService} has no stored preferences yet. * Existing preferences are left as-is. * + * When preferences are initialized, both marketing channels (push, and + * in-app feature announcements) follow `AnalyticsController` marketing + * consent. + * * @param opts - optional options to mutate this functionality - * @param opts.hasMarketingConsent - The user's marketing-consent flag. - * Used only during initialization to seed marketing push notifications. - * @param opts.productAnnouncementEnabled - The user's product-announcement flag. - * Used only during initialization to seed marketing in-app notifications. + * @param opts.hasMarketingConsent - Deprecated and ignored. + * @param opts.productAnnouncementEnabled - Deprecated and ignored. * @param opts.registerPushNotifications - Whether to attempt FCM/device push registration. * @returns The updated or newly created user storage. * @throws {Error} Throws an error if unauthenticated or from other operations. @@ -1039,11 +1130,6 @@ export class NotificationServicesController extends BaseController< 'AuthenticatedUserStorageService:getNotificationPreferences', ); - const hasMarketingConsent = Boolean(opts?.hasMarketingConsent); - const productAnnouncementEnabled = Boolean( - opts?.productAnnouncementEnabled, - ); - const isFirstTimeSetup = preferences === null; // 2. Subscribe the keyring's accounts on first-time setup only. @@ -1083,12 +1169,14 @@ export class NotificationServicesController extends BaseController< // failed subscribe leave the user enabled with no accounts subscribed // and no second chance to seed them. if (isFirstTimeSetup) { + // Marketing notifications require marketing consent, read from its + // source rather than from the caller. + const { optedInToMarketing } = this.messenger.call( + 'AnalyticsController:getState', + ); await this.messenger.call( 'AuthenticatedUserStorageService:putNotificationPreferences', - buildFreshPreferences( - hasMarketingConsent, - productAnnouncementEnabled, - ), + buildFreshPreferences(optedInToMarketing === true), this.#featureAnnouncementEnv.platform, ); } @@ -1591,4 +1679,116 @@ export class NotificationServicesController extends BaseController< // Do Nothing } } + + /** + * Saves a change to the user's notification preferences in Authenticated + * User Storage. Sections and fields left out keep their stored value, so + * callers send only what they change. + * + * Marketing notifications require marketing consent, so an update that + * turns a marketing channel on while the user has not consented is + * rejected. Only the fields in the update are checked: a marketing channel + * already on in storage doesn't block updates to other preferences. Turning + * consent off turns marketing channels off on its own (see + * `MarketingConsentController`). + * + * Read preferences from `AuthenticatedUserStorageService:getNotificationPreferences` + * (e.g. through `@metamask/react-data-query`), whose cache keeps UIs up to + * date; write them through this method so the consent rule is enforced. + * + * @param update - The sections and fields to change. + * @returns The saved preferences. + * @throws {MarketingConsentRequiredError} If the update turns a marketing + * channel on without marketing consent. + * @throws If notification preferences have not been set up yet; they are + * created when notifications are first enabled. + */ + public async updateNotificationPreferences( + update: NotificationPreferencesUpdate, + ): Promise { + this.#queuedPreferencesUpdates += 1; + const result = this.#preferencesUpdate.then(async () => { + try { + return await this.#applyNotificationPreferencesUpdate(update); + } finally { + this.#queuedPreferencesUpdates -= 1; + // Re-read once the queue drains, not after every write: UIs bound to + // the AUS cache would otherwise briefly show a state without the + // updates still queued. + if ( + this.#queuedPreferencesUpdates === 0 && + this.#preferencesWrittenSinceRefresh + ) { + this.#preferencesWrittenSinceRefresh = false; + await this.#refreshNotificationPreferences(); + } + } + }); + this.#preferencesUpdate = result.catch(() => undefined); + return result; + } + + /** + * Re-reads notification preferences. A write only invalidates the cached + * preferences; re-reading them publishes the new value to UIs bound to the + * AUS cache. A failed re-read is logged; the UI catches up on its next read. + */ + async #refreshNotificationPreferences(): Promise { + await this.messenger + .call('AuthenticatedUserStorageService:getNotificationPreferences') + .catch((error: unknown) => { + log.error('Failed to refresh notification preferences', error); + }); + } + + /** + * Applies one queued preferences update. + * + * @param update - The sections and fields to change. + * @returns The saved preferences. + */ + async #applyNotificationPreferencesUpdate( + update: NotificationPreferencesUpdate, + ): Promise { + const { marketing } = update; + const { optedInToMarketing } = this.messenger.call( + 'AnalyticsController:getState', + ); + if ( + (marketing?.pushNotificationsEnabled === true || + marketing?.inAppNotificationsEnabled === true) && + optedInToMarketing !== true + ) { + throw new MarketingConsentRequiredError(); + } + + // Merge over a fresh read: the AUS cache can be a minute old, and a stale + // copy would bring back values changed since. + await this.messenger.call( + 'AuthenticatedUserStorageService:invalidateQueries', + { + queryKey: [ + 'AuthenticatedUserStorageService:getNotificationPreferences', + ], + }, + ); + const stored = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + // Stored preferences mark first-time setup as done (see + // `createOnChainTriggers`), so an update must never create them. + if (!stored) { + throw new Error('Notification preferences have not been set up yet'); + } + + const preferences = mergeNotificationPreferences(stored, update); + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + preferences, + this.#featureAnnouncementEnv.platform, + ); + this.#preferencesWrittenSinceRefresh = true; + + return preferences; + } } diff --git a/packages/notification-services-controller/src/NotificationServicesController/index.ts b/packages/notification-services-controller/src/NotificationServicesController/index.ts index 08fa0e6df85..ce0336dd61a 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/index.ts @@ -28,6 +28,7 @@ export type { NotificationServicesControllerFetchAndUpdateMetamaskNotificationsAction, NotificationServicesControllerGetNotificationsByTypeAction, NotificationServicesControllerDeleteNotificationByIdAction, + NotificationServicesControllerUpdateNotificationPreferencesAction, NotificationServicesControllerDeleteNotificationsByIdAction, NotificationServicesControllerMarkMetamaskNotificationsAsReadAction, NotificationServicesControllerUpdateMetamaskNotificationsListAction, diff --git a/packages/notification-services-controller/tsconfig.build.json b/packages/notification-services-controller/tsconfig.build.json index 90f84279096..cac34d4a4b8 100644 --- a/packages/notification-services-controller/tsconfig.build.json +++ b/packages/notification-services-controller/tsconfig.build.json @@ -6,6 +6,9 @@ "skipLibCheck": true }, "references": [ + { + "path": "../analytics-controller/tsconfig.build.json" + }, { "path": "../authenticated-user-storage/tsconfig.build.json" }, diff --git a/packages/notification-services-controller/tsconfig.json b/packages/notification-services-controller/tsconfig.json index b4c95866421..94109b1c4e7 100644 --- a/packages/notification-services-controller/tsconfig.json +++ b/packages/notification-services-controller/tsconfig.json @@ -4,6 +4,9 @@ "types": ["jest"] }, "references": [ + { + "path": "../analytics-controller" + }, { "path": "../authenticated-user-storage" }, diff --git a/packages/notification-services-controller/tsconfig.lint.json b/packages/notification-services-controller/tsconfig.lint.json index f0e5c6e6073..3e2027fb74a 100644 --- a/packages/notification-services-controller/tsconfig.lint.json +++ b/packages/notification-services-controller/tsconfig.lint.json @@ -5,6 +5,9 @@ "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" }, "references": [ + { + "path": "../analytics-controller/tsconfig.lint.json" + }, { "path": "../messenger/tsconfig.lint.json" }, diff --git a/teams.json b/teams.json index 96a9cf20052..661925808da 100644 --- a/teams.json +++ b/teams.json @@ -32,7 +32,7 @@ "metamask/money-account-utils": "team-earn", "metamask/money-account-api-data-service": "team-earn", "metamask/money-account-mfa-whitelist": "team-earn", - "metamask/notification-services-controller": "team-assets", + "metamask/notification-services-controller": "team-engagement", "metamask/marketing-consent-controller": "team-engagement", "metamask/compliance-controller": "team-perps", "metamask/perps-controller": "team-perps", diff --git a/yarn.lock b/yarn.lock index 734f2dcf5a5..e110580cf72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8230,6 +8230,7 @@ __metadata: "@contentful/rich-text-html-renderer": "npm:^16.6.10" "@lavamoat/allow-scripts": "npm:^5.1.0" "@lavamoat/preinstall-always-fail": "npm:^3.0.0" + "@metamask/analytics-controller": "npm:^4.0.0" "@metamask/authenticated-user-storage": "npm:^4.1.0" "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.1.0"