Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
602241e
feat: update notification-services-controller to support new Segment …
zelkibuilds Jun 1, 2026
b765c4a
fix: getNotificationSubtype
zelkibuilds Jun 1, 2026
16656ed
chore: update comments after re-generating types
zelkibuilds Jun 1, 2026
e3aabce
test: add missing case to subtype helper suite
zelkibuilds Jun 1, 2026
5ec0e5b
chore: fix CI changelog check
zelkibuilds Jun 1, 2026
d94df65
feat: add subtype to base notification
zelkibuilds Jun 2, 2026
3400f69
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jun 2, 2026
e68d492
chore: cleanup
zelkibuilds Jun 2, 2026
81b5f29
feat: move toPushAnalytics from web to package utils
zelkibuilds Jun 3, 2026
5620ede
feat: update get subtype helper + notifications api schema
zelkibuilds Jun 9, 2026
b68e461
chore: update changelog
zelkibuilds Jun 9, 2026
31d7e96
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jun 9, 2026
97af197
fix: format CHANGELOG.md
zelkibuilds Jun 9, 2026
3af52b7
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jun 11, 2026
8e2437b
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jun 16, 2026
6cc5f39
fix: remove profile_id from push analytics
zelkibuilds Jun 16, 2026
8f815b0
test: remove profile_id from missing test
zelkibuilds Jun 16, 2026
24f6466
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jun 17, 2026
bb35b41
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jul 10, 2026
1590150
fix: handle new push while in-flight request
zelkibuilds Jul 10, 2026
3d7cd4b
fix: rename isOnChainNotification to isInboxAPINotification
zelkibuilds Jul 10, 2026
7e1a18c
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jul 10, 2026
069d5ce
Merge branch 'main' into feat/update-notifications-controller
zelkibuilds Jul 20, 2026
9211e3b
chore: fix changelog entry
zelkibuilds Jul 21, 2026
ae4885f
fix: rename isInboxAPINotification to isAPINotification
zelkibuilds Jul 21, 2026
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
15 changes: 15 additions & 0 deletions packages/notification-services-controller/CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `PushAnalyticsPayload` type carrying the first-class push notification fields (`notification_id`, `notification_type`, `notification_subtype`, `chain_id`, `deeplink`). ([#8944](https://github.com/MetaMask/core/pull/8944))
- Add `getNotificationSubtype` helper that derives a normalised `notification_subtype` from an `INotification`, so both clients pull the subtype from one place. ([#8944](https://github.com/MetaMask/core/pull/8944))
- Add a `notification_subtype` field to the generated platform notification schema (`PlatformNotification`), surfaced via `getNotificationSubtype` so platform notifications report their server-set subtype (e.g. `position_liquidated`) instead of the generic `platform` label. ([#8944](https://github.com/MetaMask/core/pull/8944))
- Export `toPushAnalyticsPayload` from `@metamask/notification-services-controller/push-services` so web and mobile clients can parse FCM analytics fields from a shared helper. ([#8944](https://github.com/MetaMask/core/pull/8944))

### Changed

- **BREAKING:** The `NotificationServicesPushController:onNewNotifications` and `NotificationServicesPushController:pushNotificationClicked` messenger events now carry `PushAnalyticsPayload` instead of `INotification`. ([#8944](https://github.com/MetaMask/core/pull/8944))
- The push payload no longer carries the full notification body; clients construct their analytics events directly from the first-class fields.
- The `onReceivedHandler` / `onClickHandler` callbacks passed to `createSubscribeToPushNotifications` now receive a `PushAnalyticsPayload` instead of an `INotification`.
- On push receive, the controller now re-fetches the notifications list from the API rather than inserting the push payload, since the push payload no longer contains the notification body. ([#8944](https://github.com/MetaMask/core/pull/8944))
- Bump `@metamask/authenticated-user-storage` from `^3.0.0` to `^3.0.1` ([#9458](https://github.com/MetaMask/core/pull/9458))
- Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463))

### Removed

- **BREAKING:** Remove the nested `data["data"]` / `metadata` FCM payload parsing path; push payloads are now read from the top-level FCM fields written by push-services. ([#8944](https://github.com/MetaMask/core/pull/8944))

### Fixed

- Update `isOnChainRawNotification` to detect on-chain notifications using the v4 `notification_type` discriminator instead of legacy payload field checks, fixing web push notification handling after the v4 API migration ([#9407](https://github.com/MetaMask/core/pull/9407))
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -453,6 +453,51 @@ describe('NotificationServicesController', () => {
expect(mockSubscribeToPushNotifications).not.toHaveBeenCalled();
});
});

it('queues one re-fetch (not concurrent) when pushes arrive while a fetch is in-flight', async () => {
const mocks = arrangeMocks();
let resolveFetch!: (v: INotification[]) => void;
const controller = new NotificationServicesController({
messenger: mocks.messenger,
env: { featureAnnouncements: featureAnnouncementsEnv },
state: { isNotificationServicesEnabled: true },
});
controller.init();

const fetchSpy = jest
.spyOn(controller, 'fetchAndUpdateMetamaskNotifications')
.mockImplementationOnce(
() =>
new Promise<INotification[]>((resolve) => {
resolveFetch = resolve;
}),
)
.mockResolvedValue([]);

// First push — starts the in-flight fetch
mocks.globalMessenger.publish(
'NotificationServicesPushController:onNewNotifications',
[],
);
expect(fetchSpy).toHaveBeenCalledTimes(1);

// Second and third pushes arrive while fetch is still in-flight — both coalesced into one pending refetch
mocks.globalMessenger.publish(
'NotificationServicesPushController:onNewNotifications',
[],
);
mocks.globalMessenger.publish(
'NotificationServicesPushController:onNewNotifications',
[],
);
expect(fetchSpy).toHaveBeenCalledTimes(1);

// Resolve the first fetch — should trigger exactly one queued re-fetch
resolveFetch([]);
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
});

// See /utils for more in-depth testing
Expand DownExpand Up@@ -1242,6 +1287,7 @@ describe('NotificationServicesController', () => {
expect(filteredNotifications).toStrictEqual([
{
type: TRIGGER_TYPES.SNAP,
notification_subtype: TRIGGER_TYPES.SNAP,
id: expect.any(String),
createdAt: expect.any(String),
isRead: false,
Expand DownExpand Up@@ -1611,6 +1657,7 @@ describe('NotificationServicesController', () => {
expect(controller.state.metamaskNotificationsList).toStrictEqual([
{
type: TRIGGER_TYPES.SNAP,
notification_subtype: TRIGGER_TYPES.SNAP,
id: expect.any(String),
createdAt: expect.any(String),
readDate: null,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -526,11 +526,34 @@ export class NotificationServicesController extends BaseController<
}
},
subscribe: (): void => {
// Coalesce pushes: at most one fetch in-flight, one queued.
// Re-fetch after completion because the new row may not be visible yet when the push arrives.
let pushFetchInFlight = false;
let pendingPushRefetch = false;

const fetchOnPush = async (): Promise<void> => {
pushFetchInFlight = true;
try {
await this.fetchAndUpdateMetamaskNotifications();
} finally {
pushFetchInFlight = false;
if (pendingPushRefetch) {
pendingPushRefetch = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
fetchOnPush();
}
}
};

this.messenger.subscribe(
'NotificationServicesPushController:onNewNotifications',
(notification): void => {
(): void => {
if (pushFetchInFlight) {
pendingPushRefetch = true;
return;
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateMetamaskNotificationsList(notification);
fetchOnPush();
},
);
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ export * from './constants';
export * as Mocks from './mocks';
export * from '../shared';
export { isVersionInBounds } from './utils/isVersionInBounds';
export { getNotificationSubtype } from './utils/get-notification-subtype';

export type {
NotificationServicesControllerInitAction,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { NormalisedAPINotification } from '../types/notification-api/notification-api';
import type { INotification } from '../types/notification/notification';
import { getNotificationSubtype } from '../utils/get-notification-subtype';
import { shouldAutoExpire } from '../utils/should-auto-expire';

/**
Expand All@@ -17,6 +18,7 @@ export function processAPINotifications(
return {
...notification,
id: notification.id,
notification_subtype: getNotificationSubtype(notification),
createdAt: createdAtDate.toISOString(),
isRead: expired || !notification.unread,
};
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement';
import type { INotification } from '../types/notification/notification';
import { getNotificationSubtype } from '../utils/get-notification-subtype';
import { shouldAutoExpire } from '../utils/should-auto-expire';

/**
Expand DownExpand Up@@ -32,6 +33,7 @@ export function processFeatureAnnouncement(
return {
type: notification.type,
id: notification.data.id,
notification_subtype: getNotificationSubtype(notification),
createdAt: new Date(notification.createdAt).toISOString(),
data: notification.data,
isRead: false,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
} from './process-feature-announcement';
import { processSnapNotification } from './process-snap-notifications';

const isOnChainNotification = (
const isAPINotification = (
notification: RawNotificationUnion,
): notification is NormalisedAPINotification =>
NOTIFICATION_API_TRIGGER_TYPES_SET.has(notification.type);
Expand DownExpand Up@@ -61,7 +61,7 @@ export function processNotification(
return processSnapNotification(notification);
}

if (isOnChainNotification(notification)) {
if (isAPINotification(notification)) {
return processAPINotifications(notification);
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid';

import type { INotification } from '../types';
import type { RawSnapNotification } from '../types/snaps';
import { getNotificationSubtype } from '../utils/get-notification-subtype';

/**
* Processes a snap notification into a normalized shape.
Expand All@@ -15,6 +16,7 @@ export const processSnapNotification = (
const { data, type, readDate } = snapNotification;
return {
id: uuid(),
notification_subtype: getNotificationSubtype(snapNotification),
readDate,
createdAt: new Date().toISOString(),
isRead: false,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type { Compute } from '../type-utils';

export type BaseNotification = {
id: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
notification_subtype: string;
createdAt: string;
isRead: boolean;
};
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
import { TRIGGER_TYPES } from '../constants/notification-schema';
import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements';
import {
createMockNotificationEthReceived,
createMockPlatformNotification,
} from '../mocks/mock-raw-notifications';
import { createMockSnapNotification } from '../mocks/mock-snap-notification';
import { processNotification } from '../processors/process-notifications';
import { getNotificationSubtype } from './get-notification-subtype';

describe('getNotificationSubtype', () => {
it('returns the trigger kind for on-chain notifications', () => {
const notification = processNotification(
createMockNotificationEthReceived(),
);
expect(getNotificationSubtype(notification)).toBe(
TRIGGER_TYPES.ETH_RECEIVED,
);
});

it('returns the server-set notification_subtype for platform notifications', () => {
const notification = processNotification(createMockPlatformNotification());
expect(getNotificationSubtype(notification)).toBe('position_liquidated');
});

it('returns the snap subtype for snap notifications', () => {
const notification = processNotification(createMockSnapNotification());
expect(getNotificationSubtype(notification)).toBe(TRIGGER_TYPES.SNAP);
});

it('returns a stable label for feature-announcement notifications', () => {
const notification = processNotification(
createMockFeatureAnnouncementRaw(),
);
expect(getNotificationSubtype(notification)).toBe(
TRIGGER_TYPES.FEATURES_ANNOUNCEMENT,
);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { isOnChainRawNotification } from '../../shared/is-onchain-notification';
import { TRIGGER_TYPES } from '../constants/notification-schema';
import type { RawNotificationUnion } from '../types/notification/notification';

/**
* Derives the normalised `notification_subtype` for a processed in-app
* notification. This is the team-owned axis (e.g. `eth_received`) and is
* always derivable from an `INotification`, so every consumer (both clients)
* pulls it from one place rather than recomputing a fallback chain.
*
* - on-chain: the trigger kind (`payload.data.kind`, e.g. `eth_received`).
* - platform: the server-set `notification_subtype` from the inbox API.
* - everything else (snap, feature-announcement): the top-level `type`
* (`snap` / `features_announcement`).
*
* @param notification - a raw or processed notification.
* @returns the normalised subtype string.
*/
export function getNotificationSubtype(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth adding a small getNotificationType helper to avoid the confusion between subtypes and types in the clients?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getNotificationSubtype exists because notification_subtype needs to be derived and has to be computed from different source fields depending on the shape. notification.type, on the other hand, is always set upstream and directly readable on every notification shape, so there's nothing to derive unless I'm missing some context.

Would getNotificationType mainly be for ergonomics? Giving clients a consistent helper to read type the same way they'd call getNotificationSubtype for subtype?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's my suggestion. But feel free not to add it if you don't find it relevant

notification: RawNotificationUnion,
): string {
// On-chain: the trigger kind (e.g. `eth_received`).
if (isOnChainRawNotification(notification)) {
return notification.payload.data.kind;
}

// Platform: the server-set `notification_subtype` from the inbox API.
if (notification.type === TRIGGER_TYPES.PLATFORM) {
return notification.notification_subtype;
Comment thread
cursor[bot] marked this conversation as resolved.
}

// Fallback (snap, feature-announcement): the top-level `type`.
return notification.type;
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,6 @@ import type { Messenger } from '@metamask/messenger';
import type { AuthenticationController } from '@metamask/profile-sync-controller';
import log from 'loglevel';

import type { Types } from '../NotificationServicesController';
import type { NotificationServicesPushControllerMethodActions } from './NotificationServicesPushController-method-action-types';
import type { ENV } from './services/endpoints';
import type { RegToken } from './services/services';
Expand All@@ -18,7 +17,7 @@ import {
deactivatePushNotifications,
updateLinksAPI,
} from './services/services';
import type { PushNotificationEnv } from './types';
import type { PushAnalyticsPayload, PushNotificationEnv } from './types';
import type { PushService } from './types/push-service-interface';

const controllerName = 'NotificationServicesPushController';
Expand DownExpand Up@@ -59,12 +58,12 @@ export type NotificationServicesPushControllerStateChangeEvent =

export type NotificationServicesPushControllerOnNewNotificationEvent = {
type: `${typeof controllerName}:onNewNotifications`;
payload: [Types.INotification];
payload: [PushAnalyticsPayload];
};

export type NotificationServicesPushControllerPushNotificationClickedEvent = {
type: `${typeof controllerName}:pushNotificationClicked`;
payload: [Types.INotification];
payload: [PushAnalyticsPayload];
};

export type Events =
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
export type * from './firebase';
export type * from './push-analytics';
export type * from './push-service-interface';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
// snake_case mirrors the FCM payload and Segment schema keys
/* eslint-disable @typescript-eslint/naming-convention */

/**
* Analytics fields carried by the `NotificationServicesPushController` messenger
* events (`onNewNotifications`, `pushNotificationClicked`). Read directly from
* top-level FCM payload keys, so clients build Segment events without fallback
* chains or parsing a `metadata` blob.
*/
export type PushAnalyticsPayload = {
notification_id: string;
/** Free-form snake_case label set by the producer. */
notification_type: string;
/** Team-owned, open-ended (e.g. `eth_received`). */
notification_subtype: string;
/** Only present when the notification has a chain context. */
chain_id?: number;
/** Platform notifications only; the CTA link to route to on tap. */
deeplink?: string;
};
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
export * from './get-notification-data';
export * from './get-notification-message';
export * from './to-push-analytics-payload';
Loading