Skip to content

Commit f7f7c77

Browse files
authored
feat: solana opt-in modal (#14298)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Adding the solana modal to let users know the new feature is available. For users that already have a solana address, the blue button will say "Got it". For new users (without a solana account) the button will invite them to create their 1st solana account. The modal will be visible only once. <img src="https://github.com/user-attachments/assets/d4a11353-d4db-4de7-9943-5f5247e41e87" height="700" /> <img src="https://github.com/user-attachments/assets/bdfa5636-f10b-4c02-99da-b117a49175de" height="700" /> ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.
1 parent 7ed0306 commit f7f7c77

File tree

12 files changed

+1590
-0
lines changed

12 files changed

+1590
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { StyleSheet } from 'react-native';
2+
import { fontStyles } from '../../../styles/common';
3+
4+
const createStyles = (colors: {
5+
text: { default: string; alternative: string };
6+
}) =>
7+
StyleSheet.create({
8+
container: {
9+
flexDirection: 'row',
10+
marginBottom: 16,
11+
},
12+
iconContainer: {
13+
marginRight: 12,
14+
marginTop: 2,
15+
},
16+
content: {
17+
flex: 1,
18+
},
19+
title: {
20+
...fontStyles.bold,
21+
fontSize: 14,
22+
color: colors.text.default,
23+
marginBottom: 4,
24+
},
25+
description: {
26+
...fontStyles.normal,
27+
fontSize: 14,
28+
color: colors.text.alternative,
29+
},
30+
});
31+
32+
export default createStyles;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
import Text from '../../../component-library/components/Texts/Text';
4+
import { useTheme } from '../../../util/theme';
5+
import Icon, {
6+
IconName,
7+
IconSize,
8+
} from '../../../component-library/components/Icons/Icon';
9+
import createStyles from './FeatureItem.styles';
10+
11+
interface FeatureItemProps {
12+
title: string;
13+
description: string;
14+
}
15+
16+
const FeatureItem = ({ title, description }: FeatureItemProps) => {
17+
const { colors } = useTheme();
18+
const styles = createStyles(colors);
19+
20+
return (
21+
<View style={styles.container}>
22+
<View style={styles.iconContainer}>
23+
<Icon
24+
name={IconName.Info}
25+
size={IconSize.Md}
26+
color={colors.primary.default}
27+
/>
28+
</View>
29+
<View style={styles.content}>
30+
<Text style={styles.title}>{title}</Text>
31+
<Text style={styles.description}>{description}</Text>
32+
</View>
33+
</View>
34+
);
35+
};
36+
37+
export default FeatureItem;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { StyleSheet } from 'react-native';
2+
import { fontStyles } from '../../../styles/common';
3+
4+
const createStyles = (colors: {
5+
background: { default: string };
6+
text: { default: string };
7+
primary: { default: string };
8+
}) =>
9+
StyleSheet.create({
10+
modal: {
11+
margin: 0,
12+
justifyContent: 'flex-end',
13+
},
14+
wrapper: {
15+
backgroundColor: colors.background.default,
16+
borderRadius: 12,
17+
padding: 24,
18+
paddingBottom: 0,
19+
alignItems: 'center',
20+
},
21+
title: {
22+
...fontStyles.bold,
23+
fontSize: 18,
24+
color: colors.text.default,
25+
marginTop: 20,
26+
marginBottom: 20,
27+
textAlign: 'center',
28+
},
29+
featureList: {
30+
width: '100%',
31+
marginBottom: 24,
32+
},
33+
cancelButton: {
34+
marginTop: 12,
35+
},
36+
});
37+
38+
export default createStyles;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { render, fireEvent, waitFor } from '@testing-library/react-native';
4+
import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context';
5+
import { KeyringClient } from '@metamask/keyring-snap-client';
6+
import SolanaNewFeatureContent from './SolanaNewFeatureContent';
7+
import StorageWrapper from '../../../store/storage-wrapper';
8+
9+
const mockUseTheme = jest.fn();
10+
jest.mock('../../../util/theme', () => ({
11+
useTheme: () => mockUseTheme(),
12+
}));
13+
14+
jest.mock('../../../../locales/i18n', () => ({
15+
strings: (key: string) => key,
16+
}));
17+
18+
jest.mock('react-redux', () => ({
19+
useSelector: jest.fn(),
20+
}));
21+
22+
jest.mock('../../../store/storage-wrapper', () => ({
23+
getItem: jest.fn(),
24+
setItem: jest.fn(),
25+
}));
26+
27+
jest.mock('@metamask/keyring-snap-client', () => ({
28+
KeyringClient: jest.fn().mockImplementation(() => ({
29+
createAccount: jest.fn(),
30+
})),
31+
}));
32+
33+
jest.mock('../../../core/SnapKeyring/SolanaWalletSnap', () => ({
34+
SolanaWalletSnapSender: jest.fn(),
35+
}));
36+
37+
jest.mock('@react-navigation/native', () => ({
38+
useNavigation: () => ({
39+
navigate: jest.fn(),
40+
goBack: jest.fn(),
41+
}),
42+
}));
43+
44+
const initialMetrics: Metrics = {
45+
frame: { x: 0, y: 0, width: 320, height: 640 },
46+
insets: { top: 0, left: 0, right: 0, bottom: 0 },
47+
};
48+
49+
const renderWithProviders = (component: React.ReactElement) =>
50+
render(
51+
<SafeAreaProvider initialMetrics={initialMetrics}>
52+
{component}
53+
</SafeAreaProvider>,
54+
);
55+
56+
describe('SolanaNewFeatureContent', () => {
57+
beforeEach(() => {
58+
jest.clearAllMocks();
59+
(useSelector as jest.Mock).mockReturnValue(false);
60+
(StorageWrapper.getItem as jest.Mock).mockResolvedValue('false');
61+
});
62+
63+
it('renders correctly', async () => {
64+
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);
65+
66+
await waitFor(() => {
67+
expect(getByText('solana_new_feature_content.title')).toBeTruthy();
68+
expect(
69+
getByText('solana_new_feature_content.feature_1_title'),
70+
).toBeTruthy();
71+
expect(
72+
getByText('solana_new_feature_content.feature_2_title'),
73+
).toBeTruthy();
74+
expect(
75+
getByText('solana_new_feature_content.feature_3_title'),
76+
).toBeTruthy();
77+
});
78+
});
79+
80+
it('calls setItem when the "close" button is pressed', async () => {
81+
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);
82+
83+
await waitFor(() => {
84+
const closeButton = getByText('solana_new_feature_content.not_now');
85+
fireEvent.press(closeButton);
86+
});
87+
88+
expect(StorageWrapper.setItem).toHaveBeenCalledWith(
89+
'@MetaMask:solanaFeatureModalShown',
90+
'true',
91+
);
92+
});
93+
94+
it('shows the "create account" button for new users', async () => {
95+
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);
96+
97+
await waitFor(() => {
98+
expect(
99+
getByText('solana_new_feature_content.create_solana_account'),
100+
).toBeTruthy();
101+
});
102+
});
103+
104+
it('shows the "got it" button for existing users', async () => {
105+
(useSelector as jest.Mock).mockReturnValue(true);
106+
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);
107+
108+
await waitFor(() => {
109+
expect(getByText('solana_new_feature_content.got_it')).toBeTruthy();
110+
});
111+
});
112+
113+
it('creates an account when "create account" button is pressed', async () => {
114+
const mockCreateAccount = jest.fn();
115+
(KeyringClient as jest.Mock).mockImplementation(() => ({
116+
createAccount: mockCreateAccount,
117+
}));
118+
119+
const { getByText } = renderWithProviders(<SolanaNewFeatureContent />);
120+
121+
await waitFor(() => {
122+
const createButton = getByText(
123+
'solana_new_feature_content.create_solana_account',
124+
);
125+
fireEvent.press(createButton);
126+
});
127+
128+
expect(mockCreateAccount).toHaveBeenCalledWith({
129+
scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
130+
});
131+
expect(StorageWrapper.setItem).toHaveBeenCalledWith(
132+
'@MetaMask:solanaFeatureModalShown',
133+
'true',
134+
);
135+
});
136+
137+
it('does not render when modal has been shown before', async () => {
138+
(StorageWrapper.getItem as jest.Mock).mockResolvedValue('true');
139+
const { queryByText } = renderWithProviders(<SolanaNewFeatureContent />);
140+
141+
await waitFor(() => {
142+
expect(queryByText('solana_new_feature_content.title')).toBeNull();
143+
});
144+
});
145+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { View } from 'react-native';
4+
import { KeyringClient } from '@metamask/keyring-snap-client';
5+
import { SolScope } from '@metamask/keyring-api';
6+
import Text from '../../../component-library/components/Texts/Text';
7+
import BottomSheet, {
8+
BottomSheetRef,
9+
} from '../../../component-library/components/BottomSheets/BottomSheet';
10+
import { SolanaWalletSnapSender } from '../../../core/SnapKeyring/SolanaWalletSnap';
11+
import Logger from '../../../util/Logger';
12+
import Button, {
13+
ButtonVariants,
14+
ButtonWidthTypes,
15+
} from '../../../component-library/components/Buttons/Button';
16+
import FeatureItem from './FeatureItem';
17+
import { useTheme } from '../../../util/theme';
18+
import SolanaLogo from '../../../images/solana-logo-transparent.svg';
19+
import { strings } from '../../../../locales/i18n';
20+
import { selectHasCreatedSolanaMainnetAccount } from '../../../selectors/accountsController';
21+
import createStyles from './SolanaNewFeatureContent.styles';
22+
import StorageWrapper from '../../../store/storage-wrapper';
23+
import { SOLANA_FEATURE_MODAL_SHOWN } from '../../../constants/storage';
24+
25+
const SolanaNewFeatureContent = () => {
26+
const [isVisible, setIsVisible] = useState(false);
27+
const sheetRef = useRef<BottomSheetRef>(null);
28+
29+
const { colors } = useTheme();
30+
const styles = createStyles(colors);
31+
const hasExistingSolanaAccount = useSelector(
32+
selectHasCreatedSolanaMainnetAccount,
33+
);
34+
35+
useEffect(() => {
36+
const checkModalStatus = async () => {
37+
const hasSeenModal = await StorageWrapper.getItem(
38+
SOLANA_FEATURE_MODAL_SHOWN,
39+
);
40+
setIsVisible(hasSeenModal !== 'true');
41+
};
42+
checkModalStatus();
43+
}, []);
44+
45+
const handleClose = async () => {
46+
await StorageWrapper.setItem(SOLANA_FEATURE_MODAL_SHOWN, 'true');
47+
setIsVisible(false);
48+
sheetRef.current?.onCloseBottomSheet();
49+
};
50+
51+
const createSolanaAccount = async () => {
52+
try {
53+
const client = new KeyringClient(new SolanaWalletSnapSender());
54+
55+
await client.createAccount({
56+
scope: SolScope.Mainnet,
57+
});
58+
} catch (error) {
59+
Logger.error(error as Error, 'Solana account creation failed');
60+
} finally {
61+
handleClose();
62+
}
63+
};
64+
65+
const features = [
66+
{
67+
title: strings('solana_new_feature_content.feature_1_title'),
68+
description: strings('solana_new_feature_content.feature_1_description'),
69+
},
70+
{
71+
title: strings('solana_new_feature_content.feature_2_title'),
72+
description: strings('solana_new_feature_content.feature_2_description'),
73+
},
74+
{
75+
title: strings('solana_new_feature_content.feature_3_title'),
76+
description: strings('solana_new_feature_content.feature_3_description'),
77+
},
78+
];
79+
80+
if (!isVisible) return null;
81+
82+
return (
83+
<BottomSheet
84+
ref={sheetRef}
85+
onClose={handleClose}
86+
shouldNavigateBack={false}
87+
>
88+
<View style={styles.wrapper}>
89+
<SolanaLogo name="solana-logo" height={65} />
90+
<Text style={styles.title}>
91+
{strings('solana_new_feature_content.title')}
92+
</Text>
93+
94+
<View style={styles.featureList}>
95+
{features.map((feature, index) => (
96+
<FeatureItem
97+
key={index}
98+
title={feature.title}
99+
description={feature.description}
100+
/>
101+
))}
102+
</View>
103+
104+
<Button
105+
variant={ButtonVariants.Primary}
106+
label={strings(
107+
hasExistingSolanaAccount
108+
? 'solana_new_feature_content.got_it'
109+
: 'solana_new_feature_content.create_solana_account',
110+
)}
111+
onPress={hasExistingSolanaAccount ? handleClose : createSolanaAccount}
112+
width={ButtonWidthTypes.Full}
113+
/>
114+
115+
<Button
116+
variant={ButtonVariants.Link}
117+
label={strings('solana_new_feature_content.not_now')}
118+
onPress={handleClose}
119+
style={styles.cancelButton}
120+
/>
121+
</View>
122+
</BottomSheet>
123+
);
124+
};
125+
126+
export default SolanaNewFeatureContent;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './SolanaNewFeatureContent';

0 commit comments

Comments
 (0)