Skip to content

Commit a77c047

Browse files
committed
feat(suite-native): add native receive copy menu
1 parent 7b37cda commit a77c047

16 files changed

Lines changed: 738 additions & 204 deletions

.yarnrc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ npmMinimalAgeGate: 20160
1919
# Skip age gate for experimental, rapidly changing packages
2020
npmPreapprovedPackages:
2121
- "@types/invity-api@*"
22+
- "@expo/ui@56.0.24"
2223
# 16.0.1 (the newest gate-passing release) pins vulnerable axios 1.16.1; 16.1.0 pins patched 1.18.0
2324
- "@stellar/stellar-sdk@16.1.0"
2425

suite-native/app/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
"version": "1.0.0",
44
"suiteNativeVersion": "26.8.1",
55
"main": "index.js",
6+
"expo": {
7+
"autolinking": {
8+
"android": {
9+
"exclude": [
10+
"@expo/ui"
11+
]
12+
}
13+
}
14+
},
615
"scripts": {
716
"depcheck": "yarn g:depcheck",
817
"android": "expo run:android",
@@ -28,6 +37,7 @@
2837
"@evolu/common": "8.0.0-next.5",
2938
"@evolu/react-native": "15.0.0-next.2",
3039
"@exodus/patch-broken-hermes-typed-arrays": "^1.0.0-alpha.1",
40+
"@expo/ui": "56.0.24",
3141
"@gorhom/bottom-sheet": "5.2.9",
3242
"@jest/reporters": "^29.7.0",
3343
"@react-native-async-storage/async-storage": "2.2.0",

suite-native/clipboard/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
"type-check": "yarn g:tsc --build"
1212
},
1313
"dependencies": {
14+
"@expo/ui": "56.0.24",
1415
"@suite-native/intl": "workspace:*",
1516
"@suite-native/toasts": "workspace:*",
1617
"expo-clipboard": "~56.0.3",
17-
"react": "19.2.3"
18+
"expo-haptics": "~56.0.3",
19+
"react": "19.2.3",
20+
"react-native": "0.85.3"
1821
}
1922
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Button, ContextMenu, Host, RNHostView } from '@expo/ui/swift-ui';
2+
import * as Haptics from 'expo-haptics';
3+
4+
import { useTranslate } from '@suite-native/intl';
5+
6+
import type { ClipboardCopyMenuProps } from './ClipboardCopyMenu.types';
7+
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';
8+
9+
export const ClipboardCopyMenu = ({
10+
value,
11+
children,
12+
copyMessage,
13+
copyLabel,
14+
onCopy,
15+
style,
16+
}: ClipboardCopyMenuProps) => {
17+
const copyToClipboard = useCopyToClipboard();
18+
const { translate } = useTranslate();
19+
20+
const handleCopy = () => {
21+
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
22+
23+
if (onCopy) {
24+
void onCopy();
25+
26+
return;
27+
}
28+
29+
if (value !== undefined) {
30+
void copyToClipboard(value, copyMessage);
31+
}
32+
};
33+
34+
return (
35+
<Host matchContents style={style}>
36+
<ContextMenu>
37+
<ContextMenu.Items>
38+
<Button
39+
label={copyLabel ?? translate('generic.buttons.copy')}
40+
onPress={handleCopy}
41+
/>
42+
</ContextMenu.Items>
43+
<ContextMenu.Trigger>
44+
<RNHostView matchContents>{children}</RNHostView>
45+
</ContextMenu.Trigger>
46+
</ContextMenu>
47+
</Host>
48+
);
49+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Pressable } from 'react-native';
2+
3+
import * as Haptics from 'expo-haptics';
4+
5+
import type { ClipboardCopyMenuProps } from './ClipboardCopyMenu.types';
6+
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';
7+
8+
export const ClipboardCopyMenu = ({
9+
value,
10+
children,
11+
copyMessage,
12+
onCopy,
13+
style,
14+
}: ClipboardCopyMenuProps) => {
15+
const copyToClipboard = useCopyToClipboard();
16+
17+
const handleLongPress = () => {
18+
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
19+
20+
if (onCopy) {
21+
void onCopy();
22+
23+
return;
24+
}
25+
26+
if (value !== undefined) {
27+
void copyToClipboard(value, copyMessage);
28+
}
29+
};
30+
31+
return (
32+
<Pressable onLongPress={handleLongPress} style={style}>
33+
{children}
34+
</Pressable>
35+
);
36+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { ReactElement } from 'react';
2+
import type { StyleProp, ViewStyle } from 'react-native';
3+
4+
export type ClipboardCopyMenuProps = {
5+
value?: string;
6+
children: ReactElement;
7+
copyMessage?: string;
8+
copyLabel?: string;
9+
onCopy?: () => void | Promise<void>;
10+
style?: StyleProp<ViewStyle>;
11+
};

suite-native/clipboard/src/hooks/useCopyToClipboard.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,29 @@ import * as Clipboard from 'expo-clipboard';
55
import { useTranslate } from '@suite-native/intl';
66
import { useToast } from '@suite-native/toasts';
77

8+
type CopyToClipboardOptions = {
9+
shouldShowToast?: boolean;
10+
};
11+
812
export function useCopyToClipboard() {
913
const { translate } = useTranslate();
1014
const { showToast } = useToast();
1115

1216
const copyToClipboard = useCallback(
13-
async (value: string, toastMessage?: string) => {
17+
async (
18+
value: string,
19+
toastMessage?: string,
20+
{ shouldShowToast = true }: CopyToClipboardOptions = {},
21+
) => {
1422
await Clipboard.setStringAsync(value);
1523

16-
showToast({
17-
intent: 'neutral',
18-
message: toastMessage ?? translate('moduleClipboard.copiedToClipboard'),
19-
icon: 'copy',
20-
});
24+
if (shouldShowToast) {
25+
showToast({
26+
intent: 'neutral',
27+
message: toastMessage ?? translate('moduleClipboard.copiedToClipboard'),
28+
icon: 'copy',
29+
});
30+
}
2131
},
2232
[showToast, translate],
2333
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './components/ClipboardCopyMenu';
12
export * from './hooks/useCopyToClipboard';

suite-native/module-receive/src/components/ReceiveAddressActions.test.tsx

Lines changed: 17 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,17 @@
11
import { Share } from 'react-native';
22

3-
import { useNavigation } from '@react-navigation/native';
4-
53
import { getTranslation } from '@suite-native/intl';
6-
import { ReceiveAddressVerificationSource, ReceiveStackRoutes } from '@suite-native/navigation';
4+
import { ReceiveAddressVerificationSource } from '@suite-native/navigation';
75
import { renderWithBasicProvider, userEvent, waitFor } from '@suite-native/test-utils';
86

97
import { ReceiveAddressActions } from './ReceiveAddressActions';
108

11-
const mockCopyToClipboard = jest.fn();
12-
const mockOpenCopiedAddressBottomSheet = jest.fn();
13-
const mockCloseCopiedAddressBottomSheet = jest.fn();
149
const mockOpenSharedAddressBottomSheet = jest.fn();
1510
const mockCloseSharedAddressBottomSheet = jest.fn();
11+
const mockCopyAddress = jest.fn();
1612
const mockVerifyAddress = jest.fn();
1713
const mockShare = jest.spyOn(Share, 'share');
1814
const mockUseBottomSheetModal = jest.fn();
19-
const mockNavigate = jest.fn();
20-
21-
jest.mock('@react-navigation/native', () => ({
22-
...jest.requireActual('@react-navigation/native'),
23-
useNavigation: jest.fn(),
24-
}));
25-
26-
jest.mock('@suite-native/clipboard', () => ({
27-
useCopyToClipboard: () => mockCopyToClipboard,
28-
}));
2915

3016
jest.mock('@suite-native/atoms', () => ({
3117
...jest.requireActual('@suite-native/atoms'),
@@ -34,70 +20,45 @@ jest.mock('@suite-native/atoms', () => ({
3420

3521
describe('ReceiveAddressActions', () => {
3622
const address = 'bc1qreceiveaddress';
37-
const mockUseNavigation = jest.mocked(useNavigation);
3823

3924
const renderActions = () =>
4025
renderWithBasicProvider(
41-
<ReceiveAddressActions address={address} onVerifyAddress={mockVerifyAddress} />,
26+
<ReceiveAddressActions
27+
address={address}
28+
onCopyAddress={mockCopyAddress}
29+
onVerifyAddress={mockVerifyAddress}
30+
/>,
4231
);
4332

4433
beforeEach(() => {
4534
jest.clearAllMocks();
46-
mockCopyToClipboard.mockResolvedValue(undefined);
47-
mockVerifyAddress.mockResolvedValue(undefined);
35+
mockCopyAddress.mockResolvedValue(undefined);
4836
mockShare.mockResolvedValue({ action: Share.sharedAction });
49-
mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never);
50-
mockUseBottomSheetModal
51-
.mockReturnValueOnce({
52-
bottomSheetRef: { current: null },
53-
openModal: mockOpenCopiedAddressBottomSheet,
54-
closeModal: mockCloseCopiedAddressBottomSheet,
55-
})
56-
.mockReturnValueOnce({
57-
bottomSheetRef: { current: null },
58-
openModal: mockOpenSharedAddressBottomSheet,
59-
closeModal: mockCloseSharedAddressBottomSheet,
60-
});
37+
mockUseBottomSheetModal.mockReturnValue({
38+
bottomSheetRef: { current: null },
39+
openModal: mockOpenSharedAddressBottomSheet,
40+
closeModal: mockCloseSharedAddressBottomSheet,
41+
});
6142
});
6243

63-
it('opens the verification sheet after copying the address', async () => {
44+
it('copies the address', async () => {
6445
const { getByText } = renderActions();
6546

6647
await userEvent.press(getByText(getTranslation('qrCode.copyButton')));
6748

6849
await waitFor(() => {
69-
expect(mockCopyToClipboard).toHaveBeenCalledWith(
70-
address,
71-
getTranslation('qrCode.addressCopied'),
72-
);
73-
expect(mockOpenCopiedAddressBottomSheet).toHaveBeenCalledTimes(1);
50+
expect(mockCopyAddress).toHaveBeenCalledTimes(1);
7451
expect(mockOpenSharedAddressBottomSheet).not.toHaveBeenCalled();
7552
});
7653
});
7754

78-
it('closes the sheet and starts address verification', async () => {
79-
const { getByTestId } = renderActions();
80-
81-
await userEvent.press(getByTestId('@receive/address-verification/pasted/verify-button'));
82-
83-
expect(mockCloseCopiedAddressBottomSheet).toHaveBeenCalledTimes(1);
84-
expect(mockNavigate).toHaveBeenCalledWith(ReceiveStackRoutes.ReceiveAddressVerification, {
85-
source: ReceiveAddressVerificationSource.Pasted,
86-
});
87-
expect(mockVerifyAddress).toHaveBeenCalledWith();
88-
});
89-
9055
it('starts address verification directly', async () => {
9156
const { getByText } = renderActions();
9257

9358
await userEvent.press(getByText(getTranslation('moduleReceive.addressActions.verify')));
9459

95-
expect(mockCloseCopiedAddressBottomSheet).not.toHaveBeenCalled();
9660
expect(mockCloseSharedAddressBottomSheet).not.toHaveBeenCalled();
97-
expect(mockNavigate).toHaveBeenCalledWith(ReceiveStackRoutes.ReceiveAddressVerification, {
98-
source: ReceiveAddressVerificationSource.Pasted,
99-
});
100-
expect(mockVerifyAddress).toHaveBeenCalledWith();
61+
expect(mockVerifyAddress).toHaveBeenCalledWith(ReceiveAddressVerificationSource.Pasted);
10162
});
10263

10364
it('opens shared address verification after sharing', async () => {
@@ -109,10 +70,7 @@ describe('ReceiveAddressActions', () => {
10970
expect(mockShare).toHaveBeenCalledWith({ message: address });
11071
expect(mockOpenSharedAddressBottomSheet).toHaveBeenCalledTimes(1);
11172
expect(mockCloseSharedAddressBottomSheet).toHaveBeenCalledTimes(1);
112-
expect(mockNavigate).toHaveBeenCalledWith(ReceiveStackRoutes.ReceiveAddressVerification, {
113-
source: ReceiveAddressVerificationSource.Shared,
114-
});
115-
expect(mockVerifyAddress).toHaveBeenCalledWith();
73+
expect(mockVerifyAddress).toHaveBeenCalledWith(ReceiveAddressVerificationSource.Shared);
11674
});
11775

11876
it('does not open shared address verification after cancelling sharing', async () => {

0 commit comments

Comments
 (0)