diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3a1fa1f32..f6b1ba60e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -29,6 +29,7 @@ "@algorandfoundation/algokit-utils": "catalog:", "@algorandfoundation/xhd-wallet-api": "catalog:", "@craftzdog/react-native-buffer": "^6.1.1", + "@expo/config-plugins": "~55.0.7", "@gorhom/bottom-sheet": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@legendapp/list": "^2.0.19", @@ -78,7 +79,6 @@ "@tanstack/react-query-persist-client": "catalog:", "bip39": "catalog:", "decimal.js": "^10.6.0", - "@expo/config-plugins": "~55.0.7", "expo": "^55.0.8", "expo-application": "^55.0.10", "expo-build-properties": "~55.0.10", @@ -86,6 +86,7 @@ "expo-dev-client": "~55.0.18", "expo-device": "^55.0.10", "expo-font": "~55.0.4", + "expo-haptics": "^55.0.9", "expo-linear-gradient": "^55.0.9", "expo-localization": "^55.0.9", "expo-splash-screen": "~55.0.12", @@ -131,7 +132,6 @@ "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/preset-env": "^7.28.5", "@babel/runtime": "catalog:", - "@react-native/babel-preset": "0.83.0", "@react-native/codegen": "^0.83.0", "@react-native/typescript-config": "0.83.0", @@ -139,7 +139,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "catalog:", "@types/react": "catalog:", - "@vitejs/plugin-react": "catalog:", "@welldone-software/why-did-you-render": "^10.0.1", "babel-plugin-module-resolver": "^5.0.2", diff --git a/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx b/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx index f354c2400..5994f84bd 100644 --- a/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx +++ b/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx @@ -14,10 +14,10 @@ import { PWIcon, PWText, PWTextProps, - PWTouchableOpacity, PWTouchableOpacityProps, PWView, } from '@components/core' +import { CopyableText } from '@components/CopyableText' import { useStyles } from './styles' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { useAllAccounts } from '@perawallet/wallet-core-accounts' @@ -92,10 +92,9 @@ export const AddressDisplay = ({ : truncateAlgorandAddress(address) return ( - {!!account && ( @@ -137,6 +136,6 @@ export const AddressDisplay = ({ onPress={copyAddress} /> )} - + ) } diff --git a/apps/mobile/src/components/CopyableText/CopyableText.tsx b/apps/mobile/src/components/CopyableText/CopyableText.tsx new file mode 100644 index 000000000..0d53bec1d --- /dev/null +++ b/apps/mobile/src/components/CopyableText/CopyableText.tsx @@ -0,0 +1,51 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useCallback } from 'react' +import { GestureResponderEvent } from 'react-native' +import { PWTouchableOpacity, PWTouchableOpacityProps } from '@components/core' +import { useClipboard } from '@hooks/useClipboard' + +export type CopyableTextProps = { + copyValue: string + children: React.ReactNode +} & Omit + +export const CopyableText = ({ + copyValue, + children, + onLongPress, + activeOpacity = 1, + accessibilityHint = 'Long press to copy', + ...rest +}: CopyableTextProps) => { + const { copyToClipboard } = useClipboard() + + const handleLongPress = useCallback( + (event: GestureResponderEvent) => { + copyToClipboard(copyValue) + onLongPress?.(event) + }, + [copyValue, copyToClipboard, onLongPress], + ) + + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/components/CopyableText/__tests__/CopyableText.spec.tsx b/apps/mobile/src/components/CopyableText/__tests__/CopyableText.spec.tsx new file mode 100644 index 000000000..4251e4b31 --- /dev/null +++ b/apps/mobile/src/components/CopyableText/__tests__/CopyableText.spec.tsx @@ -0,0 +1,52 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { render, screen } from '@test-utils/render' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PWText } from '@components/core' +import { CopyableText } from '../CopyableText' + +const mockCopyToClipboard = vi.fn() + +vi.mock('@hooks/useClipboard', () => ({ + useClipboard: () => ({ copyToClipboard: mockCopyToClipboard }), +})) + +describe('CopyableText', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders children', () => { + render( + + Asset 12345 + , + ) + + expect(screen.getByTestId('child-text')).toBeTruthy() + expect(screen.getByText('Asset 12345')).toBeTruthy() + }) + + it('renders with testID for accessibility', () => { + render( + + 67890 + , + ) + + expect(screen.getByTestId('copyable')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/CopyableText/index.ts b/apps/mobile/src/components/CopyableText/index.ts new file mode 100644 index 000000000..eba6e5c3f --- /dev/null +++ b/apps/mobile/src/components/CopyableText/index.ts @@ -0,0 +1,14 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export { CopyableText } from './CopyableText' +export type { CopyableTextProps } from './CopyableText' diff --git a/apps/mobile/src/components/CopyableText/styles.ts b/apps/mobile/src/components/CopyableText/styles.ts new file mode 100644 index 000000000..4456226a8 --- /dev/null +++ b/apps/mobile/src/components/CopyableText/styles.ts @@ -0,0 +1,15 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { makeStyles } from '@rneui/themed' + +export const useStyles = makeStyles(() => ({})) diff --git a/apps/mobile/src/hooks/__tests__/useClipboard.test.ts b/apps/mobile/src/hooks/__tests__/useClipboard.test.ts index 310918e0c..f8f8bf4d7 100644 --- a/apps/mobile/src/hooks/__tests__/useClipboard.test.ts +++ b/apps/mobile/src/hooks/__tests__/useClipboard.test.ts @@ -14,12 +14,18 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useClipboard } from '../useClipboard' import * as Clipboard from 'expo-clipboard' +import * as Haptics from 'expo-haptics' import { useToast } from '../useToast' vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(), })) +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + NotificationFeedbackType: { Success: 'success' }, +})) + vi.mock('../useToast', () => ({ useToast: vi.fn(() => ({ showToast: vi.fn() })), })) @@ -48,6 +54,9 @@ describe('useClipboard', () => { }) expect(Clipboard.setStringAsync).toHaveBeenCalledWith('test text') + expect(Haptics.notificationAsync).toHaveBeenCalledWith( + Haptics.NotificationFeedbackType.Success, + ) expect(mockShowToast).toHaveBeenCalledWith( { title: 'common.copied_to_clipboard.title', diff --git a/apps/mobile/src/hooks/useClipboard.ts b/apps/mobile/src/hooks/useClipboard.ts index 2cfb87c1e..0e18710a3 100644 --- a/apps/mobile/src/hooks/useClipboard.ts +++ b/apps/mobile/src/hooks/useClipboard.ts @@ -13,6 +13,7 @@ import { useToast } from './useToast' import { useLanguage } from '@hooks/useLanguage' import * as Clipboard from 'expo-clipboard' +import * as Haptics from 'expo-haptics' import { useCallback } from 'react' import type { NotifierRoot } from 'react-native-notifier' @@ -23,6 +24,7 @@ export const useClipboard = () => { const copyToClipboard = useCallback( async (text: string, notifier?: NotifierRoot) => { await Clipboard.setStringAsync(text) + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) showToast( { title: t('common.copied_to_clipboard.title'), diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx index d09394ee4..56a9284c1 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/WalletStructureTree.tsx @@ -17,6 +17,7 @@ import { PWTouchableOpacity, PWView, } from '@components/core' +import { CopyableText } from '@components/CopyableText' import { HDWalletAccount } from '@perawallet/wallet-core-accounts' import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' import { useLanguage } from '@hooks/useLanguage' @@ -65,12 +66,14 @@ export const WalletStructureTree = ({ {account.name ?? t('account_info.main_address')} - - {truncateAlgorandAddress(account.address)} - + + + {truncateAlgorandAddress(account.address)} + + diff --git a/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx b/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx index f3d14773b..8922ccced 100644 --- a/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx +++ b/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx @@ -22,6 +22,7 @@ import { PWView, PWViewProps, } from '@components/core' +import { CopyableText } from '@components/CopyableText' import { ALGO_ASSET_ID, useAssetsQuery } from '@perawallet/wallet-core-assets' import { AssetWithAccountBalance } from '@perawallet/wallet-core-accounts' import { useStyles } from './styles' @@ -123,14 +124,16 @@ export const AccountAssetItemView = ({ {verificationIcon} - - {asset.unitName} - {asset.assetId !== ALGO_ASSET_ID && - ` - ${asset.assetId}`} - + + + {asset.unitName} + {asset.assetId !== ALGO_ASSET_ID && + ` - ${asset.assetId}`} + + {showId && ( - - {asset.assetId} - + + + {asset.assetId} + + )} diff --git a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx index c78a65d91..3be91f7a1 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportRekeyedAddressesScreen/ImportRekeyedAddressesItem.tsx @@ -20,6 +20,7 @@ import { PWChip, PWRoundIcon, } from '@components/core' +import { CopyableText } from '@components/CopyableText' import { WalletAccount } from '@perawallet/wallet-core-accounts' import { useLanguage } from '@hooks/useLanguage' import { useModalState } from '@hooks/useModalState' @@ -73,14 +74,16 @@ export const ImportRekeyedAddressesItem = ({ - - {account.address} - + + + {account.address} + + - - {account.address} - + + + {account.address} + + - - {authAddress} - + + + {authAddress} + + {application ? ( - - - {application.name} - {!!application?.project?.verificationTier && ( - - )} + + + + {application.name} + {!!application?.project?.verificationTier && ( + + )} + + + {t('transactions.summary.app_id', { + id: applicationId, + })} + - - {t('transactions.summary.app_id', { - id: applicationId, - })} - - + ) : ( - - {valueOnlyOnFallback - ? applicationId - : t('transactions.summary.app_id', { - id: applicationId, - })} - + + + {valueOnlyOnFallback + ? applicationId + : t('transactions.summary.app_id', { + id: applicationId, + })} + + )} ) diff --git a/apps/mobile/src/modules/projects/components/ApplicationDisplay/__tests__/ApplicationDisplay.spec.tsx b/apps/mobile/src/modules/projects/components/ApplicationDisplay/__tests__/ApplicationDisplay.spec.tsx index c45f36315..bef27278f 100644 --- a/apps/mobile/src/modules/projects/components/ApplicationDisplay/__tests__/ApplicationDisplay.spec.tsx +++ b/apps/mobile/src/modules/projects/components/ApplicationDisplay/__tests__/ApplicationDisplay.spec.tsx @@ -48,6 +48,12 @@ vi.mock('@hooks/useLanguage', () => ({ }), })) +vi.mock('@components/CopyableText', () => ({ + CopyableText: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + vi.mock('@components/LoadingView', () => ({ LoadingView: ({ children, diff --git a/apps/mobile/src/modules/transactions/components/transaction-details/AppCallTransactionDisplay/AppCallDetailsPanel.tsx b/apps/mobile/src/modules/transactions/components/transaction-details/AppCallTransactionDisplay/AppCallDetailsPanel.tsx index 62c0944b0..57ba692bd 100644 --- a/apps/mobile/src/modules/transactions/components/transaction-details/AppCallTransactionDisplay/AppCallDetailsPanel.tsx +++ b/apps/mobile/src/modules/transactions/components/transaction-details/AppCallTransactionDisplay/AppCallDetailsPanel.tsx @@ -17,6 +17,7 @@ import { useStyles } from './styles' import { useLanguage } from '@hooks/useLanguage' import { TitledExpandablePanel } from '@components/ExpandablePanel/TitledExpandablePanel' import { AddressDisplay } from '@components/AddressDisplay' +import { CopyableText } from '@components/CopyableText' export type AppCallDetailsPanelProps = { transaction: PeraDisplayableTransaction @@ -79,12 +80,14 @@ export const AppCallDetailsPanel = ({ title={t('transactions.app_call.foreign_apps')} > {appCall.foreignApps.map(appId => ( - - {appId.toString()} - + + {appId.toString()} + + ))} )} @@ -95,12 +98,14 @@ export const AppCallDetailsPanel = ({ title={t('transactions.app_call.foreign_assets')} > {appCall.foreignAssets.map(assetId => ( - - {assetId.toString()} - + + {assetId.toString()} + + ))} )} diff --git a/apps/mobile/src/modules/transactions/components/transaction-details/AssetConfigDisplay/AssetConfigDisplay.tsx b/apps/mobile/src/modules/transactions/components/transaction-details/AssetConfigDisplay/AssetConfigDisplay.tsx index 3923aa72d..0d5d4190b 100644 --- a/apps/mobile/src/modules/transactions/components/transaction-details/AssetConfigDisplay/AssetConfigDisplay.tsx +++ b/apps/mobile/src/modules/transactions/components/transaction-details/AssetConfigDisplay/AssetConfigDisplay.tsx @@ -13,6 +13,7 @@ import { PWButton, PWDivider, PWText, PWView } from '@components/core' import { KeyValueRow } from '@components/KeyValueRow' import { AddressDisplay } from '@components/AddressDisplay' +import { CopyableText } from '@components/CopyableText' import { microAlgosToAlgos, PeraDisplayableTransaction, @@ -74,7 +75,9 @@ export const AssetConfigDisplay = ({ {configType !== 'create' && assetId !== undefined && ( - {assetId.toString()} + + {assetId.toString()} + )} diff --git a/apps/mobile/src/modules/transactions/components/transaction-details/AssetFreezeDisplay/AssetFreezeDisplay.tsx b/apps/mobile/src/modules/transactions/components/transaction-details/AssetFreezeDisplay/AssetFreezeDisplay.tsx index fcc91dc28..ce0ff38e4 100644 --- a/apps/mobile/src/modules/transactions/components/transaction-details/AssetFreezeDisplay/AssetFreezeDisplay.tsx +++ b/apps/mobile/src/modules/transactions/components/transaction-details/AssetFreezeDisplay/AssetFreezeDisplay.tsx @@ -13,6 +13,7 @@ import { PWDivider, PWText, PWView } from '@components/core' import { KeyValueRow } from '@components/KeyValueRow' import { AddressDisplay } from '@components/AddressDisplay' +import { CopyableText } from '@components/CopyableText' import { microAlgosToAlgos, type PeraDisplayableTransaction, @@ -65,7 +66,9 @@ export const AssetFreezeDisplay = ({ - {assetId} + + {assetId} + diff --git a/apps/mobile/src/modules/transactions/screens/claim-assets/AssetClaimDetailScreen/AssetClaimDetailScreen.tsx b/apps/mobile/src/modules/transactions/screens/claim-assets/AssetClaimDetailScreen/AssetClaimDetailScreen.tsx index 91fc94554..49bfe3364 100644 --- a/apps/mobile/src/modules/transactions/screens/claim-assets/AssetClaimDetailScreen/AssetClaimDetailScreen.tsx +++ b/apps/mobile/src/modules/transactions/screens/claim-assets/AssetClaimDetailScreen/AssetClaimDetailScreen.tsx @@ -19,6 +19,7 @@ import { PWTouchableOpacity, PWView, } from '@components/core' +import { CopyableText } from '@components/CopyableText' import { useLanguage } from '@hooks/useLanguage' import { DEFAULT_PRECISION } from '@perawallet/wallet-core-shared' import { RejectConfirmBottomSheet } from '@modules/transactions/components/claim-assets/RejectConfirmBottomSheet' @@ -81,7 +82,11 @@ export const AssetClaimDetailScreen = () => { {request.id && ( <> - {request.id} + + + {request.id} + + { {getAccountDisplayName(account)} - {account.address} + + + {account.address} + + diff --git a/apps/mobile/src/modules/transactions/screens/receive-funds/QRViewScreen/__tests__/QRViewScreen.spec.tsx b/apps/mobile/src/modules/transactions/screens/receive-funds/QRViewScreen/__tests__/QRViewScreen.spec.tsx index a93bc9970..1da712f7c 100644 --- a/apps/mobile/src/modules/transactions/screens/receive-funds/QRViewScreen/__tests__/QRViewScreen.spec.tsx +++ b/apps/mobile/src/modules/transactions/screens/receive-funds/QRViewScreen/__tests__/QRViewScreen.spec.tsx @@ -77,6 +77,12 @@ vi.mock('react-native-qrcode-svg', () => ({ default: () =>
, })) +vi.mock('@components/CopyableText', () => ({ + CopyableText: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + vi.mock('@components/EmptyView', () => ({ EmptyView: ({ title, body }: { title: string; body: string }) => (
diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index 3903b5609..beab7de75 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -573,6 +573,18 @@ vi.mock('expo-clipboard', () => ({ getStringAsync: vi.fn(), })) +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + impactAsync: vi.fn(), + selectionAsync: vi.fn(), + NotificationFeedbackType: { + Success: 'success', + Warning: 'warning', + Error: 'error', + }, + ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' }, +})) + vi.mock('expo-localization', () => ({ getLocales: () => [{ languageTag: 'en-US', regionCode: 'US' }], })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f7eb464f..c400c8ba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,6 +378,9 @@ importers: expo-font: specifier: ~55.0.4 version: 55.0.4(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-haptics: + specifier: ^55.0.9 + version: 55.0.9(expo@55.0.8) expo-linear-gradient: specifier: ^55.0.9 version: 55.0.9(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) @@ -5895,6 +5898,11 @@ packages: react: '*' react-native: 0.83.2 + expo-haptics@55.0.9: + resolution: {integrity: sha512-KCRyHr/uu4syXmoq3aIQ6ahuaX6FGtlPkWGlLlHJ836WF3nG+5+oCaCQiI7qMTpml+Tp/V/zP4ZaowM2KHgLNA==} + peerDependencies: + expo: '*' + expo-json-utils@55.0.0: resolution: {integrity: sha512-aupt/o5PDAb8dXDCb0JcRdkqnTLxe/F+La7jrnyd/sXlYFfRgBJLFOa1SqVFXm1E/Xam1SE/yw6eAb+DGY7Arg==} @@ -13194,6 +13202,10 @@ snapshots: react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + expo-haptics@55.0.9(expo@55.0.8): + dependencies: + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-json-utils@55.0.0: {} expo-keep-awake@55.0.4(expo@55.0.8)(react@19.2.0):