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 = () => { > +