From c544d29b7b5f8c093f2f818ccb9a77d03ea285b5 Mon Sep 17 00:00:00 2001 From: Yasin <6695727+yasin-ce@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:52:00 +0300 Subject: [PATCH 1/4] fix(contacts): edit contact creates duplicate [PERA-3989] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saveContact: insert-only → upsert (functional set). - Schema: id and image optional so edits round-trip the id. - deleteContact: simplified; setContacts removed (unused). --- packages/contacts/src/models/index.ts | 3 +- packages/contacts/src/schema/index.ts | 2 ++ .../src/store/__tests__/store.test.ts | 29 ++++++------------- packages/contacts/src/store/store.ts | 29 ++++++++++--------- 4 files changed, 28 insertions(+), 35 deletions(-) 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..0e826dbaf 100644 --- a/packages/contacts/src/store/__tests__/store.test.ts +++ b/packages/contacts/src/store/__tests__/store.test.ts @@ -67,7 +67,7 @@ describe('ContactsStore', () => { expect(result.current.contacts[0].id).toBeTruthy() }) - test('saveContact returns false for duplicate id', async () => { + test('saveContact updates an existing contact by id', async () => { const { useContactsStore } = await import('../index') const { result } = renderHook(() => useContactsStore()) const contact: Contact = { @@ -80,13 +80,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 +149,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 +159,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..c10c7746e 100644 --- a/packages/contacts/src/store/store.ts +++ b/packages/contacts/src/store/store.ts @@ -36,28 +36,31 @@ 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)) { - set({ contacts: [...existing, newContact] }) - return true - } - return false + set(state => { + const existing = state.contacts ?? [] + const existingIndex = existing.findIndex( + r => r.id === newContact.id, + ) + if (existingIndex >= 0) { + const updated = [...existing] + updated[existingIndex] = newContact + return { contacts: updated } + } + return { contacts: [...existing, newContact] } + }) + 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), }), From 58d67671a69eacadc2cf0b2726fb344f7b1623f3 Mon Sep 17 00:00:00 2001 From: Yasin <6695727+yasin-ce@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:10:57 +0300 Subject: [PATCH 2/4] chore(mobile): shared primitives for contact flows - ConfirmActionBottomSheet: generic confirmation sheet. - useImagePicker: gallery picker with permission UX; strips EXIF. - QRScannerView: pad empty state; re-enable scanner after unrecognized codes; guard requestPermission against unmount. - PWIcon: register contacts + pen-solid; drop white fill on moon. - Add SHORT_ADDRESS_FORMAT (9) to @constants/ui. - iOS: photo-library permission + expo-image-picker plugin. - Add expo-image-picker dep. --- apps/mobile/app.config.js | 9 ++ apps/mobile/assets/icons/contacts.svg | 10 ++ apps/mobile/assets/icons/moon.svg | 2 +- apps/mobile/assets/icons/pen-solid.svg | 10 ++ apps/mobile/package.json | 1 + .../ConfirmActionBottomSheet.tsx | 95 +++++++++++++++++++ .../ConfirmActionBottomSheet.spec.tsx | 65 +++++++++++++ .../ConfirmActionBottomSheet/index.ts | 14 +++ .../ConfirmActionBottomSheet/styles.ts | 37 ++++++++ .../QRScannerView/QRScannerView.tsx | 17 ++-- .../src/components/QRScannerView/styles.ts | 1 + .../QRScannerView/useQRScannerView.ts | 51 +++++----- .../src/components/core/PWIcon/constants.ts | 4 + apps/mobile/src/constants/ui.ts | 1 + .../hooks/__tests__/useImagePicker.spec.ts | 90 ++++++++++++++++++ apps/mobile/src/hooks/useImagePicker.ts | 74 +++++++++++++++ apps/mobile/vitest.setup.ts | 25 ++--- pnpm-lock.yaml | 22 +++++ 18 files changed, 486 insertions(+), 42 deletions(-) create mode 100644 apps/mobile/assets/icons/contacts.svg create mode 100644 apps/mobile/assets/icons/pen-solid.svg create mode 100644 apps/mobile/src/components/ConfirmActionBottomSheet/ConfirmActionBottomSheet.tsx create mode 100644 apps/mobile/src/components/ConfirmActionBottomSheet/__tests__/ConfirmActionBottomSheet.spec.tsx create mode 100644 apps/mobile/src/components/ConfirmActionBottomSheet/index.ts create mode 100644 apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts create mode 100644 apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts create mode 100644 apps/mobile/src/hooks/useImagePicker.ts 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..4998f4d93 --- /dev/null +++ b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts @@ -0,0 +1,37 @@ +/* + 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, + fontSize: 15, + lineHeight: 24, + }, + actions: { + width: '100%', + paddingHorizontal: theme.spacing.lg, + paddingTop: theme.spacing.md, + gap: theme.spacing.sm, + }, +})) 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..0e3665bce --- /dev/null +++ b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts @@ -0,0 +1,90 @@ +/* + 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 } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as ImagePicker from 'expo-image-picker' +import { useImagePicker } from '../useImagePicker' + +vi.mock('expo-image-picker', () => ({ + getMediaLibraryPermissionsAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), + launchImageLibraryAsync: vi.fn(), +})) + +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: vi.fn(() => ({ + t: (key: string) => key, + })), +})) + +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('returns null when permission is denied and not reprompt-able', async () => { + vi.mocked( + ImagePicker.getMediaLibraryPermissionsAsync, + ).mockResolvedValue({ + granted: false, + canAskAgain: false, + } as Awaited< + ReturnType + >) + + const { result } = renderHook(() => useImagePicker()) + const uri = await result.current.pickFromGallery() + + expect(uri).toBeNull() + expect(ImagePicker.launchImageLibraryAsync).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/hooks/useImagePicker.ts b/apps/mobile/src/hooks/useImagePicker.ts new file mode 100644 index 000000000..21b4fcdb5 --- /dev/null +++ b/apps/mobile/src/hooks/useImagePicker.ts @@ -0,0 +1,74 @@ +/* + 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 { Alert, Linking } from 'react-native' +import * as ImagePicker from 'expo-image-picker' + +import { useLanguage } from '@hooks/useLanguage' + +export type UseImagePickerResult = { + pickFromGallery: () => Promise +} + +/** + * Returns a URI for an image the user picks from their photo library. + * Handles the iOS/Android permission prompt and guides the user to + * Settings if they've denied access. + */ +export const useImagePicker = (): UseImagePickerResult => { + const { t } = useLanguage() + + const ensurePermission = useCallback(async () => { + const current = await ImagePicker.getMediaLibraryPermissionsAsync() + if (current.granted) { + return true + } + if (!current.canAskAgain) { + Alert.alert( + t('image_picker.permission_title'), + t('image_picker.permission_body'), + [ + { text: t('common.cancel.label'), style: 'cancel' }, + { + text: t('image_picker.open_settings.label'), + onPress: () => Linking.openSettings(), + }, + ], + ) + return false + } + const next = await ImagePicker.requestMediaLibraryPermissionsAsync() + return next.granted + }, [t]) + + 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]) + + return { pickFromGallery } +} diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index f4ad1a84e..1fa773b14 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6afb92a4..08a4e0fdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,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) @@ -6765,6 +6768,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: @@ -14322,6 +14335,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) From 43dbc517c3f1bcbf1bd8e415f076c77f3d2b5f2c Mon Sep 17 00:00:00 2001 From: Yasin <6695727+yasin-ce@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:08:28 +0300 Subject: [PATCH 3/4] refactor(contacts): move duplicate-address check to package layer per review Instead of the UI hooks calling findContacts to guard against duplicate addresses, saveContact now enforces uniqueness at the store layer and throws DuplicateAddressError on conflict. Downstream hooks catch and surface to form state. Addresses PR #373 review comment to keep business rules in the package, not the UI layer. --- packages/contacts/src/errors.ts | 26 ++++++++++ packages/contacts/src/index.ts | 1 + .../src/store/__tests__/store.test.ts | 51 +++++++++++++++++++ packages/contacts/src/store/store.ts | 32 +++++++----- 4 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 packages/contacts/src/errors.ts 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/store/__tests__/store.test.ts b/packages/contacts/src/store/__tests__/store.test.ts index 0e826dbaf..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,6 +68,56 @@ describe('ContactsStore', () => { expect(result.current.contacts[0].id).toBeTruthy() }) + 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()) diff --git a/packages/contacts/src/store/store.ts b/packages/contacts/src/store/store.ts index c10c7746e..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, @@ -41,18 +42,25 @@ export const useContactsStore: UseBoundStore< ...contact, id: contact.id ?? generateOrderedUniqueId(), } - set(state => { - const existing = state.contacts ?? [] - const existingIndex = existing.findIndex( - r => r.id === newContact.id, - ) - if (existingIndex >= 0) { - const updated = [...existing] - updated[existingIndex] = newContact - return { contacts: updated } - } - return { contacts: [...existing, newContact] } - }) + 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 }, deleteContact: (contact: Contact) => { From 1df4c465cff998c2bd1fdda846fde426663c7b26 Mon Sep 17 00:00:00 2001 From: Yasin <6695727+yasin-ce@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:14:17 +0300 Subject: [PATCH 4/4] refactor(mobile): use ConfirmActionBottomSheet for photo permission per review Replace the Alert.alert call in useImagePicker with a ConfirmActionBottomSheet, consistent with the rest of the codebase which doesn't use Alert. Extract PhotoPermissionDeniedSheet as a reusable component wrapping ConfirmActionBottomSheet with the photo-specific i18n copy. The hook now returns a permissionDenied state object (isVisible, close, openSettings) that callers pair with at the screen root. Also drops custom fontSize/lineHeight from ConfirmActionBottomSheet/styles.ts in favor of the PWText body variant at the callsite. --- .../ConfirmActionBottomSheet/styles.ts | 2 - .../PhotoPermissionDeniedSheet.tsx | 46 +++++++++++++ .../PhotoPermissionDeniedSheet.spec.tsx | 57 ++++++++++++++++ .../PhotoPermissionDeniedSheet/index.ts | 14 ++++ .../hooks/__tests__/useImagePicker.spec.ts | 67 ++++++++++++++++--- apps/mobile/src/hooks/useImagePicker.ts | 57 ++++++++++------ apps/mobile/src/i18n/locales/en.json | 7 ++ apps/mobile/vitest.setup.ts | 1 + 8 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/components/PhotoPermissionDeniedSheet/PhotoPermissionDeniedSheet.tsx create mode 100644 apps/mobile/src/components/PhotoPermissionDeniedSheet/__tests__/PhotoPermissionDeniedSheet.spec.tsx create mode 100644 apps/mobile/src/components/PhotoPermissionDeniedSheet/index.ts diff --git a/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts index 4998f4d93..8c6f0094b 100644 --- a/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts +++ b/apps/mobile/src/components/ConfirmActionBottomSheet/styles.ts @@ -25,8 +25,6 @@ export const useStyles = makeStyles(theme => ({ paddingHorizontal: theme.spacing.lg, paddingVertical: theme.spacing.md, color: theme.colors.textGray, - fontSize: 15, - lineHeight: 24, }, actions: { width: '100%', 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/hooks/__tests__/useImagePicker.spec.ts b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts index 0e3665bce..0b49392e4 100644 --- a/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts +++ b/apps/mobile/src/hooks/__tests__/useImagePicker.spec.ts @@ -10,9 +10,10 @@ limitations under the License */ -import { renderHook } from '@testing-library/react' +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', () => ({ @@ -21,12 +22,6 @@ vi.mock('expo-image-picker', () => ({ launchImageLibraryAsync: vi.fn(), })) -vi.mock('@hooks/useLanguage', () => ({ - useLanguage: vi.fn(() => ({ - t: (key: string) => key, - })), -})) - describe('useImagePicker', () => { beforeEach(() => { vi.clearAllMocks() @@ -71,7 +66,7 @@ describe('useImagePicker', () => { expect(uri).toBeNull() }) - it('returns null when permission is denied and not reprompt-able', async () => { + it('exposes the permission-denied state when permission is not reprompt-able', async () => { vi.mocked( ImagePicker.getMediaLibraryPermissionsAsync, ).mockResolvedValue({ @@ -82,9 +77,63 @@ describe('useImagePicker', () => { >) const { result } = renderHook(() => useImagePicker()) - const uri = await result.current.pickFromGallery() + 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 index 21b4fcdb5..53b60a17a 100644 --- a/apps/mobile/src/hooks/useImagePicker.ts +++ b/apps/mobile/src/hooks/useImagePicker.ts @@ -10,23 +10,43 @@ limitations under the License */ -import { useCallback } from 'react' -import { Alert, Linking } from 'react-native' +import { useCallback, useMemo, useState } from 'react' +import { Linking } from 'react-native' import * as ImagePicker from 'expo-image-picker' -import { useLanguage } from '@hooks/useLanguage' +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 and guides the user to - * Settings if they've denied access. + * 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 { t } = useLanguage() + 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() @@ -34,22 +54,12 @@ export const useImagePicker = (): UseImagePickerResult => { return true } if (!current.canAskAgain) { - Alert.alert( - t('image_picker.permission_title'), - t('image_picker.permission_body'), - [ - { text: t('common.cancel.label'), style: 'cancel' }, - { - text: t('image_picker.open_settings.label'), - onPress: () => Linking.openSettings(), - }, - ], - ) + setPermissionDeniedVisible(true) return false } const next = await ImagePicker.requestMediaLibraryPermissionsAsync() return next.granted - }, [t]) + }, []) const pickFromGallery = useCallback(async () => { const granted = await ensurePermission() @@ -70,5 +80,14 @@ export const useImagePicker = (): UseImagePickerResult => { return result.assets[0]?.uri ?? null }, [ensurePermission]) - return { pickFromGallery } + 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 08a79f458..9d54d0893 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -572,6 +572,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 1fa773b14..84abcede2 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -1049,6 +1049,7 @@ vi.mock('react-native', () => { }, Linking: { openURL: vi.fn(), + openSettings: vi.fn(), canOpenURL: vi.fn(), getInitialURL: vi.fn(), addEventListener: vi.fn(),