From 887440c191e0933ea397db0d97fa47102f0df35b Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Tue, 14 Apr 2026 13:24:58 +0200 Subject: [PATCH 1/3] feat: add useScanRouteParams hook and ScanRoute type --- .../hooks/useScanRouteParams.ts | 25 +++++++++++++++++++ .../request/step/IOURequestStepScan/types.ts | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts new file mode 100644 index 000000000000..b4111d7a92e2 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts @@ -0,0 +1,25 @@ +import {useNavigation, useRoute} from '@react-navigation/native'; +import type {ScanRoute} from '@pages/iou/request/step/IOURequestStepScan/types'; + +type ScanRouteParams = ScanRoute['params']; + +/** + * Returns typed route params for the scan screen. + * When rendered as a standalone screen (STEP_SCAN), params come from useRoute() directly. + * When rendered inside a TopTab (CREATE flow), params come from the parent navigator's current route. + */ +function useScanRouteParams(): ScanRouteParams { + const route = useRoute(); + const navigation = useNavigation(); + + if (route.params) { + return route.params as ScanRouteParams; + } + + const parentState = navigation.getParent()?.getState(); + const parentRoute = parentState?.routes[parentState?.index ?? 0]; + return parentRoute?.params as ScanRouteParams; +} + +export default useScanRouteParams; +export type {ScanRouteParams}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/types.ts b/src/pages/iou/request/step/IOURequestStepScan/types.ts index 5382186b62f9..834a2c757ec0 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/types.ts @@ -1,5 +1,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import type {WithWritableReportOrNotFoundProps} from '@pages/iou/request/step/withWritableReportOrNotFound'; import type {IOUAction, IOUType} from '@src/CONST'; import type {Route} from '@src/ROUTES'; @@ -98,5 +100,7 @@ type ReceiptFile = { transactionID: string; }; +type ScanRoute = PlatformStackRouteProp; + export default IOURequestStepScanProps; -export type {ReceiptFile, UseMobileReceiptScanParams, UseReceiptScanParams}; +export type {ReceiptFile, ScanRoute, UseMobileReceiptScanParams, UseReceiptScanParams}; From 3a3f61dbeefe910cfcd96eca1264521db7b83c21 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Tue, 14 Apr 2026 13:25:07 +0200 Subject: [PATCH 2/3] feat: add MultiScanContext and MultiScanEducationalModal --- .../components/MultiScanContext.tsx | 106 ++++++++++++++++++ .../components/MultiScanEducationalModal.tsx | 42 +++++++ 2 files changed, 148 insertions(+) create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/MultiScanContext.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanContext.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanContext.tsx new file mode 100644 index 000000000000..b50876cdc787 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanContext.tsx @@ -0,0 +1,106 @@ +import React, {createContext, useContext, useState} from 'react'; +import {InteractionManager} from 'react-native'; +import useOnyx from '@hooks/useOnyx'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import {isMobile} from '@libs/Browser'; +import useScanRouteParams from '@pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams'; +import {removeDraftTransactionsByIDs, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; + +type MultiScanState = { + isMultiScanEnabled: boolean; + canUseMultiScan: boolean; + showEducationalPopup: boolean; +}; + +type MultiScanActions = { + toggleMultiScan: () => void; + dismissEducationalPopup: () => void; +}; + +const defaultState: MultiScanState = { + isMultiScanEnabled: false, + canUseMultiScan: false, + showEducationalPopup: false, +}; + +const defaultActions: MultiScanActions = { + toggleMultiScan: () => {}, + dismissEducationalPopup: () => {}, +}; + +const MultiScanStateContext = createContext(defaultState); +const MultiScanActionsContext = createContext(defaultActions); + +function useMultiScanState(): MultiScanState { + return useContext(MultiScanStateContext); +} + +function useMultiScanActions(): MultiScanActions { + return useContext(MultiScanActionsContext); +} + +type MultiScanProviderProps = { + children: React.ReactNode; +}; + +function MultiScanProvider({children}: MultiScanProviderProps) { + const {iouType} = useScanRouteParams(); + + const [isMultiScanEnabled, setIsMultiScanEnabled] = useState(false); + const [showEducationalPopup, setShowEducationalPopup] = useState(false); + const [dismissedProductTrainingResult] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + + const canUseMultiScan = iouType !== CONST.IOU.TYPE.SPLIT; + + function toggleMultiScan() { + if (!dismissedProductTrainingResult?.[CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]) { + setShowEducationalPopup(true); + } + removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + removeDraftTransactionsByIDs(draftTransactionIDs, true); + setIsMultiScanEnabled((prev) => !prev); + } + + function dismissEducationalPopup() { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- InteractionManager is standard RN API for deferred work + InteractionManager.runAfterInteractions(() => { + dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL); + setShowEducationalPopup(false); + }); + } + + const stateValue: MultiScanState = { + isMultiScanEnabled, + canUseMultiScan, + showEducationalPopup, + }; + + const actionsValue: MultiScanActions = { + toggleMultiScan, + dismissEducationalPopup, + }; + + return ( + + {children} + + ); +} + +/** + * Platform gate — renders MultiScanProvider on mobile (web + native), passes children through on desktop. + * Multi-scan is a camera-based mobile interaction; desktop uses file picker with shouldAcceptMultipleFiles instead. + */ +function MultiScanGate({children}: MultiScanProviderProps) { + if (isMobile()) { + return {children}; + } + return children; +} + +export {MultiScanGate, MultiScanProvider, useMultiScanState, useMultiScanActions}; +export type {MultiScanState, MultiScanActions}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx new file mode 100644 index 000000000000..878942ce38ad --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import FeatureTrainingModal from '@components/FeatureTrainingModal'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {useMultiScanActions, useMultiScanState} from './MultiScanContext'; + +/** + * Self-contained educational modal for multi-scan. Reads visibility from state context, dismiss from actions context. + * Renders nothing when context is absent or popup is hidden. + */ +function MultiScanEducationalModal() { + const {showEducationalPopup} = useMultiScanState(); + const {dismissEducationalPopup} = useMultiScanActions(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan']); + + if (!showEducationalPopup || !dismissEducationalPopup) { + return null; + } + + return ( + + ); +} + +export default MultiScanEducationalModal; From 81d47b7aa4460e3f6b666a704e11420c2856a378 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Tue, 14 Apr 2026 13:25:14 +0200 Subject: [PATCH 3/3] feat: add Camera component with platform variants --- .../components/Camera/CameraCapture.tsx | 452 ++++++++++++++++ .../components/Camera/FileUpload.tsx | 126 +++++ .../components/Camera/index.native.tsx | 493 ++++++++++++++++++ .../components/Camera/index.tsx | 33 ++ .../components/Camera/types.ts | 18 + 5 files changed, 1122 insertions(+) create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx new file mode 100644 index 000000000000..b3473f52e942 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx @@ -0,0 +1,452 @@ +import {useIsFocused} from '@react-navigation/native'; +import React, {useEffect, useReducer, useRef, useState} from 'react'; +import type {LayoutRectangle} from 'react-native'; +import {StyleSheet, View} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withSequence, withTiming} from 'react-native-reanimated'; +import type Webcam from 'react-webcam'; +import ActivityIndicator from '@components/ActivityIndicator'; +import AttachmentPicker from '@components/AttachmentPicker'; +import Button from '@components/Button'; +import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {isMobileWebKit} from '@libs/Browser'; +import {base64ToFile} from '@libs/fileDownload/FileUtils'; +import HapticFeedback from '@libs/HapticFeedback'; +import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {useMultiScanActions, useMultiScanState} from '@pages/iou/request/step/IOURequestStepScan/components/MultiScanContext'; +import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera'; +import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import CONST from '@src/CONST'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {CameraProps} from './types'; + +/** + * Preload camera permission state at module load so first render can use a cached value. + */ +let cachedPermissionState: PermissionState | undefined; + +if (typeof navigator !== 'undefined' && navigator.permissions) { + navigator.permissions + .query({name: 'camera'}) + .then((status) => { + cachedPermissionState = status.state; + if ('addEventListener' in status) { + status.addEventListener('change', () => { + cachedPermissionState = status.state; + }); + } + }) + .catch(() => { + cachedPermissionState = 'denied'; + }); +} + +function queryCameraPermission(): Promise { + if (cachedPermissionState !== undefined) { + return Promise.resolve(cachedPermissionState); + } + + if (typeof navigator === 'undefined' || !navigator.permissions) { + return Promise.resolve('denied'); + } + + return navigator.permissions + .query({name: 'camera'}) + .then((status) => status.state) + .catch(() => 'denied'); +} + +const BLINK_DURATION_MS = 80; + +/** + * CameraCapture — mobile web capture variant. + * Renders a camera viewfinder, shutter button, flash toggle and gallery picker. + * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. + */ +function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}: CameraProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'Shutter']); + const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']); + const {isMultiScanEnabled, canUseMultiScan} = useMultiScanState(); + const {toggleMultiScan} = useMultiScanActions(); + const isTabActive = useIsFocused(); + const [cameraPermissionState, setCameraPermissionState] = useState(() => cachedPermissionState ?? 'prompt'); + const [isFlashLightOn, toggleFlashlight] = useReducer((state: boolean) => !state, false); + const [isTorchAvailable, setIsTorchAvailable] = useState(false); + const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(() => cachedPermissionState !== undefined); + const [deviceConstraints, setDeviceConstraints] = useState(); + const videoConstraints = isTabActive ? deviceConstraints : undefined; + const cameraRef = useRef(null); + const trackRef = useRef(null); + const viewfinderLayout = useRef(null); + const getScreenshotTimeoutRef = useRef(null); + + // Blink animation for shutter feedback + const blinkOpacity = useSharedValue(0); + const blinkStyle = useAnimatedStyle(() => ({ + opacity: blinkOpacity.get(), + })); + + const showBlink = () => { + blinkOpacity.set(withSequence(withTiming(1, {duration: BLINK_DURATION_MS}), withTiming(0, {duration: BLINK_DURATION_MS}))); + HapticFeedback.press(); + }; + + /** + * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default. + * The last deviceId is of regular lens camera. + */ + const requestCameraPermission = () => { + const defaultConstraints = {facingMode: {exact: 'environment'}}; + navigator.mediaDevices + .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}}) + .then((stream) => { + setCameraPermissionState('granted'); + for (const track of stream.getTracks()) { + track.stop(); + } + // Only Safari 17+ supports zoom constraint + if (isMobileWebKit() && stream.getTracks().length > 0) { + let deviceId; + for (const track of stream.getTracks()) { + const setting = track.getSettings(); + if (setting.zoom === 1) { + deviceId = setting.deviceId; + break; + } + } + if (deviceId) { + setDeviceConstraints({deviceId}); + return; + } + } + if (!navigator.mediaDevices.enumerateDevices) { + setDeviceConstraints(defaultConstraints); + return; + } + navigator.mediaDevices.enumerateDevices().then((devices) => { + let lastBackDeviceId = ''; + for (let i = devices.length - 1; i >= 0; i--) { + const device = devices.at(i); + if (device?.kind === 'videoinput') { + lastBackDeviceId = device.deviceId; + break; + } + } + if (!lastBackDeviceId) { + setDeviceConstraints(defaultConstraints); + return; + } + setDeviceConstraints({deviceId: lastBackDeviceId}); + }); + }) + .catch(() => { + setDeviceConstraints(defaultConstraints); + setCameraPermissionState('denied'); + }); + }; + + useEffect(() => { + if (!isTabActive) { + return; + } + queryCameraPermission() + .then((state) => { + setCameraPermissionState(state); + if (state === 'granted') { + requestCameraPermission(); + } + }) + .catch(() => { + setCameraPermissionState('denied'); + }) + .finally(() => { + setIsQueriedPermissionState(true); + }); + // Refresh permission state whenever this tab regains focus. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTabActive]); + + useEffect( + () => () => { + cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + if (!getScreenshotTimeoutRef.current) { + return; + } + clearTimeout(getScreenshotTimeoutRef.current); + }, + [], + ); + + const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => { + setCameraPermissionState('granted'); + + const [track] = stream.getVideoTracks(); + const capabilities = track.getCapabilities(); + + if ('torch' in capabilities && capabilities.torch) { + trackRef.current = track; + } + setIsTorchAvailable('torch' in capabilities && !!capabilities.torch); + }; + + const clearTorchConstraints = () => { + if (!trackRef.current) { + return; + } + trackRef.current.applyConstraints({ + advanced: [{torch: false}], + }); + }; + + const getScreenshot = () => { + if (!cameraRef.current) { + requestCameraPermission(); + return; + } + + if (!isMultiScanEnabled) { + startSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, { + name: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, + op: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, + attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: 'web'}, + }); + } + startSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { + name: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, + op: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, + parentSpan: getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION), + attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: 'web'}, + }); + + const imageBase64 = cameraRef.current.getScreenshot(); + + if (imageBase64 === null) { + cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + return; + } + + showBlink(); + + const originalFileName = `receipt_${Date.now()}.png`; + const originalFile = base64ToFile(imageBase64 ?? '', originalFileName); + const imageObject: ImageObject = {file: originalFile, filename: originalFile.name, source: URL.createObjectURL(originalFile)}; + // Some browsers center-crop the viewfinder inside the video element (due to object-position: center), + // while other browsers let the video element overflow and the container crops it from the top. + // We crop and align the result image the same way. + const videoHeight = cameraRef.current.video?.getBoundingClientRect?.()?.height ?? NaN; + const viewFinderHeight = viewfinderLayout.current?.height ?? NaN; + const shouldAlignTop = videoHeight > viewFinderHeight; + cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, shouldAlignTop).then(({file, source}) => { + endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + onCapture(file, source); + }); + }; + + const capturePhoto = () => { + if (trackRef.current && isFlashLightOn) { + trackRef.current + .applyConstraints({ + advanced: [{torch: true}], + }) + .then(() => { + getScreenshotTimeoutRef.current = setTimeout(() => { + getScreenshot(); + clearTorchConstraints(); + }, CONST.RECEIPT.FLASH_DELAY_MS); + }); + return; + } + + getScreenshot(); + }; + + const emitPickedFiles = (files: FileObject[]) => { + for (const file of files) { + const source = file.uri ?? URL.createObjectURL(file as Blob); + onCapture(file, source); + } + }; + + return ( + onLayout?.()} + style={[styles.flex1]} + > + + + {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && ( + + )} + {cameraPermissionState !== 'granted' && isQueriedPermissionState && ( + + + {translate('receipt.takePhoto')} + {cameraPermissionState === 'denied' ? ( + + + + ) : ( + {translate('receipt.cameraAccess')} + )} +