diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index d92e71fd0..1f59e9b1b 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -84,6 +84,7 @@ module.exports = { NSBluetoothPeripheralUsageDescription: '$(PRODUCT_NAME) will use Bluetooth to communicate with Ledger X.', NSFaceIDUsageDescription: '$(PRODUCT_NAME) uses Face ID to secure access to your wallet.', NSPhotoLibraryAddUsageDescription: '$(PRODUCT_NAME) will save QR codes to your photo library.', + NSPhotoLibraryUsageDescription: '$(PRODUCT_NAME) needs access to your photo library so you can pick a contact photo.', NSLocationWhenInUseUsageDescription: '$(PRODUCT_NAME) needs access to your location for Bluetooth communication with Ledger devices.', LSApplicationQueriesSchemes: ['itms-apps'], UIRequiredDeviceCapabilities: ['arm64'], @@ -237,6 +238,14 @@ module.exports = { enableCodeScanner: true, }, ], + // Image picker for contact photos + [ + 'expo-image-picker', + { + photosPermission: 'Pera needs access to your photo library so you can pick a contact photo.', + cameraPermission: 'Pera needs access to your camera.', + }, + ], // Note: The following packages are autolinked and don't require config plugins: // - expo-sqlite // - react-native-mmkv diff --git a/apps/mobile/assets/icons/contacts.svg b/apps/mobile/assets/icons/contacts.svg new file mode 100644 index 000000000..e4b8df0d6 --- /dev/null +++ b/apps/mobile/assets/icons/contacts.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/assets/icons/moon.svg b/apps/mobile/assets/icons/moon.svg index 155515cb2..e5ba9ae94 100644 --- a/apps/mobile/assets/icons/moon.svg +++ b/apps/mobile/assets/icons/moon.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/mobile/assets/icons/pen-solid.svg b/apps/mobile/assets/icons/pen-solid.svg new file mode 100644 index 000000000..ec2973fe1 --- /dev/null +++ b/apps/mobile/assets/icons/pen-solid.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7b2453838..a525140ed 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -97,6 +97,7 @@ "expo-font": "~55.0.6", "expo-haptics": "^55.0.14", "expo-image": "^55.0.8", + "expo-image-picker": "55.0.18", "expo-linear-gradient": "^55.0.13", "expo-localization": "^55.0.13", "expo-media-library": "^55.0.14", diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx b/apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx new file mode 100644 index 000000000..658d6dc4c --- /dev/null +++ b/apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx @@ -0,0 +1,95 @@ +/* + 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 { + PWBottomSheet, + PWButton, + PWIcon, + PWText, + PWView, +} from '@components/core' +import { useStyles } from './styles' + +import type { IconName, PWButtonProps, PWIconVariant } from '@components/core' + +/** + * Generic confirmation bottom sheet. Callers supply labels, the + * confirm/cancel handlers, and — if they want one — an icon. No default + * icon is rendered so the component doesn't quietly assume a destructive + * context. Button variants default to primary/secondary; override per + * callsite when the design diverges. + */ +export type ConfirmActionBottomSheetProps = { + isVisible: boolean + onClose: () => void + onConfirm: () => void + icon?: IconName + iconVariant?: PWIconVariant + title: string + message: string + confirmLabel: string + cancelLabel: string + confirmVariant?: PWButtonProps['variant'] + cancelVariant?: PWButtonProps['variant'] + testID?: string +} + +export const ConfirmActionBottomSheet = ({ + isVisible, + onClose, + onConfirm, + icon, + iconVariant = 'primary', + title, + message, + confirmLabel, + cancelLabel, + confirmVariant = 'primary', + cancelVariant = 'secondary', + testID, +}: ConfirmActionBottomSheetProps) => { + const styles = useStyles() + + return ( + + {!!icon && ( + + )} + {title} + {message} + + + + + + ) +} diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx b/apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx new file mode 100644 index 000000000..9b5c8eb55 --- /dev/null +++ b/apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx @@ -0,0 +1,65 @@ +/* + 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 { fireEvent, render, screen } from '@test-utils/render' +import { describe, it, expect, vi } from 'vitest' +import { ConfirmActionBottomSheet } from '../ConfirmActionBottomSheet' + +describe('ConfirmActionBottomSheet', () => { + const baseProps = { + isVisible: true, + title: 'Delete contact', + message: 'Are you sure you want to delete this contact?', + confirmLabel: 'Yes, delete contact', + cancelLabel: 'Keep it', + } + + it('renders the title, message and actions when visible', () => { + render( + , + ) + expect(screen.getByText(baseProps.title)).toBeTruthy() + expect(screen.getByText(baseProps.message)).toBeTruthy() + expect(screen.getByText(baseProps.confirmLabel)).toBeTruthy() + expect(screen.getByText(baseProps.cancelLabel)).toBeTruthy() + }) + + it('calls onConfirm when the confirm button is pressed', () => { + const onConfirm = vi.fn() + render( + , + ) + fireEvent.click(screen.getByText(baseProps.confirmLabel)) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when the cancel button is pressed', () => { + const onClose = vi.fn() + render( + , + ) + fireEvent.click(screen.getByText(baseProps.cancelLabel)) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/index.ts b/apps/mobile/src/components/ConfirmActionBottomSheet/index.ts new file mode 100644 index 000000000..bfe55f52d --- /dev/null +++ b/apps/mobile/src/components/ConfirmActionBottomSheet/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 { ConfirmActionBottomSheet } from './ConfirmActionBottomSheet' +export type { ConfirmActionBottomSheetProps } from './ConfirmActionBottomSheet' diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts new file mode 100644 index 000000000..8c6f0094b --- /dev/null +++ b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts @@ -0,0 +1,35 @@ +/* + 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(theme => ({ + container: { + paddingVertical: theme.spacing.xxl, + alignItems: 'center', + }, + icon: { + marginBottom: theme.spacing.lg, + }, + message: { + textAlign: 'center', + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + color: theme.colors.textGray, + }, + actions: { + width: '100%', + paddingHorizontal: theme.spacing.lg, + paddingTop: theme.spacing.md, + gap: theme.spacing.sm, + }, +})) diff --git a/apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx b/apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx new file mode 100644 index 000000000..85a7eccd0 --- /dev/null +++ b/apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx @@ -0,0 +1,46 @@ +/* + 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 { ConfirmActionBottomSheet } from '@components/ConfirmActionBottomSheet' +import { useLanguage } from '@hooks/useLanguage' + +export type PhotoPermissionDeniedSheetProps = { + isVisible: boolean + onClose: () => void + onOpenSettings: () => void +} + +/** + * Bottom sheet shown when the photo-library permission has been denied + * permanently (iOS / legacy Android). Guides the user to the system + * Settings app to grant access. Owns its own i18n copy so callers only + * have to wire visibility + handlers. + */ +export const PhotoPermissionDeniedSheet = ({ + isVisible, + onClose, + onOpenSettings, +}: PhotoPermissionDeniedSheetProps) => { + const { t } = useLanguage() + + return ( + + ) +} diff --git a/apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx b/apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx new file mode 100644 index 000000000..4941e87e0 --- /dev/null +++ b/apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx @@ -0,0 +1,57 @@ +/* + 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 { fireEvent, render, screen } from '@test-utils/render' +import { describe, it, expect, vi } from 'vitest' +import { PhotoPermissionDeniedSheet } from '../PhotoPermissionDeniedSheet' + +describe('PhotoPermissionDeniedSheet', () => { + it('renders the permission copy when visible', () => { + render( + , + ) + expect(screen.getByText('image_picker.permission_title')).toBeTruthy() + expect( + screen.getByText('image_picker.open_settings.label'), + ).toBeTruthy() + }) + + it('invokes onOpenSettings when the confirm button is pressed', () => { + const onOpenSettings = vi.fn() + render( + , + ) + fireEvent.click(screen.getByText('image_picker.open_settings.label')) + expect(onOpenSettings).toHaveBeenCalledTimes(1) + }) + + it('invokes onClose when the cancel button is pressed', () => { + const onClose = vi.fn() + render( + , + ) + fireEvent.click(screen.getByText('common.cancel.label')) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/src/components/PhotoPermissionDeniedSheet/index.ts b/apps/mobile/src/components/PhotoPermissionDeniedSheet/index.ts new file mode 100644 index 000000000..9b88689ce --- /dev/null +++ b/apps/mobile/src/components/PhotoPermissionDeniedSheet/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 { PhotoPermissionDeniedSheet } from './PhotoPermissionDeniedSheet' +export type { PhotoPermissionDeniedSheetProps } from './PhotoPermissionDeniedSheet' diff --git a/apps/mobile/src/components/QRScannerView/QRScannerView.tsx b/apps/mobile/src/components/QRScannerView/QRScannerView.tsx index 7f889ba89..1d3575d7f 100644 --- a/apps/mobile/src/components/QRScannerView/QRScannerView.tsx +++ b/apps/mobile/src/components/QRScannerView/QRScannerView.tsx @@ -33,11 +33,16 @@ export const QRScannerView = (props: QRScannerViewProps) => { const styles = useStyles(insets) const { t } = useLanguage() - const { device, codeScanner, scanningEnabled, permissionDenied } = - useQRScannerView({ - isVisible: props.isVisible, - onSuccess: props.onSuccess, - }) + const { + device, + codeScanner, + scanningEnabled, + permissionDenied, + hasPermission, + } = useQRScannerView({ + isVisible: props.isVisible, + onSuccess: props.onSuccess, + }) return ( { visible={props.isVisible} animationType={props.animationType} > - {device == null || permissionDenied ? ( + {device == null || permissionDenied || !hasPermission ? ( <> { bottom: 0, left: 0, right: 0, + paddingHorizontal: theme.spacing.xl, backgroundColor: theme.colors.background, }, } diff --git a/apps/mobile/src/components/QRScannerView/useQRScannerView.ts b/apps/mobile/src/components/QRScannerView/useQRScannerView.ts index c50f30e56..3e93247aa 100644 --- a/apps/mobile/src/components/QRScannerView/useQRScannerView.ts +++ b/apps/mobile/src/components/QRScannerView/useQRScannerView.ts @@ -49,23 +49,26 @@ export const useQRScannerView = ({ try { const url = codes.at(0)?.value setScanningEnabled(false) - if (url) { - if (isValidDeepLink(url)) { - handleDeepLink( - url, - true, - 'qr', - () => setScanningEnabled(true), - () => { - logger.debug( - 'QRScannerView: Deep link handled successfully', - { url }, - ) - onSuccess(url, () => setScanningEnabled(true)) - }, - ) - } + if (!url) return + if (!isValidDeepLink(url)) { + // Unrecognized code — re-arm the scanner so the user + // can try again without closing the modal. + setScanningEnabled(true) + return } + handleDeepLink( + url, + true, + 'qr', + () => setScanningEnabled(true), + () => { + logger.debug( + 'QRScannerView: Deep link handled successfully', + { url }, + ) + onSuccess(url, () => setScanningEnabled(true)) + }, + ) } catch (error) { logger.error('QRScannerView: QR scanner error:', { error }) } @@ -73,14 +76,14 @@ export const useQRScannerView = ({ }) useEffect(() => { - if (!hasPermission && isVisible) { - requestPermission().then(result => { - if (!result) { - setPermissionDenied(true) - } else { - setPermissionDenied(false) - } - }) + if (hasPermission || !isVisible) return + let active = true + requestPermission().then(result => { + if (!active) return + setPermissionDenied(!result) + }) + return () => { + active = false } }, [isVisible, hasPermission, requestPermission]) diff --git a/apps/mobile/src/components/core/PWIcon/constants.ts b/apps/mobile/src/components/core/PWIcon/constants.ts index 2e538a5da..596e4a2b9 100644 --- a/apps/mobile/src/components/core/PWIcon/constants.ts +++ b/apps/mobile/src/components/core/PWIcon/constants.ts @@ -28,6 +28,7 @@ import ChevronDownIcon from '@assets/icons/chevron-down.svg' import ChevronLeftIcon from '@assets/icons/chevron-left.svg' import ChevronRightIcon from '@assets/icons/chevron-right.svg' import CodeIcon from '@assets/icons/code.svg' +import ContactsIcon from '@assets/icons/contacts.svg' import CopyIcon from '@assets/icons/copy.svg' import CrossIcon from '@assets/icons/cross.svg' import Cube3dIcon from '@assets/icons/cube-3d.svg' @@ -66,6 +67,7 @@ import PersonKeyIcon from '@assets/icons/person-key.svg' import PersonMenuIcon from '@assets/icons/person-menu.svg' import PersonIcon from '@assets/icons/person.svg' import PauseIcon from '@assets/icons/pause.svg' +import PenSolidIcon from '@assets/icons/pen-solid.svg' import PlayIcon from '@assets/icons/play.svg' import PlusWithBorderIcon from '@assets/icons/plus-with-border.svg' import PlusIcon from '@assets/icons/plus.svg' @@ -158,6 +160,7 @@ export const ICON_LIBRARY = { 'chevron-down': ChevronDownIcon, 'chevron-left': ChevronLeftIcon, 'chevron-right': ChevronRightIcon, + contacts: ContactsIcon, copy: CopyIcon, code: CodeIcon, cross: CrossIcon, @@ -200,6 +203,7 @@ export const ICON_LIBRARY = { person: PersonIcon, 'person-key': PersonKeyIcon, pause: PauseIcon, + 'pen-solid': PenSolidIcon, play: PlayIcon, 'plus-with-border': PlusWithBorderIcon, plus: PlusIcon, diff --git a/apps/mobile/src/constants/ui.ts b/apps/mobile/src/constants/ui.ts index fe32c3201..7c839058b 100644 --- a/apps/mobile/src/constants/ui.ts +++ b/apps/mobile/src/constants/ui.ts @@ -46,6 +46,7 @@ export const SHORT_PROMPT_DISPLAY_DELAY = 300 export const LONG_PROMPT_DISPLAY_DELAY = 3000 export const LONG_ADDRESS_FORMAT = 20 +export const SHORT_ADDRESS_FORMAT = 9 export const SEARCH_DEBOUNCE_TIME = 400 export const SEARCH_DEBOUNCE_TIME_SHORT = 75 diff --git a/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts new file mode 100644 index 000000000..0b49392e4 --- /dev/null +++ b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts @@ -0,0 +1,139 @@ +/* + 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 { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as ImagePicker from 'expo-image-picker' +import { Linking } from 'react-native' +import { useImagePicker } from '../useImagePicker' + +vi.mock('expo-image-picker', () => ({ + getMediaLibraryPermissionsAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), + launchImageLibraryAsync: vi.fn(), +})) + +describe('useImagePicker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the picked URI when permission is granted and user selects', async () => { + vi.mocked( + ImagePicker.getMediaLibraryPermissionsAsync, + ).mockResolvedValue({ + granted: true, + canAskAgain: true, + } as Awaited< + ReturnType + >) + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///tmp/photo.jpg' }], + } as Awaited>) + + const { result } = renderHook(() => useImagePicker()) + const uri = await result.current.pickFromGallery() + + expect(uri).toBe('file:///tmp/photo.jpg') + }) + + it('returns null when user cancels the picker', async () => { + vi.mocked( + ImagePicker.getMediaLibraryPermissionsAsync, + ).mockResolvedValue({ + granted: true, + canAskAgain: true, + } as Awaited< + ReturnType + >) + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: true, + } as Awaited>) + + const { result } = renderHook(() => useImagePicker()) + const uri = await result.current.pickFromGallery() + + expect(uri).toBeNull() + }) + + it('exposes the permission-denied state when permission is not reprompt-able', async () => { + vi.mocked( + ImagePicker.getMediaLibraryPermissionsAsync, + ).mockResolvedValue({ + granted: false, + canAskAgain: false, + } as Awaited< + ReturnType + >) + + const { result } = renderHook(() => useImagePicker()) + expect(result.current.permissionDenied.isVisible).toBe(false) + + let uri: string | null = null + await act(async () => { + uri = await result.current.pickFromGallery() + }) + + expect(uri).toBeNull() + expect(ImagePicker.launchImageLibraryAsync).not.toHaveBeenCalled() + expect(result.current.permissionDenied.isVisible).toBe(true) + }) + + it('hides the permission-denied state when close is called', async () => { + vi.mocked( + ImagePicker.getMediaLibraryPermissionsAsync, + ).mockResolvedValue({ + granted: false, + canAskAgain: false, + } as Awaited< + ReturnType + >) + + const { result } = renderHook(() => useImagePicker()) + await act(async () => { + await result.current.pickFromGallery() + }) + expect(result.current.permissionDenied.isVisible).toBe(true) + + act(() => { + result.current.permissionDenied.close() + }) + expect(result.current.permissionDenied.isVisible).toBe(false) + }) + + it('invokes Linking.openSettings and hides the sheet when openSettings is called', async () => { + vi.mocked( + ImagePicker.getMediaLibraryPermissionsAsync, + ).mockResolvedValue({ + granted: false, + canAskAgain: false, + } as Awaited< + ReturnType + >) + const openSettingsSpy = vi + .spyOn(Linking, 'openSettings') + .mockResolvedValue(undefined) + + const { result } = renderHook(() => useImagePicker()) + await act(async () => { + await result.current.pickFromGallery() + }) + + act(() => { + result.current.permissionDenied.openSettings() + }) + + expect(openSettingsSpy).toHaveBeenCalledTimes(1) + expect(result.current.permissionDenied.isVisible).toBe(false) + }) +}) diff --git a/apps/mobile/src/hooks/useImagePicker.ts b/apps/mobile/src/hooks/useImagePicker.ts new file mode 100644 index 000000000..53b60a17a --- /dev/null +++ b/apps/mobile/src/hooks/useImagePicker.ts @@ -0,0 +1,93 @@ +/* + 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, useMemo, useState } from 'react' +import { Linking } from 'react-native' +import * as ImagePicker from 'expo-image-picker' + +export type PermissionDeniedState = { + isVisible: boolean + close: () => void + openSettings: () => void +} + +export type UseImagePickerResult = { + pickFromGallery: () => Promise + /** + * State + handlers for the "permission denied and not re-askable" bottom + * sheet. Pair with `` at the screen root. + */ + permissionDenied: PermissionDeniedState +} + +/** + * Returns a URI for an image the user picks from their photo library. + * Handles the iOS/Android permission prompt. When permission has been + * denied permanently, surfaces a `permissionDenied` state the caller + * renders via `PhotoPermissionDeniedSheet`. + */ +export const useImagePicker = (): UseImagePickerResult => { + const [permissionDeniedVisible, setPermissionDeniedVisible] = + useState(false) + + const closePermissionDenied = useCallback( + () => setPermissionDeniedVisible(false), + [], + ) + const openSettings = useCallback(() => { + setPermissionDeniedVisible(false) + Linking.openSettings() + }, []) + + const ensurePermission = useCallback(async () => { + const current = await ImagePicker.getMediaLibraryPermissionsAsync() + if (current.granted) { + return true + } + if (!current.canAskAgain) { + setPermissionDeniedVisible(true) + return false + } + const next = await ImagePicker.requestMediaLibraryPermissionsAsync() + return next.granted + }, []) + + const pickFromGallery = useCallback(async () => { + const granted = await ensurePermission() + if (!granted) { + return null + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + // Strip EXIF — avatars should not leak GPS/camera metadata. + exif: false, + }) + if (result.canceled) { + return null + } + return result.assets[0]?.uri ?? null + }, [ensurePermission]) + + const permissionDenied = useMemo( + () => ({ + isVisible: permissionDeniedVisible, + close: closePermissionDenied, + openSettings, + }), + [permissionDeniedVisible, closePermissionDenied, openSettings], + ) + + return { pickFromGallery, permissionDenied } +} diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index fb64eb568..c35390647 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -606,6 +606,13 @@ "unsupported_url": "Unsupported URL: {{url}}" } }, + "image_picker": { + "permission_title": "Photo library access needed", + "permission_body": "Pera needs permission to access your photo library so you can pick a contact avatar. Open Settings to enable it.", + "open_settings": { + "label": "Open Settings" + } + }, "menu": { "cards": "Cards", "title": "Menu", diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index 25bd4bf9c..608dade5d 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -971,18 +971,21 @@ vi.mock('react-native', () => { ) }, ), - TextInput: vi.fn().mockImplementation(({ testID, ...props }) => - require('react').createElement( - 'input', - { - ...props, - ...(testID - ? { 'data-testid': testID, testid: testID } - : {}), - }, - props.children, + TextInput: vi + .fn() + .mockImplementation(({ testID, onChangeText, ...props }) => + require('react').createElement( + 'input', + { + ...props, + onChange: (e: any) => onChangeText?.(e.target.value), + ...(testID + ? { 'data-testid': testID, testid: testID } + : {}), + }, + props.children, + ), ), - ), Modal: vi.fn().mockImplementation((args: any) => { const { @@ -1046,6 +1049,7 @@ vi.mock('react-native', () => { }, Linking: { openURL: vi.fn(), + openSettings: vi.fn(), canOpenURL: vi.fn(), getInitialURL: vi.fn(), addEventListener: vi.fn(), diff --git a/packages/contacts/src/errors.ts b/packages/contacts/src/errors.ts new file mode 100644 index 000000000..04a9b6c73 --- /dev/null +++ b/packages/contacts/src/errors.ts @@ -0,0 +1,26 @@ +/* + 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 + */ + +/** + * Thrown by `saveContact` when a contact with the same address already + * exists (and is not the contact being updated). UI layers catch this and + * surface it as a form-level error. + */ +export class DuplicateAddressError extends Error { + readonly address: string + + constructor(address: string) { + super(`A contact with address ${address} already exists`) + this.name = 'DuplicateAddressError' + this.address = address + } +} diff --git a/packages/contacts/src/index.ts b/packages/contacts/src/index.ts index b7ab23be2..a9ff77f57 100644 --- a/packages/contacts/src/index.ts +++ b/packages/contacts/src/index.ts @@ -12,6 +12,7 @@ export const name = '@perawallet/wallet-core-contacts' +export * from './errors' export * from './models' export * from './schema' export * from './hooks' diff --git a/packages/contacts/src/models/index.ts b/packages/contacts/src/models/index.ts index f25860646..4735c3539 100644 --- a/packages/contacts/src/models/index.ts +++ b/packages/contacts/src/models/index.ts @@ -24,7 +24,6 @@ export type ContactsState = BaseStoreState & { contacts: Contact[] selectedContact: Nullable setSelectedContact: (contact: Nullable) => void - setContacts: (contacts: Contact[]) => void - saveContact: (contact: Contact) => void + saveContact: (contact: Contact) => boolean deleteContact: (contact: Contact) => boolean } diff --git a/packages/contacts/src/schema/index.ts b/packages/contacts/src/schema/index.ts index c15e556b9..bc427429b 100644 --- a/packages/contacts/src/schema/index.ts +++ b/packages/contacts/src/schema/index.ts @@ -13,6 +13,7 @@ import { z } from 'zod' export const contactSchema = z.object({ + id: z.string().optional(), name: z .string('Please enter a valid name') .min(1, { message: 'Please enter a valid name' }), @@ -28,4 +29,5 @@ export const contactSchema = z.object({ message: 'Please enter a valid NF Domain name', }) .optional(), + image: z.string().optional(), }) diff --git a/packages/contacts/src/store/__tests__/store.test.ts b/packages/contacts/src/store/__tests__/store.test.ts index d21979d92..4fe2ca509 100644 --- a/packages/contacts/src/store/__tests__/store.test.ts +++ b/packages/contacts/src/store/__tests__/store.test.ts @@ -13,6 +13,7 @@ import { describe, test, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import type { Contact } from '../../models' +import { DuplicateAddressError } from '../../errors' const registerStoreMock = vi.fn() @@ -67,7 +68,57 @@ describe('ContactsStore', () => { expect(result.current.contacts[0].id).toBeTruthy() }) - test('saveContact returns false for duplicate id', async () => { + test('saveContact throws DuplicateAddressError when another contact uses that address', async () => { + const { useContactsStore } = await import('../index') + const { result } = renderHook(() => useContactsStore()) + + act(() => { + result.current.saveContact({ + id: 'alice-id', + name: 'Alice', + address: 'SHARED_ADDRESS', + }) + }) + + expect(() => + result.current.saveContact({ + id: 'bob-id', + name: 'Bob', + address: 'SHARED_ADDRESS', + }), + ).toThrow(DuplicateAddressError) + // Original contact unchanged. + expect(result.current.contacts).toHaveLength(1) + expect(result.current.contacts[0]?.name).toBe('Alice') + }) + + test('saveContact allows updating the same contact with its own address', async () => { + const { useContactsStore } = await import('../index') + const { result } = renderHook(() => useContactsStore()) + + act(() => { + result.current.saveContact({ + id: 'alice-id', + name: 'Alice', + address: 'SHARED_ADDRESS', + }) + }) + + act(() => { + // Same id, same address, new name — should succeed (it's the same + // row being updated, not a new duplicate). + result.current.saveContact({ + id: 'alice-id', + name: 'Alice Updated', + address: 'SHARED_ADDRESS', + }) + }) + + expect(result.current.contacts).toHaveLength(1) + expect(result.current.contacts[0]?.name).toBe('Alice Updated') + }) + + test('saveContact updates an existing contact by id', async () => { const { useContactsStore } = await import('../index') const { result } = renderHook(() => useContactsStore()) const contact: Contact = { @@ -80,13 +131,17 @@ describe('ContactsStore', () => { result.current.saveContact(contact) }) - let added: boolean | undefined + let saved: boolean | undefined act(() => { - added = result.current.saveContact(contact) + saved = result.current.saveContact({ + ...contact, + name: 'Alice Updated', + }) }) - expect(added).toBe(false) + expect(saved).toBe(true) expect(result.current.contacts).toHaveLength(1) + expect(result.current.contacts[0]?.name).toBe('Alice Updated') }) test('deleteContact removes an existing contact', async () => { @@ -145,21 +200,6 @@ describe('ContactsStore', () => { expect(result.current.selectedContact).toBeNull() }) - test('setContacts replaces the contacts list', async () => { - const { useContactsStore } = await import('../index') - const { result } = renderHook(() => useContactsStore()) - const contacts: Contact[] = [ - { id: '1', name: 'Alice', address: 'A' }, - { id: '2', name: 'Bob', address: 'B' }, - ] - - act(() => { - result.current.setContacts(contacts) - }) - - expect(result.current.contacts).toEqual(contacts) - }) - test('registerStore wires clearStorage and resetState', async () => { await import('../index') @@ -170,7 +210,7 @@ describe('ContactsStore', () => { act(() => { useContactsStore .getState() - .setContacts([{ id: '1', name: 'Alice', address: 'A' }]) + .saveContact({ id: '1', name: 'Alice', address: 'A' }) }) expect(useContactsStore.getState().contacts).toHaveLength(1) diff --git a/packages/contacts/src/store/store.ts b/packages/contacts/src/store/store.ts index 84ed82d29..a521fd0be 100644 --- a/packages/contacts/src/store/store.ts +++ b/packages/contacts/src/store/store.ts @@ -13,6 +13,7 @@ import { create, type StoreApi, type UseBoundStore } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import type { Contact, ContactsState } from '../models' +import { DuplicateAddressError } from '../errors' import { generateOrderedUniqueId, registerStore, @@ -36,28 +37,38 @@ export const useContactsStore: UseBoundStore< ...initialState, setSelectedContact: (contact: Nullable) => set({ selectedContact: contact }), - setContacts: (contacts: Contact[]) => set({ contacts }), saveContact: (contact: Contact) => { - const existing = get().contacts ?? [] const newContact = { ...contact, id: contact.id ?? generateOrderedUniqueId(), } - if (!existing.find(r => r.id === newContact.id)) { + const existing = get().contacts ?? [] + const duplicate = existing.find( + c => + c.address === newContact.address && + c.id !== newContact.id, + ) + if (duplicate) { + throw new DuplicateAddressError(newContact.address) + } + const existingIndex = existing.findIndex( + c => c.id === newContact.id, + ) + if (existingIndex >= 0) { + const updated = [...existing] + updated[existingIndex] = newContact + set({ contacts: updated }) + } else { set({ contacts: [...existing, newContact] }) - return true } - return false + return true }, deleteContact: (contact: Contact) => { const existing = get().contacts ?? [] const remaining = existing.filter(r => r.id !== contact.id) - - if (remaining.length != existing.length) { - set({ contacts: remaining }) - } - - return remaining.length != existing.length + if (remaining.length === existing.length) return false + set({ contacts: remaining }) + return true }, resetState: () => set(initialState), }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c609d29f0..0ceb84f68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,6 +405,9 @@ importers: expo-image: specifier: ^55.0.8 version: 55.0.8(expo@55.0.15)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + expo-image-picker: + specifier: 55.0.18 + version: 55.0.18(expo@55.0.15) expo-linear-gradient: specifier: ^55.0.13 version: 55.0.13(expo@55.0.15)(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) @@ -6800,6 +6803,16 @@ packages: peerDependencies: expo: '*' + expo-image-loader@55.0.0: + resolution: {integrity: sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==} + peerDependencies: + expo: '*' + + expo-image-picker@55.0.18: + resolution: {integrity: sha512-lGpPGRu+7mE8qN0ma2boRsCmfOGbdHZ2bXTpWVeWly0JCZdogGlTrYFnhTqgS8+lmiRb/UCOs7iTm2P5Rra6kw==} + peerDependencies: + expo: '*' + expo-image@55.0.8: resolution: {integrity: sha512-fNdvdYVcGn3g1x6o5AXHKzk4xX8U6rg2W9vFdE1pQO80kWCNReh003ypqSrGy4dD+zA8FtZjrNF3oMDGnPpIGQ==} peerDependencies: @@ -14394,6 +14407,15 @@ snapshots: dependencies: expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + expo-image-loader@55.0.0(expo@55.0.15): + dependencies: + expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + + expo-image-picker@55.0.18(expo@55.0.15): + dependencies: + expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + expo-image-loader: 55.0.0(expo@55.0.15) + expo-image@55.0.8(expo@55.0.15)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5): dependencies: expo: 55.0.15(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(react-dom@19.2.5(react@19.2.5))(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)