From 721673ef3ce58a22d75519286144a543ea306a05 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 27 Jun 2024 10:47:12 +0200 Subject: [PATCH] chore: Bump Snaps packages (#25505) ## **Description** Bump snaps packages to latest and handle breaking changes. Summary of changes in the snaps deps: - Add `Checkbox` component - Add `Tooltip` component - Add `FileInput` component - Add `alignment` prop to `Text` - Support additional components inside forms - Support conditional children in most JSX components - Support parameters in `setTimeout` and `setInterval` Closes #25385 Closes https://github.com/MetaMask/snaps/issues/2500 Closes https://github.com/MetaMask/MetaMask-planning/issues/2416 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/25505?quickstart=1) --------- Co-authored-by: Guillaume Roux Co-authored-by: Maarten Zuidhoorn --- app/_locales/en/messages.json | 6 + builds.yml | 8 +- package.json | 12 +- test/e2e/snaps/enums.js | 2 +- ui/components/app/app-components.scss | 1 + .../safe-component-list.js | 6 + .../snaps/snap-ui-button/snap-ui-button.tsx | 5 +- .../app/snaps/snap-ui-checkbox/index.ts | 1 + .../snap-ui-checkbox/snap-ui-checkbox.tsx | 81 +++++++ .../snap-ui-dropdown/snap-ui-dropdown.tsx | 2 +- .../app/snaps/snap-ui-file-input/index.scss | 21 ++ .../app/snaps/snap-ui-file-input/index.ts | 1 + .../snap-ui-file-input/snap-ui-file-input.tsx | 212 ++++++++++++++++++ .../app/snaps/snap-ui-form/snap-ui-form.tsx | 5 +- .../app/snaps/snap-ui-input/snap-ui-input.tsx | 2 +- .../snap-ui-renderer/components/checkbox.ts | 16 ++ .../snap-ui-renderer/components/field.ts | 34 +++ .../snap-ui-renderer/components/file-input.ts | 19 ++ .../snap-ui-renderer/components/index.ts | 6 + .../snaps/snap-ui-renderer/components/text.ts | 1 + .../snap-ui-renderer/components/tooltip.ts | 23 ++ .../snap-ui-renderer/components/types.ts | 4 +- .../app/snaps/snap-ui-tooltip/index.ts | 1 + .../snaps/snap-ui-tooltip/snap-ui-tooltip.tsx | 22 ++ ui/contexts/snaps/snap-interface.tsx | 114 ++++++++-- ui/contexts/snaps/utils.ts | 4 +- yarn.lock | 68 +++--- 27 files changed, 607 insertions(+), 70 deletions(-) create mode 100644 ui/components/app/snaps/snap-ui-checkbox/index.ts create mode 100644 ui/components/app/snaps/snap-ui-checkbox/snap-ui-checkbox.tsx create mode 100644 ui/components/app/snaps/snap-ui-file-input/index.scss create mode 100644 ui/components/app/snaps/snap-ui-file-input/index.ts create mode 100644 ui/components/app/snaps/snap-ui-file-input/snap-ui-file-input.tsx create mode 100644 ui/components/app/snaps/snap-ui-renderer/components/checkbox.ts create mode 100644 ui/components/app/snaps/snap-ui-renderer/components/file-input.ts create mode 100644 ui/components/app/snaps/snap-ui-renderer/components/tooltip.ts create mode 100644 ui/components/app/snaps/snap-ui-tooltip/index.ts create mode 100644 ui/components/app/snaps/snap-ui-tooltip/snap-ui-tooltip.tsx diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 8a5b75b3524f..53961ff86b35 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -6173,6 +6173,12 @@ "updatedWithDate": { "message": "Updated $1" }, + "uploadDropFile": { + "message": "Drop your file here" + }, + "uploadFile": { + "message": "Upload file" + }, "urlErrorMsg": { "message": "URLs require the appropriate HTTP/HTTPS prefix." }, diff --git a/builds.yml b/builds.yml index af64cfb8d0ef..ff41977bf1cc 100644 --- a/builds.yml +++ b/builds.yml @@ -26,7 +26,7 @@ buildTypes: - SEGMENT_WRITE_KEY_REF: SEGMENT_PROD_WRITE_KEY - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.4.0/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.5.0/index.html - ACCOUNT_SNAPS_DIRECTORY_URL: https://snaps.metamask.io/account-management - BTC_BETA_SUPPORT: false # Main build uses the default browser manifest @@ -47,7 +47,7 @@ buildTypes: - SEGMENT_WRITE_KEY_REF: SEGMENT_BETA_WRITE_KEY - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.4.0/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.5.0/index.html - ACCOUNT_SNAPS_DIRECTORY_URL: https://snaps.metamask.io/account-management # Modifies how the version is displayed. # eg. instead of 10.25.0 -> 10.25.0-beta.2 @@ -68,7 +68,7 @@ buildTypes: - SEGMENT_FLASK_WRITE_KEY - ALLOW_LOCAL_SNAPS: true - REQUIRE_SNAPS_ALLOWLIST: false - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.4.0/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.5.0/index.html - SUPPORT_LINK: https://support.metamask.io/ - SUPPORT_REQUEST_LINK: https://support.metamask.io/ - INFURA_ENV_KEY_REF: INFURA_FLASK_PROJECT_ID @@ -92,7 +92,7 @@ buildTypes: - SEGMENT_WRITE_KEY_REF: SEGMENT_MMI_WRITE_KEY - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.4.0/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.5.0/index.html - MMI_CONFIGURATION_SERVICE_URL: https://configuration.metamask-institutional.io/v2/configuration/default - SUPPORT_LINK: https://mmi-support.metamask.io/hc/en-us - SUPPORT_REQUEST_LINK: https://mmi-support.metamask.io/hc/en-us/requests/new diff --git a/package.json b/package.json index ac75935f2f60..1afbb70c1e20 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,7 @@ "semver@7.3.8": "^7.5.4", "@trezor/schema-utils@npm:1.0.2": "patch:@trezor/schema-utils@npm%3A1.0.2#~/.yarn/patches/@trezor-schema-utils-npm-1.0.2-7dd48689b2.patch", "lavamoat-core@npm:^15.1.1": "patch:lavamoat-core@npm%3A15.1.1#~/.yarn/patches/lavamoat-core-npm-15.1.1-51fbe39988.patch", - "@metamask/snaps-sdk": "^5.0.0", + "@metamask/snaps-sdk": "^6.0.0", "@metamask/transaction-controller": "^32.0.0", "@babel/runtime@npm:^7.7.6": "patch:@babel/runtime@npm%3A7.24.0#~/.yarn/patches/@babel-runtime-npm-7.24.0-7eb1dd11a2.patch", "@babel/runtime@npm:^7.9.2": "patch:@babel/runtime@npm%3A7.24.0#~/.yarn/patches/@babel-runtime-npm-7.24.0-7eb1dd11a2.patch", @@ -335,11 +335,11 @@ "@metamask/selected-network-controller": "^15.0.2", "@metamask/signature-controller": "^16.0.0", "@metamask/smart-transactions-controller": "^10.1.2", - "@metamask/snaps-controllers": "^9.0.0", - "@metamask/snaps-execution-environments": "^6.4.0", - "@metamask/snaps-rpc-methods": "^9.1.3", - "@metamask/snaps-sdk": "^5.0.0", - "@metamask/snaps-utils": "^7.6.0", + "@metamask/snaps-controllers": "^9.2.0", + "@metamask/snaps-execution-environments": "^6.5.0", + "@metamask/snaps-rpc-methods": "^9.1.4", + "@metamask/snaps-sdk": "^6.0.0", + "@metamask/snaps-utils": "^7.7.0", "@metamask/transaction-controller": "^32.0.0", "@metamask/user-operation-controller": "^10.0.0", "@metamask/utils": "^8.2.1", diff --git a/test/e2e/snaps/enums.js b/test/e2e/snaps/enums.js index daeaf9db924f..98bbdfea099a 100644 --- a/test/e2e/snaps/enums.js +++ b/test/e2e/snaps/enums.js @@ -1,3 +1,3 @@ module.exports = { - TEST_SNAPS_WEBSITE_URL: 'https://metamask.github.io/snaps/test-snaps/2.9.0/', + TEST_SNAPS_WEBSITE_URL: 'https://metamask.github.io/snaps/test-snaps/2.11.0/', }; diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 34b0bce89369..63c916a5b256 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -23,6 +23,7 @@ @import 'snaps/snap-ui-renderer/index'; @import 'snaps/snap-ui-markdown/index'; @import 'snaps/snap-ui-button/index'; +@import 'snaps/snap-ui-file-input/index'; @import 'snaps/snap-delineator/index'; @import 'snaps/snap-list-item/index'; @import 'snaps/copyable/index'; diff --git a/ui/components/app/metamask-template-renderer/safe-component-list.js b/ui/components/app/metamask-template-renderer/safe-component-list.js index 2c7905c9d2a2..5c7f9218283a 100644 --- a/ui/components/app/metamask-template-renderer/safe-component-list.js +++ b/ui/components/app/metamask-template-renderer/safe-component-list.js @@ -27,10 +27,13 @@ import { SnapUIMarkdown } from '../snaps/snap-ui-markdown'; import { SnapUILink } from '../snaps/snap-ui-link'; import { SmartTransactionStatusPage } from '../../../pages/smart-transactions/smart-transaction-status-page'; import { SnapUIImage } from '../snaps/snap-ui-image'; +import { SnapUIFileInput } from '../snaps/snap-ui-file-input'; import { SnapUIInput } from '../snaps/snap-ui-input'; import { SnapUIForm } from '../snaps/snap-ui-form'; import { SnapUIButton } from '../snaps/snap-ui-button'; import { SnapUIDropdown } from '../snaps/snap-ui-dropdown'; +import { SnapUICheckbox } from '../snaps/snap-ui-checkbox'; +import { SnapUITooltip } from '../snaps/snap-ui-tooltip'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { SnapAccountSuccessMessage } from '../../../pages/confirmations/components/snap-account-success-message'; import { SnapAccountErrorMessage } from '../../../pages/confirmations/components/snap-account-error-message'; @@ -78,10 +81,13 @@ export const safeComponentList = { ConfirmInfoRow, ConfirmInfoRowAddress, ConfirmInfoRowValueDouble, + SnapUIFileInput, SnapUIInput, SnapUIButton, SnapUIForm, SnapUIDropdown, + SnapUICheckbox, + SnapUITooltip, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) CreateSnapAccount, RemoveSnapAccount, diff --git a/ui/components/app/snaps/snap-ui-button/snap-ui-button.tsx b/ui/components/app/snaps/snap-ui-button/snap-ui-button.tsx index 5c6735d6d16a..998a7ca2d782 100644 --- a/ui/components/app/snaps/snap-ui-button/snap-ui-button.tsx +++ b/ui/components/app/snaps/snap-ui-button/snap-ui-button.tsx @@ -36,7 +36,10 @@ export const SnapUIButton: FunctionComponent< event.preventDefault(); } - handleEvent({ event: UserInputEventType.ButtonClickEvent, name }); + handleEvent({ + event: UserInputEventType.ButtonClickEvent, + name, + }); }; const overriddenVariant = disabled ? 'disabled' : variant; diff --git a/ui/components/app/snaps/snap-ui-checkbox/index.ts b/ui/components/app/snaps/snap-ui-checkbox/index.ts new file mode 100644 index 000000000000..e75fe2634f38 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-checkbox/index.ts @@ -0,0 +1 @@ +export * from './snap-ui-checkbox'; diff --git a/ui/components/app/snaps/snap-ui-checkbox/snap-ui-checkbox.tsx b/ui/components/app/snaps/snap-ui-checkbox/snap-ui-checkbox.tsx new file mode 100644 index 000000000000..39df1c0ff8f7 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-checkbox/snap-ui-checkbox.tsx @@ -0,0 +1,81 @@ +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { useSnapInterfaceContext } from '../../../../contexts/snaps'; +import { + Display, + FlexDirection, +} from '../../../../helpers/constants/design-system'; +import { + Box, + Label, + HelpText, + HelpTextSeverity, + Checkbox, +} from '../../../component-library'; +import ToggleButton from '../../../ui/toggle-button'; + +export type SnapUICheckboxProps = { + name: string; + fieldLabel?: string; + variant?: 'default' | 'toggle'; + label?: string; + error?: string; + form?: string; +}; + +export const SnapUICheckbox: FunctionComponent = ({ + name, + variant, + fieldLabel, + label, + error, + form, + ...props +}) => { + const { handleInputChange, getValue } = useSnapInterfaceContext(); + + const initialValue = getValue(name, form); + + const [value, setValue] = useState(initialValue ?? false); + + useEffect(() => { + if (initialValue !== undefined && initialValue !== null) { + setValue(initialValue); + } + }, [initialValue]); + + const handleChange = () => { + setValue(!value); + handleInputChange(name, !value, form); + }; + + return ( + + {fieldLabel && } + {variant === 'toggle' ? ( + + ) : ( + + )} + {error && ( + + {error} + + )} + + ); +}; diff --git a/ui/components/app/snaps/snap-ui-dropdown/snap-ui-dropdown.tsx b/ui/components/app/snaps/snap-ui-dropdown/snap-ui-dropdown.tsx index f729436c776b..2e70f2809399 100644 --- a/ui/components/app/snaps/snap-ui-dropdown/snap-ui-dropdown.tsx +++ b/ui/components/app/snaps/snap-ui-dropdown/snap-ui-dropdown.tsx @@ -29,7 +29,7 @@ export const SnapUIDropdown: FunctionComponent = ({ }) => { const { handleInputChange, getValue } = useSnapInterfaceContext(); - const initialValue = getValue(name, form); + const initialValue = getValue(name, form); const [value, setValue] = useState(initialValue ?? ''); diff --git a/ui/components/app/snaps/snap-ui-file-input/index.scss b/ui/components/app/snaps/snap-ui-file-input/index.scss new file mode 100644 index 000000000000..787eb6150728 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-file-input/index.scss @@ -0,0 +1,21 @@ +.snap-ui-renderer { + &__file-input { + &__drop-zone { + background-color: var(--color-background-alternative); + + .mm-icon, + .mm-text { + color: var(--color-icon-alternative); + } + + &:hover .mm-icon, + &:hover .mm-text { + color: var(--color-info-default); + } + + &:hover { + background-color: var(--color-background-alternative-hover); + } + } + } +} diff --git a/ui/components/app/snaps/snap-ui-file-input/index.ts b/ui/components/app/snaps/snap-ui-file-input/index.ts new file mode 100644 index 000000000000..fa2b7b5ad2a1 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-file-input/index.ts @@ -0,0 +1 @@ +export * from './snap-ui-file-input'; diff --git a/ui/components/app/snaps/snap-ui-file-input/snap-ui-file-input.tsx b/ui/components/app/snaps/snap-ui-file-input/snap-ui-file-input.tsx new file mode 100644 index 000000000000..9508dae3e0d3 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-file-input/snap-ui-file-input.tsx @@ -0,0 +1,212 @@ +import React, { + ChangeEvent, + // eslint-disable-next-line @typescript-eslint/no-shadow + DragEvent, + FunctionComponent, + useRef, + useState, +} from 'react'; +import classnames from 'classnames'; +import { useSnapInterfaceContext } from '../../../../contexts/snaps'; +import { + Box, + ButtonIcon, + ButtonIconSize, + HelpText, + HelpTextSeverity, + Icon, + IconName, + IconSize, + Label, + Text, +} from '../../../component-library'; +import { + AlignItems, + BackgroundColor, + BorderColor, + BorderRadius, + BorderStyle, + Display, + FlexDirection, + IconColor, + JustifyContent, + TextAlign, +} from '../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; + +export type SnapUIFileInputProps = { + name: string; + label?: string; + form?: string; + accept?: string[]; + compact?: boolean; + error?: boolean; + helpText?: string; +}; + +/** + * A file input component, which is used to create a file input field for Snaps + * user interfaces. + * + * @param props - The props of the component. + * @param props.name - The name of the file input. This is used to identify the + * file input field in the form data. + * @param props.label - The label of the file input, which is displayed above + * the file input field. + * @param props.form - The name of the form that the file input belongs to. This + * is used to group the file input field with other form fields. + * @param props.accept - The types of files that the file input can accept. This + * is used to filter the files that the user can select when the input field is + * clicked. + * @param props.compact - Whether the file input should be displayed in a + * compact mode. In compact mode, the file input is displayed as a button with + * an icon. + * @param props.error - Whether the file input has an error. If the file input + * has an error, the help text is displayed in red. + * @param props.helpText - The help text of the file input, which is displayed + * below the file input field. + * @returns A file input element. + */ +export const SnapUIFileInput: FunctionComponent = ({ + name, + label, + form, + accept, + compact, + error, + helpText, +}) => { + const t = useI18nContext(); + const { handleFileChange } = useSnapInterfaceContext(); + const ref = useRef(null); + const [active, setActive] = useState(false); + + const handleClick = () => { + ref.current?.click(); + }; + + const handleChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] ?? null; + handleFileChange(name, file, form); + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + setActive(true); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + setActive(false); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + setActive(false); + + const file = event.dataTransfer?.files?.[0] ?? null; + handleFileChange(name, file, form); + }; + + const header = ( + <> + {label && ( + + )} + + + ); + + const footer = ( + <> + {helpText && ( + + {helpText} + + )} + + ); + + if (compact) { + return ( + + {header} + + {footer} + + ); + } + + return ( + + {header} + + + + {t('uploadDropFile')} + + + {footer} + + ); +}; diff --git a/ui/components/app/snaps/snap-ui-form/snap-ui-form.tsx b/ui/components/app/snaps/snap-ui-form/snap-ui-form.tsx index 4dfe245b854f..6f265bc71710 100644 --- a/ui/components/app/snaps/snap-ui-form/snap-ui-form.tsx +++ b/ui/components/app/snaps/snap-ui-form/snap-ui-form.tsx @@ -19,7 +19,10 @@ export const SnapUIForm: FunctionComponent = ({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); - handleEvent({ event: UserInputEventType.FormSubmitEvent, name }); + handleEvent({ + event: UserInputEventType.FormSubmitEvent, + name, + }); }; return ( diff --git a/ui/components/app/snaps/snap-ui-input/snap-ui-input.tsx b/ui/components/app/snaps/snap-ui-input/snap-ui-input.tsx index 2374e00f3927..43c612517139 100644 --- a/ui/components/app/snaps/snap-ui-input/snap-ui-input.tsx +++ b/ui/components/app/snaps/snap-ui-input/snap-ui-input.tsx @@ -17,7 +17,7 @@ export const SnapUIInput: FunctionComponent< > = ({ name, form, ...props }) => { const { handleInputChange, getValue } = useSnapInterfaceContext(); - const initialValue = getValue(name, form); + const initialValue = getValue(name, form); const [value, setValue] = useState(initialValue ?? ''); diff --git a/ui/components/app/snaps/snap-ui-renderer/components/checkbox.ts b/ui/components/app/snaps/snap-ui-renderer/components/checkbox.ts new file mode 100644 index 000000000000..862ca5203950 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-renderer/components/checkbox.ts @@ -0,0 +1,16 @@ +import { CheckboxElement } from '@metamask/snaps-sdk/jsx'; + +import { UIComponentFactory } from './types'; + +export const checkbox: UIComponentFactory = ({ + element, + form, +}) => ({ + element: 'SnapUICheckbox', + props: { + name: element.props.name, + label: element.props.label, + variant: element.props.variant, + form, + }, +}); diff --git a/ui/components/app/snaps/snap-ui-renderer/components/field.ts b/ui/components/app/snaps/snap-ui-renderer/components/field.ts index 193f618346ca..eb5b0b0e2256 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/field.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/field.ts @@ -4,10 +4,12 @@ import { ButtonElement, JSXElement, DropdownElement, + CheckboxElement, } from '@metamask/snaps-sdk/jsx'; import { getJsxChildren } from '@metamask/snaps-utils'; import { button as buttonFn } from './button'; import { dropdown as dropdownFn } from './dropdown'; +import { checkbox as checkboxFn } from './checkbox'; import { UIComponentFactory, UIComponentParams } from './types'; export const field: UIComponentFactory = ({ element, form }) => { @@ -16,12 +18,28 @@ export const field: UIComponentFactory = ({ element, form }) => { const child = children[0] as JSXElement; switch (child.type) { + case 'FileInput': { + return { + element: 'SnapUIFileInput', + props: { + name: child.props.name, + accept: child.props.accept, + compact: child.props.compact, + label: element.props.label, + form, + error: element.props.error !== undefined, + helpText: element.props.error, + }, + }; + } + case 'Input': { const input = child as InputElement; const button = children[1] as ButtonElement; const buttonMapped = button && buttonFn({ element: button } as UIComponentParams); + return { element: 'SnapUIInput', props: { @@ -66,6 +84,22 @@ export const field: UIComponentFactory = ({ element, form }) => { }; } + case 'Checkbox': { + const checkbox = child as CheckboxElement; + const checkboxMapped = checkboxFn({ + element: checkbox, + } as UIComponentParams); + return { + element: 'SnapUICheckbox', + props: { + ...checkboxMapped.props, + fieldLabel: element.props.label, + form, + error: element.props.error, + }, + }; + } + default: throw new Error(`Invalid Field child: ${child.type}`); } diff --git a/ui/components/app/snaps/snap-ui-renderer/components/file-input.ts b/ui/components/app/snaps/snap-ui-renderer/components/file-input.ts new file mode 100644 index 000000000000..edeba6d89551 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-renderer/components/file-input.ts @@ -0,0 +1,19 @@ +import { FileInputElement } from '@metamask/snaps-sdk/jsx'; + +import { UIComponentFactory } from './types'; + +export const fileInput: UIComponentFactory = ({ + element, + form, +}) => ({ + element: 'SnapUIInput', + props: { + element: 'SnapUIFileInput', + props: { + name: element.props.name, + accept: element.props.accept, + compact: element.props.compact, + form, + }, + }, +}); diff --git a/ui/components/app/snaps/snap-ui-renderer/components/index.ts b/ui/components/app/snaps/snap-ui-renderer/components/index.ts index 9aa6796de1a3..eed08c75fb91 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/index.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/index.ts @@ -8,6 +8,7 @@ import { row } from './row'; import { address } from './address'; import { copyable } from './copyable'; import { button } from './button'; +import { fileInput } from './file-input'; import { form } from './form'; import { input } from './input'; import { bold } from './bold'; @@ -16,6 +17,8 @@ import { link } from './link'; import { field } from './field'; import { dropdown } from './dropdown'; import { value } from './value'; +import { checkbox } from './checkbox'; +import { tooltip } from './tooltip'; export const COMPONENT_MAPPING = { Box: box, @@ -28,6 +31,7 @@ export const COMPONENT_MAPPING = { Row: row, Address: address, Button: button, + FileInput: fileInput, Form: form, Input: input, Bold: bold, @@ -36,4 +40,6 @@ export const COMPONENT_MAPPING = { Field: field, Dropdown: dropdown, Value: value, + Checkbox: checkbox, + Tooltip: tooltip, }; diff --git a/ui/components/app/snaps/snap-ui-renderer/components/text.ts b/ui/components/app/snaps/snap-ui-renderer/components/text.ts index 7ed7d9be1236..9502df1aabaa 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/text.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/text.ts @@ -23,5 +23,6 @@ export const text: UIComponentFactory = ({ overflowWrap: OverflowWrap.Anywhere, color: TextColor.inherit, className: 'snap-ui-renderer__text', + textAlign: element.props.alignment, }, }); diff --git a/ui/components/app/snaps/snap-ui-renderer/components/tooltip.ts b/ui/components/app/snaps/snap-ui-renderer/components/tooltip.ts new file mode 100644 index 000000000000..facf3dcd7168 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-renderer/components/tooltip.ts @@ -0,0 +1,23 @@ +import { JSXElement, Text, TooltipElement } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { mapToTemplate } from '../utils'; +import { UIComponentFactory } from './types'; + +export const tooltip: UIComponentFactory = ({ + element, + ...params +}) => ({ + element: 'SnapUITooltip', + children: getJsxChildren(element).map((children) => + mapToTemplate({ element: children as JSXElement, ...params }), + ), + propComponents: { + content: mapToTemplate({ + element: + typeof element.props.content === 'string' + ? Text({ children: element.props.content }) + : element.props.content, + ...params, + }), + }, +}); diff --git a/ui/components/app/snaps/snap-ui-renderer/components/types.ts b/ui/components/app/snaps/snap-ui-renderer/components/types.ts index ab1048d0b3a7..4e4824f686e1 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/types.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/types.ts @@ -1,4 +1,4 @@ -import { JSXElement, MaybeArray } from '@metamask/snaps-sdk/jsx'; +import { JSXElement, SnapsChildren } from '@metamask/snaps-sdk/jsx'; export type UIComponentParams = { map: Record; @@ -9,7 +9,7 @@ export type UIComponentParams = { export type UIComponent = { element: string; props?: Record; - children?: MaybeArray; + children?: SnapsChildren; key?: string; }; diff --git a/ui/components/app/snaps/snap-ui-tooltip/index.ts b/ui/components/app/snaps/snap-ui-tooltip/index.ts new file mode 100644 index 000000000000..8a97b4d74e26 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-tooltip/index.ts @@ -0,0 +1 @@ +export * from './snap-ui-tooltip'; diff --git a/ui/components/app/snaps/snap-ui-tooltip/snap-ui-tooltip.tsx b/ui/components/app/snaps/snap-ui-tooltip/snap-ui-tooltip.tsx new file mode 100644 index 000000000000..953ee50c87b5 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-tooltip/snap-ui-tooltip.tsx @@ -0,0 +1,22 @@ +import React, { FunctionComponent, ReactNode } from 'react'; +import Tooltip from '../../../ui/tooltip'; + +export type SnapUITooltipProps = { + content: ReactNode; +}; + +export const SnapUITooltip: FunctionComponent = ({ + content, + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/ui/contexts/snaps/snap-interface.tsx b/ui/contexts/snaps/snap-interface.tsx index 1943e35a2745..45af0572a03e 100644 --- a/ui/contexts/snaps/snap-interface.tsx +++ b/ui/contexts/snaps/snap-interface.tsx @@ -1,8 +1,10 @@ import { + File as FileObject, FormState, InterfaceState, UserInputEventType, } from '@metamask/snaps-sdk'; +import { encodeBase64 } from '@metamask/snaps-utils'; import { Json } from '@metamask/utils'; import { debounce, throttle } from 'lodash'; import React, { @@ -20,25 +22,32 @@ import { } from '../../store/actions'; import { mergeValue } from './utils'; -export type HandleEvent = (args: { +export type HandleEvent = (args: { event: UserInputEventType; name?: string; - value?: string; + value?: Type; flush?: boolean; }) => void; -export type HandleInputChange = ( +export type HandleInputChange = ( name: string, - value: string | null, + value: Type | null, form?: string, ) => void; -export type GetValue = (name: string, form?: string) => string | undefined; +export type GetValue = (name: string, form?: string) => Type | undefined; + +export type HandleFileChange = ( + name: string, + file: File | null, + form?: string, +) => void; export type SnapInterfaceContextType = { handleEvent: HandleEvent; getValue: GetValue; handleInputChange: HandleInputChange; + handleFileChange: HandleFileChange; }; export const SnapInterfaceContext = @@ -75,12 +84,13 @@ export const SnapInterfaceContextProvider: FunctionComponent< > = ({ children, interfaceId, snapId, initialState, context }) => { const dispatch = useDispatch(); - // We keep an internal copy of the state to speed-up the state update in the UI. - // It's kept in a ref to avoid useless re-rendering of the entire tree of components. + // We keep an internal copy of the state to speed up the state update in the + // UI. It's kept in a ref to avoid useless re-rendering of the entire tree of + // components. const internalState = useRef(initialState ?? {}); - // Since the internal state is kept in a reference, it won't update when the interface is updated. - // We have to manually update it + // Since the internal state is kept in a reference, it won't update when the + // interface is updated. We have to manually update it. useEffect(() => { internalState.current = initialState; }, [initialState]); @@ -88,8 +98,8 @@ export const SnapInterfaceContextProvider: FunctionComponent< const rawSnapRequestFunction = ( event: UserInputEventType, name?: string, - value?: string, - ) => + value?: unknown, + ) => { handleSnapRequest({ snapId, origin: '', @@ -109,13 +119,14 @@ export const SnapInterfaceContextProvider: FunctionComponent< }, }, }).then(() => forceUpdateMetamaskState(dispatch)); + }; - // The submittion of user input events is debounced or throttled to avoid crashing the snap if - // there's too much events sent at the same time + // The submission of user input events is debounced or throttled to avoid + // crashing the snap if there's too many events sent at the same time. const snapRequestDebounced = debounce(rawSnapRequestFunction, 200); const snapRequestThrottled = throttle(rawSnapRequestFunction, 200); - // The update of the state is debounced to avoid crashes due to too much + // The update of the state is debounced to avoid crashes due to too many // updates in a short amount of time. const updateStateDebounced = debounce( (state) => dispatch(updateInterfaceState(interfaceId, state)), @@ -129,7 +140,8 @@ export const SnapInterfaceContextProvider: FunctionComponent< * @param options.event - The event type. * @param options.name - The name of the component emitting the event. * @param options.value - The value of the component emitting the event. - * @param options.flush - Optional flag to indicate whether the debounce should be flushed. + * @param options.flush - Optional flag to indicate whether the debounce + * should be flushed. */ const handleEvent: HandleEvent = ({ event, @@ -139,9 +151,11 @@ export const SnapInterfaceContextProvider: FunctionComponent< }) => { // We always flush the debounced request for updating the state. updateStateDebounced.flush(); + const fn = THROTTLED_EVENTS.includes(event) ? snapRequestThrottled : snapRequestDebounced; + fn(event, name, value); // Certain events have their own debounce or throttling logic @@ -175,7 +189,73 @@ export const SnapInterfaceContextProvider: FunctionComponent< internalState.current = state; updateStateDebounced(state); - handleInputChangeDebounced(name, value ?? ''); + handleInputChangeDebounced(name, value); + }; + + const uploadFile = (name: string, file: File | null) => { + handleSnapRequest({ + snapId, + origin: '', + handler: 'onUserInput', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FileUploadEvent, + name, + file, + }, + id: interfaceId, + context, + }, + }, + }).then(() => forceUpdateMetamaskState(dispatch)); + }; + + /** + * Handle the file change of an input. + * + * @param name - The name of the input. + * @param file - The file to upload. + * @param form - The name of the form containing the input. + */ + const handleFileChange: HandleFileChange = (name, file, form) => { + if (file) { + file + .arrayBuffer() + .then((arrayBuffer) => new Uint8Array(arrayBuffer)) + .then((uint8Array) => encodeBase64(uint8Array)) + .then((base64) => { + const fileObject: FileObject = { + name: file.name, + size: file.size, + contentType: file.type, + contents: base64, + }; + + const state = mergeValue( + internalState.current, + name, + fileObject, + form, + ); + + internalState.current = state; + updateStateDebounced(state); + updateStateDebounced.flush(); + uploadFile(name, fileObject); + }); + + return; + } + + const state = mergeValue(internalState.current, name, null, form); + + internalState.current = state; + updateStateDebounced(state); + updateStateDebounced.flush(); + uploadFile(name, null); }; /** @@ -200,7 +280,7 @@ export const SnapInterfaceContextProvider: FunctionComponent< return ( {children} diff --git a/ui/contexts/snaps/utils.ts b/ui/contexts/snaps/utils.ts index 9d13c13eec1d..2dbf75be662a 100644 --- a/ui/contexts/snaps/utils.ts +++ b/ui/contexts/snaps/utils.ts @@ -10,10 +10,10 @@ import { FormState, InterfaceState } from '@metamask/snaps-sdk'; * Optional if the input is not contained in a form. * @returns The interface state with the new value merged in. */ -export const mergeValue = ( +export const mergeValue = ( state: InterfaceState, name: string, - value: string | null, + value: Type | null, form?: string, ): InterfaceState => { if (form) { diff --git a/yarn.lock b/yarn.lock index 3d4c54fc1746..c9fac8508f47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6396,9 +6396,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^9.0.0": - version: 9.0.0 - resolution: "@metamask/snaps-controllers@npm:9.0.0" +"@metamask/snaps-controllers@npm:^9.2.0": + version: 9.2.0 + resolution: "@metamask/snaps-controllers@npm:9.2.0" dependencies: "@metamask/approval-controller": "npm:^7.0.0" "@metamask/base-controller": "npm:^6.0.0" @@ -6410,9 +6410,9 @@ __metadata: "@metamask/post-message-stream": "npm:^8.1.0" "@metamask/rpc-errors": "npm:^6.2.1" "@metamask/snaps-registry": "npm:^3.1.0" - "@metamask/snaps-rpc-methods": "npm:^9.1.3" - "@metamask/snaps-sdk": "npm:^5.0.0" - "@metamask/snaps-utils": "npm:^7.6.0" + "@metamask/snaps-rpc-methods": "npm:^9.1.4" + "@metamask/snaps-sdk": "npm:^6.0.0" + "@metamask/snaps-utils": "npm:^7.7.0" "@metamask/utils": "npm:^8.3.0" "@xstate/fsm": "npm:^2.0.0" browserify-zlib: "npm:^0.2.0" @@ -6425,30 +6425,30 @@ __metadata: readable-web-to-node-stream: "npm:^3.0.2" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^6.4.0 + "@metamask/snaps-execution-environments": ^6.5.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/12e89e622865025e89c0f06f34e9dad278f91b3621e2b9944b154a7f190e5b47d5a899beb65e431627e023ebcd00969c492a280a5051239b57ed24cf5c3347ec + checksum: 10/3926bdfc3f064d57162825028aca2cf433e7adfe02ecdade174e2c75686c3dff407ee195f352662754846fc4edad8d2c6c5ce73471e3a09191a91070a98ba046 languageName: node linkType: hard -"@metamask/snaps-execution-environments@npm:^6.4.0": - version: 6.4.0 - resolution: "@metamask/snaps-execution-environments@npm:6.4.0" +"@metamask/snaps-execution-environments@npm:^6.5.0": + version: 6.5.0 + resolution: "@metamask/snaps-execution-environments@npm:6.5.0" dependencies: "@metamask/json-rpc-engine": "npm:^9.0.0" "@metamask/object-multiplex": "npm:^2.0.0" "@metamask/post-message-stream": "npm:^8.1.0" "@metamask/providers": "npm:^17.0.0" "@metamask/rpc-errors": "npm:^6.2.1" - "@metamask/snaps-sdk": "npm:^5.0.0" - "@metamask/snaps-utils": "npm:^7.6.0" + "@metamask/snaps-sdk": "npm:^6.0.0" + "@metamask/snaps-utils": "npm:^7.7.0" "@metamask/utils": "npm:^8.3.0" nanoid: "npm:^3.1.31" readable-stream: "npm:^3.6.2" superstruct: "npm:^1.0.3" - checksum: 10/ef487a7b8e2b1aa46c214d06e14d482a741abdc75f5904ff61f38b019cd253422cd5ae95fba7263b0fab57256eb081a3c44eeafcdff8ad71d5bf9163d65fd5dc + checksum: 10/f502fb1ff1c7c574bf4b7e189cc636d85aacb9e86116720a8c6305ce2054a3ebd966b2f73be242bf5f8f301c79f6b3461a7d2c631301b6593ad1d3f09e100c21 languageName: node linkType: hard @@ -6464,38 +6464,38 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^9.1.2, @metamask/snaps-rpc-methods@npm:^9.1.3": - version: 9.1.3 - resolution: "@metamask/snaps-rpc-methods@npm:9.1.3" +"@metamask/snaps-rpc-methods@npm:^9.1.2, @metamask/snaps-rpc-methods@npm:^9.1.4": + version: 9.1.4 + resolution: "@metamask/snaps-rpc-methods@npm:9.1.4" dependencies: "@metamask/key-tree": "npm:^9.1.1" "@metamask/permission-controller": "npm:^10.0.0" "@metamask/rpc-errors": "npm:^6.2.1" - "@metamask/snaps-sdk": "npm:^5.0.0" - "@metamask/snaps-utils": "npm:^7.6.0" + "@metamask/snaps-sdk": "npm:^6.0.0" + "@metamask/snaps-utils": "npm:^7.7.0" "@metamask/utils": "npm:^8.3.0" "@noble/hashes": "npm:^1.3.1" superstruct: "npm:^1.0.3" - checksum: 10/d346f12fb170dac694b4b5d1580fe9bc212cd266cece063636be85c442be0a7d0fa98e1d93d999da3dc67a9b5ce49c5a67e5345567b97062dd94e7d10c4e2959 + checksum: 10/db4963c2eaf1763ca48be4f095b0adae29596efc9ebf7876ac92fa6e3ef8d2bc5d45634293566b8a4703109c569f2666b4350700139d0566cb29f65be77a17f1 languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/snaps-sdk@npm:5.0.0" +"@metamask/snaps-sdk@npm:^6.0.0": + version: 6.0.0 + resolution: "@metamask/snaps-sdk@npm:6.0.0" dependencies: "@metamask/key-tree": "npm:^9.1.1" "@metamask/providers": "npm:^17.0.0" "@metamask/rpc-errors": "npm:^6.2.1" "@metamask/utils": "npm:^8.3.0" superstruct: "npm:^1.0.3" - checksum: 10/2419edb193c32fbd7292ebddbe37e9af6b09dc175fa03b74e9b732b859cdd1b6ddd5d18d19dcf0d550fdcfdb6210ec89168e020c382d3cf41178f51e0e1e888c + checksum: 10/91774ea791423a3cfef4ddbe30fc0351d9bfb9bad071ebce6a74be85cdd9b864b2666785cb6f0e8257a9778bc630f2de71544258d0367a93917ffe3d72ef90a5 languageName: node linkType: hard -"@metamask/snaps-utils@npm:^7.1.0, @metamask/snaps-utils@npm:^7.4.0, @metamask/snaps-utils@npm:^7.5.0, @metamask/snaps-utils@npm:^7.6.0": - version: 7.6.0 - resolution: "@metamask/snaps-utils@npm:7.6.0" +"@metamask/snaps-utils@npm:^7.1.0, @metamask/snaps-utils@npm:^7.4.0, @metamask/snaps-utils@npm:^7.5.0, @metamask/snaps-utils@npm:^7.7.0": + version: 7.7.0 + resolution: "@metamask/snaps-utils@npm:7.7.0" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" @@ -6505,7 +6505,7 @@ __metadata: "@metamask/rpc-errors": "npm:^6.2.1" "@metamask/slip44": "npm:^3.1.0" "@metamask/snaps-registry": "npm:^3.1.0" - "@metamask/snaps-sdk": "npm:^5.0.0" + "@metamask/snaps-sdk": "npm:^6.0.0" "@metamask/utils": "npm:^8.3.0" "@noble/hashes": "npm:^1.3.1" "@scure/base": "npm:^1.1.1" @@ -6520,7 +6520,7 @@ __metadata: ses: "npm:^1.1.0" superstruct: "npm:^1.0.3" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/e81e0185ab1678822b47e400c4422023449be1641bea7ce3d679386257545091ecc73c9a4626c0ed585d93d9fe3a1354dc8e4b8310c27c33871f7fb0bb029506 + checksum: 10/ac5b216b81157f71244e5922befccb214be402f0f5b784086cea46d7b0142f14f555ec7986cb7c867d1b99bfe43b08a687d8db22db85fada55764c2f4afcff97 languageName: node linkType: hard @@ -25506,11 +25506,11 @@ __metadata: "@metamask/selected-network-controller": "npm:^15.0.2" "@metamask/signature-controller": "npm:^16.0.0" "@metamask/smart-transactions-controller": "npm:^10.1.2" - "@metamask/snaps-controllers": "npm:^9.0.0" - "@metamask/snaps-execution-environments": "npm:^6.4.0" - "@metamask/snaps-rpc-methods": "npm:^9.1.3" - "@metamask/snaps-sdk": "npm:^5.0.0" - "@metamask/snaps-utils": "npm:^7.6.0" + "@metamask/snaps-controllers": "npm:^9.2.0" + "@metamask/snaps-execution-environments": "npm:^6.5.0" + "@metamask/snaps-rpc-methods": "npm:^9.1.4" + "@metamask/snaps-sdk": "npm:^6.0.0" + "@metamask/snaps-utils": "npm:^7.7.0" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:^8.4.0" "@metamask/transaction-controller": "npm:^32.0.0"