diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index a1ca7774ac8..c08c085d9a9 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -126,7 +126,7 @@ const getStories = () => { "./app/components/Views/confirmations/components/UI/InfoRow/InfoRow.stories.tsx": require("../app/components/Views/confirmations/components/UI/InfoRow/InfoRow.stories.tsx"), "./app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.stories.tsx": require("../app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.stories.tsx"), "./app/components/Views/confirmations/components/UI/Tooltip/Tooltip.stories.tsx": require("../app/components/Views/confirmations/components/UI/Tooltip/Tooltip.stories.tsx"), - "./app/components/Views/confirmations/components/UI/CopyButton/CopyButton.stories.tsx": require("../app/components/Views/confirmations/components/UI/CopyButton/CopyButton.stories.tsx"), + "./app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx": require("../app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx"), }; }; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.stories.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.stories.tsx new file mode 100644 index 00000000000..e5a705d98dd --- /dev/null +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.stories.tsx @@ -0,0 +1,57 @@ +/* eslint-disable react/display-name */ +/* eslint-disable react-native/no-inline-styles */ +// External dependencies. +import React from 'react'; +import { View, Text } from 'react-native'; + +// Internal dependencies. +import PickerBase from './PickerBase'; +import { IconSize } from '../../Icons/Icon'; + +const PickerBaseMeta = { + title: 'Component Library / Pickers', + component: PickerBase, + argTypes: { + children: { + control: { type: 'text' }, + defaultValue: 'Select an option', + }, + iconSize: { + options: Object.values(IconSize), + control: { type: 'select' }, + defaultValue: IconSize.Md, + }, + }, +}; + +export default PickerBaseMeta; + +export const Default = { + render: ({ + children, + iconSize, + }: { + children: string; + iconSize: IconSize; + }) => ( + + null} iconSize={iconSize}> + {children} + + + ), +}; + +export const WithCustomStyles = { + render: () => ( + + null} + style={{ width: 200 }} + dropdownIconStyle={{ marginLeft: 20 }} + > + Custom Styled Picker + + + ), +}; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts b/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts index 475e466e6b1..cad2a763c44 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts @@ -21,7 +21,8 @@ const styleSheet = (params: { }) => { const { vars, theme } = params; const { colors } = theme; - const { style } = vars; + const { style, dropdownIconStyle } = vars; + return StyleSheet.create({ base: Object.assign( { @@ -35,9 +36,12 @@ const styleSheet = (params: { } as ViewStyle, style, ) as ViewStyle, - dropdownIcon: { - marginLeft: 16, - }, + dropdownIcon: Object.assign( + { + marginLeft: 16, + } as ViewStyle, + dropdownIconStyle, + ), }); }; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx index 662319adb46..94ec1e73255 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx @@ -1,18 +1,78 @@ // Third party dependencies. import React from 'react'; -import { View } from 'react-native'; -import { render } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; // Internal dependencies. import PickerBase from './PickerBase'; +import { IconName, IconSize } from '../../Icons/Icon'; describe('PickerBase', () => { it('should render correctly', () => { const { toJSON } = render( - - + + Test Content , ); expect(toJSON()).toMatchSnapshot(); }); + + it('should call onPress when pressed', () => { + const onPressMock = jest.fn(); + const { getByText } = render( + + Test Content + , + ); + + fireEvent.press(getByText('Test Content')); + expect(onPressMock).toHaveBeenCalledTimes(1); + }); + + it('should render children correctly', () => { + const { getByText } = render( + + Child Component + , + ); + + expect(getByText('Child Component')).toBeTruthy(); + }); + + it('should render dropdown icon', () => { + const { UNSAFE_getByProps } = render( + + Test Content + , + ); + + const icon = UNSAFE_getByProps({ name: IconName.ArrowDown }); + expect(icon).toBeTruthy(); + }); + + it('should apply custom icon size', () => { + const { UNSAFE_getByProps } = render( + + Test Content + , + ); + + const icon = UNSAFE_getByProps({ + name: IconName.ArrowDown, + size: IconSize.Lg, + }); + expect(icon).toBeTruthy(); + }); + + it('should apply custom dropdown icon style', () => { + const customStyle = { marginLeft: 20 }; + const { UNSAFE_getByProps } = render( + + Test Content + , + ); + + const icon = UNSAFE_getByProps({ name: IconName.ArrowDown }); + expect(icon.props.style).toEqual(expect.objectContaining(customStyle)); + }); }); diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx index 5488138d687..10d39f7be7e 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx @@ -15,15 +15,18 @@ import styleSheet from './PickerBase.styles'; const PickerBase: React.ForwardRefRenderFunction< TouchableOpacity, PickerBaseProps -> = ({ style, children, ...props }, ref) => { - const { styles, theme } = useStyles(styleSheet, { style }); +> = ( + { iconSize = IconSize.Md, style, dropdownIconStyle, children, ...props }, + ref, +) => { + const { styles, theme } = useStyles(styleSheet, { style, dropdownIconStyle }); const { colors } = theme; return ( {children} ; +export type PickerBaseStyleSheetVars = Pick< + PickerBaseProps, + 'style' | 'dropdownIconStyle' +>; diff --git a/app/component-library/components/Pickers/PickerBase/README.md b/app/component-library/components/Pickers/PickerBase/README.md index 74ec00f6ba7..ea5b298becd 100644 --- a/app/component-library/components/Pickers/PickerBase/README.md +++ b/app/component-library/components/Pickers/PickerBase/README.md @@ -1,10 +1,10 @@ # PickerBase -PickerBase is a **wrapper** component used for providing a dropdown icon next to wrapped content. +PickerBase is a **wrapper** component used for providing a dropdown icon next to wrapped content. It's designed to be a flexible base for various picker-style components. ## Props -This component extends `TouchableOpacityProps` from React Native's [TouchableOpacityProps](https://reactnative.dev/docs/touchableOpacity) opacity. +This component extends `TouchableOpacityProps` from React Native's [TouchableOpacity](https://reactnative.dev/docs/touchableopacity). ### `onPress` @@ -22,11 +22,55 @@ Content to wrap in PickerBase. | :-------------------------------------------------- | :------------------------------------------------------ | | ReactNode | Yes | +### `iconSize` + +Size of the dropdown icon. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| IconSize | No | IconSize.Md | + +### `dropdownIconStyle` + +Custom styles for the dropdown icon. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| ViewStyle | No | + +### `style` + +Custom styles for the main container. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| ViewStyle | No | + +## Usage + ```javascript -// Replace import with relative path. +import React from 'react'; +import { Text } from 'react-native'; import PickerBase from 'app/component-library/components/Pickers/PickerBase'; +import { IconSize } from 'app/component-library/components/Icons/Icon'; + +const ExampleComponent = () => ( + console.log('Picker pressed')} + iconSize={IconSize.Lg} + dropdownIconStyle={{ marginLeft: 20 }} + style={{ backgroundColor: 'lightgray' }} + > + Select an option + +); - - -; +export default ExampleComponent; ``` + +## Notes + +- The component uses a `TouchableOpacity` as its base, providing press feedback. +- It automatically includes a dropdown icon (ArrowDown) to the right of the content. +- The component is designed to be flexible and can be customized using the `style` and `dropdownIconStyle` props. +- The dropdown icon color is determined by the theme's `colors.icon.default`. \ No newline at end of file diff --git a/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap b/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap index f93ccf2a438..21db5a5774a 100644 --- a/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap +++ b/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap @@ -2,7 +2,7 @@ exports[`PickerBase should render correctly 1`] = ` - + + Test Content + { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + bottomSheet: { + flex: 1, + }, + container: { + paddingHorizontal: 16, + }, + description: { + paddingVertical: 8, + }, + buttonContainer: { + paddingTop: 16, + }, + input: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: 4, + padding: 10, + marginVertical: 10, + }, + errorText: { + color: colors.error.default, + }, + placeholderText: { + color: colors.text.muted, + }, + scrollView: { + flexGrow: 1, + maxHeight: 300, + }, + }); +}; + +export default styleSheet; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx new file mode 100644 index 00000000000..b2ec560b5dd --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx @@ -0,0 +1,226 @@ +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +import React, { + useEffect, + useRef, + useState, + useCallback, + useMemo, +} from 'react'; +import { View, TextInput, ScrollView } from 'react-native'; +import { NativeViewGestureHandler } from 'react-native-gesture-handler'; +import { Snap } from '@metamask/snaps-utils'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import Text, { + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { InternalAccount } from '@metamask/keyring-api'; +import BannerAlert from '../../../../component-library/components/Banners/Banner/variants/BannerAlert'; +import { BannerAlertSeverity } from '../../../../component-library/components/Banners/Banner'; +import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { useStyles } from '../../../hooks/useStyles'; +import stylesheet from './KeyringSnapRemovalWarning.styles'; +import { strings } from '../../../../../locales/i18n'; +import { KeyringAccountListItem } from '../components/KeyringAccountListItem'; +import { getAccountLink } from '@metamask/etherscan-link'; +import { useSelector } from 'react-redux'; +import { selectProviderConfig } from '../../../../selectors/networkController'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { + ButtonProps, + ButtonSize, + ButtonVariants, +} from '../../../../component-library/components/Buttons/Button/Button.types'; +import { + KEYRING_SNAP_REMOVAL_WARNING, + KEYRING_SNAP_REMOVAL_WARNING_CANCEL, + KEYRING_SNAP_REMOVAL_WARNING_CONTINUE, + KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT, +} from './KeyringSnapRemovalWarning.constants'; +import Logger from '../../../../util/Logger'; + +interface KeyringSnapRemovalWarningProps { + snap: Snap; + keyringAccounts: InternalAccount[]; + onCancel: () => void; + onClose: () => void; + onSubmit: () => void; +} + +export default function KeyringSnapRemovalWarning({ + snap, + keyringAccounts, + onCancel, + onClose, + onSubmit, +}: KeyringSnapRemovalWarningProps) { + const [showConfirmation, setShowConfirmation] = useState(false); + const [confirmedRemoval, setConfirmedRemoval] = useState(false); + const [confirmationInput, setConfirmationInput] = useState(''); + const [error, setError] = useState(false); + const { chainId } = useSelector(selectProviderConfig); + const { styles } = useStyles(stylesheet, {}); + const bottomSheetRef = useRef(null); + + useEffect(() => { + setShowConfirmation(keyringAccounts.length === 0); + }, [keyringAccounts]); + + const validateConfirmationInput = useCallback( + (input: string): boolean => input === snap.manifest.proposedName, + [snap.manifest.proposedName], + ); + + const handleConfirmationInputChange = useCallback( + (text: string) => { + setConfirmationInput(text); + setConfirmedRemoval(validateConfirmationInput(text)); + }, + [validateConfirmationInput], + ); + + const handleContinuePress = useCallback(() => { + if (!showConfirmation) { + setShowConfirmation(true); + } else if (confirmedRemoval) { + try { + onSubmit(); + } catch (e) { + Logger.error( + e as Error, + 'KeyringSnapRemovalWarning: error while removing snap', + ); + setError(true); + } + } + }, [showConfirmation, confirmedRemoval, onSubmit]); + + const cancelButtonProps: ButtonProps = useMemo( + () => ({ + variant: ButtonVariants.Secondary, + label: strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.cancel_button', + ), + size: ButtonSize.Lg, + onPress: onCancel, + testID: KEYRING_SNAP_REMOVAL_WARNING_CANCEL, + }), + [onCancel], + ); + + const continueButtonProps: ButtonProps = useMemo( + () => ({ + variant: ButtonVariants.Primary, + label: showConfirmation + ? strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_snap_button', + ) + : strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.continue_button', + ), + size: ButtonSize.Lg, + onPress: handleContinuePress, + isDisabled: showConfirmation && !confirmedRemoval, + isDanger: showConfirmation, + testID: KEYRING_SNAP_REMOVAL_WARNING_CONTINUE, + }), + [showConfirmation, confirmedRemoval, handleContinuePress], + ); + + const buttonPropsArray = useMemo( + () => [cancelButtonProps, continueButtonProps], + [cancelButtonProps, continueButtonProps], + ); + + const accountListItems = useMemo( + () => + keyringAccounts.map((account, index) => ( + + )), + [keyringAccounts, chainId], + ); + + return ( + + + + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.title', + )} + + + + {showConfirmation ? ( + <> + + {`${strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_account_snap_alert_description_1', + )} `} + + {snap.manifest.proposedName} + + {` ${strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_account_snap_alert_description_2', + )}`} + + + {error && ( + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_snap_error', + { + snapName: snap.manifest.proposedName, + }, + )} + + )} + + ) : ( + <> + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.description', + )} + + + + {accountListItems} + + + + )} + + + + ); +} +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts b/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts new file mode 100644 index 00000000000..94d58b3d412 --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts @@ -0,0 +1,3 @@ +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +export { default as KeyringSnapRemovalWarning } from './KeyringSnapRemovalWarning'; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/test/KeyringSnapRemovalWarning.test.tsx b/app/components/Views/Snaps/KeyringSnapRemovalWarning/test/KeyringSnapRemovalWarning.test.tsx new file mode 100644 index 00000000000..6359b5a7b3d --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/test/KeyringSnapRemovalWarning.test.tsx @@ -0,0 +1,337 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import KeyringSnapRemovalWarning from '../KeyringSnapRemovalWarning'; +import { + KEYRING_SNAP_REMOVAL_WARNING, + KEYRING_SNAP_REMOVAL_WARNING_CANCEL, + KEYRING_SNAP_REMOVAL_WARNING_CONTINUE, + KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT, +} from '../KeyringSnapRemovalWarning.constants'; +import { useSelector } from 'react-redux'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { Snap, SnapStatus } from '@metamask/snaps-utils'; +import { SnapId } from '@metamask/snaps-sdk'; +import { SemVerVersion } from '@metamask/utils'; +import { createMockSnapInternalAccount } from '../../../../../util/test/accountsControllerTestUtils'; +import { KEYRING_ACCOUNT_LIST_ITEM } from '../../components/KeyringAccountListItem/KeyringAccountListItem.constants'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('react-native-safe-area-context', () => { + // using disting digits for mock rects to make sure they are not mixed up + const inset = { top: 1, right: 2, bottom: 3, left: 4 }; + const frame = { width: 5, height: 6, x: 7, y: 8 }; + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaConsumer: jest + .fn() + .mockImplementation(({ children }) => children(inset)), + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + useSafeAreaFrame: jest.fn().mockImplementation(() => frame), + }; +}); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: jest.fn(), + }), + }; +}); + +describe('KeyringSnapRemovalWarning', () => { + const mockSnapName = 'MetaMask Simple Snap Keyring'; + const mockSnap: Snap = { + blocked: false, + enabled: true, + id: 'npm:@metamask/snap-simple-keyring-snap' as SnapId, + initialPermissions: { + 'endowment:keyring': { + allowedOrigins: ['https://metamask.github.io', 'metamask.github.io'], + }, + 'endowment:rpc': { + dapps: true, + }, + snap_manageAccounts: {}, + snap_manageState: {}, + }, + manifest: { + version: '1.1.6' as SemVerVersion, + description: 'An example of a key management snap for a simple keyring.', + proposedName: mockSnapName, + repository: { + type: 'git', + url: 'git+https://github.com/MetaMask/snap-simple-keyring.git', + }, + source: { + shasum: 'P2BbaJn6jb7+ecBF6mJJnheQ4j8dtEZ8O4FLqLv8e8M=', + location: { + npm: { + filePath: 'dist/bundle.js', + iconPath: 'images/icon.svg', + packageName: '@metamask/snap-simple-keyring-snap', + registry: 'https://registry.npmjs.org/', + }, + }, + }, + initialPermissions: { + 'endowment:keyring': { + allowedOrigins: ['https://metamask.github.io', 'metamask.github.io'], + }, + 'endowment:rpc': { + dapps: true, + }, + snap_manageAccounts: {}, + snap_manageState: {}, + }, + manifestVersion: '0.1', + }, + status: 'stopped' as SnapStatus, + sourceCode: '', + version: '1.1.6' as SemVerVersion, + versionHistory: [ + { + version: '1.1.6', + date: 1727403640652, + origin: 'https://metamask.github.io', + }, + ], + auxiliaryFiles: [], + localizationFiles: [], + }; + + const MOCK_ADDRESS_1 = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; + const MOCK_ADDRESS_2 = '0xA7E9922b0e7DB390c3B108127739eFebe4d6293E'; + + const mockKeyringAccount1 = createMockSnapInternalAccount( + MOCK_ADDRESS_1, + 'Snap Account 1', + ); + const mockKeyringAccount2 = createMockSnapInternalAccount( + MOCK_ADDRESS_2, + 'Snap Account 2', + ); + + const mockKeyringAccounts = [mockKeyringAccount1, mockKeyringAccount2]; + const onCancelMock = jest.fn(); + const onCloseMock = jest.fn(); + const onSubmitMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useSelector as jest.Mock).mockReturnValue({ chainId: '1' }); + }); + + it('renders correctly with initial props', () => { + const { getByTestId, queryByText } = renderWithProvider( + , + ); + + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + expect(continueButton).toBeTruthy(); + expect(continueButton.props.children[1].props.children).toBe('Continue'); + + const cancelButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CANCEL); + expect(cancelButton).toBeTruthy(); + expect(cancelButton.props.children[1].props.children).toBe('Cancel'); + + const warningBannerTitle = queryByText( + 'Be sure you can access any accounts created by this Snap on your own before removing it', + ); + expect(warningBannerTitle).toBeTruthy(); + }); + + it('renders the correct number of keyring account list items', () => { + const { getAllByTestId } = renderWithProvider( + , + ); + + const accountListItems = getAllByTestId(KEYRING_ACCOUNT_LIST_ITEM); + expect(accountListItems).toHaveLength(mockKeyringAccounts.length); + }); + it('shows confirmation input when keyringAccounts is empty', () => { + const { getByTestId } = renderWithProvider( + , + ); + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + expect(continueButton).toBeTruthy(); + expect(continueButton.props.disabled).toBe(true); + expect(continueButton.props.children[1].props.children).toBe('Remove Snap'); + + const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT); + expect(textInput).toBeTruthy(); + }); + + it('enables continue button when correct snap name is entered', async () => { + const { getByTestId } = renderWithProvider( + , + ); + + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + expect(continueButton.props.disabled).toBe(true); + + const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT); + expect(textInput).toBeTruthy(); + fireEvent.changeText(textInput, mockSnapName); + + await waitFor(() => { + expect(continueButton.props.disabled).toBe(false); + }); + }); + + it('does not enable continue button when incorrect snap name is entered', async () => { + const { getByTestId } = renderWithProvider( + , + ); + + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + expect(continueButton.props.disabled).toBe(true); + + const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT); + expect(textInput).toBeTruthy(); + fireEvent.changeText(textInput, 'Wrong snap name'); + + await waitFor(() => { + expect(continueButton.props.disabled).toBe(true); + }); + }); + + it('calls onSubmit when confirmed and continue is pressed', async () => { + const { getByTestId } = renderWithProvider( + , + ); + + const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT); + expect(textInput).toBeTruthy(); + fireEvent.changeText(textInput, mockSnapName); + + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + fireEvent.press(continueButton); + expect(onSubmitMock).toHaveBeenCalled(); + }); + + it('displays error when onSubmit throws', async () => { + onSubmitMock.mockImplementation(() => { + throw new Error('Error'); + }); + + const { getByTestId, getByText } = renderWithProvider( + , + ); + + const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT); + fireEvent.changeText(textInput, mockSnapName); + + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + fireEvent.press(continueButton); + + await waitFor(() => { + expect( + getByText( + `Failed to remove ${mockSnapName}`, + ), + ).toBeTruthy(); + }); + }); + + it('calls onCancel when cancel button is pressed', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const cancelButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CANCEL); + fireEvent.press(cancelButton); + + expect(onCancelMock).toHaveBeenCalled(); + }); + + it('calls onClose when BottomSheet is closed', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const bottomSheet = getByTestId(KEYRING_SNAP_REMOVAL_WARNING); + fireEvent(bottomSheet, 'onClose'); + + expect(onCloseMock).toHaveBeenCalled(); + }); + it('allows removal of snaps with empty names and keeps the continue button enabled', () => { + const { getByTestId } = renderWithProvider( + , + ); + const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT); + fireEvent.changeText(textInput, ''); + const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE); + expect(continueButton.props.disabled).toBe(false); + expect(textInput.props.value).toBe(''); + expect(continueButton.props.children[1].props.children).toBe('Remove Snap'); + }); +}); diff --git a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx index 19f739400eb..d56c1d0b200 100644 --- a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx +++ b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx @@ -1,5 +1,5 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -import React, { useCallback, useEffect } from 'react'; +///: BEGIN:ONLY_INCLUDE_IF(external-snaps,keyring-snaps) +import React, { useCallback, useEffect, useState } from 'react'; import { View, ScrollView, SafeAreaView } from 'react-native'; import Engine from '../../../../core/Engine'; @@ -28,7 +28,11 @@ import { useStyles } from '../../../hooks/useStyles'; import { useSelector } from 'react-redux'; import SNAP_SETTINGS_REMOVE_BUTTON from './SnapSettings.constants'; import { selectPermissionControllerState } from '../../../../selectors/snaps/permissionController'; - +import KeyringSnapRemovalWarning from '../KeyringSnapRemovalWarning/KeyringSnapRemovalWarning'; +import { getAccountsBySnapId } from '../../../../core/SnapKeyring/utils/getAccountsBySnapId'; +import { selectInternalAccounts } from '../../../../selectors/accountsController'; +import { InternalAccount } from '@metamask/keyring-api'; +import Logger from '../../../../util/Logger'; interface SnapSettingsProps { snap: Snap; } @@ -42,9 +46,16 @@ const SnapSettings = () => { const navigation = useNavigation(); const { snap } = useParams(); - const permissionsState = useSelector(selectPermissionControllerState); + const [ + isShowingSnapKeyringRemoveWarning, + setIsShowingSnapKeyringRemoveWarning, + ] = useState(false); + + const [keyringAccounts, setKeyringAccounts] = useState([]); + const internalAccounts = useSelector(selectInternalAccounts); + // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any function getPermissionSubjects(state: any) { @@ -70,53 +81,118 @@ const SnapSettings = () => { ); }, [colors, navigation, snap.manifest.proposedName]); + const isKeyringSnap = Boolean(permissionsFromController?.snap_manageAccounts); + + useEffect(() => { + if (isKeyringSnap) { + (async () => { + const addresses = await getAccountsBySnapId(snap.id); + const snapIdentities = Object.values(internalAccounts).filter( + (internalAccount) => + addresses.includes(internalAccount.address.toLowerCase()), + ); + setKeyringAccounts(snapIdentities); + })(); + } + }, [snap?.id, internalAccounts, isKeyringSnap]); + + const handleKeyringSnapRemovalWarningClose = useCallback(() => { + setIsShowingSnapKeyringRemoveWarning(false); + }, []); + + const removeSnap = useCallback(async () => { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { SnapController } = Engine.context as any; + const { SnapController } = Engine.context; await SnapController.removeSnap(snap.id); + + if (isKeyringSnap && keyringAccounts.length > 0) { + try { + for (const keyringAccount of keyringAccounts) { + await Engine.removeAccount(keyringAccount.address); + } + } catch(error) { + Logger.error(error as Error, 'SnapSettings: failed to remove snap accounts when calling Engine.removeAccount'); + } + } navigation.goBack(); - }, [navigation, snap.id]); + }, [isKeyringSnap, keyringAccounts, navigation, snap.id]); + + const handleRemoveSnap = useCallback(() => { + if (isKeyringSnap && keyringAccounts.length > 0) { + setIsShowingSnapKeyringRemoveWarning(true); + } else { + removeSnap(); + } + }, [isKeyringSnap, keyringAccounts.length, removeSnap]); + + + const handleRemoveSnapKeyring = useCallback(() => { + try { + setIsShowingSnapKeyringRemoveWarning(true); + removeSnap(); + setIsShowingSnapKeyringRemoveWarning(false); + } catch { + setIsShowingSnapKeyringRemoveWarning(false); + } finally { + setIsShowingSnapKeyringRemoveWarning(false); + } + }, [removeSnap]); + + const shouldRenderRemoveSnapAccountWarning = + isShowingSnapKeyringRemoveWarning && + isKeyringSnap && + keyringAccounts.length > 0; return ( - - - - - - - - - - - - {strings( - 'app_settings.snaps.snap_settings.remove_snap_section_title', - )} - - - {strings( - 'app_settings.snaps.snap_settings.remove_snap_section_description', - )} - -