diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json
index d0e563b2c0..2214c8ea2e 100644
--- a/packages/examples/packages/browserify-plugin/snap.manifest.json
+++ b/packages/examples/packages/browserify-plugin/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "jwgw2+/MUcLjedTMSpFqL/dhwR8/j3gHWmNJVevvfWE=",
+ "shasum": "Rvk4nDRt8bofy5oUtavVGwVB0pnZ7BdsmO9V315Qs+w=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json
index 10e6d77211..e8a3a4c059 100644
--- a/packages/examples/packages/browserify/snap.manifest.json
+++ b/packages/examples/packages/browserify/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "u+bnPdpw6us0nDDr3k28cfDoIA+5CT1Y7A88nYJLNGk=",
+ "shasum": "3LsbiHzT2I8gHJPQCu6JbTiKKCy5XA2VQQdW9Qkn3XU=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json
index 34b7a7350f..194585d11c 100644
--- a/packages/snaps-controllers/coverage.json
+++ b/packages/snaps-controllers/coverage.json
@@ -1,6 +1,6 @@
{
"branches": 92.6,
- "functions": 96.95,
- "lines": 98.02,
- "statements": 97.72
+ "functions": 96.65,
+ "lines": 97.97,
+ "statements": 97.67
}
diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts
index fb323471f5..70cb81abdd 100644
--- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts
+++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts
@@ -25,6 +25,7 @@ import { assert } from '@metamask/utils';
import { castDraft } from 'immer';
import { nanoid } from 'nanoid';
+import type { GetSnap } from '../snaps';
import {
constructState,
getJsxInterface,
@@ -74,7 +75,8 @@ export type SnapInterfaceControllerAllowedActions =
| TestOrigin
| MaybeUpdateState
| HasApprovalRequest
- | AcceptRequest;
+ | AcceptRequest
+ | GetSnap;
export type SnapInterfaceControllerActions =
| CreateInterface
@@ -379,6 +381,10 @@ export class SnapInterfaceController extends BaseController<
);
await this.#triggerPhishingListUpdate();
- validateJsxLinks(element, this.#checkPhishingList.bind(this));
+ validateJsxLinks(
+ element,
+ this.#checkPhishingList.bind(this),
+ (id: string) => this.messagingSystem.call('SnapController:get', id),
+ );
}
}
diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js
index 964b05f888..e48443e6ec 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.62,
+ branches: 92.68,
functions: 97.17,
- lines: 97.67,
- statements: 97.16,
+ lines: 97.71,
+ statements: 97.21,
},
},
});
diff --git a/packages/snaps-rpc-methods/src/restricted/notify.test.ts b/packages/snaps-rpc-methods/src/restricted/notify.test.tsx
similarity index 53%
rename from packages/snaps-rpc-methods/src/restricted/notify.test.ts
rename to packages/snaps-rpc-methods/src/restricted/notify.test.tsx
index aeecba1a38..cb1482ee8b 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 { Box, Text } from '@metamask/snaps-sdk/jsx';
import {
getImplementation,
@@ -20,6 +21,8 @@ describe('snap_notify', () => {
showInAppNotification: jest.fn(),
isOnPhishingList: jest.fn(),
maybeUpdatePhishingList: jest.fn(),
+ createInterface: jest.fn(),
+ getSnap: jest.fn(),
};
expect(
@@ -41,13 +44,17 @@ describe('snap_notify', () => {
const showNativeNotification = jest.fn().mockResolvedValueOnce(true);
const showInAppNotification = jest.fn().mockResolvedValueOnce(true);
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,
});
await notificationImplementation({
@@ -67,17 +74,64 @@ 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,
+ );
+ });
+
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({
showNativeNotification,
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
});
await notificationImplementation({
@@ -102,12 +156,16 @@ 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({
showNativeNotification,
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
});
await notificationImplementation({
@@ -132,12 +190,16 @@ 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({
showNativeNotification,
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
});
await expect(
@@ -154,18 +216,100 @@ 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 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 is on the phishing list', async () => {
+ 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({
showNativeNotification,
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
});
await expect(
@@ -175,24 +319,28 @@ 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({
showNativeNotification,
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
});
await expect(
@@ -202,71 +350,102 @@ describe('snap_notify', () => {
},
method: 'snap_notify',
params: {
- type: 'native',
+ type: 'inApp',
message: '[test](http://foo.bar)',
},
}),
).rejects.toThrow(
- 'Invalid URL: Protocol must be one of: https:, mailto:.',
+ '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 8c4c11f882..5f5feb36c8 100644
--- a/packages/snaps-rpc-methods/src/restricted/notify.ts
+++ b/packages/snaps-rpc-methods/src/restricted/notify.ts
@@ -5,31 +5,67 @@ 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 { enumValue, NotificationType, union } from '@metamask/snaps-sdk';
import type {
NotifyParams,
NotifyResult,
- EnumToUnion,
+ NotificationComponent,
} from '@metamask/snaps-sdk';
-import { validateTextLinks } from '@metamask/snaps-utils';
+import { NotificationComponentsStruct } from '@metamask/snaps-sdk/jsx';
+import {
+ createUnion,
+ validateLink,
+ validateTextLinks,
+ type Snap,
+} from '@metamask/snaps-utils';
+import type { InferMatching } from '@metamask/snaps-utils';
+import { object, string } 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: NotificationComponentsStruct,
+ title: string(),
+});
+
+const InAppNotificationWithDetailsAndFooterStruct = object({
+ type: enumValue(NotificationType.InApp),
+ message: string(),
+ content: NotificationComponentsStruct,
+ title: string(),
+ footerLink: object({
+ href: string(),
+ text: string(),
+ }),
+});
+
+const NotificationParametersStruct = union([
+ InAppNotificationStruct,
+ InAppNotificationWithDetailsStruct,
+ InAppNotificationWithDetailsAndFooterStruct,
+ NativeNotificationStruct,
+]);
+
+export type NotificationParameters = InferMatching<
+ typeof NotificationParametersStruct,
+ NotifyParams
+>;
export type NotifyMethodHooks = {
/**
@@ -38,7 +74,7 @@ export type NotifyMethodHooks = {
*/
showNativeNotification: (
snapId: string,
- args: NotificationArgs,
+ args: NotificationParameters,
) => Promise;
/**
@@ -47,12 +83,18 @@ export type NotifyMethodHooks = {
*/
showInAppNotification: (
snapId: string,
- args: NotificationArgs,
+ args: NotificationParameters,
) => Promise;
isOnPhishingList: (url: string) => boolean;
maybeUpdatePhishingList: () => Promise;
+
+ createInterface: (
+ origin: string,
+ content: NotificationComponent,
+ ) => Promise;
+ getSnap: (snapId: string) => Snap | undefined;
};
type SpecificationBuilderOptions = {
@@ -95,6 +137,8 @@ const methodHooks: MethodHooksObject = {
showInAppNotification: true,
isOnPhishingList: true,
maybeUpdatePhishingList: true,
+ createInterface: true,
+ getSnap: true,
};
export const notifyBuilder = Object.freeze({
@@ -111,6 +155,8 @@ 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.
*/
@@ -119,6 +165,8 @@ export function getImplementation({
showInAppNotification,
isOnPhishingList,
maybeUpdatePhishingList,
+ createInterface,
+ getSnap,
}: NotifyMethodHooks) {
return async function implementation(
args: RestrictedMethodOptions,
@@ -128,11 +176,22 @@ export function getImplementation({
context: { origin },
} = args;
- const validatedParams = getValidatedParams(params);
-
await maybeUpdatePhishingList();
- validateTextLinks(validatedParams.message, isOnPhishingList);
+ const validatedParams = getValidatedParams(
+ params,
+ isOnPhishingList,
+ getSnap,
+ );
+
+ let id;
+ if (hasProperty(validatedParams, 'content')) {
+ id = await createInterface(
+ origin,
+ validatedParams.content as NotificationComponent,
+ );
+ validatedParams.content = id;
+ }
switch (validatedParams.type) {
case NotificationType.Native:
@@ -152,9 +211,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.',
@@ -195,5 +261,28 @@ 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')) {
+ validateTextLinks(
+ validatedParams.footerLink.text,
+ isOnPhishingList,
+ getSnap,
+ );
+ validateLink(validatedParams.footerLink.href, isOnPhishingList, getSnap);
+ }
+
+ return validatedParams;
+ } catch (error) {
+ throw rpcErrors.invalidParams({
+ message: `Invalid params: ${error.message}`,
+ });
+ }
}
diff --git a/packages/snaps-sdk/src/jsx/components/Link.test.tsx b/packages/snaps-sdk/src/jsx/components/Link.test.tsx
index a28fedd27f..31752f1a07 100644
--- a/packages/snaps-sdk/src/jsx/components/Link.test.tsx
+++ b/packages/snaps-sdk/src/jsx/components/Link.test.tsx
@@ -1,3 +1,4 @@
+import { Icon } from './Icon';
import { Link } from './Link';
describe('Link', () => {
@@ -27,6 +28,27 @@ describe('Link', () => {
});
});
+ it('renders a link with an icon', () => {
+ const result = (
+
+
+
+ );
+
+ expect(result).toStrictEqual({
+ type: 'Link',
+ key: null,
+ props: {
+ href: 'metamask://client/',
+ children: {
+ type: 'Icon',
+ key: null,
+ props: { name: 'arrow-left', size: 'md' },
+ },
+ },
+ });
+ });
+
it('renders a link with a conditional value', () => {
const result = (
diff --git a/packages/snaps-sdk/src/jsx/components/Link.ts b/packages/snaps-sdk/src/jsx/components/Link.ts
index 047ba7b0ed..b9698536f6 100644
--- a/packages/snaps-sdk/src/jsx/components/Link.ts
+++ b/packages/snaps-sdk/src/jsx/components/Link.ts
@@ -1,11 +1,15 @@
import type { SnapsChildren } from '../component';
import { createSnapComponent } from '../component';
import type { StandardFormattingElement } from './formatting';
+import { type IconElement } from './Icon';
+import { type ImageElement } from './Image';
/**
* The children of the {@link Link} component.
*/
-export type LinkChildren = SnapsChildren;
+export type LinkChildren = SnapsChildren<
+ string | StandardFormattingElement | IconElement | ImageElement
+>;
/**
* The props of the {@link Link} component.
diff --git a/packages/snaps-sdk/src/jsx/index.ts b/packages/snaps-sdk/src/jsx/index.ts
index b5cb2e6a51..651d253e37 100644
--- a/packages/snaps-sdk/src/jsx/index.ts
+++ b/packages/snaps-sdk/src/jsx/index.ts
@@ -11,4 +11,5 @@ export {
BoxChildStruct,
FormChildStruct,
FieldChildUnionStruct,
+ NotificationComponentsStruct,
} from './validation';
diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx
index 8c6137e70b..c0ec8df9a6 100644
--- a/packages/snaps-sdk/src/jsx/validation.test.tsx
+++ b/packages/snaps-sdk/src/jsx/validation.test.tsx
@@ -68,6 +68,7 @@ import {
IconStruct,
SelectorStruct,
SectionStruct,
+ NotificationComponentsStruct,
} from './validation';
describe('KeyStruct', () => {
@@ -543,6 +544,87 @@ describe('BoxStruct', () => {
});
});
+describe('NotificationComponentsStruct', () => {
+ it.each([
+
+ foo
+ ,
+
+ foo
+ bar
+ ,
+
+ foo
+
+
+
+ ,
+
+ foo
+
+
+
+ ,
+
+ foo
+
+
+
+ ,
+
+ Foo
+ {[1, 2, 3, 4, 5].map((value) => (
+ {value.toString()}
+ ))}
+ ,
+ foo,
+
+
+
,
+ ])(
+ "validates content returned for a notification's detailed view",
+ (value) => {
+ expect(is(value, NotificationComponentsStruct)).toBe(true);
+ },
+ );
+
+ it.each([
+ 'foo',
+ 42,
+ null,
+ undefined,
+ {},
+ [],
+ // @ts-expect-error - Invalid props.
+
+ foo
+
+
+
+ ,
+ // @ts-expect-error - Invalid props.
+
+ foo
+
+
+
+ ,
+
+
+ ,
+
+
+ ,
+ ])('does not validate "%p"', (value) => {
+ expect(is(value, NotificationComponentsStruct)).toBe(false);
+ });
+});
+
describe('FooterStruct', () => {
it.each([