diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js
index c02c90e6d6..077ed1c65d 100644
--- a/packages/snaps-rpc-methods/jest.config.js
+++ b/packages/snaps-rpc-methods/jest.config.js
@@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, {
],
coverageThreshold: {
global: {
- branches: 92.88,
+ branches: 92.94,
functions: 97.26,
- lines: 97.84,
- statements: 97.36,
+ lines: 97.87,
+ statements: 97.39,
},
},
});
diff --git a/packages/snaps-rpc-methods/src/restricted/notify.test.ts b/packages/snaps-rpc-methods/src/restricted/notify.test.tsx
similarity index 55%
rename from packages/snaps-rpc-methods/src/restricted/notify.test.ts
rename to packages/snaps-rpc-methods/src/restricted/notify.test.tsx
index fc62b2854a..c78a28ad1a 100644
--- a/packages/snaps-rpc-methods/src/restricted/notify.test.ts
+++ b/packages/snaps-rpc-methods/src/restricted/notify.test.tsx
@@ -1,5 +1,6 @@
import { PermissionType, SubjectType } from '@metamask/permission-controller';
-import { NotificationType } from '@metamask/snaps-sdk';
+import { ContentType, NotificationType } from '@metamask/snaps-sdk';
+import { Box, Text } from '@metamask/snaps-sdk/jsx';
import {
getImplementation,
@@ -20,6 +21,7 @@ describe('snap_notify', () => {
showInAppNotification: jest.fn(),
isOnPhishingList: jest.fn(),
maybeUpdatePhishingList: jest.fn(),
+ createInterface: jest.fn(),
getSnap: jest.fn(),
};
@@ -44,12 +46,14 @@ describe('snap_notify', () => {
const isOnPhishingList = jest.fn().mockResolvedValueOnce(false);
const getSnap = jest.fn();
const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
const notificationImplementation = getImplementation({
showNativeNotification,
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
});
@@ -70,11 +74,57 @@ describe('snap_notify', () => {
});
});
+ it('shows inApp notifications with a detailed view', async () => {
+ const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
+ const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
+ const isOnPhishingList = jest.fn().mockResolvedValueOnce(false);
+ const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn().mockResolvedValueOnce(1);
+ const getSnap = jest.fn();
+
+ const notificationImplementation = getImplementation({
+ showNativeNotification,
+ showInAppNotification,
+ isOnPhishingList,
+ maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
+ });
+
+ await notificationImplementation({
+ context: {
+ origin: 'extension',
+ },
+ method: 'snap_notify',
+ params: {
+ type: NotificationType.InApp,
+ message: 'Some message',
+ title: 'Detailed view title',
+ content: Hello,
+ },
+ });
+
+ expect(showInAppNotification).toHaveBeenCalledWith('extension', {
+ type: NotificationType.InApp,
+ message: 'Some message',
+ title: 'Detailed view title',
+ content: 1,
+ });
+
+ expect(createInterface).toHaveBeenCalledWith(
+ 'extension',
+ Hello,
+ undefined,
+ ContentType.Notification,
+ );
+ });
+
it('shows native notification', async () => {
const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
const isOnPhishingList = jest.fn().mockResolvedValueOnce(false);
const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
const getSnap = jest.fn();
const notificationImplementation = getImplementation({
@@ -82,6 +132,7 @@ describe('snap_notify', () => {
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
});
@@ -107,6 +158,7 @@ describe('snap_notify', () => {
const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
const isOnPhishingList = jest.fn().mockResolvedValueOnce(false);
const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
const getSnap = jest.fn();
const notificationImplementation = getImplementation({
@@ -114,6 +166,7 @@ describe('snap_notify', () => {
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
});
@@ -139,6 +192,7 @@ describe('snap_notify', () => {
const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
const isOnPhishingList = jest.fn().mockResolvedValueOnce(false);
const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
const getSnap = jest.fn();
const notificationImplementation = getImplementation({
@@ -146,6 +200,7 @@ describe('snap_notify', () => {
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
});
@@ -163,12 +218,91 @@ describe('snap_notify', () => {
}),
).rejects.toThrow('Must specify a valid notification "type".');
});
+ });
+
+ describe('getValidatedParams', () => {
+ it('throws an error if the params is not an object', () => {
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ expect(() => getValidatedParams([], isOnPhishingList, jest.fn())).toThrow(
+ 'Expected params to be a single object.',
+ );
+ });
+
+ it('throws an error if the type is missing from params object', () => {
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ expect(() =>
+ getValidatedParams(
+ { type: undefined, message: 'Something happened.' },
+ isOnPhishingList,
+ jest.fn(),
+ ),
+ ).toThrow('Must specify a valid notification "type".');
+ });
+
+ it('throws an error if the message is empty', () => {
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ expect(() =>
+ getValidatedParams(
+ { type: NotificationType.InApp, message: '' },
+ isOnPhishingList,
+ jest.fn(),
+ ),
+ ).toThrow(
+ 'Must specify a non-empty string "message" less than 500 characters long.',
+ );
+ });
+
+ it('throws an error if the message is not a string', () => {
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ expect(() =>
+ getValidatedParams(
+ { type: NotificationType.InApp, message: 123 },
+ isOnPhishingList,
+ jest.fn(),
+ ),
+ ).toThrow(
+ 'Must specify a non-empty string "message" less than 500 characters long.',
+ );
+ });
+
+ it('throws an error if the message is larger than or equal to 50 characters for native notifications', () => {
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ expect(() =>
+ getValidatedParams(
+ {
+ type: NotificationType.Native,
+ message: 'test'.repeat(20),
+ },
+ isOnPhishingList,
+ jest.fn(),
+ ),
+ ).toThrow(
+ 'Must specify a non-empty string "message" less than 50 characters long.',
+ );
+ });
- it('throws an error if a link is on the phishing list', async () => {
+ it('throws an error if the message is larger than or equal to 500 characters for in app notifications', () => {
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ expect(() =>
+ getValidatedParams(
+ {
+ type: NotificationType.InApp,
+ message: 'test'.repeat(150),
+ },
+ isOnPhishingList,
+ jest.fn(),
+ ),
+ ).toThrow(
+ 'Must specify a non-empty string "message" less than 500 characters long.',
+ );
+ });
+
+ it('throws an error if a link in the `message` property is on the phishing list', async () => {
const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
- const isOnPhishingList = jest.fn().mockResolvedValueOnce(true);
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
const getSnap = jest.fn();
const notificationImplementation = getImplementation({
@@ -176,6 +310,7 @@ describe('snap_notify', () => {
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
});
@@ -186,18 +321,19 @@ describe('snap_notify', () => {
},
method: 'snap_notify',
params: {
- type: 'native',
+ type: 'inApp',
message: '[test link](https://foo.bar)',
},
}),
).rejects.toThrow('Invalid URL: The specified URL is not allowed.');
});
- it('throws an error if a link is invalid', async () => {
+ it('throws an error if a link in the `message` property is invalid', async () => {
const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
- const isOnPhishingList = jest.fn().mockResolvedValueOnce(true);
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
const getSnap = jest.fn();
const notificationImplementation = getImplementation({
@@ -205,6 +341,7 @@ describe('snap_notify', () => {
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
});
@@ -215,7 +352,7 @@ describe('snap_notify', () => {
},
method: 'snap_notify',
params: {
- type: 'native',
+ type: 'inApp',
message: '[test](http://foo.bar)',
},
}),
@@ -223,63 +360,94 @@ describe('snap_notify', () => {
'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.',
);
});
- });
- describe('getValidatedParams', () => {
- it('throws an error if the params is not an object', () => {
- expect(() => getValidatedParams([])).toThrow(
- 'Expected params to be a single object.',
- );
- });
+ it('throws an error if a link in the `footerLink` property is on the phishing list', async () => {
+ const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
+ const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
+ const getSnap = jest.fn();
- it('throws an error if the type is missing from params object', () => {
- expect(() =>
- getValidatedParams({ type: undefined, message: 'Something happened.' }),
- ).toThrow('Must specify a valid notification "type".');
- });
+ const notificationImplementation = getImplementation({
+ showNativeNotification,
+ showInAppNotification,
+ isOnPhishingList,
+ maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
+ });
- it('throws an error if the message is empty', () => {
- expect(() =>
- getValidatedParams({ type: NotificationType.InApp, message: '' }),
- ).toThrow(
- 'Must specify a non-empty string "message" less than 500 characters long.',
+ const content = (
+
+ Hello, world!
+
);
- });
- it('throws an error if the message is not a string', () => {
- expect(() =>
- getValidatedParams({ type: NotificationType.InApp, message: 123 }),
- ).toThrow(
- 'Must specify a non-empty string "message" less than 500 characters long.',
- );
+ await expect(
+ notificationImplementation({
+ context: {
+ origin: 'extension',
+ },
+ method: 'snap_notify',
+ params: {
+ type: 'inApp',
+ message: 'message',
+ footerLink: { href: 'https://www.metamask.io', text: 'test' },
+ title: 'A title',
+ content,
+ },
+ }),
+ ).rejects.toThrow('Invalid URL: The specified URL is not allowed.');
});
- it('throws an error if the message is larger than or equal to 50 characters for native notifications', () => {
- expect(() =>
- getValidatedParams({
- type: NotificationType.Native,
- message:
- 'test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg',
- }),
- ).toThrow(
- 'Must specify a non-empty string "message" less than 50 characters long.',
+ it('throws an error if a link in the `footerLink` property is invalid', async () => {
+ const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
+ const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
+ const isOnPhishingList = jest.fn().mockResolvedValue(true);
+ const maybeUpdatePhishingList = jest.fn();
+ const createInterface = jest.fn();
+ const getSnap = jest.fn();
+
+ const notificationImplementation = getImplementation({
+ showNativeNotification,
+ showInAppNotification,
+ isOnPhishingList,
+ maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
+ });
+
+ const content = (
+
+ Hello, world!
+
);
- });
- it('throws an error if the message is larger than or equal to 500 characters for in app notifications', () => {
- expect(() =>
- getValidatedParams({
- type: NotificationType.InApp,
- message:
- 'test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg',
+ await expect(
+ notificationImplementation({
+ context: {
+ origin: 'extension',
+ },
+ method: 'snap_notify',
+ params: {
+ type: 'inApp',
+ message: 'message',
+ footerLink: { href: 'http://foo.bar', text: 'test' },
+ title: 'A title',
+ content,
+ },
}),
- ).toThrow(
- 'Must specify a non-empty string "message" less than 500 characters long.',
+ ).rejects.toThrow(
+ 'Invalid params: Invalid URL: Protocol must be one of: https:, mailto:, metamask:.',
);
});
it('returns valid parameters', () => {
- expect(getValidatedParams(validParams)).toStrictEqual(validParams);
+ const isNotOnPhishingList = jest.fn().mockResolvedValueOnce(false);
+ expect(
+ getValidatedParams(validParams, isNotOnPhishingList, jest.fn()),
+ ).toStrictEqual(validParams);
});
});
});
diff --git a/packages/snaps-rpc-methods/src/restricted/notify.ts b/packages/snaps-rpc-methods/src/restricted/notify.ts
index 86d551951a..82e46a3033 100644
--- a/packages/snaps-rpc-methods/src/restricted/notify.ts
+++ b/packages/snaps-rpc-methods/src/restricted/notify.ts
@@ -5,31 +5,68 @@ import type {
} from '@metamask/permission-controller';
import { PermissionType, SubjectType } from '@metamask/permission-controller';
import { rpcErrors } from '@metamask/rpc-errors';
-import { NotificationType } from '@metamask/snaps-sdk';
import type {
NotifyParams,
NotifyResult,
- EnumToUnion,
+ InterfaceContext,
+ ComponentOrElement,
} from '@metamask/snaps-sdk';
-import { type Snap, validateTextLinks } from '@metamask/snaps-utils';
+import {
+ enumValue,
+ NotificationType,
+ union,
+ ContentType,
+ getErrorMessage,
+ ComponentOrElementStruct,
+} from '@metamask/snaps-sdk';
+import {
+ createUnion,
+ validateLink,
+ validateTextLinks,
+ type Snap,
+} from '@metamask/snaps-utils';
+import type { InferMatching } from '@metamask/snaps-utils';
+import { object, string, optional } from '@metamask/superstruct';
import type { NonEmptyArray } from '@metamask/utils';
-import { isObject } from '@metamask/utils';
+import { hasProperty, isObject } from '@metamask/utils';
import { type MethodHooksObject } from '../utils';
const methodName = 'snap_notify';
-export type NotificationArgs = {
- /**
- * Enum type to determine notification type.
- */
- type: EnumToUnion;
+const NativeNotificationStruct = object({
+ type: enumValue(NotificationType.Native),
+ message: string(),
+});
- /**
- * A message to show on the notification.
- */
- message: string;
-};
+const InAppNotificationStruct = object({
+ type: enumValue(NotificationType.InApp),
+ message: string(),
+});
+
+const InAppNotificationWithDetailsStruct = object({
+ type: enumValue(NotificationType.InApp),
+ message: string(),
+ content: ComponentOrElementStruct,
+ title: string(),
+ footerLink: optional(
+ object({
+ href: string(),
+ text: string(),
+ }),
+ ),
+});
+
+const NotificationParametersStruct = union([
+ InAppNotificationStruct,
+ InAppNotificationWithDetailsStruct,
+ NativeNotificationStruct,
+]);
+
+export type NotificationArgs = InferMatching<
+ typeof NotificationParametersStruct,
+ NotifyParams
+>;
export type NotifyMethodHooks = {
/**
@@ -54,6 +91,12 @@ export type NotifyMethodHooks = {
maybeUpdatePhishingList: () => Promise;
+ createInterface: (
+ origin: string,
+ content: ComponentOrElement,
+ context?: InterfaceContext,
+ contentType?: ContentType,
+ ) => Promise;
getSnap: (snapId: string) => Snap | undefined;
};
@@ -97,6 +140,7 @@ const methodHooks: MethodHooksObject = {
showInAppNotification: true,
isOnPhishingList: true,
maybeUpdatePhishingList: true,
+ createInterface: true,
getSnap: true,
};
@@ -114,6 +158,7 @@ export const notifyBuilder = Object.freeze({
* @param hooks.showInAppNotification - A function that shows a notification in the MetaMask UI.
* @param hooks.isOnPhishingList - A function that checks for links against the phishing list.
* @param hooks.maybeUpdatePhishingList - A function that updates the phishing list if needed.
+ * @param hooks.createInterface - A function that creates the interface in SnapInterfaceController.
* @param hooks.getSnap - A function that checks if a snap is installed.
* @returns The method implementation which returns `null` on success.
* @throws If the params are invalid.
@@ -123,6 +168,7 @@ export function getImplementation({
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
getSnap,
}: NotifyMethodHooks) {
return async function implementation(
@@ -133,11 +179,23 @@ export function getImplementation({
context: { origin },
} = args;
- const validatedParams = getValidatedParams(params);
-
await maybeUpdatePhishingList();
- validateTextLinks(validatedParams.message, isOnPhishingList, getSnap);
+ const validatedParams = getValidatedParams(
+ params,
+ isOnPhishingList,
+ getSnap,
+ );
+
+ if (hasProperty(validatedParams, 'content')) {
+ const id = await createInterface(
+ origin,
+ validatedParams.content as ComponentOrElement,
+ undefined,
+ ContentType.Notification,
+ );
+ validatedParams.content = id;
+ }
switch (validatedParams.type) {
case NotificationType.Native:
@@ -157,9 +215,16 @@ export function getImplementation({
* type. Throws if validation fails.
*
* @param params - The unvalidated params object from the method request.
+ * @param isOnPhishingList - The function that checks for links against the phishing list.
+ * @param getSnap - A function that checks if a snap is installed.
* @returns The validated method parameter object.
+ * @throws If the params are invalid.
*/
-export function getValidatedParams(params: unknown): NotifyParams {
+export function getValidatedParams(
+ params: unknown,
+ isOnPhishingList: NotifyMethodHooks['isOnPhishingList'],
+ getSnap: NotifyMethodHooks['getSnap'],
+): NotifyParams {
if (!isObject(params)) {
throw rpcErrors.invalidParams({
message: 'Expected params to be a single object.',
@@ -200,5 +265,23 @@ export function getValidatedParams(params: unknown): NotifyParams {
});
}
- return params as NotificationArgs;
+ try {
+ const validatedParams = createUnion(
+ params,
+ NotificationParametersStruct,
+ 'type',
+ );
+
+ validateTextLinks(validatedParams.message, isOnPhishingList, getSnap);
+
+ if (hasProperty(validatedParams, 'footerLink')) {
+ validateLink(validatedParams.footerLink.href, isOnPhishingList, getSnap);
+ }
+
+ return validatedParams;
+ } catch (error) {
+ throw rpcErrors.invalidParams({
+ message: `Invalid params: ${getErrorMessage(error)}`,
+ });
+ }
}
diff --git a/packages/snaps-sdk/src/types/methods/notify.ts b/packages/snaps-sdk/src/types/methods/notify.ts
index d9a5f7ca9b..baf48da8d1 100644
--- a/packages/snaps-sdk/src/types/methods/notify.ts
+++ b/packages/snaps-sdk/src/types/methods/notify.ts
@@ -1,4 +1,5 @@
-import type { EnumToUnion } from '../../internals';
+import { type EnumToUnion } from '../../internals';
+import type { ComponentOrElement } from '../interface';
/**
* The types of notifications that can be displayed.
@@ -17,10 +18,22 @@ export enum NotificationType {
* @property type - The type of notification to display.
* @property message - The message to display in the notification.
*/
-export type NotifyParams = {
- type: EnumToUnion;
- message: string;
-};
+export type NotifyParams =
+ | {
+ type: EnumToUnion;
+ message: string;
+ }
+ | {
+ type: EnumToUnion;
+ message: string;
+ }
+ | {
+ type: EnumToUnion;
+ message: string;
+ content: ComponentOrElement;
+ title: string;
+ footerLink?: { href: string; text: string };
+ };
/**
* The result returned by the `snap_notify` method.