Skip to content
Open
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
1 change: 1 addition & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ npmMinimalAgeGate: 20160
# Skip age gate for experimental, rapidly changing packages
npmPreapprovedPackages:
- "@types/invity-api@*"
- "@expo/ui@56.0.24"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an expo library so I'm not too worried. Checked with AI, no vulnerabilities found.

# 16.0.1 (the newest gate-passing release) pins vulnerable axios 1.16.1; 16.1.0 pins patched 1.18.0
- "@stellar/stellar-sdk@16.1.0"

Expand Down
10 changes: 10 additions & 0 deletions suite-native/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
"version": "1.0.0",
"suiteNativeVersion": "26.8.1",
"main": "index.js",
"expo": {
"autolinking": {
"android": {
"exclude": [
"@expo/ui"
]
}
}
},
"scripts": {
"depcheck": "yarn g:depcheck",
"android": "expo run:android",
Expand All @@ -28,6 +37,7 @@
"@evolu/common": "8.0.0-next.5",
"@evolu/react-native": "15.0.0-next.2",
"@exodus/patch-broken-hermes-typed-arrays": "^1.0.0-alpha.1",
"@expo/ui": "56.0.24",
"@gorhom/bottom-sheet": "5.2.9",
"@jest/reporters": "^29.7.0",
"@react-native-async-storage/async-storage": "2.2.0",
Expand Down
5 changes: 4 additions & 1 deletion suite-native/clipboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
"type-check": "yarn g:tsc --build"
},
"dependencies": {
"@expo/ui": "56.0.24",
"@suite-native/intl": "workspace:*",
"@suite-native/toasts": "workspace:*",
"expo-clipboard": "~56.0.3",
"react": "19.2.3"
"expo-haptics": "~56.0.3",
"react": "19.2.3",
"react-native": "0.85.3"
}
}
49 changes: 49 additions & 0 deletions suite-native/clipboard/src/components/ClipboardCopyMenu.ios.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Button, ContextMenu, Host, RNHostView } from '@expo/ui/swift-ui';
import * as Haptics from 'expo-haptics';

import { useTranslate } from '@suite-native/intl';

import type { ClipboardCopyMenuProps } from './ClipboardCopyMenu.types';
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';

export const ClipboardCopyMenu = ({
value,
children,
copyMessage,
copyLabel,
onCopy,
style,
}: ClipboardCopyMenuProps) => {
const copyToClipboard = useCopyToClipboard();
const { translate } = useTranslate();

const handleCopy = () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);

if (onCopy) {
void onCopy();

return;
}

if (value !== undefined) {
void copyToClipboard(value, copyMessage);
}
};

return (
<Host matchContents style={style}>
<ContextMenu>
<ContextMenu.Items>
<Button
label={copyLabel ?? translate('generic.buttons.copy')}
onPress={handleCopy}
/>
</ContextMenu.Items>
<ContextMenu.Trigger>
<RNHostView matchContents>{children}</RNHostView>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
};
36 changes: 36 additions & 0 deletions suite-native/clipboard/src/components/ClipboardCopyMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Pressable } from 'react-native';

import * as Haptics from 'expo-haptics';

import type { ClipboardCopyMenuProps } from './ClipboardCopyMenu.types';
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';

export const ClipboardCopyMenu = ({
value,
children,
copyMessage,
onCopy,
style,
}: ClipboardCopyMenuProps) => {
const copyToClipboard = useCopyToClipboard();

const handleLongPress = () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);

if (onCopy) {
void onCopy();

return;
}

if (value !== undefined) {
void copyToClipboard(value, copyMessage);
}
};

return (
<Pressable onLongPress={handleLongPress} style={style}>
{children}
</Pressable>
);
};
11 changes: 11 additions & 0 deletions suite-native/clipboard/src/components/ClipboardCopyMenu.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ReactElement } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';

export type ClipboardCopyMenuProps = {
value?: string;
children: ReactElement;
copyMessage?: string;
copyLabel?: string;
onCopy?: () => void | Promise<void>;
style?: StyleProp<ViewStyle>;
};
22 changes: 16 additions & 6 deletions suite-native/clipboard/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@ import * as Clipboard from 'expo-clipboard';
import { useTranslate } from '@suite-native/intl';
import { useToast } from '@suite-native/toasts';

type CopyToClipboardOptions = {
shouldShowToast?: boolean;
};

export function useCopyToClipboard() {
const { translate } = useTranslate();
const { showToast } = useToast();

const copyToClipboard = useCallback(
async (value: string, toastMessage?: string) => {
async (
value: string,
toastMessage?: string,
{ shouldShowToast = true }: CopyToClipboardOptions = {},
) => {
await Clipboard.setStringAsync(value);

showToast({
intent: 'neutral',
message: toastMessage ?? translate('moduleClipboard.copiedToClipboard'),
icon: 'copy',
});
if (shouldShowToast) {
showToast({
intent: 'neutral',
message: toastMessage ?? translate('moduleClipboard.copiedToClipboard'),
icon: 'copy',
});
}
},
[showToast, translate],
);
Expand Down
1 change: 1 addition & 0 deletions suite-native/clipboard/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './components/ClipboardCopyMenu';
export * from './hooks/useCopyToClipboard';
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
import { Share } from 'react-native';

import { useNavigation } from '@react-navigation/native';

import { getTranslation } from '@suite-native/intl';
import { ReceiveAddressVerificationSource, ReceiveStackRoutes } from '@suite-native/navigation';
import { ReceiveAddressVerificationSource } from '@suite-native/navigation';
import { renderWithBasicProvider, userEvent, waitFor } from '@suite-native/test-utils';

import { ReceiveAddressActions } from './ReceiveAddressActions';

const mockCopyToClipboard = jest.fn();
const mockOpenCopiedAddressBottomSheet = jest.fn();
const mockCloseCopiedAddressBottomSheet = jest.fn();
const mockOpenSharedAddressBottomSheet = jest.fn();
const mockCloseSharedAddressBottomSheet = jest.fn();
const mockCopyAddress = jest.fn();
const mockVerifyAddress = jest.fn();
const mockShare = jest.spyOn(Share, 'share');
const mockUseBottomSheetModal = jest.fn();
const mockNavigate = jest.fn();

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

jest.mock('@suite-native/clipboard', () => ({
useCopyToClipboard: () => mockCopyToClipboard,
}));

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

describe('ReceiveAddressActions', () => {
const address = 'bc1qreceiveaddress';
const mockUseNavigation = jest.mocked(useNavigation);

const renderActions = () =>
renderWithBasicProvider(
<ReceiveAddressActions address={address} onVerifyAddress={mockVerifyAddress} />,
<ReceiveAddressActions
address={address}
onCopyAddress={mockCopyAddress}
onVerifyAddress={mockVerifyAddress}
/>,
);

beforeEach(() => {
jest.clearAllMocks();
mockCopyToClipboard.mockResolvedValue(undefined);
mockVerifyAddress.mockResolvedValue(undefined);
mockCopyAddress.mockResolvedValue(undefined);
mockShare.mockResolvedValue({ action: Share.sharedAction });
mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never);
mockUseBottomSheetModal
.mockReturnValueOnce({
bottomSheetRef: { current: null },
openModal: mockOpenCopiedAddressBottomSheet,
closeModal: mockCloseCopiedAddressBottomSheet,
})
.mockReturnValueOnce({
bottomSheetRef: { current: null },
openModal: mockOpenSharedAddressBottomSheet,
closeModal: mockCloseSharedAddressBottomSheet,
});
mockUseBottomSheetModal.mockReturnValue({
bottomSheetRef: { current: null },
openModal: mockOpenSharedAddressBottomSheet,
closeModal: mockCloseSharedAddressBottomSheet,
});
});

it('opens the verification sheet after copying the address', async () => {
it('copies the address', async () => {
const { getByText } = renderActions();

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

await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith(
address,
getTranslation('qrCode.addressCopied'),
);
expect(mockOpenCopiedAddressBottomSheet).toHaveBeenCalledTimes(1);
expect(mockCopyAddress).toHaveBeenCalledTimes(1);
expect(mockOpenSharedAddressBottomSheet).not.toHaveBeenCalled();
});
});

it('closes the sheet and starts address verification', async () => {
const { getByTestId } = renderActions();

await userEvent.press(getByTestId('@receive/address-verification/pasted/verify-button'));

expect(mockCloseCopiedAddressBottomSheet).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(ReceiveStackRoutes.ReceiveAddressVerification, {
source: ReceiveAddressVerificationSource.Pasted,
});
expect(mockVerifyAddress).toHaveBeenCalledWith();
});

it('starts address verification directly', async () => {
const { getByText } = renderActions();

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

expect(mockCloseCopiedAddressBottomSheet).not.toHaveBeenCalled();
expect(mockCloseSharedAddressBottomSheet).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith(ReceiveStackRoutes.ReceiveAddressVerification, {
source: ReceiveAddressVerificationSource.Pasted,
});
expect(mockVerifyAddress).toHaveBeenCalledWith();
expect(mockVerifyAddress).toHaveBeenCalledWith(ReceiveAddressVerificationSource.Pasted);
});

it('opens shared address verification after sharing', async () => {
Expand All @@ -109,10 +70,7 @@ describe('ReceiveAddressActions', () => {
expect(mockShare).toHaveBeenCalledWith({ message: address });
expect(mockOpenSharedAddressBottomSheet).toHaveBeenCalledTimes(1);
expect(mockCloseSharedAddressBottomSheet).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(ReceiveStackRoutes.ReceiveAddressVerification, {
source: ReceiveAddressVerificationSource.Shared,
});
expect(mockVerifyAddress).toHaveBeenCalledWith();
expect(mockVerifyAddress).toHaveBeenCalledWith(ReceiveAddressVerificationSource.Shared);
});

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