diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx
index a3561840df..a7481d65f3 100644
--- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx
+++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx
@@ -1,5 +1,13 @@
+import { getPersistentState } from '@metamask/base-controller';
import type { SnapId } from '@metamask/snaps-sdk';
-import { form, image, input, panel, text } from '@metamask/snaps-sdk';
+import {
+ form,
+ image,
+ input,
+ panel,
+ text,
+ ContentType,
+} from '@metamask/snaps-sdk';
import {
Box,
Field,
@@ -29,6 +37,40 @@ jest.mock('@metamask/snaps-utils', () => ({
}));
describe('SnapInterfaceController', () => {
+ describe('constructor', () => {
+ it('persists notification interfaces', () => {
+ const rootMessenger = getRootSnapInterfaceControllerMessenger();
+ const controllerMessenger =
+ getRestrictedSnapInterfaceControllerMessenger(rootMessenger);
+
+ const controller = new SnapInterfaceController({
+ messenger: controllerMessenger,
+ state: {
+ interfaces: {
+ // @ts-expect-error missing properties
+ '1': {
+ contentType: ContentType.Notification,
+ },
+ // @ts-expect-error missing properties
+ '2': {
+ contentType: ContentType.Dialog,
+ },
+ },
+ },
+ });
+
+ expect(
+ getPersistentState(controller.state, controller.metadata),
+ ).toStrictEqual({
+ interfaces: {
+ '1': {
+ contentType: ContentType.Notification,
+ },
+ },
+ });
+ });
+ });
+
describe('createInterface', () => {
it('can create a new interface', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
@@ -160,6 +202,41 @@ describe('SnapInterfaceController', () => {
expect(context).toStrictEqual({ foo: 'bar' });
});
+ it('supports providing an interface content type', async () => {
+ const rootMessenger = getRootSnapInterfaceControllerMessenger();
+ const controllerMessenger =
+ getRestrictedSnapInterfaceControllerMessenger(rootMessenger);
+
+ /* eslint-disable-next-line no-new */
+ new SnapInterfaceController({
+ messenger: controllerMessenger,
+ });
+
+ const element = (
+
+
+ foo
+
+
+ );
+
+ const id = await rootMessenger.call(
+ 'SnapInterfaceController:createInterface',
+ MOCK_SNAP_ID,
+ element,
+ { foo: 'bar' },
+ ContentType.Notification,
+ );
+
+ const { contentType } = rootMessenger.call(
+ 'SnapInterfaceController:getInterface',
+ MOCK_SNAP_ID,
+ id,
+ );
+
+ expect(contentType).toStrictEqual(ContentType.Notification);
+ });
+
it('throws if interface context is too large', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
@@ -1068,6 +1145,51 @@ describe('SnapInterfaceController', () => {
});
});
+ describe('updateInterfaceContentType', () => {
+ it("can update an interface's content type", async () => {
+ const rootMessenger = getRootSnapInterfaceControllerMessenger();
+ const controllerMessenger =
+ getRestrictedSnapInterfaceControllerMessenger(rootMessenger);
+
+ /* eslint-disable-next-line no-new */
+ new SnapInterfaceController({
+ messenger: controllerMessenger,
+ });
+
+ const content = form({ name: 'foo', children: [input({ name: 'bar' })] });
+
+ let contentType;
+
+ const id = await rootMessenger.call(
+ 'SnapInterfaceController:createInterface',
+ MOCK_SNAP_ID,
+ content,
+ );
+
+ contentType = rootMessenger.call(
+ 'SnapInterfaceController:getInterface',
+ MOCK_SNAP_ID,
+ id,
+ ).contentType;
+
+ expect(contentType).toBeNull();
+
+ rootMessenger.call(
+ 'SnapInterfaceController:updateInterfaceContentType',
+ id,
+ ContentType.Dialog,
+ );
+
+ contentType = rootMessenger.call(
+ 'SnapInterfaceController:getInterface',
+ MOCK_SNAP_ID,
+ id,
+ ).contentType;
+
+ expect(contentType).toStrictEqual(ContentType.Dialog);
+ });
+ });
+
describe('resolveInterface', () => {
it('resolves the interface with the given value', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts
index cca147b9d8..807a8ea36d 100644
--- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts
+++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts
@@ -18,6 +18,7 @@ import type {
ComponentOrElement,
InterfaceContext,
} from '@metamask/snaps-sdk';
+import { ContentType } from '@metamask/snaps-sdk';
import type { JSXElement } from '@metamask/snaps-sdk/jsx';
import { getJsonSizeUnsafe, validateJsxLinks } from '@metamask/snaps-utils';
import type { Json } from '@metamask/utils';
@@ -61,6 +62,11 @@ export type UpdateInterfaceState = {
handler: SnapInterfaceController['updateInterfaceState'];
};
+export type UpdateInterfaceContentType = {
+ type: `${typeof controllerName}:updateInterfaceContentType`;
+ handler: SnapInterfaceController['updateInterfaceContentType'];
+};
+
export type ResolveInterface = {
type: `${typeof controllerName}:resolveInterface`;
handler: SnapInterfaceController['resolveInterface'];
@@ -84,6 +90,7 @@ export type SnapInterfaceControllerActions =
| UpdateInterface
| DeleteInterface
| UpdateInterfaceState
+ | UpdateInterfaceContentType
| ResolveInterface
| SnapInterfaceControllerGetStateAction;
@@ -109,6 +116,7 @@ export type StoredInterface = {
content: JSXElement;
state: InterfaceState;
context: InterfaceContext | null;
+ contentType: ContentType | null;
};
export type SnapInterfaceControllerState = {
@@ -132,7 +140,22 @@ export class SnapInterfaceController extends BaseController<
super({
messenger,
metadata: {
- interfaces: { persist: false, anonymous: false },
+ interfaces: {
+ persist: (interfaces: Record) => {
+ return Object.entries(interfaces).reduce<
+ Record
+ >((persistedInterfaces, [id, snapInterface]) => {
+ switch (snapInterface.contentType) {
+ case ContentType.Notification:
+ persistedInterfaces[id] = snapInterface;
+ return persistedInterfaces;
+ default:
+ return persistedInterfaces;
+ }
+ }, {});
+ },
+ anonymous: false,
+ },
},
name: controllerName,
state: { interfaces: {}, ...state },
@@ -161,6 +184,11 @@ export class SnapInterfaceController extends BaseController<
this.updateInterface.bind(this),
);
+ this.messagingSystem.registerActionHandler(
+ `${controllerName}:updateInterfaceContentType`,
+ this.updateInterfaceContentType.bind(this),
+ );
+
this.messagingSystem.registerActionHandler(
`${controllerName}:deleteInterface`,
this.deleteInterface.bind(this),
@@ -183,12 +211,14 @@ export class SnapInterfaceController extends BaseController<
* @param snapId - The snap id that created the interface.
* @param content - The interface content.
* @param context - An optional interface context object.
+ * @param contentType - The type of content.
* @returns The newly interface id.
*/
async createInterface(
snapId: SnapId,
content: ComponentOrElement,
context?: InterfaceContext,
+ contentType?: ContentType,
) {
const element = getJsxInterface(content);
await this.#validateContent(element);
@@ -205,6 +235,7 @@ export class SnapInterfaceController extends BaseController<
content: castDraft(element),
state: componentState,
context: context ?? null,
+ contentType: contentType ?? null,
};
});
@@ -255,6 +286,20 @@ export class SnapInterfaceController extends BaseController<
});
}
+ /**
+ * Update the type of content in an interface.
+ *
+ * @param id - The interface id.
+ * @param contentType - The type of content.
+ */
+ updateInterfaceContentType(id: string, contentType: ContentType) {
+ this.#validateContentType(contentType);
+ assert(this.state.interfaces[id], 'Interface does not exist.');
+ this.update((draftState) => {
+ draftState.interfaces[id].contentType = contentType;
+ });
+ }
+
/**
* Delete an interface from state.
*
@@ -393,4 +438,17 @@ export class SnapInterfaceController extends BaseController<
(id: string) => this.messagingSystem.call('SnapController:get', id),
);
}
+
+ /**
+ * Utility function to validate the type of interface content.
+ * Must be a value of the enum ContentType.
+ * Throws if the passed string is invalid.
+ *
+ * @param contentType - The content type.
+ */
+ #validateContentType(contentType: string) {
+ if (!(contentType in ContentType)) {
+ throw new Error('Invalid content type.');
+ }
+ }
}
diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts
index 532ddf41d1..d71259c0cf 100644
--- a/packages/snaps-controllers/src/snaps/SnapController.ts
+++ b/packages/snaps-controllers/src/snaps/SnapController.ts
@@ -47,7 +47,11 @@ import type {
SnapId,
ComponentOrElement,
} from '@metamask/snaps-sdk';
-import { AuxiliaryFileEncoding, getErrorMessage } from '@metamask/snaps-sdk';
+import {
+ AuxiliaryFileEncoding,
+ getErrorMessage,
+ ContentType,
+} from '@metamask/snaps-sdk';
import type {
FetchedSnapFiles,
InitialConnections,
@@ -110,7 +114,7 @@ import type { Patch } from 'immer';
import { nanoid } from 'nanoid';
import { forceStrict, validateMachine } from '../fsm';
-import type { CreateInterface, GetInterface } from '../interface';
+import { type CreateInterface, type GetInterface } from '../interface';
import { log } from '../logging';
import type {
ExecuteSnapAction,
@@ -3311,16 +3315,20 @@ export class SnapController extends BaseController<
*
* @param snapId - The snap ID.
* @param content - The initial interface content.
+ * @param contentType - The type of content.
* @returns An identifier that can be used to identify the interface.
*/
async #createInterface(
snapId: SnapId,
content: ComponentOrElement,
+ contentType: ContentType,
): Promise {
return this.messagingSystem.call(
'SnapInterfaceController:createInterface',
snapId,
content,
+ undefined,
+ contentType,
);
}
@@ -3358,10 +3366,20 @@ export class SnapController extends BaseController<
// If a handler returns static content, we turn it into a dynamic UI
if (castResult && hasProperty(castResult, 'content')) {
const { content, ...rest } = castResult;
+ const getContentType = (handler: HandlerType) => {
+ if (
+ handler === HandlerType.OnSignature ||
+ handler === HandlerType.OnTransaction
+ ) {
+ return ContentType.Insight;
+ }
+ return ContentType.HomePage;
+ };
const id = await this.#createInterface(
snapId,
content as ComponentOrElement,
+ getContentType(handlerType),
);
return { ...rest, id };
diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts
index bcb7e27708..809a4cc105 100644
--- a/packages/snaps-controllers/src/test-utils/controller.ts
+++ b/packages/snaps-controllers/src/test-utils/controller.ts
@@ -761,6 +761,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = (
'PhishingController:maybeUpdateState',
'ApprovalController:hasRequest',
'ApprovalController:acceptRequest',
+ 'SnapController:get',
],
allowedEvents: [],
});
diff --git a/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx b/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx
index dd5d3c7fec..a5c72fcf77 100644
--- a/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx
+++ b/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx
@@ -1,6 +1,12 @@
import { PermissionType, SubjectType } from '@metamask/permission-controller';
import { rpcErrors } from '@metamask/rpc-errors';
-import { DialogType, heading, panel, text } from '@metamask/snaps-sdk';
+import {
+ ContentType,
+ DialogType,
+ heading,
+ panel,
+ text,
+} from '@metamask/snaps-sdk';
import { Box, Text } from '@metamask/snaps-sdk/jsx';
import type { DialogMethodHooks } from './dialog';
@@ -30,6 +36,7 @@ describe('builder', () => {
requestUserApproval: jest.fn(),
createInterface: jest.fn(),
getInterface: jest.fn(),
+ updateInterfaceContentType: jest.fn(),
},
}),
).toStrictEqual({
@@ -50,6 +57,7 @@ describe('implementation', () => {
getInterface: jest
.fn()
.mockReturnValue({ content: text('foo'), state: {}, snapId: 'foo' }),
+ updateInterfaceContentType: jest.fn(),
} as DialogMethodHooks);
it('accepts string dialog types', async () => {
@@ -117,7 +125,11 @@ describe('implementation', () => {
},
});
- expect(hooks.createInterface).toHaveBeenCalledWith('foo', content);
+ expect(hooks.createInterface).toHaveBeenCalledWith(
+ 'foo',
+ content,
+ ContentType.Dialog,
+ );
expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1);
expect(hooks.requestUserApproval).toHaveBeenCalledWith({
id: 'bar',
@@ -137,6 +149,7 @@ describe('implementation', () => {
getInterface: jest
.fn()
.mockReturnValue({ content: text('foo'), state: {}, snapId: 'foo' }),
+ updateInterfaceContentType: jest.fn(),
};
const implementation = getDialogImplementation(hooks);
@@ -177,7 +190,11 @@ describe('implementation', () => {
},
});
- expect(hooks.createInterface).toHaveBeenCalledWith('foo', content);
+ expect(hooks.createInterface).toHaveBeenCalledWith(
+ 'foo',
+ content,
+ ContentType.Dialog,
+ );
expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1);
expect(hooks.requestUserApproval).toHaveBeenCalledWith({
id: undefined,
@@ -209,7 +226,11 @@ describe('implementation', () => {
},
});
- expect(hooks.createInterface).toHaveBeenCalledWith('foo', content);
+ expect(hooks.createInterface).toHaveBeenCalledWith(
+ 'foo',
+ content,
+ ContentType.Dialog,
+ );
expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1);
expect(hooks.requestUserApproval).toHaveBeenCalledWith({
id: undefined,
@@ -229,6 +250,7 @@ describe('implementation', () => {
getInterface: jest.fn().mockImplementation((_snapId, id) => {
throw new Error(`Interface with id '${id}' not found.`);
}),
+ updateInterfaceContentType: jest.fn(),
};
const implementation = getDialogImplementation(hooks);
diff --git a/packages/snaps-rpc-methods/src/restricted/dialog.ts b/packages/snaps-rpc-methods/src/restricted/dialog.ts
index d4b5fb6f78..8b110edb5f 100644
--- a/packages/snaps-rpc-methods/src/restricted/dialog.ts
+++ b/packages/snaps-rpc-methods/src/restricted/dialog.ts
@@ -9,6 +9,7 @@ import {
DialogType,
enumValue,
ComponentOrElementStruct,
+ ContentType,
selectiveUnion,
} from '@metamask/snaps-sdk';
import type {
@@ -61,6 +62,7 @@ type RequestUserApproval = (
type CreateInterface = (
snapId: string,
content: ComponentOrElement,
+ contentType?: ContentType,
) => Promise;
type GetInterface = (
@@ -68,6 +70,11 @@ type GetInterface = (
id: string,
) => { content: ComponentOrElement; snapId: SnapId; state: InterfaceState };
+type UpdateInterfaceContentType = (
+ id: string,
+ contentType: ContentType,
+) => void;
+
export type DialogMethodHooks = {
/**
* @param opts - The `requestUserApproval` options.
@@ -90,6 +97,11 @@ export type DialogMethodHooks = {
* @param id - The interface ID.
*/
getInterface: GetInterface;
+ /**
+ * @param id - The interface ID.
+ * @param contentType - The type of the interface content.
+ */
+ updateInterfaceContentType: UpdateInterfaceContentType;
};
type DialogSpecificationBuilderOptions = {
@@ -139,6 +151,7 @@ const methodHooks: MethodHooksObject = {
requestUserApproval: true,
createInterface: true,
getInterface: true,
+ updateInterfaceContentType: true,
};
export const dialogBuilder = Object.freeze({
@@ -249,6 +262,7 @@ export type DialogParameters = InferMatching<
* This function should return a Promise that resolves with the appropriate value when the user has approved or rejected the request.
* @param hooks.createInterface - A function that creates the interface in SnapInterfaceController.
* @param hooks.getInterface - A function that gets an interface from SnapInterfaceController.
+ * @param hooks.updateInterfaceContentType - A function that updates an interface's content type.
* @returns The method implementation which return value depends on the dialog
* type, valid return types are: string, boolean, null.
*/
@@ -256,6 +270,7 @@ export function getDialogImplementation({
requestUserApproval,
createInterface,
getInterface,
+ updateInterfaceContentType,
}: DialogMethodHooks) {
return async function dialogImplementation(
args: RestrictedMethodOptions,
@@ -289,6 +304,7 @@ export function getDialogImplementation({
const id = await createInterface(
origin,
validatedParams.content as Component,
+ ContentType.Dialog,
);
return requestUserApproval({
@@ -300,6 +316,9 @@ export function getDialogImplementation({
}
validateInterface(origin, validatedParams.id, getInterface);
+ // we update the content type here since if we are receiving this interface
+ // as an id that means it was already created with a snap_createInterface call
+ updateInterfaceContentType(validatedParams.id, ContentType.Dialog);
return requestUserApproval({
id:
diff --git a/packages/snaps-sdk/src/types/interface.ts b/packages/snaps-sdk/src/types/interface.ts
index 3e8290cb3c..a587a0283c 100644
--- a/packages/snaps-sdk/src/types/interface.ts
+++ b/packages/snaps-sdk/src/types/interface.ts
@@ -45,3 +45,10 @@ export const ComponentOrElementStruct = selectiveUnion((value) => {
export const InterfaceContextStruct = record(string(), JsonStruct);
export type InterfaceContext = Infer;
+
+export enum ContentType {
+ Insight = 'Insight',
+ Dialog = 'Dialog',
+ Notification = 'Notification',
+ HomePage = 'HomePage',
+}