diff --git a/packages/examples/packages/notifications/snap.config.ts b/packages/examples/packages/notifications/snap.config.ts
index fdc400ca35..a48618dfa8 100644
--- a/packages/examples/packages/notifications/snap.config.ts
+++ b/packages/examples/packages/notifications/snap.config.ts
@@ -1,7 +1,7 @@
import type { SnapConfig } from '@metamask/snaps-cli';
const config: SnapConfig = {
- input: './src/index.ts',
+ input: './src/index.tsx',
server: {
port: 8016,
},
diff --git a/packages/examples/packages/notifications/snap.manifest.json b/packages/examples/packages/notifications/snap.manifest.json
index aa47593a39..aa01ccd9f8 100644
--- a/packages/examples/packages/notifications/snap.manifest.json
+++ b/packages/examples/packages/notifications/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "jrOzxCG7RKSqVS0TPw4sZiUoB4A00AXqdfkA5iGJ94g=",
+ "shasum": "DV0AnwJKM1iKxKscfZOjr/y8qAKP0tdp8wU+2PBMxk8=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/notifications/src/index.test.ts b/packages/examples/packages/notifications/src/index.test.tsx
similarity index 63%
rename from packages/examples/packages/notifications/src/index.test.ts
rename to packages/examples/packages/notifications/src/index.test.tsx
index 950bdddb79..d796688dda 100644
--- a/packages/examples/packages/notifications/src/index.test.ts
+++ b/packages/examples/packages/notifications/src/index.test.tsx
@@ -1,6 +1,7 @@
import { expect } from '@jest/globals';
import { installSnap } from '@metamask/snaps-jest';
import { NotificationType } from '@metamask/snaps-sdk';
+import { Address, Box, Row } from '@metamask/snaps-sdk/jsx';
describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
@@ -38,6 +39,34 @@ describe('onRpcRequest', () => {
});
});
+ describe('inApp-expanded', () => {
+ it('sends an expanded view notification', async () => {
+ const { request } = await installSnap();
+
+ const response = await request({
+ method: 'inApp-expanded',
+ origin: 'Jest',
+ });
+
+ expect(response).toRespondWith(null);
+ expect(response).toSendNotification(
+ 'Hello from MetaMask, click here for an expanded view!',
+ NotificationType.InApp,
+ 'Hello World!',
+
+
+
+
+ ,
+ { text: 'Go home', href: 'metamask://client/' },
+ );
+ });
+ });
+
describe('native', () => {
it('sends a native notification', async () => {
const { request } = await installSnap();
diff --git a/packages/examples/packages/notifications/src/index.ts b/packages/examples/packages/notifications/src/index.tsx
similarity index 70%
rename from packages/examples/packages/notifications/src/index.ts
rename to packages/examples/packages/notifications/src/index.tsx
index 03cb37a4be..399b562a9a 100644
--- a/packages/examples/packages/notifications/src/index.ts
+++ b/packages/examples/packages/notifications/src/index.tsx
@@ -1,5 +1,6 @@
import { MethodNotFoundError, NotificationType } from '@metamask/snaps-sdk';
import type { OnRpcRequestHandler } from '@metamask/snaps-sdk';
+import { Box, Row, Address } from '@metamask/snaps-sdk/jsx';
/**
* Handle incoming JSON-RPC requests from the dapp, sent through the
@@ -39,6 +40,28 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
},
});
+ case 'inApp-expanded':
+ return await snap.request({
+ method: 'snap_notify',
+ params: {
+ type: NotificationType.InApp,
+ message: 'Hello from MetaMask, click here for an expanded view!',
+ title: 'Hello World!',
+ content: (
+
+
+
+
+
+ ),
+ footerLink: { text: 'Go home', href: 'metamask://client/' },
+ },
+ });
+
default:
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw new MethodNotFoundError({ method: request.method });
diff --git a/packages/examples/packages/notifications/tsconfig.json b/packages/examples/packages/notifications/tsconfig.json
index 17a40a6a74..2ee6d93be5 100644
--- a/packages/examples/packages/notifications/tsconfig.json
+++ b/packages/examples/packages/notifications/tsconfig.json
@@ -1,8 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
- "composite": false,
- "baseUrl": "./"
+ "baseUrl": "./",
+ "composite": false
},
"include": ["src", "snap.config.ts"]
}
diff --git a/packages/snaps-jest/src/global.ts b/packages/snaps-jest/src/global.ts
index 288f675efa..979a164471 100644
--- a/packages/snaps-jest/src/global.ts
+++ b/packages/snaps-jest/src/global.ts
@@ -5,6 +5,7 @@ import type {
NotificationType,
ComponentOrElement,
} from '@metamask/snaps-sdk';
+import type { JSXElement } from '@metamask/snaps-sdk/jsx';
interface SnapsMatchers {
/**
@@ -42,8 +43,10 @@ interface SnapsMatchers {
* `expect(response.notifications).toContainEqual({ message, type })`.
*
* @param message - The expected notification message.
- * @param type - The expected notification type, i.e., 'inApp' or 'native'. If
- * not provided, the type will be ignored.
+ * @param type - The expected notification type, i.e., 'inApp' or 'native'.
+ * @param title - The title of an expanded notification.
+ * @param content - The content of an expanded notification.
+ * @param footerLink - The footer link of an expanded notification (if it exists).
* @throws If the snap did not send a notification with the expected message.
* @example
* const response = await request({ method: 'foo' });
@@ -51,7 +54,10 @@ interface SnapsMatchers {
*/
toSendNotification(
message: string,
- type?: EnumToUnion,
+ type: EnumToUnion,
+ title?: string,
+ content?: JSXElement,
+ footerLink?: { text: string; href: string },
): void;
/**
diff --git a/packages/snaps-jest/src/matchers.test.tsx b/packages/snaps-jest/src/matchers.test.tsx
index 6adecea449..41f917afbd 100644
--- a/packages/snaps-jest/src/matchers.test.tsx
+++ b/packages/snaps-jest/src/matchers.test.tsx
@@ -1,6 +1,9 @@
import { expect } from '@jest/globals';
-import { panel, text } from '@metamask/snaps-sdk';
+import { NotificationType, panel, text } from '@metamask/snaps-sdk';
import { Box, Text } from '@metamask/snaps-sdk/jsx';
+import { getInterfaceActions } from '@metamask/snaps-simulation';
+import type { RootControllerMessenger } from '@metamask/snaps-simulation';
+import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils';
import {
toRender,
@@ -8,7 +11,11 @@ import {
toRespondWithError,
toSendNotification,
} from './matchers';
-import { getMockInterfaceResponse, getMockResponse } from './test-utils';
+import {
+ getMockInterfaceResponse,
+ getMockResponse,
+ getRootControllerMessenger,
+} from './test-utils';
expect.extend({
toRespondWith,
@@ -160,18 +167,115 @@ describe('toRespondWithError', () => {
});
describe('toSendNotification', () => {
- it('passes when the notification is correct', () => {
+ it('passes when a notification is correct', () => {
+ expect(
+ getMockResponse({
+ notifications: [
+ {
+ id: '1',
+ type: NotificationType.Native,
+ message: 'foo',
+ },
+ ],
+ }),
+ ).toSendNotification('foo', 'native');
+ });
+
+ it('passes when an expanded view notification is correct', () => {
+ const controllerMessenger = getRootControllerMessenger();
+ const actions = getInterfaceActions(
+ MOCK_SNAP_ID,
+ controllerMessenger as unknown as RootControllerMessenger,
+ {
+ content: (
+
+ Foo
+
+ ),
+ id: 'abcd',
+ },
+ );
+ expect(
+ getMockResponse({
+ notifications: [
+ {
+ id: '1',
+ type: NotificationType.InApp,
+ message: 'foo',
+ title: 'bar',
+ content: 'abcd',
+ footerLink: { text: 'foo', href: 'https://metamask.io' },
+ },
+ ],
+ getInterface: () => {
+ return {
+ content: (
+
+ Foo
+
+ ),
+ ...actions,
+ };
+ },
+ }),
+ ).toSendNotification(
+ 'foo',
+ 'inApp',
+ 'bar',
+
+ Foo
+ ,
+ {
+ text: 'foo',
+ href: 'https://metamask.io',
+ },
+ );
+ });
+
+ it('passes when an expanded view notification without footer is correct', () => {
+ const controllerMessenger = getRootControllerMessenger();
+ const actions = getInterfaceActions(
+ MOCK_SNAP_ID,
+ controllerMessenger as unknown as RootControllerMessenger,
+ {
+ content: (
+
+ Foo
+
+ ),
+ id: 'abcd',
+ },
+ );
expect(
getMockResponse({
notifications: [
{
id: '1',
- type: 'native',
+ type: NotificationType.InApp,
message: 'foo',
+ title: 'bar',
+ content: 'abcd',
},
],
+ getInterface: () => {
+ return {
+ content: (
+
+ Foo
+
+ ),
+ ...actions,
+ };
+ },
}),
- ).toSendNotification('foo');
+ ).toSendNotification(
+ 'foo',
+ 'inApp',
+ 'bar',
+
+ Foo
+ ,
+ );
});
it('passes when the notification is correct with a type', () => {
@@ -180,7 +284,7 @@ describe('toSendNotification', () => {
notifications: [
{
id: '1',
- type: 'native',
+ type: NotificationType.Native,
message: 'foo',
},
],
@@ -188,7 +292,7 @@ describe('toSendNotification', () => {
).toSendNotification('foo', 'native');
});
- it('fails when the notification is incorrect', () => {
+ it('fails when a notification message is incorrect', () => {
expect(() =>
expect(
getMockResponse({
@@ -200,11 +304,11 @@ describe('toSendNotification', () => {
},
],
}),
- ).toSendNotification('bar'),
- ).toThrow('Received:');
+ ).toSendNotification('bar', 'native'),
+ ).toThrow('Received');
});
- it('fails when the notification is incorrect with a type', () => {
+ it('fails when a notification type is incorrect', () => {
expect(() =>
expect(
getMockResponse({
@@ -217,25 +321,157 @@ describe('toSendNotification', () => {
],
}),
).toSendNotification('foo', 'inApp'),
- ).toThrow('Received:');
+ ).toThrow('Received');
});
- describe('not', () => {
- it('passes when the notification is correct', () => {
+ it("fails when an expanded view notification's title is incorrect", () => {
+ const controllerMessenger = getRootControllerMessenger();
+ const actions = getInterfaceActions(
+ MOCK_SNAP_ID,
+ controllerMessenger as unknown as RootControllerMessenger,
+ {
+ content: (
+
+ Foo
+
+ ),
+ id: 'abcd',
+ },
+ );
+ expect(() =>
expect(
getMockResponse({
notifications: [
{
id: '1',
- type: 'native',
+ type: 'inApp',
message: 'foo',
+ title: 'bar',
+ content: 'abcd',
},
],
+ getInterface: () => {
+ return {
+ content: (
+
+ Foo
+
+ ),
+ ...actions,
+ };
+ },
}),
- ).not.toSendNotification('bar');
- });
+ ).toSendNotification(
+ 'foo',
+ 'inApp',
+ 'baz',
+
+ Foo
+ ,
+ ),
+ ).toThrow('Received');
+ });
- it('passes when the notification is correct with a type', () => {
+ it("fails when an expanded view notification's footerLink is incorrect", () => {
+ const controllerMessenger = getRootControllerMessenger();
+ const actions = getInterfaceActions(
+ MOCK_SNAP_ID,
+ controllerMessenger as unknown as RootControllerMessenger,
+ {
+ content: (
+
+ Foo
+
+ ),
+ id: 'abcd',
+ },
+ );
+ expect(() =>
+ expect(
+ getMockResponse({
+ notifications: [
+ {
+ id: '1',
+ type: 'inApp',
+ message: 'foo',
+ title: 'bar',
+ content: 'abcd',
+ footerLink: { text: 'Leave site', href: 'https://metamask.io' },
+ },
+ ],
+ getInterface: () => {
+ return {
+ content: (
+
+ Foo
+
+ ),
+ ...actions,
+ };
+ },
+ }),
+ ).toSendNotification(
+ 'foo',
+ 'inApp',
+ 'bar',
+
+ Foo
+ ,
+ {
+ text: 'Go back',
+ href: 'metamask://client/',
+ },
+ ),
+ ).toThrow('Received');
+ });
+
+ it("fails when an expanded view notification's content is missing", () => {
+ const controllerMessenger = getRootControllerMessenger();
+ const actions = getInterfaceActions(
+ MOCK_SNAP_ID,
+ controllerMessenger as unknown as RootControllerMessenger,
+ {
+ content: (
+
+ Foo
+
+ ),
+ id: 'abcd',
+ },
+ );
+ expect(() =>
+ expect(
+ getMockResponse({
+ notifications: [
+ {
+ id: '1',
+ type: 'inApp',
+ message: 'foo',
+ title: 'bar',
+ content: 'abcd',
+ footerLink: { text: 'Leave site', href: 'https://metamask.io' },
+ },
+ ],
+ getInterface: () => {
+ return {
+ content: (
+
+ Foo
+
+ ),
+ ...actions,
+ };
+ },
+ }),
+ ).toSendNotification('foo', 'inApp', 'bar', undefined, {
+ text: 'Leave site',
+ href: 'https://metamask.io',
+ }),
+ ).toThrow('Received');
+ });
+
+ describe('not', () => {
+ it('passes when the notification is correct', () => {
expect(
getMockResponse({
notifications: [
@@ -246,26 +482,10 @@ describe('toSendNotification', () => {
},
],
}),
- ).not.toSendNotification('foo', 'inApp');
+ ).not.toSendNotification('bar', 'native');
});
it('fails when the notification is incorrect', () => {
- expect(() =>
- expect(
- getMockResponse({
- notifications: [
- {
- id: '1',
- type: 'native',
- message: 'foo',
- },
- ],
- }),
- ).not.toSendNotification('foo'),
- ).toThrow('Received:');
- });
-
- it('fails when the notification is incorrect with a type', () => {
expect(() =>
expect(
getMockResponse({
@@ -278,7 +498,7 @@ describe('toSendNotification', () => {
],
}),
).not.toSendNotification('foo', 'native'),
- ).toThrow('Received:');
+ ).toThrow('Received');
});
});
});
diff --git a/packages/snaps-jest/src/matchers.ts b/packages/snaps-jest/src/matchers.ts
index b4d8898430..659ab48b14 100644
--- a/packages/snaps-jest/src/matchers.ts
+++ b/packages/snaps-jest/src/matchers.ts
@@ -6,13 +6,13 @@
import type { MatcherFunction } from '@jest/expect';
import { expect } from '@jest/globals';
import type {
- NotificationType,
EnumToUnion,
ComponentOrElement,
Component,
+ NotificationType,
} from '@metamask/snaps-sdk';
-import type { JSXElement } from '@metamask/snaps-sdk/jsx';
-import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx';
+import type { JSXElement, SnapNode } from '@metamask/snaps-sdk/jsx';
+import { isJSXElementUnsafe, JSXElementStruct } from '@metamask/snaps-sdk/jsx';
import type { SnapResponse } from '@metamask/snaps-simulation';
import {
InterfaceStruct,
@@ -158,33 +158,148 @@ export const toRespondWithError: MatcherFunction<[expected: Json]> = function (
* is intended to be used with the `expect` global.
*
* @param actual - The actual response.
- * @param expected - The expected notification message.
- * @param type - The expected notification type.
+ * @param expectedMessage - The expected notification message.
+ * @param expectedType - The expected notification type.
+ * @param expectedTitle - The expected notification title.
+ * @param expectedContent - The expected notification JSX content.
+ * @param expectedFooterLink - The expected footer link object.
* @returns The status and message.
*/
export const toSendNotification: MatcherFunction<
- [expected: string, type?: EnumToUnion | undefined]
-> = function (actual, expected, type) {
+ [
+ expectedMessage: string,
+ expectedType?: EnumToUnion | undefined,
+ expectedTitle?: string | undefined,
+ expectedContent?: JSXElement | undefined,
+ expectedFooterLink?: { text: string; href: string } | undefined,
+ ]
+> = function (
+ actual,
+ expectedMessage,
+ expectedType,
+ expectedTitle,
+ expectedContent,
+ expectedFooterLink,
+) {
assertActualIsSnapResponse(actual, 'toSendNotification');
const { notifications } = actual;
- const pass = notifications.some(
- (notification) =>
- this.equals(notification.message, expected) &&
- (type === undefined || notification.type === type),
- );
+ let jsxContent: JSXElement | undefined;
- const message = pass
- ? () =>
- `${this.utils.matcherHint('.not.toSendNotification')}\n\n` +
- `Expected: ${this.utils.printExpected(expected)}\n` +
- `Expected type: ${this.utils.printExpected(type)}\n` +
- `Received: ${this.utils.printReceived(notifications)}`
- : () =>
- `${this.utils.matcherHint('.toSendNotification')}\n\n` +
- `Expected: ${this.utils.printExpected(expected)}\n` +
- `Expected type: ${this.utils.printExpected(type)}\n` +
- `Received: ${this.utils.printReceived(notifications)}`;
+ if ('getInterface' in actual) {
+ jsxContent = actual.getInterface().content;
+ }
+
+ const notificationValidator = (
+ notification: SnapResponse['notifications'][number],
+ ) => {
+ const { type, message, title, footerLink } = notification as Record<
+ string,
+ unknown
+ >;
+
+ if (!this.equals(message, expectedMessage)) {
+ return false;
+ }
+
+ if (expectedType && type !== expectedType) {
+ return false;
+ }
+
+ if (title && !this.equals(title, expectedTitle)) {
+ return false;
+ }
+
+ if (jsxContent && !this.equals(jsxContent, expectedContent)) {
+ return false;
+ }
+
+ if (footerLink && !this.equals(footerLink, expectedFooterLink)) {
+ return false;
+ }
+
+ return true;
+ };
+
+ const pass = notifications.some(notificationValidator);
+
+ const transformedNotifications = notifications.map((notification) => {
+ return {
+ ...notification,
+ // Ok to cast here as the function returns if the param is falsy
+ content: serialiseJsx(jsxContent as SnapNode),
+ };
+ });
+
+ const message = () => {
+ let testMessage = pass
+ ? `${this.utils.matcherHint('.not.toSendNotification')}\n\n`
+ : `${this.utils.matcherHint('.toSendNotification')}\n\n`;
+
+ const {
+ title,
+ type,
+ message: notifMessage,
+ footerLink,
+ content,
+ } = transformedNotifications[0];
+
+ testMessage += `Expected message: ${this.utils.printExpected(
+ expectedMessage,
+ )}\n`;
+
+ if (expectedType) {
+ testMessage += `Expected type: ${this.utils.printExpected(
+ expectedType,
+ )}\n`;
+ }
+
+ if (title) {
+ testMessage += `Expected title: ${this.utils.printExpected(
+ expectedTitle,
+ )}\n`;
+
+ // We want to check if the expected content is actually JSX content, otherwise `serialiseJsx` won't return something useful.
+ if (is(expectedContent, JSXElementStruct)) {
+ testMessage += `Expected content: ${this.utils.printExpected(
+ serialiseJsx(expectedContent),
+ )}\n`;
+ } else {
+ testMessage += `Expected content: ${this.utils.printExpected(
+ expectedContent,
+ )}\n`;
+ }
+ }
+
+ if (footerLink) {
+ testMessage += `Expected footer link: ${this.utils.printExpected(
+ expectedFooterLink,
+ )}\n`;
+ }
+
+ testMessage += `Received message: ${this.utils.printExpected(
+ notifMessage,
+ )}\n`;
+
+ if (expectedType) {
+ testMessage += `Received type: ${this.utils.printReceived(type)}\n`;
+ }
+
+ if (title) {
+ testMessage += `Received title: ${this.utils.printReceived(title)}\n`;
+ testMessage += `Received content: ${this.utils.printReceived(
+ serialiseJsx(content),
+ )}\n`;
+ }
+
+ if (footerLink) {
+ testMessage += `Received footer link: ${this.utils.printReceived(
+ footerLink,
+ )}\n`;
+ }
+
+ return testMessage;
+ };
return { message, pass };
};
diff --git a/packages/snaps-jest/src/test-utils/response.ts b/packages/snaps-jest/src/test-utils/response.ts
index cf71d8bacf..d525f9d48a 100644
--- a/packages/snaps-jest/src/test-utils/response.ts
+++ b/packages/snaps-jest/src/test-utils/response.ts
@@ -2,6 +2,7 @@ import type { JSXElement } from '@metamask/snaps-sdk/jsx';
import type {
SnapHandlerInterface,
SnapResponse,
+ SnapResponseWithInterface,
} from '@metamask/snaps-simulation';
/**
@@ -11,6 +12,7 @@ import type {
* @param options.id - The ID to use.
* @param options.response - The response to use.
* @param options.notifications - The notifications to use.
+ * @param options.getInterface - The `getInterface` function to use.
* @returns The mock response.
*/
export function getMockResponse({
@@ -19,11 +21,13 @@ export function getMockResponse({
result: 'foo',
},
notifications = [],
-}: Partial): SnapResponse {
+ getInterface,
+}: Partial): SnapResponse {
return {
id,
response,
notifications,
+ ...(getInterface ? { getInterface } : {}),
};
}
diff --git a/packages/snaps-simulation/src/interface.test.tsx b/packages/snaps-simulation/src/interface.test.tsx
index 02368bfaed..3ad185e5d9 100644
--- a/packages/snaps-simulation/src/interface.test.tsx
+++ b/packages/snaps-simulation/src/interface.test.tsx
@@ -386,6 +386,33 @@ describe('getInterfaceResponse', () => {
});
});
+ it('returns the interface actions and content for a notification', () => {
+ const { runSaga } = createStore(getMockOptions());
+ const response = getInterfaceResponse(
+ runSaga,
+ 'Notification',
+
+ Foo
+ ,
+ interfaceActions,
+ );
+
+ expect(response).toStrictEqual({
+ content: (
+
+ Foo
+
+ ),
+ clickElement: expect.any(Function),
+ typeInField: expect.any(Function),
+ selectInDropdown: expect.any(Function),
+ selectFromRadioGroup: expect.any(Function),
+ selectFromSelector: expect.any(Function),
+ uploadFile: expect.any(Function),
+ waitForUpdate: expect.any(Function),
+ });
+ });
+
it('throws an error for unknown dialog types', () => {
const { runSaga } = createStore(getMockOptions());
diff --git a/packages/snaps-simulation/src/interface.ts b/packages/snaps-simulation/src/interface.ts
index 1c9aee7c28..d3c4872f56 100644
--- a/packages/snaps-simulation/src/interface.ts
+++ b/packages/snaps-simulation/src/interface.ts
@@ -51,7 +51,7 @@ const MAX_FILE_SIZE = 10_000_000; // 10 MB
*/
export function getInterfaceResponse(
runSaga: RunSagaFunction,
- type: DialogApprovalTypes[DialogType | 'default'],
+ type: DialogApprovalTypes[DialogType | 'default'] | 'Notification',
content: JSXElement,
interfaceActions: SnapInterfaceActions,
): SnapInterface {
@@ -115,6 +115,13 @@ export function getInterfaceResponse(
};
}
+ case 'Notification': {
+ return {
+ ...interfaceActions,
+ content,
+ };
+ }
+
default:
throw new Error(`Unknown or unsupported dialog type: "${String(type)}".`);
}
diff --git a/packages/snaps-simulation/src/methods/hooks/notifications.test.ts b/packages/snaps-simulation/src/methods/hooks/notifications.test.ts
index 09c430c4a2..0288eb90dd 100644
--- a/packages/snaps-simulation/src/methods/hooks/notifications.test.ts
+++ b/packages/snaps-simulation/src/methods/hooks/notifications.test.ts
@@ -30,23 +30,35 @@ describe('getShowNativeNotificationImplementation', () => {
});
describe('getShowInAppNotificationImplementation', () => {
- it('returns the implementation of the `showInAppNotification` hook', async () => {
- const { store, runSaga } = createStore(getMockOptions());
- const fn = getShowInAppNotificationImplementation(runSaga);
+ const baseRequest = {
+ title: undefined,
+ content: undefined,
+ footerLink: undefined,
+ };
- expect(
- await fn('snap-id', {
- type: NotificationType.InApp,
- message: 'message',
- }),
- ).toBeNull();
+ it.each([
+ { type: NotificationType.InApp, message: 'message' },
+ {
+ type: NotificationType.InApp,
+ content: 'abcd',
+ title: 'foo',
+ message: 'bar',
+ },
+ ])(
+ 'returns the implementation of the `showInAppNotification` hook',
+ async (request) => {
+ const { store, runSaga } = createStore(getMockOptions());
+ const fn = getShowInAppNotificationImplementation(runSaga);
- expect(store.getState().notifications.notifications).toStrictEqual([
- {
- id: expect.any(String),
- type: NotificationType.InApp,
- message: 'message',
- },
- ]);
- });
+ expect(await fn('snap-id', request)).toBeNull();
+
+ expect(store.getState().notifications.notifications).toStrictEqual([
+ {
+ ...baseRequest,
+ ...request,
+ id: expect.any(String),
+ },
+ ]);
+ },
+ );
});
diff --git a/packages/snaps-simulation/src/methods/hooks/notifications.ts b/packages/snaps-simulation/src/methods/hooks/notifications.ts
index be6960148b..8645dedcb8 100644
--- a/packages/snaps-simulation/src/methods/hooks/notifications.ts
+++ b/packages/snaps-simulation/src/methods/hooks/notifications.ts
@@ -5,7 +5,7 @@ import type { SagaIterator } from 'redux-saga';
import { put } from 'redux-saga/effects';
import type { RunSagaFunction } from '../../store';
-import { addNotification } from '../../store';
+import { addNotification, setInterface } from '../../store';
/**
* Show a native notification to the user.
@@ -46,21 +46,43 @@ export function getShowNativeNotificationImplementation(
};
}
+type InAppNotificationParams = {
+ type: NotificationType;
+ message: string;
+ title?: string | undefined;
+ content?: string | undefined;
+ footerLink?: { text: string; href: string } | undefined;
+};
+
/**
* Show an in-app notification to the user.
*
* @param _snapId - The ID of the Snap that created the notification.
* @param options - The notification options.
* @param options.message - The message to show in the notification.
+ * @param options.title - The title to show in the notification.
+ * @param options.content - The JSX content to show in the notification.
+ * @param options.footerLink - The footer to show in the notification.
* @yields Adds the notification to the store.
* @returns `null`.
*/
function* showInAppNotificationImplementation(
_snapId: string,
- { message }: NotifyParams,
+ { message, title, content, footerLink }: InAppNotificationParams,
): SagaIterator {
+ if (content) {
+ yield put(setInterface({ type: 'Notification', id: content }));
+ }
+
yield put(
- addNotification({ id: nanoid(), type: NotificationType.InApp, message }),
+ addNotification({
+ id: nanoid(),
+ type: NotificationType.InApp,
+ message,
+ title,
+ content,
+ footerLink,
+ }),
);
return null;
diff --git a/packages/snaps-simulation/src/request.ts b/packages/snaps-simulation/src/request.ts
index 1436fbcfe6..adf55c0b36 100644
--- a/packages/snaps-simulation/src/request.ts
+++ b/packages/snaps-simulation/src/request.ts
@@ -84,6 +84,7 @@ export function handleRequest({
})
.then(async (result) => {
const notifications = getNotifications(store.getState());
+ const interfaceId = notifications[0]?.content;
store.dispatch(clearNotifications());
try {
@@ -91,6 +92,7 @@ export function handleRequest({
result,
snapId,
controllerMessenger,
+ interfaceId,
);
return {
@@ -176,6 +178,7 @@ export async function getInterfaceFromResult(
is(result.content, ComponentOrElementStruct),
'The Snap returned an invalid interface.',
);
+
const id = await controllerMessenger.call(
'SnapInterfaceController:createInterface',
snapId,
@@ -195,12 +198,14 @@ export async function getInterfaceFromResult(
* @param result - The handler result object.
* @param snapId - The Snap ID.
* @param controllerMessenger - The controller messenger.
+ * @param contentId - The id of the interface if it exists outside of the result.
* @returns The content components if any.
*/
export async function getInterfaceApi(
result: unknown,
snapId: SnapId,
controllerMessenger: RootControllerMessenger,
+ contentId?: string,
): Promise<(() => SnapHandlerInterface) | undefined> {
const interfaceId = await getInterfaceFromResult(
result,
@@ -208,16 +213,18 @@ export async function getInterfaceApi(
controllerMessenger,
);
- if (interfaceId) {
+ const id = interfaceId ?? contentId;
+
+ if (id) {
return () => {
const { content } = controllerMessenger.call(
'SnapInterfaceController:getInterface',
snapId,
- interfaceId,
+ id,
);
const actions = getInterfaceActions(snapId, controllerMessenger, {
- id: interfaceId,
+ id,
content,
});
diff --git a/packages/snaps-simulation/src/store/notifications.ts b/packages/snaps-simulation/src/store/notifications.ts
index bf14b8251b..bf8ac96061 100644
--- a/packages/snaps-simulation/src/store/notifications.ts
+++ b/packages/snaps-simulation/src/store/notifications.ts
@@ -10,11 +10,17 @@ import type { ApplicationState } from './store';
* @property id - A unique ID for the notification.
* @property message - The notification message.
* @property type - The notification type.
+ * @property title - The notification title (expanded view).
+ * @property content - The notification JSX content (expanded view).
+ * @property footerLink - The notification footer (expanded view).
*/
export type Notification = {
id: string;
message: string;
type: NotificationType;
+ title?: string;
+ content?: string;
+ footerLink?: { text: string; href: string };
};
/**
diff --git a/packages/snaps-simulation/src/store/ui.ts b/packages/snaps-simulation/src/store/ui.ts
index e1f4d85829..db9f1e9dab 100644
--- a/packages/snaps-simulation/src/store/ui.ts
+++ b/packages/snaps-simulation/src/store/ui.ts
@@ -6,7 +6,7 @@ import { createAction, createSelector, createSlice } from '@reduxjs/toolkit';
import type { ApplicationState } from './store';
export type Interface = {
- type: DialogApprovalTypes[DialogType | 'default'];
+ type: DialogApprovalTypes[DialogType | 'default'] | 'Notification';
id: string;
};
diff --git a/packages/snaps-simulation/src/structs.ts b/packages/snaps-simulation/src/structs.ts
index de055ed215..6e90495006 100644
--- a/packages/snaps-simulation/src/structs.ts
+++ b/packages/snaps-simulation/src/structs.ts
@@ -255,6 +255,14 @@ export const SnapResponseWithoutInterfaceStruct = object({
enumValue(NotificationType.InApp),
enumValue(NotificationType.Native),
]),
+ title: optional(string()),
+ content: optional(string()),
+ footerLink: optional(
+ object({
+ href: string(),
+ text: string(),
+ }),
+ ),
}),
),
});
diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts
index 5edfbf8f37..7dfcf82e7c 100644
--- a/packages/snaps-simulation/src/types.ts
+++ b/packages/snaps-simulation/src/types.ts
@@ -523,6 +523,9 @@ export type SnapResponseWithInterface = {
id: string;
message: string;
type: EnumToUnion;
+ title?: string | undefined;
+ content?: string | undefined;
+ footerLink?: { text: string; href: string } | undefined;
}[];
getInterface(): SnapHandlerInterface;
};
diff --git a/packages/test-snaps/src/features/snaps/notifications/Notifications.tsx b/packages/test-snaps/src/features/snaps/notifications/Notifications.tsx
index 3fcd0a8938..4d3aa343b6 100644
--- a/packages/test-snaps/src/features/snaps/notifications/Notifications.tsx
+++ b/packages/test-snaps/src/features/snaps/notifications/Notifications.tsx
@@ -31,7 +31,6 @@ export const Notifications: FunctionComponent = () => {
>
+