diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx index 4078026d14d..803a4405507 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.tsx @@ -20,6 +20,7 @@ import { } from './AvatarFavicon.constants'; import stylesheet from './AvatarFavicon.styles'; import { AvatarFaviconProps } from './AvatarFavicon.types'; +import { AvatarSize } from '../../Avatar.types'; const AvatarFavicon = ({ imageSource, @@ -50,7 +51,7 @@ const AvatarFavicon = ({ // requires that the domain is passed in as a prop from the parent const renderFallbackFavicon = () => ( ); @@ -104,8 +105,12 @@ const AvatarFavicon = ({ const renderFavicon = () => (svgSource ? renderSvg() : renderImage()); return ( - - {error || !isValidSource ? renderFallbackFavicon() : renderFavicon()} + + {error ? renderFallbackFavicon() : renderFavicon()} ); }; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.types.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.types.ts index bf1cce22dd8..1a5e4f0295a 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.types.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.types.ts @@ -1,7 +1,5 @@ // Third party dependencies. -import { ImageSourcePropType } from 'react-native'; - -// External dependencies. +import { ImageSourcePropType, StyleProp, ViewStyle } from 'react-native'; import { AvatarBaseProps } from '../../foundation/AvatarBase'; /** @@ -11,7 +9,20 @@ export interface AvatarFaviconProps extends AvatarBaseProps { /** * A favicon image from either a local or remote source. */ - imageSource: ImageSourcePropType; + imageSource?: ImageSourcePropType; + /** + * Optional boolean to includes border or not. + * @default false + */ + includesBorder?: boolean; + /** + * The name of the avatar. + */ + style?: StyleProp; + /** + * The name of the avatar. + */ + name?: string; } /** diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/__snapshots__/AvatarFavicon.test.tsx.snap b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/__snapshots__/AvatarFavicon.test.tsx.snap index 4adafb779d8..fa97b443e62 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/__snapshots__/AvatarFavicon.test.tsx.snap +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/__snapshots__/AvatarFavicon.test.tsx.snap @@ -2,7 +2,7 @@ exports[`AvatarFavicon should match the snapshot 1`] = ` { const { variant, ...restProps } = buttonProps; - switch (variant) { + // Capitalize first letter of variant. Snaps sends the variant in lowercase. + const capitalizedVariant = variant.charAt(0).toUpperCase() + variant.slice(1); + + switch (capitalizedVariant) { case ButtonVariants.Link: return ; case ButtonVariants.Primary: diff --git a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.types.ts b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.types.ts index 6b0fb32ca7e..9d212412015 100644 --- a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.types.ts +++ b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.types.ts @@ -1,10 +1,16 @@ // Third party dependencies. -import { ColorValue, TouchableOpacityProps } from 'react-native'; +import { + ColorValue, + StyleProp, + TextStyle, + TouchableOpacityProps, +} from 'react-native'; // External dependencies. import { IconProps } from '../../../../Icons/Icon'; import { ButtonSize, ButtonWidthTypes } from '../../Button.types'; import { TextVariant } from '../../../../Texts/Text'; +import { ButtonType } from '@metamask/snaps-sdk'; /** * ButtonBase component props. @@ -13,7 +19,7 @@ export interface ButtonBaseProps extends TouchableOpacityProps { /** * Button text. */ - label: string | React.ReactNode; + label?: string | React.ReactNode; /** * Optional prop for the color of label. Applies to icon too. */ @@ -55,6 +61,14 @@ export interface ButtonBaseProps extends TouchableOpacityProps { * An optional loading state of Button. */ loading?: boolean; + /** + * An optional type of Button. + */ + type?: ButtonType; + /** + * An optional text props of Button. + */ + textProps?: StyleProp; } /** diff --git a/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.tsx b/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.tsx index c402bd98bd8..c7073780cbf 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.tsx +++ b/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.tsx @@ -23,6 +23,8 @@ const ButtonPrimary = ({ onPressOut, isDanger = false, label, + startIconName, + endIconName, ...props }: ButtonPrimaryProps) => { const [pressed, setPressed] = useState(false); @@ -60,9 +62,9 @@ const ButtonPrimary = ({ label ); - const renderLoading = () => ( - - ); + const renderLoading = () => ( + + ); return ( + ); +}; diff --git a/app/components/Approvals/Snaps/SnapUIIcon/SnapUIIcon.tsx b/app/components/Approvals/Snaps/SnapUIIcon/SnapUIIcon.tsx new file mode 100644 index 00000000000..20ca9f27064 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIIcon/SnapUIIcon.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { IconColor } from '../SnapUIRenderer/utils'; +import Icon, { + IconName, + IconSize, +} from '../../../../component-library/components/Icons/Icon'; +import { ViewStyle } from 'react-native'; +import { StyleProp } from 'react-native'; + +export type SnapUIIconProps = { + name: IconName; + color?: IconColor; + size?: IconSize; + style?: StyleProp; +}; + +export const SnapUIIcon = ({ name, color, size, style }: SnapUIIconProps) => { + return ; +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/SnapInterfaceContext.tsx b/app/components/Approvals/Snaps/SnapUIRenderer/SnapInterfaceContext.tsx new file mode 100644 index 00000000000..0ed54f3ac83 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/SnapInterfaceContext.tsx @@ -0,0 +1,198 @@ +import { + FormState, + InterfaceState, + SnapId, + State, + UserInputEventType, +} from '@metamask/snaps-sdk'; +import { Json } from '@metamask/utils'; +import React, { + FunctionComponent, + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { mergeValue } from './utils'; +import Engine from '../../../../core/Engine/Engine'; +import { HandlerType } from '@metamask/snaps-utils'; +import { handleSnapRequest } from '../../../../core/Snaps/utils'; + +export type HandleEvent = (args: { + event: UserInputEventType; + name?: string; + value?: Type | null; +}) => void; + +export type HandleInputChange = ( + name: string, + value: Type | null, + form?: string, +) => void; + +export type GetValue = (name: string, form?: string) => State | undefined; + +export type SetCurrentInputFocus = (name: string | null) => void; + +export interface SnapInterfaceContextType { + handleEvent: HandleEvent; + getValue: GetValue; + handleInputChange: HandleInputChange; + setCurrentFocusedInput: SetCurrentInputFocus; + focusedInput: string | null; + snapId: string; +} + +export const SnapInterfaceContext = + createContext(null); + +export interface SnapInterfaceContextProviderProps { + interfaceId: string; + snapId: string; + initialState: InterfaceState; + context: Json; +} +/** + * The Snap interface context provider that handles all the interface state operations. + * + * @param params - The context provider params. + * @param params.children - The childrens to wrap with the context provider. + * @param params.interfaceId - The interface ID to use. + * @param params.snapId - The Snap ID that requested the interface. + * @param params.initialState - The initial state of the interface. + * @param params.context - The context blob of the interface. + * @returns The context provider. + */ +export const SnapInterfaceContextProvider: FunctionComponent< + SnapInterfaceContextProviderProps +> = ({ children, interfaceId, snapId, initialState, context }) => { + // 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 ?? {}); + const [focusedInput, setFocusedInput] = useState(null); + + // 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]); + + const controllerMessenger = Engine.controllerMessenger; + + const rawSnapRequestFunction = ( + event: UserInputEventType, + name?: string, + value?: unknown, + ) => { + handleSnapRequest(controllerMessenger, { + snapId: snapId as SnapId, + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: event, + ...(name !== undefined && name !== null ? { name } : {}), + ...(value !== undefined && value !== null ? { value } : {}), + }, + id: interfaceId, + context, + }, + }, + }); + }; + + const updateState = (state: InterfaceState) => + Engine.context.SnapInterfaceController.updateInterfaceState( + interfaceId, + state, + ); + /** + * Handle the submission of an user input event to the Snap. + * + * @param options - An options bag. + * @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. + */ + const handleEvent: HandleEvent = ({ + event, + name, + value = name ? internalState.current[name] : undefined, + }) => rawSnapRequestFunction(event, name, value); + + const submitInputChange = (name: string, value: State | null) => + handleEvent({ + event: UserInputEventType.InputChangeEvent, + name, + value, + }); + + /** + * Handle the value change of an input. + * + * @param name - The name of the input. + * @param value - The new value. + * @param form - The name of the form containing the input. + * Optional if the input is not contained in a form. + */ + const handleInputChange: HandleInputChange = (name, value, form) => { + const state = mergeValue(internalState.current, name, value, form); + + internalState.current = state; + updateState(state); + submitInputChange(name, value); + }; + + /** + * Get the value of an input from the interface state. + * + * @param name - The name of the input. + * @param form - The name of the form containing the input. + * Optional if the input is not contained in a form. + * @returns The value of the input or undefined if the input has no value. + */ + const getValue: GetValue = (name, form) => { + const value = form + ? (initialState[form] as FormState)?.[name] + : (initialState as FormState)?.[name]; + + if (value !== undefined && value !== null) { + return value; + } + + return undefined; + }; + + const setCurrentFocusedInput: SetCurrentInputFocus = (name) => { + setFocusedInput(name); + }; + + return ( + + {children} + + ); +}; + +/** + * The utility hook to consume the Snap inteface context. + * + * @returns The snap interface context. + */ +export function useSnapInterfaceContext() { + return useContext(SnapInterfaceContext) as SnapInterfaceContextType; +} diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/SnapUIRenderer.tsx b/app/components/Approvals/Snaps/SnapUIRenderer/SnapUIRenderer.tsx new file mode 100644 index 00000000000..4030ed8f6ed --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/SnapUIRenderer.tsx @@ -0,0 +1,91 @@ +import React, { memo, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Box } from '../../../../components/UI/Box'; +import { isEqual } from 'lodash'; +import { getMemoizedInterface } from '../../../../selectors/snaps/interfaceController'; +import { SnapInterfaceContextProvider } from './SnapInterfaceContext'; +import { mapToTemplate } from './utils'; +import TemplateRenderer from '../../../UI/TemplateRenderer'; +import { ActivityIndicator, Dimensions, StyleSheet } from 'react-native'; +import { Colors } from 'react-native/Libraries/NewAppScreen'; +import { Container } from '@metamask/snaps-sdk/jsx'; +import { strings } from '../../../../../locales/i18n'; + +interface SnapUIRendererProps { + snapId: string; + isLoading: boolean; + interfaceId: string; + onCancel: () => void; + onConfirm: () => void; +} + +const styles = StyleSheet.create({ + root: { + flexGrow: 1, + height: Dimensions.get('window').height * 0.5, + }, +}); + +// Component that maps Snaps UI JSON format to MetaMask Template Renderer format +const SnapUIRendererComponent = ({ + snapId, + isLoading = false, + interfaceId, + onCancel, + onConfirm, +}: SnapUIRendererProps) => { + const interfaceState = useSelector( + (state) => getMemoizedInterface(state, interfaceId), + // We only want to update the state if the content has changed. + // We do this to avoid useless re-renders. + (oldState, newState) => + isEqual(oldState?.content ?? null, newState?.content ?? null), + ); + + const rawContent = interfaceState?.content; + const content = + rawContent?.type === 'Container' || !rawContent + ? rawContent + : Container({ children: rawContent }); + + const useFooter = content?.props?.children?.[1]?.type === 'Footer'; + + // sections are memoized to avoid useless re-renders if one of the parents element re-renders. + const sections = useMemo( + () => + content && + mapToTemplate({ + map: {}, + element: content, + useFooter, + onCancel, + onConfirm, + t: strings, + }), + [content, useFooter, onCancel, onConfirm], + ); + + if (isLoading || !content) { + return ; + } + + const { state: initialState, context } = interfaceState; + return ( + + + + + + ); +}; + +// SnapUIRenderer is memoized to avoid useless re-renders if one of the parents element re-renders. +export const SnapUIRenderer = memo( + SnapUIRendererComponent, + (prevProps, nextProps) => isEqual(prevProps, nextProps), +); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/Heading.test.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/Heading.test.ts new file mode 100644 index 00000000000..e87bff67f33 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/Heading.test.ts @@ -0,0 +1,112 @@ +import { HeadingElement } from '@metamask/snaps-sdk/jsx'; +import { heading } from '../components/heading'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; + +describe('heading UIComponentFactory', () => { + it('should transform HeadingElement into Text format with default size', () => { + const mockHeadingElement: HeadingElement = { + type: 'Heading', + key: 'mock-key', + props: { + children: 'Test Heading', + }, + }; + + const result = heading({ + element: mockHeadingElement, + map: {}, + t: (key: string) => key, + }); + + expect(result).toEqual({ + element: 'Text', + children: 'Test Heading', + props: { + variant: TextVariant.HeadingSM, + numberOfLines: 0, + }, + }); + }); + + it('should handle empty children prop', () => { + const mockHeadingElement = { + type: 'Heading', + props: { + children: '', + }, + }; + + const result = heading({ + element: mockHeadingElement as HeadingElement, + map: {}, + t: (key: string) => key, + }); + + expect(result).toEqual({ + element: 'Text', + children: '', + props: { + variant: TextVariant.HeadingSM, + numberOfLines: 0, + }, + }); + }); + + it('should handle complex children content', () => { + const mockHeadingElement = { + type: 'Heading', + props: { + children: ['Multiple ', 'Text ', 'Nodes'], + }, + }; + + const result = heading({ + element: mockHeadingElement as HeadingElement, + map: {}, + t: (key: string) => key, + }); + + expect(result).toEqual({ + element: 'Text', + children: ['Multiple ', 'Text ', 'Nodes'], + props: { + variant: TextVariant.HeadingSM, + numberOfLines: 0, + }, + }); + }); + + it('should handle different heading sizes', () => { + const sizes = ['sm', 'md', 'lg'] as const; + const expectedVariants = { + sm: TextVariant.HeadingSM, + md: TextVariant.HeadingMD, + lg: TextVariant.HeadingLG, + }; + + sizes.forEach((size) => { + const mockHeadingElement = { + type: 'Heading', + props: { + children: 'Test', + size, + }, + }; + + const result = heading({ + element: mockHeadingElement as HeadingElement, + map: {}, + t: (key: string) => key, + }); + + expect(result).toEqual({ + element: 'Text', + children: 'Test', + props: { + variant: expectedVariants[size], + numberOfLines: 0, + }, + }); + }); + }); +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/Icon.test.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/Icon.test.ts new file mode 100644 index 00000000000..0e382f468ef --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/Icon.test.ts @@ -0,0 +1,76 @@ +import { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { IconColor } from '../utils'; +import { icon } from '../components/icon'; + +describe('Icon UIComponentFactory', () => { + const mockParams = { + map: {}, + t: jest.fn(), + }; + + it('should create correct element configuration with valid props', () => { + const mockElement = { + props: { + name: IconName.Danger, + color: 'primary', + size: 'md', + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = icon({ ...mockParams, element: mockElement as any }); + + expect(result).toEqual({ + element: 'SnapUIIcon', + props: { + name: IconName.Danger, + color: IconColor.primaryDefault, + size: IconSize.Md, + }, + }); + }); + + it('should handle minimal props with defaults', () => { + const mockElement = { + props: { + name: 'invalid-icon', + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = icon({ ...mockParams, element: mockElement as any }); + + expect(result).toEqual({ + element: 'SnapUIIcon', + props: { + name: IconName.Danger, // Invalid names default to Danger + color: IconColor.iconDefault, // Default color + size: IconSize.Inherit, // Default size + }, + }); + }); + + it('should map color values correctly', () => { + const mockElement = { + props: { + name: IconName.Danger, + color: 'muted', + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = icon({ ...mockParams, element: mockElement as any }); + + expect(result).toEqual({ + element: 'SnapUIIcon', + props: { + name: IconName.Danger, + color: IconColor.iconMuted, + size: IconSize.Inherit, + }, + }); + }); +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/SnapInterfaceContext.test.tsx b/app/components/Approvals/Snaps/SnapUIRenderer/components/SnapInterfaceContext.test.tsx new file mode 100644 index 00000000000..78f3422ce67 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/SnapInterfaceContext.test.tsx @@ -0,0 +1,212 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import React from 'react'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import Engine from '../../../../../core/Engine/Engine'; +import { handleSnapRequest } from '../../../../../core/Snaps/utils'; +import { mergeValue } from '../utils'; +import { HandlerType } from '@metamask/snaps-utils'; +import { + SnapInterfaceContextProvider, + useSnapInterfaceContext, +} from '../SnapInterfaceContext'; + +// Mock setup +jest.mock('../../../../../core/Engine/Engine', () => ({ + controllerMessenger: {}, + context: { + SnapInterfaceController: { + updateInterfaceState: jest.fn(), + }, + }, +})); +jest.mock('../../../../../core/Snaps/utils'); +jest.mock('../utils'); + +describe('SnapInterfaceContext', () => { + const mockInitialState = { + testInput: 'initial value', + testForm: { + formField: 'form value', + }, + }; + + const mockContext = {}; + const mockInterfaceId = 'test-interface'; + const mockSnapId = 'test-snap'; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + (mergeValue as jest.Mock).mockImplementation((state, name, value, form) => { + if (form) { + return { + ...state, + [form]: { + ...(state[form] || {}), + [name]: value, + }, + }; + } + return { + ...state, + [name]: value, + }; + }); + }); + + describe('useSnapInterfaceContext', () => { + it('provides context with all required methods and values', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + expect(result.current).toEqual( + expect.objectContaining({ + handleEvent: expect.any(Function), + getValue: expect.any(Function), + handleInputChange: expect.any(Function), + setCurrentFocusedInput: expect.any(Function), + focusedInput: null, + snapId: mockSnapId, + }), + ); + }); + + it('handles input focus state', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + act(() => { + result.current.setCurrentFocusedInput('testInput'); + }); + + expect(result.current.focusedInput).toBe('testInput'); + }); + + it('handles getValue correctly', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + expect(result.current.getValue('testInput')).toBe('initial value'); + expect(result.current.getValue('formField', 'testForm')).toBe( + 'form value', + ); + expect(result.current.getValue('nonexistent')).toBeUndefined(); + }); + + describe('handleEvent', () => { + it('handles button click events', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + act(() => { + result.current.handleEvent({ + event: UserInputEventType.ButtonClickEvent, + name: 'testButton', + }); + }); + + expect(handleSnapRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + snapId: mockSnapId, + handler: HandlerType.OnUserInput, + request: expect.objectContaining({ + params: expect.objectContaining({ + event: { + type: UserInputEventType.ButtonClickEvent, + name: 'testButton', + }, + }), + }), + }), + ); + }); + + it('handles input change events', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + act(() => { + result.current.handleInputChange('testInput', 'new value'); + }); + + expect( + Engine.context.SnapInterfaceController.updateInterfaceState, + ).toHaveBeenCalled(); + expect(handleSnapRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + snapId: mockSnapId, + handler: HandlerType.OnUserInput, + request: expect.objectContaining({ + params: expect.objectContaining({ + event: { + type: UserInputEventType.InputChangeEvent, + name: 'testInput', + value: 'new value', + }, + }), + }), + }), + ); + }); + }); + + describe('handleInputChange', () => { + it('updates form field state correctly', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + act(() => { + result.current.handleInputChange('newField', 'new value', 'testForm'); + }); + + expect( + Engine.context.SnapInterfaceController.updateInterfaceState, + ).toHaveBeenCalledWith( + mockInterfaceId, + expect.objectContaining({ + testForm: expect.objectContaining({ + newField: 'new value', + }), + }), + ); + }); + + it('handles null values', () => { + const { result } = renderHook(() => useSnapInterfaceContext(), { + wrapper, + }); + + act(() => { + result.current.handleInputChange('testInput', null); + }); + + expect( + Engine.context.SnapInterfaceController.updateInterfaceState, + ).toHaveBeenCalledWith( + mockInterfaceId, + expect.objectContaining({ + testInput: null, + }), + ); + }); + }); + }); +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/address.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/address.ts new file mode 100644 index 00000000000..42a4817ddcc --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/address.ts @@ -0,0 +1,13 @@ +import { AddressElement } from '@metamask/snaps-sdk/jsx'; +import { UIComponentFactory } from './types'; + +export const address: UIComponentFactory = ({ element }) => ({ + element: 'AddressElement', + props: { + address: element.props.address, + avatarSize: 'xs', + truncate: element.props.truncate, + displayName: element.props.displayName, + avatar: element.props.avatar, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/bold.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/bold.ts new file mode 100644 index 00000000000..a731884f4df --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/bold.ts @@ -0,0 +1,23 @@ +import { BoldElement, JSXElement } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { NonEmptyArray } from '@metamask/utils'; +import { mapTextToTemplate } from '../utils'; +import { UIComponentFactory } from './types'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; +import { TextColor } from '../../../../../component-library/components/Texts/Text/Text.types'; + +export const bold: UIComponentFactory = ({ + element, + ...params +}) => ({ + element: 'Text', + children: mapTextToTemplate( + getJsxChildren(element) as NonEmptyArray, + params, + ), + props: { + variant: TextVariant.BodyMD, + color: TextColor.Default, + as: 'b', + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/box.test.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/box.test.ts new file mode 100644 index 00000000000..7f9f77f505d --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/box.test.ts @@ -0,0 +1,131 @@ +import { BoxElement } from '@metamask/snaps-sdk/jsx'; +import { box } from './box'; +import { FlexDirection } from '../utils'; +import { TextColor } from '../../../../../component-library/components/Texts/Text'; + +describe('box UIComponentFactory', () => { + const mockParams = { + map: {}, + t: (key: string) => key, + }; + + const createTextElement = (text: string) => ({ + type: 'Text', + key: 'mock-key', + props: { children: text }, + }); + + it('should transform BoxElement with default props', () => { + const mockElement: BoxElement = { + type: 'Box', + key: 'mock-key', + props: { + children: [createTextElement('Test content')], + }, + }; + + const result = box({ + element: mockElement, + ...mockParams, + }); + + expect(result).toEqual({ + element: 'Box', + children: [ + { + element: 'Text', + key: 'mock-key', + children: ['Test content'], + props: { + color: 'Default', + fontWeight: 'normal', + textAlign: 'left', + variant: 'sBodyMD', + }, + }, + ], + props: { + flexDirection: FlexDirection.Column, + justifyContent: 'flex-start', + color: TextColor.Default, + alignItems: 'center', + }, + }); + }); + + it('should handle horizontal direction', () => { + const mockElement: BoxElement = { + type: 'Box', + key: 'mock-key', + props: { + direction: 'horizontal', + children: [createTextElement('Test content')], + }, + }; + + const result = box({ + element: mockElement, + ...mockParams, + }); + + expect(result.props?.flexDirection).toBe(FlexDirection.Row); + }); + + it('should handle different alignments', () => { + const alignments = [ + 'center', + 'end', + 'space-between', + 'space-around', + ] as const; + + alignments.forEach((alignment) => { + const mockElement: BoxElement = { + type: 'Box', + key: 'mock-key', + props: { + alignment, + children: [createTextElement('Test content')], + }, + }; + + const result = box({ + element: mockElement, + ...mockParams, + }); + + const expected = { + center: 'center', + end: 'flex-end', + 'space-between': 'space-between', + 'space-around': 'space-around', + }[alignment]; + + expect(result.props?.justifyContent).toBe(expected); + }); + }); + + it('should pass through additional BoxProps', () => { + const mockElement: BoxElement = { + type: 'Box', + key: 'mock-key', + props: { + children: [createTextElement('Test content')], + direction: 'horizontal', + alignment: 'center', + }, + }; + + const result = box({ + element: mockElement, + ...mockParams, + }); + + expect(result.props).toEqual( + expect.objectContaining({ + flexDirection: FlexDirection.Row, + justifyContent: 'center', + }), + ); + }); +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/box.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/box.ts new file mode 100644 index 00000000000..6925263d1a0 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/box.ts @@ -0,0 +1,50 @@ +import { BoxElement, JSXElement, BoxProps } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { NonEmptyArray } from '@metamask/utils'; +import { + AlignItems, + FlexDirection, + JustifyContent, + mapToTemplate, +} from '../utils'; +import { UIComponent, UIComponentFactory } from './types'; +import { TextColor } from '../../../../../component-library/components/Texts/Text/Text.types'; + +function generateJustifyContent(alignment?: BoxProps['alignment']) { + switch (alignment) { + default: + case 'start': + return JustifyContent.flexStart; + + case 'center': + return JustifyContent.center; + + case 'end': + return JustifyContent.flexEnd; + + case 'space-between': + return JustifyContent.spaceBetween; + + case 'space-around': + return JustifyContent.spaceAround; + } +} + +export const box: UIComponentFactory = ({ + element, + ...params +}) => ({ + element: 'Box', + children: getJsxChildren(element).map((children) => + mapToTemplate({ ...params, element: children as JSXElement }), + ) as NonEmptyArray, + props: { + flexDirection: + element.props.direction === 'horizontal' + ? FlexDirection.Row + : FlexDirection.Column, + justifyContent: generateJustifyContent(element.props.alignment), + alignItems: element.props.center || AlignItems.center, + color: TextColor.Default, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/box.types.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/box.types.ts new file mode 100644 index 00000000000..ca76d526782 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/box.types.ts @@ -0,0 +1,448 @@ +import React from 'react'; +import { + AlignItems, + TextAlign, + BlockSize, + Display, + FlexDirection, + JustifyContent, + Color, + BackgroundColor, + BorderColor, + BorderRadius, + BorderStyle, + FlexWrap, +} from '../utils'; +import { TextColor } from '../../../../../component-library/components/Texts/Text'; +import { IconColor } from '../../../../../component-library/components/Icons/Icon'; + +export type StyleDeclarationType = + | 'margin' + | 'margin-top' + | 'margin-right' + | 'margin-bottom' + | 'margin-left' + | 'margin-inline' + | 'margin-inline-start' + | 'margin-inline-end' + | 'padding' + | 'padding-top' + | 'padding-right' + | 'padding-bottom' + | 'padding-left' + | 'padding-inline' + | 'padding-inline-start' + | 'padding-inline-end' + | 'display' + | 'gap' + | 'flex-direction' + | 'flex-wrap' + | 'justify-content' + | 'align-items' + | 'text-align' + | 'width' + | 'min-width' + | 'height' + | 'color' + | 'background-color' + | 'rounded' + | 'border-style' + | 'border-color' + | 'border-width'; + +export type StylePropValueType = + | AlignItems + | AlignItemsArray + | BackgroundColor + | BackgroundColorArray + | BlockSize + | BlockSizeArray + | BorderColor + | BorderColorArray + | BorderRadius + | BorderRadiusArray + | BorderStyle + | BorderStyleArray + | Color + | Display + | DisplayArray + | FlexDirection + | FlexDirectionArray + | FlexWrap + | FlexWrapArray + | IconColor + | JustifyContent + | JustifyContentArray + | SizeNumberAndAuto + | SizeNumberAndAutoArray + | TextAlign + | TextAlignArray + | TextColor + | TextColorArray + | IconColorArray + | undefined; + +export interface ClassNamesObject { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export type FlexDirectionArray = [ + FlexDirection, + FlexDirection?, + FlexDirection?, + FlexDirection?, +]; +export type FlexWrapArray = [FlexWrap, FlexWrap?, FlexWrap?, FlexWrap?]; +export type TextAlignArray = [TextAlign, TextAlign?, TextAlign?, TextAlign?]; +export type DisplayArray = [Display, Display?, Display?, Display?]; +export type BlockSizeArray = [BlockSize, BlockSize?, BlockSize?, BlockSize?]; + +export type SizeNumber = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | null; + +export type SizeNumberArray = [ + SizeNumber, + SizeNumber?, + SizeNumber?, + SizeNumber?, +]; + +export type SizeNumberAndAuto = SizeNumber | 'auto'; + +export type SizeNumberAndAutoArray = [ + SizeNumberAndAuto, + SizeNumberAndAuto?, + SizeNumberAndAuto?, + SizeNumberAndAuto?, +]; + +export type BorderColorArray = [ + BorderColor, + BorderColor?, + BorderColor?, + BorderColor?, +]; + +export type BorderRadiusArray = [ + BorderRadius, + BorderRadius?, + BorderRadius?, + BorderRadius?, +]; + +export type BorderStyleArray = [ + BorderStyle, + BorderStyle?, + BorderStyle?, + BorderStyle?, +]; + +export type AlignItemsArray = [ + AlignItems, + AlignItems?, + AlignItems?, + AlignItems?, +]; + +export type JustifyContentArray = [ + JustifyContent, + JustifyContent?, + JustifyContent?, + JustifyContent?, +]; + +export type BackgroundColorArray = [ + BackgroundColor, + BackgroundColor?, + BackgroundColor?, + BackgroundColor?, +]; + +export type TextColorArray = [TextColor, TextColor?, TextColor?, TextColor?]; + +export type IconColorArray = [IconColor, IconColor?, IconColor?, IconColor?]; + +/** + * Polymorphic props based on Ohans Emmanuel's article below + * https://blog.logrocket.com/build-strongly-typed-polymorphic-components-react-typescript/#ensuring-as-prop-only-receives-valid-html-element-strings + */ + +/** + * Uses generic type C to create polymorphic ref type + */ +export type PolymorphicRef = + React.ComponentPropsWithRef['ref']; + +/** + * Uses generic type C to define the type for the polymorphic "as" prop + * "as" can be used to override the default HTML element + */ +interface AsProp { + /** + * An override of the default HTML tag. + * Can also be a React component. + */ + as?: C; +} + +/** + * Omits the as prop and props from component definition + */ +type PropsToOmit = keyof (AsProp & P); + +/** + * Accepts 2 generic types: C which represents the as prop and the component props - Props + */ +type PolymorphicComponentProp< + C extends React.ElementType, + // eslint-disable-next-line @typescript-eslint/ban-types + Props = {}, +> = React.PropsWithChildren> & + Omit, PropsToOmit>; + +export type PolymorphicComponentPropWithRef< + C extends React.ElementType, + // eslint-disable-next-line @typescript-eslint/ban-types + Props = {}, +> = PolymorphicComponentProp & { ref?: PolymorphicRef }; + +/** + * Includes all style utility props. This should be used to extend the props of a component. + */ +export interface StyleUtilityProps { + /** + * The flex direction of the component. + * Use the FlexDirection enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + flexDirection?: FlexDirection | FlexDirectionArray; + /** + * The flex wrap of the component. + * Use the FlexWrap enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + flexWrap?: FlexWrap | FlexWrapArray; + /** + * The gap between the component's children. + * Use 1-12 for a gap of 4px-48px. + * Accepts responsive props in the form of an array. + */ + gap?: SizeNumber | SizeNumberArray | undefined; + /** + * The margin of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + margin?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-top of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginTop?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-bottom of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginBottom?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-right of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginRight?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-left of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginLeft?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-inline of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginInline?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-inline-start of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginInlineStart?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The margin-inline-end of the component. + * Use 1-12 for 4px-48px or 'auto'. + * Accepts responsive props in the form of an array. + */ + marginInlineEnd?: SizeNumberAndAuto | SizeNumberAndAutoArray; + /** + * The padding of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + padding?: SizeNumber | SizeNumberArray; + /** + * The padding-top of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingTop?: SizeNumber | SizeNumberArray; + /** + * The padding-bottom of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingBottom?: SizeNumber | SizeNumberArray; + /** + * The padding-right of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingRight?: SizeNumber | SizeNumberArray; + /** + * The padding-left of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingLeft?: SizeNumber | SizeNumberArray; + /** + * The padding-inline of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingInline?: SizeNumber | SizeNumberArray; + /** + * The padding-inline-start of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingInlineStart?: SizeNumber | SizeNumberArray; + /** + * The padding-inline-end of the component. + * Use 1-12 for 4px-48px. + * Accepts responsive props in the form of an array. + */ + paddingInlineEnd?: SizeNumber | SizeNumberArray; + /** + * The border-color of the component. + * Use BorderColor enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + borderColor?: BorderColor | BorderColorArray; + /** + * The border-width of the component. + * Use 1-12 for 1px-12px. + * Accepts responsive props in the form of an array. + */ + borderWidth?: SizeNumber | SizeNumberArray; + /** + * The border-radius of the component. + * Use BorderRadius enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + borderRadius?: BorderRadius | BorderRadiusArray; + /** + * The border-style of the component. + * Use BorderStyle enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + borderStyle?: BorderStyle | BorderStyleArray; + /** + * The align-items of the component. + * Use AlignItems enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + alignItems?: AlignItems | AlignItemsArray; + /** + * The justify-content of the component. + * Use JustifyContent enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + justifyContent?: JustifyContent | JustifyContentArray; + /** + * The text-align of the component. + * Use TextAlign enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + textAlign?: TextAlign | TextAlignArray; + /** + * The display of the component. + * Use Display enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + display?: Display | DisplayArray; + /** + * The width of the component. + * Use BlockSize enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + width?: BlockSize | BlockSizeArray; + /** + * The min-width of the component. + * Use BlockSize enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + minWidth?: BlockSize | BlockSizeArray; + /** + * The height of the component. + * Use BlockSize enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + height?: BlockSize | BlockSizeArray; + /** + * The background-color of the component. + * Use BackgroundColor enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + backgroundColor?: BackgroundColor | BackgroundColorArray; + /** + * The text-color of the component. + * Use TextColor enum from '../../../helpers/constants/design-system'; + * Accepts responsive props in the form of an array. + */ + color?: TextColor | TextColorArray | IconColor | IconColorArray; + /** + * An optional data-testid to apply to the component. + * TypeScript is complaining about data- attributes which means we need to explicitly define this as a prop. + * TODO: Allow data- attributes. + */ + 'data-testid'?: string; +} +/** + * Box component props. + */ +// TODO: Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +interface Props extends StyleUtilityProps { + /** + * The content of the Box component. + */ + children?: React.ReactNode; + /** + * Additional className to apply to the Box component. + */ + className?: string; +} + +export type BoxProps = + PolymorphicComponentPropWithRef; + +export type BoxComponent = ( + props: BoxProps, +) => React.ReactElement | null; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/button.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/button.ts new file mode 100644 index 00000000000..523874402eb --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/button.ts @@ -0,0 +1,41 @@ +import { + ButtonElement, + ButtonProps, + JSXElement, +} from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { NonEmptyArray } from '@metamask/utils'; +import { mapTextToTemplate } from '../utils'; +import { UIComponentFactory } from './types'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; + +interface ButtonElementProps extends ButtonElement { + props: ButtonProps & { + loading?: boolean; + size?: 'sm' | 'md'; + }; +} + +export const button: UIComponentFactory = ({ + element, + ...params +}) => { + return { + element: 'Button', + props: { + type: element.type, + form: element.props.form, + variant: element.props.variant, + name: element.props.name, + disabled: element.props.disabled, + loading: element.props.loading ?? false, + label: element.props.children, + textVariant: + element.props.size === 'sm' ? TextVariant.BodySM : TextVariant.BodyMD, + }, + children: mapTextToTemplate( + getJsxChildren(element) as NonEmptyArray, + params, + ), + }; +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/card.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/card.ts new file mode 100644 index 00000000000..a3d19bf84fc --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/card.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { CardElement } from '@metamask/snaps-sdk/jsx'; +import { UIComponentFactory } from './types'; + +export const card: UIComponentFactory = ({ element }) => ({ + element: 'SnapUICard', + props: { + image: element.props.image, + title: element.props.title, + description: element.props.description, + value: element.props.value, + extra: element.props.extra, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/container.test.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/container.test.ts new file mode 100644 index 00000000000..af0a9372687 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/container.test.ts @@ -0,0 +1,92 @@ +import { BoxElement } from '@metamask/snaps-sdk/jsx'; +import { container } from '../components/container'; +import { mapToTemplate } from '../utils'; + +// First, properly mock the utils module +jest.mock('../utils', () => ({ + mapToTemplate: jest.fn(), +})); + +describe('container', () => { + const mockT = (key: string) => key; + + // Add beforeEach to set up the mock implementation for each test + beforeEach(() => { + (mapToTemplate as jest.Mock).mockReset(); + (mapToTemplate as jest.Mock).mockImplementation((e) => ({ + element: e.element.type, + props: e.element.props || {}, + children: e.element.children || [], + })); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createMockElement = (children: any[] = []): BoxElement => ({ + type: 'Box', + props: { + children, + }, + key: 'mock-key', + }); + + it('should render basic container with single child', () => { + const mockElement = createMockElement([ + { type: 'text', props: {}, children: ['Hello'] }, + ]); + + const result = container({ + element: mockElement, + useFooter: false, + t: mockT, + map: {}, + }); + + expect(result).toEqual({ + element: 'View', + children: [ + { + element: 'text', + props: {}, + children: ['Hello'], + }, + ], + props: { + style: { + flex: 1, + flexDirection: 'column', + }, + }, + }); + }); + + it('should add footer button when useFooter is true and onCancel is provided', () => { + const mockElement = createMockElement([]); + const mockOnCancel = jest.fn(); + + const result = container({ + element: mockElement, + useFooter: true, + onCancel: mockOnCancel, + t: mockT, + map: {}, + }); + + expect(Array.isArray(result.children)).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.children as any[])[0]).toEqual({ + element: '', + props: { + style: { alignItems: 'center' }, + }, + children: { + element: 'SnapUIFooterButton', + key: 'default-button', + props: { + onCancel: mockOnCancel, + isSnapAction: false, + }, + children: 'close', + }, + }); + }); +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/container.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/container.ts new file mode 100644 index 00000000000..b47428fe979 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/container.ts @@ -0,0 +1,72 @@ +import { BoxElement, JSXElement } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { mapToTemplate } from '../utils'; +import { UIComponentFactory } from './types'; + +export const container: UIComponentFactory = ({ + element: e, + useFooter, + onCancel, + promptLegacyProps, + t, + ...params +}) => { + const children = getJsxChildren(e); + + if (!useFooter && children.length === 2) { + children.pop(); + } + + const templateChildren = children.map((child) => + mapToTemplate({ + useFooter, + onCancel, + t, + ...params, + element: child as JSXElement, + }), + ); + + if (promptLegacyProps) { + templateChildren.push({ + element: 'FormTextField', + key: 'snap-prompt-input', + props: { + style: { marginHorizontal: 4 }, + value: promptLegacyProps.inputValue, + onChangeText: promptLegacyProps.onInputChange, + placeholder: promptLegacyProps.placeholder, + maxLength: 300, + }, + }); + } + + if (useFooter && onCancel && !children[1]) { + templateChildren.push({ + props: { + style: { alignItems: 'center' }, + }, + children: { + element: 'SnapUIFooterButton', + key: 'default-button', + props: { + onCancel, + isSnapAction: false, + }, + children: t('close'), + }, + element: '', + }); + } + + return { + element: 'View', + children: templateChildren, + props: { + style: { + flex: 1, + flexDirection: 'column', + }, + }, + }; +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/footer.test.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/footer.test.ts new file mode 100644 index 00000000000..7ec68c0f75e --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/footer.test.ts @@ -0,0 +1,138 @@ +import { ButtonElement, FooterElement } from '@metamask/snaps-sdk/jsx'; +import { footer, DEFAULT_FOOTER } from '../components/footer'; +import { ButtonVariant } from '@metamask/snaps-sdk'; + +describe('footer', () => { + const mockT = (value: string) => `translated_${value}`; + const mockOnCancel = jest.fn(); + + const createButtonElement = ( + name?: string, + text: string = 'Button', + ): ButtonElement => ({ + key: 'mock-key', + type: 'Button', + props: { + children: [text], + ...(name ? { name } : {}), + }, + }); + + const createFooterElement = ( + children: ButtonElement[] = [], + ): FooterElement => ({ + key: 'mock-key', + type: 'Footer', + props: { + children: + children.length === 2 + ? ([children[0], children[1]] as [ButtonElement, ButtonElement]) + : children[0] || createButtonElement(), + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return default footer structure with no buttons when no children and no onCancel', () => { + const footerElement = createFooterElement([]); + + const result = footer({ + element: footerElement, + t: mockT, + map: {}, + }); + + expect(result).toEqual({ + ...DEFAULT_FOOTER, + children: [ + { + element: 'SnapUIFooterButton', + key: 'snap-footer-button-0', + props: { + disabled: undefined, + form: undefined, + isSnapAction: true, + label: ['Button'], + loading: false, + name: undefined, + onCancel: undefined, + onConfirm: undefined, + textVariant: 'sBodyMD', + type: 'Button', + variant: 'primary', + }, + children: ['Button'], + }, + ], + }); + }); + + it('should add cancel button when onCancel is provided and only one child', () => { + const footerElement = createFooterElement([ + createButtonElement('confirm', 'Confirm'), + ]); + + const result = footer({ + element: footerElement, + t: mockT, + onCancel: mockOnCancel, + map: {}, + }); + + expect(Array.isArray(result.children)).toBe(true); + expect((result.children as any[])[0]).toEqual({ + element: 'BottomSheetFooter', + key: 'default-button', + props: { + isSnapAction: false, + label: 'translated_template_confirmation.cancel', + onCancel: mockOnCancel, + variant: 'secondary', + }, + }); + }); + + it('should handle multiple buttons with correct variants', () => { + const footerElement = createFooterElement([ + createButtonElement('reject', 'Reject'), + createButtonElement('confirm', 'Confirm'), + ]); + + const result = footer({ + element: footerElement, + t: mockT, + map: {}, + }); + + expect(Array.isArray(result.children)).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.children as any[])[0].props.variant).toBe( + ButtonVariant.Secondary, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.children as any[])[1].props.variant).toBe( + ButtonVariant.Primary, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.children as any[])[0].props.isSnapAction).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.children as any[])[1].props.isSnapAction).toBe(true); + }); + + it('should use index as key when button name is not provided', () => { + const footerElement = createFooterElement([ + createButtonElement(undefined, 'Button'), + ]); + + const result = footer({ + element: footerElement, + t: mockT, + map: {}, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.children as any[])[0].key).toBe('snap-footer-button-0'); + }); +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/footer.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/footer.ts new file mode 100644 index 00000000000..1abd89e82c9 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/footer.ts @@ -0,0 +1,94 @@ +import { FooterElement, ButtonElement } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { UIComponent, UIComponentFactory, UIComponentParams } from './types'; +import { ButtonVariant } from '@metamask/snaps-sdk'; +import { button as buttonFn } from './button'; +import { TemplateConfirmation } from '../../SnapDialogApproval/SnapsDialogApproval'; + +export const DEFAULT_FOOTER = { + element: 'Box', + key: 'default-footer', + props: { + flexDirection: 'row', + width: '100%', + gap: 4, + padding: 4, + style: { + position: 'absolute', + bottom: 0, + width: '100%', + justifyContent: 'space-evenly', + paddingVertical: 20, + }, + }, +}; + +const getDefaultButtons = ( + footer: FooterElement, + t: (value: string) => string, + onCancel?: () => void, +) => { + const children = getJsxChildren(footer); + + // If onCancel is omitted by the caller we assume that it is safe to not display the default footer. + if (children.length === 1 && onCancel) { + return { + element: 'BottomSheetFooter', + key: 'default-button', + props: { + onCancel, + variant: ButtonVariant.Secondary, + isSnapAction: false, + label: t(TemplateConfirmation.CANCEL), + }, + }; + } + + return undefined; +}; + +export const footer: UIComponentFactory = ({ + element: e, + t, + onCancel, + onConfirm, + ...params +}) => { + const defaultButtons = getDefaultButtons(e, t, onCancel); + const providedChildren = getJsxChildren(e); + + const footerChildren: UIComponent[] = ( + providedChildren as ButtonElement[] + ).map((children, index) => { + const buttonMapped = buttonFn({ + ...params, + t, + element: children, + } as UIComponentParams); + + return { + element: 'SnapUIFooterButton', + key: `snap-footer-button-${buttonMapped.props?.name ?? index}`, + props: { + ...buttonMapped.props, + variant: + providedChildren.length === 2 && index === 0 + ? ButtonVariant.Secondary + : ButtonVariant.Primary, + isSnapAction: true, + onCancel, + onConfirm, + }, + children: buttonMapped.children, + }; + }); + + if (defaultButtons) { + footerChildren.unshift(defaultButtons as UIComponent); + } + + return { + ...DEFAULT_FOOTER, + children: footerChildren, + }; +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/heading.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/heading.ts new file mode 100644 index 00000000000..5888711410e --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/heading.ts @@ -0,0 +1,25 @@ +import { HeadingElement } from '@metamask/snaps-sdk/jsx'; +import { UIComponentFactory } from './types'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; + +export const generateSize = (size: HeadingElement['props']['size']) => { + switch (size) { + case 'sm': + return TextVariant.HeadingSM; + case 'md': + return TextVariant.HeadingMD; + case 'lg': + return TextVariant.HeadingLG; + default: + return TextVariant.HeadingSM; + } +}; + +export const heading: UIComponentFactory = ({ element }) => ({ + element: 'Text', + children: element.props.children, + props: { + variant: generateSize(element.props.size), + numberOfLines: 0, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/icon.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/icon.ts new file mode 100644 index 00000000000..652da5fdc20 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/icon.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { IconElement } from '@metamask/snaps-sdk/jsx'; +import { UIComponentFactory } from './types'; +import { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { IconColor } from '../utils'; + +const ICON_NAMES = new Set(Object.values(IconName)); + +export const icon: UIComponentFactory = ({ element }) => { + const getIconName = () => { + if (ICON_NAMES.has(element.props.name as IconName)) { + return element.props.name as IconName; + } + return IconName.Danger; + }; + + const getIconColor = () => { + switch (element.props.color) { + case 'muted': + return IconColor.iconMuted; + case 'primary': + return IconColor.primaryDefault; + default: + return IconColor.iconDefault; + } + }; + + const getIconSize = () => { + switch (element.props.size) { + case 'md': + return IconSize.Md; + default: + return IconSize.Inherit; + } + }; + + return { + element: 'SnapUIIcon', + props: { + name: getIconName(), + color: getIconColor(), + size: getIconSize(), + }, + }; +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/index.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/index.ts new file mode 100644 index 00000000000..fecfba86732 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/index.ts @@ -0,0 +1,29 @@ +import { box } from './box'; +import { text } from './text'; +import { row } from './row'; +import { address } from './address'; +import { button } from './button'; +import { input } from './input'; +import { bold } from './bold'; +import { value } from './value'; +import { card } from './card'; +import { footer } from './footer'; +import { container } from './container'; +import { heading } from './heading'; +import { link } from './link'; + +export const COMPONENT_MAPPING = { + Box: box, + Text: text, + Row: row, + Address: address, + Button: button, + Input: input, + Bold: bold, + Value: value, + Card: card, + Footer: footer, + Container: container, + Heading: heading, + Link: link, +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/input.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/input.ts new file mode 100644 index 00000000000..52f866f6385 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/input.ts @@ -0,0 +1,16 @@ +import { InputElement } from '@metamask/snaps-sdk/jsx'; + +import { UIComponentFactory } from './types'; + +export const input: UIComponentFactory = ({ + element: e, + form, +}) => ({ + element: 'SnapUIInput', + props: { + id: e.props.name, + placeholder: e.props.placeholder, + name: e.props.name, + form, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/link.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/link.ts new file mode 100644 index 00000000000..f039da0f01d --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/link.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { LinkElement, JSXElement } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { NonEmptyArray } from '@metamask/utils'; +import { mapTextToTemplate } from '../utils'; +import { UIComponentFactory } from './types'; + +export const link: UIComponentFactory = ({ + element, + ...params +}) => ({ + element: 'SnapUILink', + children: mapTextToTemplate( + getJsxChildren(element) as NonEmptyArray, + params, + ), + props: { + href: element.props.href, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/row.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/row.ts new file mode 100644 index 00000000000..7d6bda4f5e6 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/row.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { JSXElement, RowElement } from '@metamask/snaps-sdk/jsx'; + +import { mapToTemplate } from '../utils'; +import { UIComponent, UIComponentFactory } from './types'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { NonEmptyArray } from '@metamask/utils'; +import { ViewProps } from 'react-native'; + +export enum RowVariant { + Default = 'default', + Critical = 'critical', + Warning = 'warning', +} + +export const row: UIComponentFactory = ({ + element, + ...params +}) => ({ + element: 'Row', + children: getJsxChildren(element).map((children) => + mapToTemplate({ ...params, element: children as JSXElement }), + ) as NonEmptyArray, + props: { + label: element.props.label, + variant: element.props.variant, + tooltip: element.props.tooltip, + ...(element.props as ViewProps), + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/text.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/text.ts new file mode 100644 index 00000000000..3fb3c708100 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/text.ts @@ -0,0 +1,75 @@ +import { JSXElement } from '@metamask/snaps-sdk/jsx'; +import { getJsxChildren } from '@metamask/snaps-utils'; +import { NonEmptyArray } from '@metamask/utils'; +import { mapTextToTemplate } from '../utils'; +import { UIComponentFactory } from './types'; +import { + FontWeight, + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text/Text.types'; + +type TextProps = JSXElement & { + type: 'Text'; + props: { + children: string | JSXElement[]; + color?: + | 'default' + | 'alternative' + | 'muted' + | 'error' + | 'success' + | 'warning'; + fontWeight?: 'bold' | 'medium' | 'regular'; + size?: 'sm' | 'md'; + alignment?: string; + }; +}; + +function getTextColor(color: TextProps['props']['color']) { + switch (color) { + case 'default': + return TextColor.Default; + case 'alternative': + return TextColor.Alternative; + case 'muted': + return TextColor.Muted; + case 'error': + return TextColor.Error; + case 'success': + return TextColor.Success; + case 'warning': + return TextColor.Warning; + default: + return TextColor.Default; + } +} + +function getFontWeight(color: TextProps['props']['fontWeight']) { + switch (color) { + case 'bold': + return FontWeight.Bold; + case 'medium': + return FontWeight.Medium; + case 'regular': + default: + return FontWeight.Normal; + } +} + +export const text: UIComponentFactory = ({ element, ...params }) => { + return { + element: 'Text', + children: mapTextToTemplate( + getJsxChildren(element) as NonEmptyArray, + params, + ), + props: { + variant: + element.props.size === 'sm' ? TextVariant.BodySM : TextVariant.BodyMD, + fontWeight: getFontWeight(element.props.fontWeight), + color: getTextColor(element.props.color), + textAlign: element.props.alignment || 'left', + }, + }; +}; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/types.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/types.ts new file mode 100644 index 00000000000..f065f67915d --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/types.ts @@ -0,0 +1,28 @@ +import { ChangeEvent as ReactChangeEvent } from 'react'; +import { JSXElement, SnapsChildren } from '@metamask/snaps-sdk/jsx'; + +export interface UIComponentParams { + map: Record; + element: T; + form?: string; + useFooter?: boolean; + onCancel?: () => void; + onConfirm?: () => void; + promptLegacyProps?: { + onInputChange: (event: ReactChangeEvent) => void; + inputValue: string; + placeholder?: string; + }; + t: (key: string) => string; +} + +export interface UIComponent { + element: string; + props?: Record; + children?: SnapsChildren; + key?: string; +} + +export type UIComponentFactory = ( + params: UIComponentParams, +) => UIComponent; diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/components/value.ts b/app/components/Approvals/Snaps/SnapUIRenderer/components/value.ts new file mode 100644 index 00000000000..37641befe74 --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/components/value.ts @@ -0,0 +1,10 @@ +import { ValueElement } from '@metamask/snaps-sdk/jsx'; +import { UIComponentFactory } from './types'; + +export const value: UIComponentFactory = ({ element: e }) => ({ + element: 'ConfirmInfoRowValueDouble', + props: { + left: e.props.extra, + right: e.props.value, + }, +}); diff --git a/app/components/Approvals/Snaps/SnapUIRenderer/utils.ts b/app/components/Approvals/Snaps/SnapUIRenderer/utils.ts new file mode 100644 index 00000000000..68f7ecdcbcc --- /dev/null +++ b/app/components/Approvals/Snaps/SnapUIRenderer/utils.ts @@ -0,0 +1,423 @@ +import { JSXElement, GenericSnapElement } from '@metamask/snaps-sdk/jsx'; +import { hasChildren } from '@metamask/snaps-utils'; +import { memoize } from 'lodash'; +import { sha256 } from '@noble/hashes/sha256'; +import { + NonEmptyArray, + bytesToHex, + hasProperty, + remove0x, +} from '@metamask/utils'; +import { COMPONENT_MAPPING } from './components'; +import { decode } from 'html-entities'; + +export interface MapToTemplateParams { + map: Record; + element: JSXElement; + form?: string; + useFooter?: boolean; + onCancel?: () => void; + onConfirm?: () => void; + t?: (key: string) => string; +} + +/** + * Get a truncated version of component children to use in a hash. + * + * @param component - The component. + * @returns A truncated version of component children to use in a hash. + */ +function getChildrenForHash(component: JSXElement) { + if (!hasChildren(component)) { + return null; + } + + const { children } = component.props; + + if (typeof children === 'string') { + // For the hash we reduce long strings + return children.slice(0, 5000); + } + + if (Array.isArray(children)) { + // For arrays of children we just use the types + return (children as GenericSnapElement[]).map((child) => ({ + type: child?.type ?? null, + })); + } + + return children; +} + +/** + * A memoized function for generating a hash that represents a Snap UI component. + * + * This can be used to generate React keys for components. + * + * @param component - The component. + * @returns A hash as a string. + */ +const generateHash = memoize((component: JSXElement) => { + const { type, props } = component; + const { name } = props as { name?: string }; + const children = getChildrenForHash(component); + return remove0x( + bytesToHex( + sha256( + JSON.stringify({ + type, + name: name ?? null, + children, + }), + ), + ), + ); +}); + +/** + * Generate a React key to be used for a Snap UI component. + * + * This function also handles collisions between duplicate keys. + * + * @param map - A map of previously used keys to be used for collision handling. + * @param component - The component. + * @returns A key. + */ +function generateKey( + map: Record, + component: JSXElement, +): string { + const hash = generateHash(component); + const count = (map[hash] ?? 0) + 1; + map[hash] = count; + return `${hash}_${count}`; +} + +/** + * Extract and return first character (letter or number) of a provided string. + * If not possible, return question mark. + * Note: This function is used for generating fallback avatars for different entities (websites, Snaps, etc.) + * Note: Only letters and numbers will be returned if possible (special characters are ignored). + * + * @param {string} subjectName - Name of a subject. + * @returns Single character, chosen from the first character or number, question mark otherwise. + */ +export const getAvatarFallbackLetter = (subjectName: string) => { + return subjectName?.match(/[a-z0-9]/iu)?.[0] ?? '?'; +}; + +export const mapToTemplate = (params: MapToTemplateParams): UIComponent => { + const { type, key } = params.element; + const elementKey = key ?? generateKey(params.map, params.element); + + if (!hasProperty(COMPONENT_MAPPING, type)) { + throw new Error(`Unknown component type: ${type}`); + } + + const mapped = + COMPONENT_MAPPING[type as keyof typeof COMPONENT_MAPPING](params); + return { ...mapped, key: elementKey } as UIComponent; +}; + +export const mapTextToTemplate = ( + elements: NonEmptyArray, + params: Pick, +): NonEmptyArray => + elements.map((e) => { + if (typeof e === 'string') { + return decode(e); + } + return mapToTemplate({ ...params, element: e }); + }) as NonEmptyArray; + +import { FormState, InterfaceState, State } from '@metamask/snaps-sdk'; +import { UIComponent } from './components/types'; + +/** + * Merge a new input value in the interface state. + * + * @param state - The current interface state. + * @param name - The input name. + * @param value - The input value. + * @param form - The name of the form containing the input. + * Optional if the input is not contained in a form. + * @returns The interface state with the new value merged in. + */ +export const mergeValue = ( + state: InterfaceState, + name: string, + value: Type | null, + form?: string, +): InterfaceState => { + if (form) { + return { + ...state, + [form]: { + ...(state[form] as FormState), + [name]: value, + }, + }; + } + return { ...state, [name]: value }; +}; + +export enum AlignItems { + flexStart = 'flex-start', + flexEnd = 'flex-end', + center = 'center', + baseline = 'baseline', + stretch = 'stretch', +} + +export enum TextAlign { + left = 'left', + right = 'right', + center = 'center', +} + +export enum JustifyContent { + flexStart = 'flex-start', + flexEnd = 'flex-end', + center = 'center', + spaceAround = 'space-around', + spaceBetween = 'space-between', + spaceEvenly = 'space-evenly', +} + +export enum FlexDirection { + Row = 'row', + RowReverse = 'row-reverse', + Column = 'column', + ColumnReverse = 'column-reverse', +} + +export enum Display { + Block = 'block', + Grid = 'grid', + InlineBlock = 'inline-block', + Inline = 'inline', + InlineFlex = 'inline-flex', + InlineGrid = 'inline-grid', + ListItem = 'list-item', + None = 'none', +} + +export enum BlockSize { + Zero = '0', + Half = '1/2', + OneThird = '1/3', + TwoThirds = '2/3', + OneFourth = '1/4', + TwoFourths = '2/4', + ThreeFourths = '3/4', + OneFifth = '1/5', + TwoFifths = '2/5', + ThreeFifths = '3/5', + FourFifths = '4/5', + OneSixth = '1/6', + TwoSixths = '2/6', + ThreeSixths = '3/6', + FourSixths = '4/6', + FiveSixths = '5/6', + OneTwelfth = '1/12', + TwoTwelfths = '2/12', + ThreeTwelfths = '3/12', + FourTwelfths = '4/12', + FiveTwelfths = '5/12', + SixTwelfths = '6/12', + SevenTwelfths = '7/12', + EightTwelfths = '8/12', + NineTwelfths = '9/12', + TenTwelfths = '10/12', + ElevenTwelfths = '11/12', + Screen = 'screen', + Max = 'max', + Min = 'min', + Full = 'full', +} + +export enum Color { + backgroundDefault = 'background-default', + backgroundAlternative = 'background-alternative', + backgroundMuted = 'background-muted', + textDefault = 'text-default', + textAlternative = 'text-alternative', + textMuted = 'text-muted', + iconDefault = 'icon-default', + iconAlternative = 'icon-alternative', + iconMuted = 'icon-muted', + borderDefault = 'border-default', + borderMuted = 'border-muted', + overlayDefault = 'overlay-default', + overlayInverse = 'overlay-inverse', + primaryDefault = 'primary-default', + primaryAlternative = 'primary-alternative', + primaryMuted = 'primary-muted', + primaryInverse = 'primary-inverse', + errorDefault = 'error-default', + errorAlternative = 'error-alternative', + errorMuted = 'error-muted', + errorInverse = 'error-inverse', + warningDefault = 'warning-default', + warningMuted = 'warning-muted', + warningInverse = 'warning-inverse', + successDefault = 'success-default', + successMuted = 'success-muted', + successInverse = 'success-inverse', + infoDefault = 'info-default', + infoMuted = 'info-muted', + infoInverse = 'info-inverse', + mainnet = 'mainnet', + goerli = 'goerli', + sepolia = 'sepolia', + lineaGoerli = 'linea-goerli', + lineaGoerliInverse = 'linea-goerli-inverse', + lineaSepolia = 'linea-sepolia', + lineaSepoliaInverse = 'linea-sepolia-inverse', + lineaMainnet = 'linea-mainnet', + lineaMainnetInverse = 'linea-mainnet-inverse', + transparent = 'transparent', + localhost = 'localhost', + inherit = 'inherit', + goerliInverse = 'goerli-inverse', + sepoliaInverse = 'sepolia-inverse', +} + +export enum IconColor { + iconDefault = 'icon-default', + iconAlternative = 'icon-alternative', + iconAlternativeSoft = 'icon-alternative-soft', + iconMuted = 'icon-muted', + overlayInverse = 'overlay-inverse', + primaryDefault = 'primary-default', + primaryInverse = 'primary-inverse', + errorDefault = 'error-default', + errorInverse = 'error-inverse', + successDefault = 'success-default', + successInverse = 'success-inverse', + warningDefault = 'warning-default', + warningInverse = 'warning-inverse', + infoDefault = 'info-default', + infoInverse = 'info-inverse', + inherit = 'inherit', + goerli = 'goerli', + sepolia = 'sepolia', + lineaGoerli = 'linea-goerli', + lineaGoerliInverse = 'linea-goerli-inverse', + lineaSepolia = 'linea-sepolia', + lineaSepoliaInverse = 'linea-sepolia-inverse', + lineaMainnet = 'linea-mainnet', + lineaMainnetInverse = 'linea-mainnet-inverse', + goerliInverse = 'goerli-inverse', + sepoliaInverse = 'sepolia-inverse', + transparent = 'transparent', +} + +export enum BackgroundColor { + backgroundDefault = 'background-default', + backgroundAlternative = 'background-alternative', + backgroundMuted = 'background-muted', + backgroundAlternativeSoft = 'background-alternative-soft', + backgroundHover = 'background-hover', + backgroundPressed = 'background-pressed', + iconDefault = 'icon-default', + iconAlternative = 'icon-alternative', + iconMuted = 'icon-muted', + overlayDefault = 'overlay-default', + overlayAlternative = 'overlay-alternative', + primaryDefault = 'primary-default', + primaryAlternative = 'primary-alternative', + primaryMuted = 'primary-muted', + errorDefault = 'error-default', + errorAlternative = 'error-alternative', + errorMuted = 'error-muted', + warningDefault = 'warning-default', + warningMuted = 'warning-muted', + successDefault = 'success-default', + successMuted = 'success-muted', + infoDefault = 'info-default', + infoMuted = 'info-muted', + mainnet = 'mainnet', + goerli = 'goerli', + sepolia = 'sepolia', + lineaGoerli = 'linea-goerli', + lineaSepolia = 'linea-sepolia', + lineaMainnet = 'linea-mainnet', + transparent = 'transparent', + localhost = 'localhost', +} + +export enum BorderColor { + borderDefault = 'border-default', + borderMuted = 'border-muted', + primaryDefault = 'primary-default', + primaryAlternative = 'primary-alternative', + primaryMuted = 'primary-muted', + errorDefault = 'error-default', + errorAlternative = 'error-alternative', + errorMuted = 'error-muted', + warningDefault = 'warning-default', + warningMuted = 'warning-muted', + successDefault = 'success-default', + successMuted = 'success-muted', + infoDefault = 'info-default', + infoMuted = 'info-muted', + mainnet = 'mainnet', + goerli = 'goerli', + sepolia = 'sepolia', + lineaGoerli = 'linea-goerli', + lineaSepolia = 'linea-sepolia', + lineaMainnet = 'linea-mainnet', + transparent = 'transparent', + localhost = 'localhost', + backgroundDefault = 'background-default', // exception for border color when element is meant to look "cut out" +} + +export enum BorderStyle { + dashed = 'dashed', + solid = 'solid', + dotted = 'dotted', + double = 'double', + none = 'none', +} + +export enum BorderRadius { + /** + * 2px + */ + XS = 'xs', + /** + * 4px + */ + SM = 'sm', + /** + * 6px + */ + MD = 'md', + /** + * 8px + */ + LG = 'lg', + /** + * 12px + */ + XL = 'xl', + /** + * 0 + */ + none = 'none', + /** + * 9999px + */ + pill = 'pill', + /** + * 50% + */ + full = 'full', +} + +export enum FlexWrap { + Wrap = 'wrap', + WrapReverse = 'wrap-reverse', + NoWrap = 'nowrap', +} diff --git a/app/components/Nav/Main/RootRPCMethodsUI.js b/app/components/Nav/Main/RootRPCMethodsUI.js index f7ccede0353..c1d3c8c60ba 100644 --- a/app/components/Nav/Main/RootRPCMethodsUI.js +++ b/app/components/Nav/Main/RootRPCMethodsUI.js @@ -74,6 +74,7 @@ import { updateSwapsTransaction } from '../../../util/swaps/swaps-transactions'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import InstallSnapApproval from '../../Approvals/InstallSnapApproval'; import { getGlobalEthQuery } from '../../../util/networks/global-network'; +import SnapDialogApproval from '../../Approvals/Snaps/SnapDialogApproval/SnapsDialogApproval'; ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import SnapAccountCustomNameApproval from '../../Approvals/SnapAccountCustomNameApproval'; @@ -534,6 +535,7 @@ const RootRPCMethodsUI = (props) => { ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) } + { ///: END:ONLY_INCLUDE_IF } diff --git a/app/components/UI/Box/index.test.tsx b/app/components/UI/Box/index.test.tsx new file mode 100644 index 00000000000..be090cee1c3 --- /dev/null +++ b/app/components/UI/Box/index.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Box } from './index'; +import { + Display, + FlexDirection, + JustifyContent, + AlignItems, + TextAlign, +} from '../../Approvals/Snaps/SnapUIRenderer/utils'; +import Text, { + TextColor, +} from '../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; + +describe('Box', () => { + it('renders children correctly', () => { + const { getByText } = render( + + Test Content + , + ); + expect(getByText('Test Content')).toBeTruthy(); + }); + + it('applies display style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + expect(getByTestId('test-box').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + display: 'block', + }), + ]), + ); + }); + + it('applies flex direction style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + const styles = getByTestId('test-box').props.style; + expect(styles).toEqual([{ flexDirection: 'row' }, undefined]); + }); + + it('applies justify content style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + expect(getByTestId('test-box').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + justifyContent: 'center', + }), + ]), + ); + }); + + it('applies align items style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + expect(getByTestId('test-box').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + alignItems: 'center', + }), + ]), + ); + }); + + it('applies text align style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + expect(getByTestId('test-box').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + textAlign: 'center', + }), + ]), + ); + }); + + it('applies gap style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + expect(getByTestId('test-box').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + gap: 8, + }), + ]), + ); + }); + + it('applies color style correctly', () => { + const { getByTestId } = render( + + Test Content + , + ); + expect(getByTestId('test-box').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + color: 'Default', + }), + ]), + ); + }); + + it('forwards ref correctly', () => { + const ref = React.createRef(); + render( + + Test Content + , + ); + expect(ref.current).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Box/index.tsx b/app/components/UI/Box/index.tsx new file mode 100644 index 00000000000..c6d5750768d --- /dev/null +++ b/app/components/UI/Box/index.tsx @@ -0,0 +1,78 @@ +import { JSXElement } from '@metamask/snaps-sdk/jsx'; +import React from 'react'; +import { View, StyleSheet, ViewProps } from 'react-native'; +import { TextColor } from '../../../component-library/components/Texts/Text'; +import { + AlignItems, + Display, + FlexDirection, + JustifyContent, + TextAlign, +} from '../../Approvals/Snaps/SnapUIRenderer/utils'; + +const styles = StyleSheet.create({ + container: { + padding: 16, + margin: 8, + }, + overflowHidden: { overflow: 'hidden' }, +}); + +const getBoxStyles = (props: { + display?: Display; + flexDirection?: FlexDirection; + justifyContent?: JustifyContent; + alignItems?: AlignItems; + textAlign?: TextAlign; + gap?: number; + color?: TextColor; +}) => { + const { + display, + flexDirection, + justifyContent, + alignItems, + textAlign, + color, + gap, + } = props; + return StyleSheet.create({ + dynamicStyles: { + ...(display && { display: display as 'none' | 'flex' }), + ...(flexDirection && { flexDirection }), + ...(justifyContent && { justifyContent }), + ...(alignItems && { alignItems }), + ...(textAlign && { textAlign }), + ...(color && { color }), + ...(gap && { gap }), + }, + }); +}; + +export default styles; + +export interface BoxProps extends ViewProps { + children: string | JSXElement | React.ReactNode; + display?: Display; + flexDirection?: FlexDirection; + justifyContent?: JustifyContent; + alignItems?: AlignItems; + textAlign?: TextAlign; + gap?: number; + color?: TextColor; + ref?: React.Ref; + testID?: string; +} + +export const Box: React.FC = React.forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ), +); diff --git a/app/components/UI/FormTextField/form-text-field.types.ts b/app/components/UI/FormTextField/form-text-field.types.ts new file mode 100644 index 00000000000..c040588d6ab --- /dev/null +++ b/app/components/UI/FormTextField/form-text-field.types.ts @@ -0,0 +1,72 @@ +import { TextFieldProps } from 'react-native-material-textfield'; +import { HelpTextProps } from '../../../component-library/components/Form/HelpText/HelpText.types'; +import { LabelProps } from '../../../component-library/components/Form/Label/Label.types'; +import { TextFieldStyleUtilityProps } from './text-field-types'; + +export enum FormTextFieldSize { + Sm = 'sm', + Md = 'md', + Lg = 'lg', +} + +// TODO: Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export interface FormTextFieldStyleUtilityProps + extends Omit { + /* + * Additional classNames to be added to the FormTextField component + */ + className?: string; + /* + * size of the FormTextField using `FormTextFieldSize` enum + */ + size?: FormTextFieldSize; + /* + * props to be passed to the TextField component + */ + textFieldProps?: TextFieldProps; + /* + * helpText to be rendered below the FormTextField + */ + helpText?: string | React.ReactNode; + /* + * props to be passed to the HelpText component + */ + helpTextProps?: HelpTextProps; +} + +// TODO: Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export interface FormTextFieldWithLabelProps + extends FormTextFieldStyleUtilityProps { + /* + * label to be rendered above the FormTextField + * if label is provided, id is required + */ + label: string | React.ReactNode; + /* + * props to be passed to the Label component + */ + labelProps?: LabelProps; + id: string; // id is required when label is provided +} + +// TODO: Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export interface FormTextFieldWithoutLabelProps + extends FormTextFieldStyleUtilityProps { + /* + * This is for when label is not provided, that way we can optionally still pass an id + */ + label?: never; + labelProps?: never; + id?: string; // id is optional when label is not provided +} + +export type FormTextFieldProps = + | FormTextFieldWithLabelProps + | FormTextFieldWithoutLabelProps; + +export type FormTextFieldComponent = ( + props: FormTextFieldProps, +) => React.ReactElement | null; diff --git a/app/components/UI/FormTextField/index.test.tsx b/app/components/UI/FormTextField/index.test.tsx new file mode 100644 index 00000000000..1c8db9736c5 --- /dev/null +++ b/app/components/UI/FormTextField/index.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { FormTextField } from './index'; +import { FormTextFieldSize } from './form-text-field.types'; + +describe('FormTextField', () => { + it('renders correctly with basic props', () => { + const { getByTestId } = render( + , + ); + + const textField = getByTestId('form-text-field'); + expect(textField).toBeTruthy(); + }); + + it('handles text input correctly', () => { + const onChangeText = jest.fn(); + const { getByTestId } = render( + , + ); + + const textField = getByTestId('form-text-field'); + fireEvent.changeText(textField, 'test input'); + expect(onChangeText).toHaveBeenCalledWith('test input'); + }); + + it('displays help text and error state correctly', () => { + const { getByText } = render(); + + const helpText = getByText('Help message'); + expect(helpText).toBeTruthy(); + }); + + it('renders in disabled state correctly', () => { + const { getByTestId } = render(); + + const textField = getByTestId('form-text-field'); + expect(textField.props.editable).toBe(false); + }); + + it('applies different sizes correctly', () => { + const { getByTestId, rerender } = render( + , + ); + + let textField = getByTestId('form-text-field'); + expect(textField.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + height: 32, + fontSize: 14, + }), + ]), + ); + + rerender(); + textField = getByTestId('form-text-field'); + expect(textField.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + height: 56, + fontSize: 18, + }), + ]), + ); + }); + + it('handles focus and blur events', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { getByTestId } = render( + , + ); + + const textField = getByTestId('form-text-field'); + fireEvent(textField, 'focus'); + expect(onFocus).toHaveBeenCalled(); + + fireEvent(textField, 'blur'); + expect(onBlur).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/FormTextField/index.tsx b/app/components/UI/FormTextField/index.tsx new file mode 100644 index 00000000000..1d701214ea1 --- /dev/null +++ b/app/components/UI/FormTextField/index.tsx @@ -0,0 +1,179 @@ +import { + StyleProp, + StyleSheet, + TextInput, + TextStyle, + View, + TextInputProps, +} from 'react-native'; +import { FormTextFieldProps, FormTextFieldSize } from './form-text-field.types'; +import Text from '../../../component-library/components/Texts/Text'; +import { TextFieldProps } from '../../../component-library/components/Form/TextField/TextField.types'; +import React from 'react'; +import { useTheme } from '../../../../app/util/theme'; + +type MobileFormTextFieldProps = FormTextFieldProps & { + ref?: React.RefObject; + style?: StyleProp; + isDisabled?: boolean; + onChangeText?: (text: string) => void; + onBlur?: () => void; + onFocus?: () => void; + value?: string; + type?: string; + truncate?: boolean; + textFieldProps?: Omit; + autoComplete?: TextInputProps['autoComplete']; + defaultValue?: TextInputProps['defaultValue']; + maxLength?: TextInputProps['maxLength']; + placeholder?: TextInputProps['placeholder']; +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + paddingVertical: 16, + }, + disabled: { + opacity: 0.5, + }, + label: { + marginBottom: 8, + fontSize: 14, + fontWeight: '500', + }, + textField: { + height: 44, + paddingHorizontal: 12, + borderWidth: 1, + borderRadius: 4, + fontSize: 16, + }, + smallTextField: { + height: 32, + fontSize: 14, + }, + largeTextField: { + height: 56, + fontSize: 18, + }, + accessoryContainer: { + flexDirection: 'row', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + pointerEvents: 'none', + }, + startAccessory: { + justifyContent: 'center', + paddingLeft: 12, + }, + endAccessory: { + justifyContent: 'center', + paddingRight: 12, + marginLeft: 'auto', + }, + helpText: { + marginTop: 4, + fontSize: 12, + }, +}); + +export const FormTextField = ({ + autoComplete, + autoFocus, + defaultValue, + disabled, + isDisabled, + error, + helpText, + helpTextProps, + id, + inputProps, + ref, + label, + labelProps, + startAccessory, + maxLength, + name, + onBlur, + onChangeText, + onFocus, + placeholder, + readOnly, + required, + endAccessory, + size = FormTextFieldSize.Md, + textFieldProps, + truncate, + value, + style, + ...props +}: MobileFormTextFieldProps) => { + const theme = useTheme(); + + return ( + + {label && ( + + {label} + + )} + + {(startAccessory || endAccessory) && ( + + {startAccessory && ( + {startAccessory} + )} + {endAccessory && ( + {endAccessory} + )} + + )} + {helpText && ( + + {helpText} + + )} + + ); +}; diff --git a/app/components/UI/FormTextField/text-field-types.ts b/app/components/UI/FormTextField/text-field-types.ts new file mode 100644 index 00000000000..b06b5f81747 --- /dev/null +++ b/app/components/UI/FormTextField/text-field-types.ts @@ -0,0 +1,138 @@ +import { InputProps } from '@metamask/snaps-sdk/jsx'; +import { TextInputComponent } from 'react-native'; +import { StyleUtilityProps } from '../../Approvals/Snaps/SnapUIRenderer/components/box.types'; + +export enum TextFieldSize { + Sm = 'sm', + Md = 'md', + Lg = 'lg', +} + +export enum TextFieldType { + Text = 'text', + // eslint-disable-next-line @typescript-eslint/no-shadow + Number = 'number', + Password = 'password', + Search = 'search', +} + +// TODO: Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export interface TextFieldStyleUtilityProps + extends Omit { + /** + * Autocomplete allows the browser to predict the value based on earlier typed values + */ + autoComplete?: boolean; + /** + * If `true`, the input will be focused during the first mount. + */ + autoFocus?: boolean; + /** + * An additional className to apply to the text-field + */ + className?: string; + /** + * The default input value, useful when not controlling the component. + */ + defaultValue?: string | number; + /** + * If `true`, the input will be disabled. + */ + disabled?: boolean; + /** + * If `true`, the input will indicate an error + */ + error?: boolean; + /** + * The id of the `input` element. + */ + id?: string; + /** + * The component that is rendered as the input + * Defaults to the Text component + */ + InputComponent?: TextInputComponent; + /** + * Attributes applied to the `input` element. + */ + inputProps?: InputProps; + /** + * Component to appear on the left side of the input + */ + startAccessory?: React.ReactNode; + /** + * Component to appear on the right side of the input + */ + endAccessory?: React.ReactNode; + /** + * Use inputRef to pass a ref to the html input element. + */ + inputRef?: + | React.MutableRefObject + | ((instance: HTMLInputElement | null) => void); + /** + * Max number of characters to allow + */ + maxLength?: number; + /** + * Name attribute of the `input` element. + */ + name?: string; + /** + * Callback fired on blur + */ + onBlur?: (event: React.FocusEvent) => void; + /** + * Callback fired when the value is changed. + */ + onChange?: (event: React.ChangeEvent) => void; + /** + * Callback fired when the TextField is clicked on + */ + onClick?: (event: React.MouseEvent) => void; + /** + * Callback fired on focus + */ + onFocus?: (event: React.FocusEvent) => void; + /** + * The short hint displayed in the input before the user enters a value. + */ + placeholder?: string; + /** + * It prevents the user from changing the value of the field (not from interacting with the field). + */ + readOnly?: boolean; + /** + * If `true`, the input will be required. Currently no visual difference is shown. + */ + required?: boolean; + /** + * The size of the text field. Changes the height of the component + * Accepts TextFieldSize.Sm(32px), TextFieldSize.Md(40px), TextFieldSize.Lg(48px) + */ + size?: TextFieldSize; + /** + * Type of the input element. Can be TextFieldType.Text, TextFieldType.Password, TextFieldType.Number + * Defaults to TextFieldType.Text ('text') + */ + type?: TextFieldType; + /** + * If true will ellipse the text of the input + * Defaults to true + */ + truncate?: boolean; + /** + * The input value, required for a controlled component. + */ + value?: string | number; + /** + * Data test ID for the InputComponent component + */ + testId?: string; +} + +export type TextFieldProps = TextFieldStyleUtilityProps; +export type TextFieldComponent = ( + props: TextFieldProps, +) => React.ReactElement | null; diff --git a/app/components/UI/Snaps/SnapUIImage/index.tsx b/app/components/UI/Snaps/SnapUIImage/index.tsx new file mode 100644 index 00000000000..79065a34706 --- /dev/null +++ b/app/components/UI/Snaps/SnapUIImage/index.tsx @@ -0,0 +1,31 @@ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) +import React, { useMemo } from 'react'; +import { Image, ImageStyle, StyleProp } from 'react-native'; + +export interface SnapUIImageProps { + value: string; + style?: StyleProp; + width?: number; + height?: number; +} + +export const SnapUIImage: React.FC = ({ + value, + width, + height, + style, +}) => { + const src = useMemo( + () => ({ uri: `data:image/svg+xml;utf8,${encodeURIComponent(value)}` }), + [value], + ); + + return ( + + ); +}; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/UI/Snaps/SnapUIInput/Index.tsx b/app/components/UI/Snaps/SnapUIInput/Index.tsx new file mode 100644 index 00000000000..9eced61b481 --- /dev/null +++ b/app/components/UI/Snaps/SnapUIInput/Index.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSnapInterfaceContext } from '../../../Approvals/Snaps/SnapUIRenderer/SnapInterfaceContext'; +import { FormTextField } from '../../FormTextField'; +import { TextInput } from 'react-native'; + +export interface SnapUIInputProps { + name: string; + form?: string; +} + +export const SnapUIInput = ({ name, form, ...props }: SnapUIInputProps) => { + const { handleInputChange, getValue, focusedInput, setCurrentFocusedInput } = + useSnapInterfaceContext(); + + const inputRef = useRef(null); + + const initialValue = getValue(name, form) as string; + + const [value, setValue] = useState(initialValue ?? ''); + + useEffect(() => { + if (initialValue !== undefined && initialValue !== null) { + setValue(initialValue); + } + }, [initialValue]); + + /* + * Focus input if the last focused input was this input + * This avoids loosing the focus when the UI is re-rendered + */ + useEffect(() => { + if (inputRef.current && name === focusedInput) { + inputRef.current.focus(); + } + }, [inputRef, name, focusedInput]); + + const handleChange = (text: string) => { + setValue(text); + handleInputChange(name, text ?? null, form); + }; + + const handleFocus = () => setCurrentFocusedInput(name); + const handleBlur = () => setCurrentFocusedInput(null); + + return ( + + ); +}; diff --git a/app/components/UI/Snaps/SnapUIInput/index.test.tsx b/app/components/UI/Snaps/SnapUIInput/index.test.tsx new file mode 100644 index 00000000000..9b346fec84a --- /dev/null +++ b/app/components/UI/Snaps/SnapUIInput/index.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { SnapUIInput } from './Index'; +import { useSnapInterfaceContext } from '../../../Approvals/Snaps/SnapUIRenderer/SnapInterfaceContext'; + +jest.mock( + '../../../Approvals/Snaps/SnapUIRenderer/SnapInterfaceContext', + () => ({ + useSnapInterfaceContext: jest.fn(), + }), +); + +describe('SnapUIInput', () => { + const mockHandleInputChange = jest.fn(); + const mockSetCurrentFocusedInput = jest.fn(); + const mockGetValue = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useSnapInterfaceContext as jest.Mock).mockReturnValue({ + handleInputChange: mockHandleInputChange, + getValue: mockGetValue, + setCurrentFocusedInput: mockSetCurrentFocusedInput, + focusedInput: null, + }); + }); + + it('renders with initial value', () => { + mockGetValue.mockReturnValue('initial value'); + + const { getByDisplayValue } = render(); + + expect(getByDisplayValue('initial value')).toBeTruthy(); + }); + + it('handles input changes', () => { + const { getByTestId } = render(); + + const input = getByTestId('form-text-field'); + fireEvent.changeText(input, 'new value'); + + expect(mockHandleInputChange).toHaveBeenCalledWith( + 'testInput', + 'new value', + undefined, + ); + }); + + it('handles form input changes', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId('form-text-field'); + fireEvent.changeText(input, 'new value'); + + expect(mockHandleInputChange).toHaveBeenCalledWith( + 'testInput', + 'new value', + 'testForm', + ); + }); + + it('handles focus events', () => { + const { getByTestId } = render(); + + const input = getByTestId('form-text-field'); + fireEvent(input, 'focus'); + + expect(mockSetCurrentFocusedInput).toHaveBeenCalledWith('testInput'); + }); + + it('handles blur events', () => { + const { getByTestId } = render(); + + const input = getByTestId('form-text-field'); + fireEvent(input, 'blur'); + + expect(mockSetCurrentFocusedInput).toHaveBeenCalledWith(null); + }); + + it('updates value when initialValue changes', () => { + mockGetValue.mockReturnValue('initial value'); + + const { getByDisplayValue, rerender } = render( + , + ); + + expect(getByDisplayValue('initial value')).toBeTruthy(); + + mockGetValue.mockReturnValue('updated value'); + rerender(); + + expect(getByDisplayValue('updated value')).toBeTruthy(); + }); + + it('maintains focus state when re-rendered', () => { + (useSnapInterfaceContext as jest.Mock).mockReturnValue({ + handleInputChange: mockHandleInputChange, + getValue: mockGetValue, + setCurrentFocusedInput: mockSetCurrentFocusedInput, + focusedInput: 'testInput', + }); + + const { getByTestId } = render(); + const input = getByTestId('form-text-field'); + + expect(input).toBeTruthy(); + expect(useSnapInterfaceContext().focusedInput).toBe('testInput'); + }); +}); diff --git a/app/components/UI/Snaps/SnapUILink/index.tsx b/app/components/UI/Snaps/SnapUILink/index.tsx new file mode 100644 index 00000000000..62eb73388e2 --- /dev/null +++ b/app/components/UI/Snaps/SnapUILink/index.tsx @@ -0,0 +1,36 @@ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) +import { LinkChildren } from '@metamask/snaps-sdk/jsx'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import React from 'react'; +import { Linking } from 'react-native'; + +export interface SnapUILinkProps { + children: LinkChildren; + href: string; +} + +const validateUrl = (href: string) => { + if (!href.startsWith('https://')) { + throw new Error('Invalid URL'); + } +}; + +const onPress = (href: string) => { + validateUrl(href); + Linking.openURL(href); +}; +// TODO: This component should show a modal for links when not using preinstalled Snaps +export const SnapUILink: React.FC = ({ href, children }) => ( + onPress(href)} + > + {children} + +); +///: END:ONLY_INCLUDE_IF diff --git a/app/components/UI/Tabs/TabThumbnail/__snapshots__/TabThumbnail.test.tsx.snap b/app/components/UI/Tabs/TabThumbnail/__snapshots__/TabThumbnail.test.tsx.snap index 09ee846e8f7..424391b7922 100644 --- a/app/components/UI/Tabs/TabThumbnail/__snapshots__/TabThumbnail.test.tsx.snap +++ b/app/components/UI/Tabs/TabThumbnail/__snapshots__/TabThumbnail.test.tsx.snap @@ -76,17 +76,18 @@ exports[`TabThumbnail should render correctly 1`] = ` } } > - - { const component = section?.element; - if (!component && !isValidElementName(component)) { + if (!component || !isValidElementName(component)) { throw new Error( `${component} is not in the safe component list for template renderer`, ); diff --git a/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap b/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap index 1f1483626a9..fec3e2e4281 100644 --- a/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap +++ b/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap @@ -137,17 +137,18 @@ exports[`AccountPermissions with feature flags ON should render AccountPermissio } } > - - { expect(engine.context).toHaveProperty('UserStorageController'); expect(engine.context).toHaveProperty('NotificationServicesController'); expect(engine.context).toHaveProperty('SelectedNetworkController'); + expect(engine.context).toHaveProperty('SnapInterfaceController'); }); it('calling Engine.init twice returns the same instance', () => { diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 9e7b2fa7da4..299430de705 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -61,6 +61,7 @@ import { JsonSnapsRegistry, SnapController, SnapsRegistryMessenger, + SnapInterfaceController, } from '@metamask/snaps-controllers'; import { WebViewExecutionService } from '@metamask/snaps-controllers/react-native'; @@ -688,6 +689,20 @@ export class Engine { origin, ); }, + createInterface: this.controllerMessenger.call.bind( + this.controllerMessenger, + 'SnapInterfaceController:createInterface', + ), + getInterface: this.controllerMessenger.call.bind( + this.controllerMessenger, + 'SnapInterfaceController:getInterface', + ), + updateInterface: this.controllerMessenger.call.bind( + this.controllerMessenger, + 'SnapInterfaceController:updateInterface', + ), + requestUserApproval: + approvalController.addAndShowApprovalRequest.bind(approvalController), hasPermission: (origin: string, target: string) => this.controllerMessenger.call<'PermissionController:hasPermission'>( 'PermissionController:hasPermission', @@ -978,6 +993,23 @@ export class Engine { }, }); + const snapInterfaceControllerMessenger = + this.controllerMessenger.getRestricted({ + name: 'SnapInterfaceController', + allowedActions: [ + 'PhishingController:maybeUpdateState', + 'PhishingController:testOrigin', + ], + allowedEvents: [ + 'NotificationServicesController:notificationsListUpdated', + ], + }); + + const snapInterfaceController = new SnapInterfaceController({ + messenger: snapInterfaceControllerMessenger, + state: initialState.SnapInterfaceController, + }); + const authenticationController = new AuthenticationController.Controller({ state: initialState.AuthenticationController, messenger: this.controllerMessenger.getRestricted({ @@ -1454,6 +1486,7 @@ export class Engine { UserStorageController: userStorageController, NotificationServicesController: notificationServicesController, NotificationServicesPushController: notificationServicesPushController, + SnapInterfaceController: snapInterfaceController, ///: END:ONLY_INCLUDE_IF AccountsController: accountsController, PPOMController: new PPOMController({ @@ -2072,6 +2105,7 @@ export default { const { AccountTrackerController, AddressBookController, + SnapInterfaceController, NftController, TokenListController, CurrencyRateController, @@ -2107,6 +2141,8 @@ export default { return { AccountTrackerController, AddressBookController, + AssetsContractController, + SnapInterfaceController, NftController, TokenListController, CurrencyRateController, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index cc33feb0f69..0887fcb97bb 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -41,5 +41,6 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'UserStorageController:stateChange', 'NotificationServicesController:stateChange', 'NotificationServicesPushController:stateChange', + 'SnapInterfaceController:stateChange', ///: END:ONLY_INCLUDE_IF ] as const; diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 7153b4a4ff0..33a6b139178 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -122,6 +122,10 @@ import { SnapControllerActions, JsonSnapsRegistry as SnapsRegistry, SnapsRegistryState, + SnapInterfaceControllerState, + SnapInterfaceControllerEvents, + SnapInterfaceControllerActions, + SnapInterfaceController, SnapsRegistryActions, SnapsRegistryEvents, } from '@metamask/snaps-controllers'; @@ -223,6 +227,7 @@ type GlobalActions = | LoggingControllerActions ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) | SnapsGlobalActions + | SnapInterfaceControllerActions | AuthenticationController.Actions | UserStorageController.Actions | NotificationServicesController.Actions @@ -255,6 +260,7 @@ type GlobalEvents = | PermissionControllerEvents ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) | SnapsGlobalEvents + | SnapInterfaceControllerEvents | AuthenticationController.Events | UserStorageController.Events | NotificationServicesController.Events @@ -335,6 +341,7 @@ export type Controllers = { UserStorageController: UserStorageController.Controller; NotificationServicesController: NotificationServicesController.Controller; NotificationServicesPushController: NotificationServicesPushController.Controller; + SnapInterfaceController: SnapInterfaceController; ///: END:ONLY_INCLUDE_IF SwapsController: SwapsController; }; @@ -376,6 +383,7 @@ export type EngineState = { UserStorageController: UserStorageController.UserStorageControllerState; NotificationServicesController: NotificationServicesController.NotificationServicesControllerState; NotificationServicesPushController: NotificationServicesPushController.NotificationServicesPushControllerState; + SnapInterfaceController: SnapInterfaceControllerState; ///: END:ONLY_INCLUDE_IF PermissionController: PermissionControllerState; ApprovalController: ApprovalControllerState; diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 09c09f23050..200782c548c 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -77,6 +77,7 @@ jest.mock('../Engine', () => { UserStorageController: { subscribe: jest.fn() }, NotificationServicesController: { subscribe: jest.fn() }, SelectedNetworkController: { subscribe: jest.fn() }, + SnapInterfaceController: { subscribe: jest.fn() }, SignatureController: { subscribe: jest.fn() }, }, }; diff --git a/app/core/Permissions/specifications.js b/app/core/Permissions/specifications.js index 6d3b0dca2f1..f7754dbed6f 100644 --- a/app/core/Permissions/specifications.js +++ b/app/core/Permissions/specifications.js @@ -409,5 +409,7 @@ export const unrestrictedMethods = Object.freeze([ 'snap_createInterface', 'snap_updateInterface', 'snap_getInterfaceState', + 'snap_getInterfaceContext', + 'snap_resolveInterface', ///: END:ONLY_INCLUDE_IF ]); diff --git a/app/core/RPCMethods/RPCMethodMiddleware.ts b/app/core/RPCMethods/RPCMethodMiddleware.ts index 932d82dc63f..9f9ffa8bc7e 100644 --- a/app/core/RPCMethods/RPCMethodMiddleware.ts +++ b/app/core/RPCMethods/RPCMethodMiddleware.ts @@ -75,6 +75,7 @@ export enum ApprovalTypes { ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) INSTALL_SNAP = 'wallet_installSnap', UPDATE_SNAP = 'wallet_updateSnap', + SNAP_DIALOG = 'snap_dialog', ///: END:ONLY_INCLUDE_IF } diff --git a/app/core/Snaps/SnapsMethodMiddleware.ts b/app/core/Snaps/SnapsMethodMiddleware.ts index c2ad0d4f3ae..a3e3344b435 100644 --- a/app/core/Snaps/SnapsMethodMiddleware.ts +++ b/app/core/Snaps/SnapsMethodMiddleware.ts @@ -15,7 +15,7 @@ export function getSnapIdFromRequest( request: Record, ): SnapId | null { const { snapId } = request; - return typeof snapId === 'string' ? snapId as SnapId : null; + return typeof snapId === 'string' ? (snapId as SnapId) : null; } // Snaps middleware /* @@ -65,10 +65,44 @@ const snapMethodMiddlewareBuilder = ( origin, RestrictedMethods.wallet_snap, ), + createInterface: controllerMessenger.call.bind( + controllerMessenger, + 'SnapInterfaceController:createInterface', + origin, + ), + updateInterface: controllerMessenger.call.bind( + controllerMessenger, + 'SnapInterfaceController:updateInterface', + origin, + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getInterfaceContext: (...args: any) => + controllerMessenger.call( + 'SnapInterfaceController:getInterface', + origin, + ...args, + ).context, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getInterfaceState: (...args: any) => + controllerMessenger.call( + 'SnapInterfaceController:getInterface', + origin, + ...args, + ).state, + resolveInterface: controllerMessenger.call.bind( + controllerMessenger, + 'SnapInterfaceController:resolveInterface', + origin, + ), getSnap: controllerMessenger.call.bind( controllerMessenger, 'SnapController:get', ), + updateInterfaceState: controllerMessenger.call.bind( + controllerMessenger, + 'SnapInterfaceController:updateInterfaceState', + origin, + ), handleSnapRpcRequest: async (request: Omit) => { const snapId = getSnapIdFromRequest(request); @@ -85,6 +119,10 @@ const snapMethodMiddlewareBuilder = ( request: request.request, }); }, + requestUserApproval: + engineContext.ApprovalController.addAndShowApprovalRequest.bind( + engineContext.ApprovalController, + ), }); export default snapMethodMiddlewareBuilder; diff --git a/app/selectors/snaps/interfaceController.ts b/app/selectors/snaps/interfaceController.ts new file mode 100644 index 00000000000..e7b8b10ba00 --- /dev/null +++ b/app/selectors/snaps/interfaceController.ts @@ -0,0 +1,46 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { createDeepEqualSelector } from '../util'; + +/** + * Get the Snap interfaces from the redux state. + * + * @param state - Redux state object. + * @returns the Snap interfaces. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getInterfaces = (state: any) => + state?.engine?.backgroundState?.SnapInterfaceController.interfaces; + +/** + * Input selector providing a way to pass a Snap interface ID as an argument. + * + * @param _state - Redux state object. + * @param interfaceId - ID of a Snap interface. + * @returns ID of a Snap Interface that can be used as input selector. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const selectInterfaceId = (_state: any, interfaceId: string) => interfaceId; + +/** + * Get a memoized version of the Snap interfaces. + */ +export const getMemoizedInterfaces = createDeepEqualSelector( + getInterfaces, + (interfaces) => interfaces, +); + +/** + * Get a Snap Interface with a given ID. + */ +export const getInterface = createSelector( + [getMemoizedInterfaces, selectInterfaceId], + (interfaces, id) => interfaces[id], +); + +/** + * Get a memoized version of a Snap interface with a given ID + */ +export const getMemoizedInterface = createDeepEqualSelector( + getInterface, + (snapInterface) => snapInterface, +); diff --git a/app/selectors/snaps/snapController.ts b/app/selectors/snaps/snapController.ts index 25e8053ada7..d51d7450b6c 100644 --- a/app/selectors/snaps/snapController.ts +++ b/app/selectors/snaps/snapController.ts @@ -16,21 +16,22 @@ export const selectSnapsMetadata = createDeepEqualSelector( selectSnaps, (snaps) => Object.values(snaps).reduce< - Record + Record >((snapsMetadata, snap) => { const snapId = snap.id; const manifest = snap.localizationFiles ? getLocalizedSnapManifest( - snap.manifest, - // TODO: Use actual locale here. - 'en', - snap.localizationFiles, - ) + snap.manifest, + // TODO: Use actual locale here. + 'en', + snap.localizationFiles, + ) : snap.manifest; snapsMetadata[snapId] = { name: manifest.proposedName, description: manifest.description, + iconUrl: manifest.source.location.npm.iconPath, }; return snapsMetadata; }, {}), diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 27c9ea11dca..eb62315555b 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -238,6 +238,9 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "snaps": {}, "unencryptedSnapStates": {}, }, + "SnapInterfaceController": { + "interfaces": {}, + }, "SnapsRegistry": { "database": null, "databaseUnavailable": false, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 953fb97a48b..f386c11182d 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -214,6 +214,9 @@ "snaps": {}, "unencryptedSnapStates": {} }, + "SnapInterfaceController": { + "interfaces": {} + }, "SnapsRegistry": { "database": null, "databaseUnavailable": false, diff --git a/package.json b/package.json index ba6cb495a7e..c2a880146c6 100644 --- a/package.json +++ b/package.json @@ -213,6 +213,7 @@ "@metamask/transaction-controller": "^43.0.0", "@metamask/utils": "^11.0.1", "@ngraveio/bc-ur": "^1.1.6", + "@noble/hashes": "^1.6.1", "@notifee/react-native": "^9.0.0", "@react-native-async-storage/async-storage": "^1.23.1", "@react-native-clipboard/clipboard": "1.8.4", @@ -272,6 +273,7 @@ "expo-build-properties": "~0.8.3", "expo-dev-client": "3.1.0", "fuse.js": "3.4.4", + "html-entities": "^2.5.2", "https-browserify": "0.0.1", "human-standard-token-abi": "^2.0.0", "humanize-duration": "^3.27.2", diff --git a/yarn.lock b/yarn.lock index ecc3322eb51..89d06ce40d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5685,11 +5685,16 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== -"@noble/hashes@1.4.0", "@noble/hashes@^1.1.2", "@noble/hashes@^1.3.1", "@noble/hashes@^1.3.2", "@noble/hashes@^1.4.0": +"@noble/hashes@1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== +"@noble/hashes@^1.1.2", "@noble/hashes@^1.3.1", "@noble/hashes@^1.3.2", "@noble/hashes@^1.4.0", "@noble/hashes@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.6.1.tgz#df6e5943edcea504bac61395926d6fd67869a0d5" + integrity sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -18811,10 +18816,10 @@ html-element-map@^1.0.0: array.prototype.filter "^1.0.0" call-bind "^1.0.2" -html-entities@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.4.0.tgz#edd0cee70402584c8c76cc2c0556db09d1f45061" - integrity sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ== +html-entities@^2.1.0, html-entities@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.5.2.tgz#201a3cf95d3a15be7099521620d19dfb4f65359f" + integrity sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA== html-escaper@^2.0.0: version "2.0.2"