Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: solana opt-in modal #14298

Merged
merged 15 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/components/UI/SolanaNewFeatureContent/FeatureItem.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { StyleSheet } from 'react-native';
import { fontStyles } from '../../../styles/common';

const createStyles = (colors: {
text: { default: string; alternative: string };
}) =>
StyleSheet.create({
container: {
flexDirection: 'row',
marginBottom: 16,
},
iconContainer: {
marginRight: 12,
marginTop: 2,
},
content: {
flex: 1,
},
title: {
...fontStyles.bold,
fontSize: 14,
color: colors.text.default,
marginBottom: 4,
},
description: {
...fontStyles.normal,
fontSize: 14,
color: colors.text.alternative,
},
});

export default createStyles;

Check warning on line 32 in app/components/UI/SolanaNewFeatureContent/FeatureItem.styles.ts

View workflow job for this annotation

GitHub Actions / scripts (lint)

Newline required at end of file but not found
37 changes: 37 additions & 0 deletions app/components/UI/SolanaNewFeatureContent/FeatureItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { View } from 'react-native';
import Text from '../../../component-library/components/Texts/Text';
import { useTheme } from '../../../util/theme';
import Icon, {
IconName,
IconSize,
} from '../../../component-library/components/Icons/Icon';
import createStyles from './FeatureItem.styles';

interface FeatureItemProps {
title: string;
description: string;
}

const FeatureItem = ({ title, description }: FeatureItemProps) => {
const { colors } = useTheme();
const styles = createStyles(colors);

return (
<View style={styles.container}>
<View style={styles.iconContainer}>
<Icon
name={IconName.Info}
size={IconSize.Md}
color={colors.primary.default}
/>
</View>
<View style={styles.content}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
</View>
</View>
);
};

export default FeatureItem;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { StyleSheet } from 'react-native';
import { fontStyles } from '../../../styles/common';

const createStyles = (colors: {
background: { default: string };
text: { default: string };
primary: { default: string };
}) =>
StyleSheet.create({
modal: {
margin: 0,
justifyContent: 'flex-end',
},
wrapper: {
backgroundColor: colors.background.default,
borderRadius: 12,
padding: 24,
paddingBottom: 0,
alignItems: 'center',
},
title: {
...fontStyles.bold,
fontSize: 18,
color: colors.text.default,
marginTop: 20,
marginBottom: 20,
textAlign: 'center',
},
featureList: {
width: '100%',
marginBottom: 24,
},
cancelButton: {
marginTop: 12,
},
});

export default createStyles;

Check warning on line 38 in app/components/UI/SolanaNewFeatureContent/SolanaNewFeatureContent.styles.ts

View workflow job for this annotation

GitHub Actions / scripts (lint)

Newline required at end of file but not found
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context';
import { KeyringClient } from '@metamask/keyring-snap-client';
import SolanaNewFeatureContent from './SolanaNewFeatureContent';
import StorageWrapper from '../../../store/storage-wrapper';

const mockUseTheme = jest.fn();
jest.mock('../../../util/theme', () => ({
useTheme: () => mockUseTheme(),
}));

jest.mock('../../../../locales/i18n', () => ({
strings: (key: string) => key,
}));

jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));

jest.mock('../../../store/storage-wrapper', () => ({
getItem: jest.fn(),
setItem: jest.fn(),
}));

jest.mock('@metamask/keyring-snap-client', () => ({
KeyringClient: jest.fn().mockImplementation(() => ({
createAccount: jest.fn(),
})),
}));

jest.mock('../../../core/SnapKeyring/SolanaWalletSnap', () => ({
SolanaWalletSnapSender: jest.fn(),
}));

jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
}),
}));

const initialMetrics: Metrics = {
frame: { x: 0, y: 0, width: 320, height: 640 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
};

const renderWithProviders = (component: React.ReactElement) =>
render(
<SafeAreaProvider initialMetrics={initialMetrics}>
{component}
</SafeAreaProvider>,
);

describe('SolanaNewFeatureContent', () => {
beforeEach(() => {
jest.clearAllMocks();
(useSelector as jest.Mock).mockReturnValue(false);
(StorageWrapper.getItem as jest.Mock).mockResolvedValue('false');
});

it('renders correctly', async () => {
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);

await waitFor(() => {
expect(getByText('solana_new_feature_content.title')).toBeTruthy();
expect(
getByText('solana_new_feature_content.feature_1_title'),
).toBeTruthy();
expect(
getByText('solana_new_feature_content.feature_2_title'),
).toBeTruthy();
expect(
getByText('solana_new_feature_content.feature_3_title'),
).toBeTruthy();
});
});

it('calls setItem when the "close" button is pressed', async () => {
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);

await waitFor(() => {
const closeButton = getByText('solana_new_feature_content.not_now');
fireEvent.press(closeButton);
});

expect(StorageWrapper.setItem).toHaveBeenCalledWith(
'@MetaMask:solanaFeatureModalShown',
'true',
);
});

it('shows the "create account" button for new users', async () => {
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);

await waitFor(() => {
expect(
getByText('solana_new_feature_content.create_solana_account'),
).toBeTruthy();
});
});

it('shows the "got it" button for existing users', async () => {
(useSelector as jest.Mock).mockReturnValue(true);
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);

await waitFor(() => {
expect(getByText('solana_new_feature_content.got_it')).toBeTruthy();
});
});

it('creates an account when "create account" button is pressed', async () => {
const mockCreateAccount = jest.fn();
(KeyringClient as jest.Mock).mockImplementation(() => ({
createAccount: mockCreateAccount,
}));

const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);

await waitFor(() => {
const createButton = getByText(
'solana_new_feature_content.create_solana_account',
);
fireEvent.press(createButton);
});

expect(mockCreateAccount).toHaveBeenCalledWith({
scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
});
expect(StorageWrapper.setItem).toHaveBeenCalledWith(
'@MetaMask:solanaFeatureModalShown',
'true',
);
});

it('does not render when modal has been shown before', async () => {
(StorageWrapper.getItem as jest.Mock).mockResolvedValue('true');
const { queryByText } = renderWithProviders(<SolanaNewFeatureContent />);

await waitFor(() => {
expect(queryByText('solana_new_feature_content.title')).toBeNull();
});
});
});
126 changes: 126 additions & 0 deletions app/components/UI/SolanaNewFeatureContent/SolanaNewFeatureContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { View } from 'react-native';
import { KeyringClient } from '@metamask/keyring-snap-client';
import { SolScope } from '@metamask/keyring-api';
import Text from '../../../component-library/components/Texts/Text';
import BottomSheet, {
BottomSheetRef,
} from '../../../component-library/components/BottomSheets/BottomSheet';
import { SolanaWalletSnapSender } from '../../../core/SnapKeyring/SolanaWalletSnap';
import Logger from '../../../util/Logger';
import Button, {
ButtonVariants,
ButtonWidthTypes,
} from '../../../component-library/components/Buttons/Button';
import FeatureItem from './FeatureItem';
import { useTheme } from '../../../util/theme';
import SolanaLogo from '../../../images/solana-logo-transparent.svg';
import { strings } from '../../../../locales/i18n';
import { selectHasCreatedSolanaMainnetAccount } from '../../../selectors/accountsController';
import createStyles from './SolanaNewFeatureContent.styles';
import StorageWrapper from '../../../store/storage-wrapper';
import { SOLANA_FEATURE_MODAL_SHOWN } from '../../../constants/storage';

const SolanaNewFeatureContent = () => {
const [isVisible, setIsVisible] = useState(false);
const sheetRef = useRef<BottomSheetRef>(null);

const { colors } = useTheme();
const styles = createStyles(colors);
const hasExistingSolanaAccount = useSelector(
selectHasCreatedSolanaMainnetAccount,
);

useEffect(() => {
const checkModalStatus = async () => {
const hasSeenModal = await StorageWrapper.getItem(
SOLANA_FEATURE_MODAL_SHOWN,
);
setIsVisible(hasSeenModal !== 'true');
};
checkModalStatus();
}, []);

const handleClose = async () => {
await StorageWrapper.setItem(SOLANA_FEATURE_MODAL_SHOWN, 'true');
setIsVisible(false);
sheetRef.current?.onCloseBottomSheet();
};

const createSolanaAccount = async () => {
try {
const client = new KeyringClient(new SolanaWalletSnapSender());

await client.createAccount({
scope: SolScope.Mainnet,
});
} catch (error) {
Logger.error(error as Error, 'Solana account creation failed');
} finally {
handleClose();
}
};

const features = [
{
title: strings('solana_new_feature_content.feature_1_title'),
description: strings('solana_new_feature_content.feature_1_description'),
},
{
title: strings('solana_new_feature_content.feature_2_title'),
description: strings('solana_new_feature_content.feature_2_description'),
},
{
title: strings('solana_new_feature_content.feature_3_title'),
description: strings('solana_new_feature_content.feature_3_description'),
},
];

if (!isVisible) return null;

return (
<BottomSheet
ref={sheetRef}
onClose={handleClose}
shouldNavigateBack={false}
>
<View style={styles.wrapper}>
<SolanaLogo name="solana-logo" height={65} />
<Text style={styles.title}>
{strings('solana_new_feature_content.title')}
</Text>

<View style={styles.featureList}>
{features.map((feature, index) => (
<FeatureItem
key={index}
title={feature.title}
description={feature.description}
/>
))}
</View>

<Button
variant={ButtonVariants.Primary}
label={strings(
hasExistingSolanaAccount
? 'solana_new_feature_content.got_it'
: 'solana_new_feature_content.create_solana_account',
)}
onPress={hasExistingSolanaAccount ? handleClose : createSolanaAccount}
width={ButtonWidthTypes.Full}
/>

<Button
variant={ButtonVariants.Link}
label={strings('solana_new_feature_content.not_now')}
onPress={handleClose}
style={styles.cancelButton}
/>
</View>
</BottomSheet>
);
};

export default SolanaNewFeatureContent;
1 change: 1 addition & 0 deletions app/components/UI/SolanaNewFeatureContent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './SolanaNewFeatureContent';

Check warning on line 1 in app/components/UI/SolanaNewFeatureContent/index.ts

View workflow job for this annotation

GitHub Actions / scripts (lint)

Newline required at end of file but not found
Loading
Loading