diff --git a/package.json b/package.json
index a8dac1bfe67f4..a5a0097aa2a73 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
"perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure",
"typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc",
"typecheck-tsgo": "tsgo --project tsconfig.tsgo.json",
- "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=334 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto",
+ "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=316 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto",
"lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 ./scripts/lintChanged.sh",
"check-lazy-loading": "ts-node scripts/checkLazyLoading.ts",
"lint-watch": "npx eslint-watch --watch --changed",
diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
index 0c5c095ab96f3..cfedd87c414db 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx
@@ -33,9 +33,9 @@ import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
+import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera';
import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
-import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/Camera';
import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound';
diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
index 4efd0282eab84..2abeaa55a5f36 100644
--- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx
@@ -25,9 +25,9 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
+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 NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera';
import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper';
import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound';
diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx
new file mode 100644
index 0000000000000..8c8fce4f8a860
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx
@@ -0,0 +1,145 @@
+import React, {useRef, useState} from 'react';
+import {PanResponder, View} from 'react-native';
+import AttachmentPicker from '@components/AttachmentPicker';
+import Button from '@components/Button';
+import DragAndDropConsumer from '@components/DragAndDrop/Consumer';
+import {useDragAndDropState} from '@components/DragAndDrop/Provider';
+import DropZoneUI from '@components/DropZone/DropZoneUI';
+import Icon from '@components/Icon';
+import ReceiptAlternativeMethods from '@components/ReceiptAlternativeMethods';
+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 StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper';
+import CONST from '@src/CONST';
+import type {FileObject} from '@src/types/utils/Attachment';
+
+type DesktopWebUploadViewProps = {
+ PDFValidationComponent: React.ReactNode;
+ shouldAcceptMultipleFiles: boolean;
+ isReplacingReceipt: boolean;
+ onLayout: () => void;
+ validateFiles: (files: FileObject[], items?: DataTransferItem[]) => void;
+ onBackButtonPress: () => void;
+ shouldShowWrapper: boolean;
+};
+
+function DesktopWebUploadView({
+ PDFValidationComponent,
+ shouldAcceptMultipleFiles,
+ isReplacingReceipt,
+ onLayout,
+ validateFiles,
+ onBackButtonPress,
+ shouldShowWrapper,
+}: DesktopWebUploadViewProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const lazyIllustrations = useMemoizedLazyIllustrations(['ReceiptStack']);
+ const lazyIcons = useMemoizedLazyExpensifyIcons(['ReplaceReceipt', 'SmartScan']);
+ const panResponder = useRef(
+ PanResponder.create({
+ onPanResponderTerminationRequest: () => false,
+ }),
+ ).current;
+
+ const {isDraggingOver} = useDragAndDropState();
+ const [containerHeight, setContainerHeight] = useState(0);
+ const [desktopUploadViewHeight, setDesktopUploadViewHeight] = useState(0);
+ const [alternativeMethodsHeight, setAlternativeMethodsHeight] = useState(0);
+ const chooseFilesPaddingVertical = Number(styles.chooseFilesView(false).paddingVertical);
+ const shouldHideAlternativeMethods = alternativeMethodsHeight + desktopUploadViewHeight + chooseFilesPaddingVertical * 2 > containerHeight;
+
+ const handleDropReceipt = (e: DragEvent) => {
+ const files = Array.from(e?.dataTransfer?.files ?? []);
+ if (files.length === 0) {
+ return;
+ }
+ for (const file of files) {
+ // eslint-disable-next-line no-param-reassign
+ file.uri = URL.createObjectURL(file);
+ }
+
+ validateFiles(files, Array.from(e.dataTransfer?.items ?? []));
+ };
+
+ return (
+
+ {(isDraggingOverWrapper) => (
+ {
+ setContainerHeight(event.nativeEvent.layout.height);
+ onLayout();
+ }}
+ style={[styles.flex1, styles.chooseFilesView(false)]}
+ >
+
+ {!(isDraggingOver ?? isDraggingOverWrapper) && (
+ {
+ setDesktopUploadViewHeight(e.nativeEvent.layout.height);
+ }}
+ >
+ {PDFValidationComponent}
+
+
+ {translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')}
+
+ {translate(shouldAcceptMultipleFiles ? 'receipt.desktopSubtitleMultiple' : 'receipt.desktopSubtitleSingle')}
+
+
+
+
+ {({openPicker}) => (
+
+
+ )}
+
+
+
+
+ {!shouldHideAlternativeMethods && setAlternativeMethodsHeight(e.nativeEvent.layout.height)} />}
+
+ )}
+
+ );
+}
+
+export default DesktopWebUploadView;
+export type {DesktopWebUploadViewProps};
diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
new file mode 100644
index 0000000000000..e351523ddb636
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx
@@ -0,0 +1,549 @@
+import {useIsFocused} from '@react-navigation/native';
+import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react';
+import type {LayoutRectangle} from 'react-native';
+import {StyleSheet, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import Animated 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 FeatureTrainingModal from '@components/FeatureTrainingModal';
+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 {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
+import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
+import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
+import useMobileReceiptScan from '@pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan';
+import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types';
+import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper';
+import {setMoneyRequestReceipt} from '@userActions/IOU';
+import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit';
+import CONST from '@src/CONST';
+import type {IOUType} from '@src/CONST';
+import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
+import type Transaction from '@src/types/onyx/Transaction';
+import type {FileObject} from '@src/types/utils/Attachment';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import NavigationAwareCamera from './NavigationAwareCamera/WebCamera';
+import ReceiptPreviews from './ReceiptPreviews';
+
+type MobileWebCameraViewProps = {
+ initialTransaction: OnyxEntry;
+ initialTransactionID: string;
+ iouType: IOUType;
+ currentUserPersonalDetails: CurrentUserPersonalDetails;
+ reportID: string;
+ isMultiScanEnabled?: boolean;
+ isStartingScan?: boolean;
+ updateScanAndNavigate: (file: FileObject, source: string) => void;
+ setIsMultiScanEnabled?: (value: boolean) => void;
+ PDFValidationComponent: React.ReactNode;
+ shouldAcceptMultipleFiles: boolean;
+ receiptFiles: ReceiptFile[];
+ isEditing: boolean;
+ validateFiles: (files: FileObject[], items?: DataTransferItem[]) => void;
+ setReceiptFiles: React.Dispatch>;
+ navigateToConfirmationStep: (files: ReceiptFile[], locationPermissionGranted?: boolean, isTestTransaction?: boolean) => void;
+ shouldSkipConfirmation: boolean;
+ setStartLocationPermissionFlow: (value: boolean) => void;
+ onLayout?: () => void;
+ onBackButtonPress: () => void;
+ shouldShowWrapper: boolean;
+};
+
+/**
+ * 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');
+}
+
+function MobileWebCameraView({
+ initialTransaction,
+ initialTransactionID,
+ iouType,
+ currentUserPersonalDetails,
+ reportID,
+ isMultiScanEnabled = false,
+ isStartingScan,
+ updateScanAndNavigate,
+ setIsMultiScanEnabled,
+ PDFValidationComponent,
+ shouldAcceptMultipleFiles,
+ receiptFiles,
+ isEditing,
+ validateFiles,
+ setReceiptFiles,
+ navigateToConfirmationStep,
+ shouldSkipConfirmation,
+ setStartLocationPermissionFlow,
+ onLayout,
+ onBackButtonPress,
+ shouldShowWrapper,
+}: MobileWebCameraViewProps) {
+ const {blinkStyle, canUseMultiScan, shouldShowMultiScanEducationalPopup, showBlink, toggleMultiScan, dismissMultiScanEducationalPopup, submitReceipts, submitMultiScanReceipts} =
+ useMobileReceiptScan({
+ initialTransaction,
+ iouType,
+ isMultiScanEnabled,
+ isStartingScan,
+ receiptFiles,
+ navigateToConfirmationStep,
+ shouldSkipConfirmation,
+ setStartLocationPermissionFlow,
+ setIsMultiScanEnabled,
+ });
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan', 'Hand', 'Shutter']);
+ const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']);
+ 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);
+
+ /**
+ * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
+ * The last deviceId is of regular lens camera.
+ */
+ const requestCameraPermission = useCallback(() => {
+ 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.
+ }, [isTabActive, requestCameraPermission]);
+
+ 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 onCapture = (file: FileObject, filename: string, source: string) => {
+ const transaction =
+ isMultiScanEnabled && initialTransaction?.receipt?.source
+ ? buildOptimisticTransactionAndCreateDraft({
+ initialTransaction,
+ currentUserPersonalDetails,
+ reportID,
+ })
+ : initialTransaction;
+ const transactionID = transaction?.transactionID ?? initialTransactionID;
+ const newReceiptFiles = [...receiptFiles, {file, source, transactionID}];
+
+ setMoneyRequestReceipt(transactionID, source, filename, !isEditing, file.type);
+ setReceiptFiles(newReceiptFiles);
+
+ if (isMultiScanEnabled) {
+ return;
+ }
+
+ if (isEditing) {
+ updateScanAndNavigate(file, source);
+ return;
+ }
+
+ submitReceipts(newReceiptFiles);
+ };
+
+ 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;
+ }
+
+ if (isMultiScanEnabled) {
+ 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, filename, source}) => {
+ endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
+ onCapture(file, filename, 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();
+ };
+
+ return (
+
+
+
+
+ {PDFValidationComponent}
+ {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && (
+
+ )}
+ {cameraPermissionState !== 'granted' && isQueriedPermissionState && (
+
+
+ {translate('receipt.takePhoto')}
+ {cameraPermissionState === 'denied' ? (
+
+
+
+ ) : (
+ {translate('receipt.cameraAccess')}
+ )}
+
+
+ )}
+ {cameraPermissionState === 'granted' && !isEmptyObject(videoConstraints) && (
+ (viewfinderLayout.current = e.nativeEvent.layout)}
+ >
+ setCameraPermissionState('denied')}
+ style={{
+ ...styles.videoContainer,
+ display: cameraPermissionState !== 'granted' ? 'none' : 'block',
+ }}
+ ref={cameraRef}
+ screenshotFormat="image/png"
+ videoConstraints={videoConstraints}
+ forceScreenshotSourceSize
+ audio={false}
+ disablePictureInPicture={false}
+ imageSmoothing={false}
+ mirrored={false}
+ screenshotQuality={0}
+ />
+ {canUseMultiScan ? (
+
+
+
+
+
+ ) : null}
+
+
+ )}
+
+
+
+
+ {({openPicker}) => (
+ {
+ openPicker({
+ onPicked: (data) => validateFiles(data),
+ });
+ }}
+ sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE}
+ >
+
+
+ )}
+
+
+
+
+ {canUseMultiScan ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {canUseMultiScan && shouldShowMultiScanEducationalPopup && (
+
+ )}
+
+
+ {canUseMultiScan && (
+
+ )}
+
+
+ );
+}
+
+export default MobileWebCameraView;
+export type {MobileWebCameraViewProps};
diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/Camera.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx
similarity index 100%
rename from src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/Camera.tsx
rename to src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx
diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera.tsx
similarity index 100%
rename from src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx
rename to src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera.tsx
diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/types.ts
similarity index 100%
rename from src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/types.ts
rename to src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/types.ts
diff --git a/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/SubmitButtonShadow/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/SubmitButtonShadow/index.native.tsx
similarity index 100%
rename from src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/SubmitButtonShadow/index.native.tsx
rename to src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/SubmitButtonShadow/index.native.tsx
diff --git a/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/SubmitButtonShadow/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/SubmitButtonShadow/index.tsx
similarity index 100%
rename from src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/SubmitButtonShadow/index.tsx
rename to src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/SubmitButtonShadow/index.tsx
diff --git a/src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/index.tsx
similarity index 100%
rename from src/pages/iou/request/step/IOURequestStepScan/ReceiptPreviews/index.tsx
rename to src/pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews/index.tsx
diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan.ts
new file mode 100644
index 0000000000000..22bf4240b67b6
--- /dev/null
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan.ts
@@ -0,0 +1,102 @@
+import shouldStartLocationPermissionFlowSelector from '@selectors/LocationPermission';
+import {useState} from 'react';
+import {InteractionManager} from 'react-native';
+import {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import useOnyx from '@hooks/useOnyx';
+import useTransactionDraftValues from '@hooks/useTransactionDraftValues';
+import {dismissProductTraining} from '@libs/actions/Welcome';
+import HapticFeedback from '@libs/HapticFeedback';
+import type {ReceiptFile, UseMobileReceiptScanParams} from '@pages/iou/request/step/IOURequestStepScan/types';
+import {removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+/**
+ * Extends useReceiptScan with mobile-only logic: multi-scan, haptic feedback, and blink animation.
+ */
+function useMobileReceiptScan({
+ initialTransaction,
+ iouType,
+ isMultiScanEnabled = false,
+ isStartingScan = false,
+ receiptFiles,
+ navigateToConfirmationStep,
+ shouldSkipConfirmation,
+ setStartLocationPermissionFlow,
+ setIsMultiScanEnabled,
+}: UseMobileReceiptScanParams) {
+ const [shouldStartLocationPermissionFlow] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, {
+ selector: shouldStartLocationPermissionFlowSelector,
+ });
+ const optimisticTransactions = useTransactionDraftValues();
+
+ const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING);
+ const [shouldShowMultiScanEducationalPopup, setShouldShowMultiScanEducationalPopup] = useState(false);
+
+ const canUseMultiScan = isStartingScan && iouType !== CONST.IOU.TYPE.SPLIT;
+
+ const blinkOpacity = useSharedValue(0);
+ const blinkStyle = useAnimatedStyle(() => ({
+ opacity: blinkOpacity.get(),
+ }));
+
+ function showBlink() {
+ blinkOpacity.set(
+ withTiming(1, {duration: 50}, () => {
+ blinkOpacity.set(withTiming(0, {duration: 150}));
+ }),
+ );
+ HapticFeedback.press();
+ }
+
+ function submitReceipts(files: ReceiptFile[]) {
+ if (shouldSkipConfirmation) {
+ const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT;
+ if (gpsRequired) {
+ if (shouldStartLocationPermissionFlow) {
+ setStartLocationPermissionFlow(true);
+ return;
+ }
+ navigateToConfirmationStep(files, true);
+ return;
+ }
+ }
+ navigateToConfirmationStep(files, false);
+ }
+
+ function submitMultiScanReceipts() {
+ const transactionIDs = new Set(optimisticTransactions?.map((transaction) => transaction?.transactionID));
+ const validReceiptFiles = receiptFiles.filter((receiptFile) => transactionIDs.has(receiptFile.transactionID));
+ submitReceipts(validReceiptFiles);
+ }
+
+ function toggleMultiScan() {
+ if (!dismissedProductTraining?.[CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]) {
+ setShouldShowMultiScanEducationalPopup(true);
+ }
+ removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
+ removeDraftTransactions(true);
+ setIsMultiScanEnabled?.(!isMultiScanEnabled);
+ }
+
+ function dismissMultiScanEducationalPopup() {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ InteractionManager.runAfterInteractions(() => {
+ dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL);
+ setShouldShowMultiScanEducationalPopup(false);
+ });
+ }
+
+ return {
+ canUseMultiScan,
+ blinkStyle,
+ showBlink,
+ shouldShowMultiScanEducationalPopup,
+ dismissMultiScanEducationalPopup,
+ toggleMultiScan,
+ submitReceipts,
+ submitMultiScanReceipts,
+ };
+}
+
+export default useMobileReceiptScan;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useReceiptScan.ts
similarity index 73%
rename from src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts
rename to src/pages/iou/request/step/IOURequestStepScan/hooks/useReceiptScan.ts
index 770a8f619757c..ae67e2a60ead4 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useReceiptScan.ts
@@ -1,8 +1,6 @@
+import shouldStartLocationPermissionFlowSelector from '@selectors/LocationPermission';
import {hasSeenTourSelector} from '@selectors/Onboarding';
import {useEffect, useState} from 'react';
-import {InteractionManager} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import TestReceipt from '@assets/images/fake-receipt.png';
import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy';
import useFilesValidation from '@hooks/useFilesValidation';
@@ -16,32 +14,17 @@ import useReportAttributes from '@hooks/useReportAttributes';
import useSelfDMReport from '@hooks/useSelfDMReport';
import {handleMoneyRequestStepScanParticipants} from '@libs/actions/IOU/MoneyRequest';
import setTestReceipt from '@libs/actions/setTestReceipt';
-import {dismissProductTraining} from '@libs/actions/Welcome';
-import DateUtils from '@libs/DateUtils';
-import HapticFeedback from '@libs/HapticFeedback';
import {isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils';
import {getSpan, startSpan} from '@libs/telemetry/activeSpans';
import {getDefaultTaxCode, hasReceipt, shouldReuseInitialTransaction} from '@libs/TransactionUtils';
+import type {ReceiptFile, UseReceiptScanParams} from '@pages/iou/request/step/IOURequestStepScan/types';
import {setMoneyRequestReceipt} from '@userActions/IOU';
-import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit';
+import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions} from '@userActions/TransactionEdit';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {validTransactionDraftsSelector} from '@src/selectors/TransactionDraft';
import type Transaction from '@src/types/onyx/Transaction';
import type {FileObject} from '@src/types/utils/Attachment';
-import type {ReceiptFile, UseReceiptScanParams} from './types';
-
-/**
- * Selector to derive whether we should start the location permission flow from the last prompt timestamp.
- * Returns true when the user has never been prompted, or when the last prompt was more than LOCATION_PERMISSION_PROMPT_THRESHOLD_DAYS ago.
- */
-function shouldStartLocationPermissionFlowSelector(lastLocationPermissionPrompt: OnyxEntry): boolean {
- return (
- !lastLocationPermissionPrompt ||
- (DateUtils.isValidDateString(lastLocationPermissionPrompt ?? '') &&
- DateUtils.getDifferenceInDaysFromNow(new Date(lastLocationPermissionPrompt ?? '')) > CONST.IOU.LOCATION_PERMISSION_PROMPT_THRESHOLD_DAYS)
- );
-}
function useReceiptScan({
report,
@@ -57,7 +40,6 @@ function useReceiptScan({
isStartingScan = false,
updateScanAndNavigate,
getSource,
- setIsMultiScanEnabled,
}: UseReceiptScanParams) {
const {isBetaEnabled} = usePermissions();
const [shouldStartLocationPermissionFlow] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, {
@@ -70,7 +52,6 @@ function useReceiptScan({
const defaultExpensePolicy = useDefaultExpensePolicy();
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${initialTransactionID}`);
- const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING);
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
const reportAttributesDerived = useReportAttributes();
@@ -80,13 +61,12 @@ function useReceiptScan({
const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector});
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
- const [transactions, optimisticTransactions] = useOptimisticDraftTransactions(initialTransaction);
+ const [transactions] = useOptimisticDraftTransactions(initialTransaction);
const selfDMReport = useSelfDMReport();
const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector});
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
const isEditing = action === CONST.IOU.ACTION.EDIT;
- const canUseMultiScan = isStartingScan && iouType !== CONST.IOU.TYPE.SPLIT;
const isArchived = isArchivedReport(reportNameValuePairs);
const isReplacingReceipt = (isEditing && hasReceipt(initialTransaction)) || (!!initialTransaction?.receipt && !!backTo);
const shouldAcceptMultipleFiles = !isEditing && !backTo;
@@ -104,30 +84,16 @@ function useReceiptScan({
const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false);
const [receiptFiles, setReceiptFiles] = useState([]);
- const [shouldShowMultiScanEducationalPopup, setShouldShowMultiScanEducationalPopup] = useState(false);
// Clear receipt files when multi-scan is disabled
useEffect(() => {
if (isMultiScanEnabled) {
return;
}
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setReceiptFiles([]);
}, [isMultiScanEnabled]);
- const blinkOpacity = useSharedValue(0);
- const blinkStyle = useAnimatedStyle(() => ({
- opacity: blinkOpacity.get(),
- }));
-
- function showBlink() {
- blinkOpacity.set(
- withTiming(1, {duration: 50}, () => {
- blinkOpacity.set(withTiming(0, {duration: 150}));
- }),
- );
- HapticFeedback.press();
- }
-
const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS);
function navigateToConfirmationStep(files: ReceiptFile[], locationPermissionGranted = false, isTestTransaction = false) {
@@ -249,67 +215,21 @@ function useReceiptScan({
const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => {
processReceipts(files, getSource);
});
-
- function submitReceipts(files: ReceiptFile[]) {
- if (shouldSkipConfirmation) {
- const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT;
- if (gpsRequired) {
- if (shouldStartLocationPermissionFlow) {
- setStartLocationPermissionFlow(true);
- return;
- }
- navigateToConfirmationStep(files, true);
- return;
- }
- }
- navigateToConfirmationStep(files, false);
- }
-
- function submitMultiScanReceipts() {
- const transactionIDs = new Set(optimisticTransactions?.map((transaction) => transaction?.transactionID));
- const validReceiptFiles = receiptFiles.filter((receiptFile) => transactionIDs.has(receiptFile.transactionID));
- submitReceipts(validReceiptFiles);
- }
-
- function toggleMultiScan() {
- if (!dismissedProductTraining?.[CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]) {
- setShouldShowMultiScanEducationalPopup(true);
- }
- removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
- removeDraftTransactions(true);
- setIsMultiScanEnabled?.(!isMultiScanEnabled);
- }
-
- function dismissMultiScanEducationalPopup() {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
- InteractionManager.runAfterInteractions(() => {
- dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL);
- setShouldShowMultiScanEducationalPopup(false);
- });
- }
-
return {
transactions,
isEditing,
- canUseMultiScan,
isReplacingReceipt,
shouldAcceptMultipleFiles,
+ shouldSkipConfirmation,
startLocationPermissionFlow,
setStartLocationPermissionFlow,
shouldStartLocationPermissionFlow,
receiptFiles,
setReceiptFiles,
- shouldShowMultiScanEducationalPopup,
navigateToConfirmationStep,
validateFiles,
PDFValidationComponent,
ErrorModal,
- submitReceipts,
- submitMultiScanReceipts,
- toggleMultiScan,
- dismissMultiScanEducationalPopup,
- blinkStyle,
- showBlink,
setTestReceiptAndNavigate,
};
}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index 59d2f45e9238d..b98d7f855d840 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -48,10 +48,11 @@ import ROUTES from '@src/ROUTES';
import type {FileObject} from '@src/types/utils/Attachment';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import CameraPermission from './CameraPermission';
-import NavigationAwareCamera from './NavigationAwareCamera/Camera';
-import ReceiptPreviews from './ReceiptPreviews';
+import NavigationAwareCamera from './components/NavigationAwareCamera/Camera';
+import ReceiptPreviews from './components/ReceiptPreviews';
+import useMobileReceiptScan from './hooks/useMobileReceiptScan';
+import useReceiptScan from './hooks/useReceiptScan';
import type IOURequestStepScanProps from './types';
-import useReceiptScan from './useReceiptScan';
function IOURequestStepScan({
report,
@@ -274,26 +275,18 @@ function IOURequestStepScan({
const getSource = useCallback((file: FileObject) => file.uri ?? '', []);
- // Shared business logic from useReceiptScan hook
const {
isEditing,
- canUseMultiScan,
shouldAcceptMultipleFiles,
+ shouldSkipConfirmation,
startLocationPermissionFlow,
setStartLocationPermissionFlow,
receiptFiles,
setReceiptFiles,
- shouldShowMultiScanEducationalPopup,
navigateToConfirmationStep,
validateFiles,
PDFValidationComponent,
ErrorModal,
- submitReceipts,
- submitMultiScanReceipts,
- toggleMultiScan,
- dismissMultiScanEducationalPopup,
- blinkStyle,
- showBlink,
setTestReceiptAndNavigate,
} = useReceiptScan({
report,
@@ -309,9 +302,21 @@ function IOURequestStepScan({
isStartingScan,
updateScanAndNavigate,
getSource,
- setIsMultiScanEnabled,
});
+ const {canUseMultiScan, shouldShowMultiScanEducationalPopup, submitReceipts, submitMultiScanReceipts, toggleMultiScan, dismissMultiScanEducationalPopup, blinkStyle, showBlink} =
+ useMobileReceiptScan({
+ initialTransaction,
+ iouType,
+ isMultiScanEnabled,
+ isStartingScan,
+ receiptFiles,
+ navigateToConfirmationStep,
+ shouldSkipConfirmation,
+ setStartLocationPermissionFlow,
+ setIsMultiScanEnabled,
+ });
+
const maybeCancelShutterSpan = useCallback(() => {
if (isMultiScanEnabled) {
return;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index 9f52a542b964b..2824febeae81a 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -1,55 +1,27 @@
-import {useIsFocused} from '@react-navigation/native';
-import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react';
-import type {LayoutRectangle} from 'react-native';
-import {PanResponder, StyleSheet, View} from 'react-native';
+import React, {useCallback, useEffect} from 'react';
import {RESULTS} from 'react-native-permissions';
-import Animated 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 DragAndDropConsumer from '@components/DragAndDrop/Consumer';
-import {useDragAndDropState} from '@components/DragAndDrop/Provider';
-import DropZoneUI from '@components/DropZone/DropZoneUI';
-import FeatureTrainingModal from '@components/FeatureTrainingModal';
-import Icon from '@components/Icon';
import LocationPermissionModal from '@components/LocationPermissionModal';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import ReceiptAlternativeMethods from '@components/ReceiptAlternativeMethods';
-import RenderHTML from '@components/RenderHTML';
-import Text from '@components/Text';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
-import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
-import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation';
-import {isMobile, isMobileWebKit} from '@libs/Browser';
-import {base64ToFile, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils';
+import {isMobile} from '@libs/Browser';
+import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils';
import getCurrentPosition from '@libs/getCurrentPosition';
import Navigation from '@libs/Navigation/Navigation';
-import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
-import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
-import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper';
+import {endSpan} from '@libs/telemetry/activeSpans';
import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound';
import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound';
-import variables from '@styles/variables';
-import {checkIfScanFileCanBeRead, replaceReceipt, setMoneyRequestReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU';
-import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit';
+import {checkIfScanFileCanBeRead, replaceReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU';
+import {removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {FileObject} from '@src/types/utils/Attachment';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import {cropImageToAspectRatio} from './cropImageToAspectRatio';
-import type {ImageObject} from './cropImageToAspectRatio';
+import DesktopWebUploadView from './components/DesktopWebUploadView';
+import MobileWebCameraView from './components/MobileWebCameraView';
+import useReceiptScan from './hooks/useReceiptScan';
import {getLocationPermission} from './LocationPermission';
-import NavigationAwareCamera from './NavigationAwareCamera/WebCamera';
-import ReceiptPreviews from './ReceiptPreviews';
import type IOURequestStepScanProps from './types';
-import useReceiptScan from './useReceiptScan';
function IOURequestStepScan({
report,
@@ -63,23 +35,8 @@ function IOURequestStepScan({
isStartingScan = false,
setIsMultiScanEnabled,
}: Omit) {
- const theme = useTheme();
- const styles = useThemeStyles();
- // we need to use isSmallScreenWidth instead of shouldUseNarrowLayout because drag and drop is not supported on mobile
- // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
- const {isSmallScreenWidth} = useResponsiveLayout();
- const {translate} = useLocalize();
- const {isDraggingOver} = useDragAndDropState();
- const [cameraPermissionState, setCameraPermissionState] = useState('prompt');
- const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false);
- const [isTorchAvailable, setIsTorchAvailable] = useState(false);
- const cameraRef = useRef(null);
- const trackRef = useRef(null);
- const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false);
- const getScreenshotTimeoutRef = useRef(null);
+ const isMobileWeb = isMobile();
const policy = usePolicy(report?.policyID);
- const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan', 'Hand', 'ReceiptStack', 'Shutter']);
- const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash', 'ReplaceReceipt', 'SmartScan']);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`);
// End the create expense span on mount for web (no camera init tracking needed)
@@ -101,28 +58,20 @@ function IOURequestStepScan({
const getSource = useCallback((file: FileObject) => file.uri ?? URL.createObjectURL(file as Blob), []);
- // Shared business logic from useReceiptScan hook
const {
transactions,
isEditing,
- canUseMultiScan,
isReplacingReceipt,
shouldAcceptMultipleFiles,
+ shouldSkipConfirmation,
startLocationPermissionFlow,
setStartLocationPermissionFlow,
receiptFiles,
setReceiptFiles,
- shouldShowMultiScanEducationalPopup,
navigateToConfirmationStep,
validateFiles,
PDFValidationComponent,
ErrorModal,
- submitReceipts,
- submitMultiScanReceipts,
- toggleMultiScan,
- dismissMultiScanEducationalPopup,
- blinkStyle,
- showBlink,
setTestReceiptAndNavigate,
} = useReceiptScan({
report,
@@ -138,69 +87,11 @@ function IOURequestStepScan({
isStartingScan,
updateScanAndNavigate,
getSource,
- setIsMultiScanEnabled,
});
- const [videoConstraints, setVideoConstraints] = useState();
- const isTabActive = useIsFocused();
-
- /**
- * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
- * The last deviceId is of regular len camera.
- */
- const requestCameraPermission = useCallback(() => {
- if (!isMobile()) {
- return;
- }
-
- 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) {
- setVideoConstraints({deviceId});
- return;
- }
- }
- if (!navigator.mediaDevices.enumerateDevices) {
- setVideoConstraints(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) {
- setVideoConstraints(defaultConstraints);
- return;
- }
- setVideoConstraints({deviceId: lastBackDeviceId});
- });
- })
- .catch(() => {
- setVideoConstraints(defaultConstraints);
- setCameraPermissionState('denied');
- });
- }, []);
+ const handleOnLayout = useCallback(() => {
+ onLayout?.(setTestReceiptAndNavigate);
+ }, [onLayout, setTestReceiptAndNavigate]);
// When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, make the user star scanning flow from scratch.
// This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then
@@ -235,31 +126,6 @@ function IOURequestStepScan({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- useEffect(() => {
- if (!isMobile() || !isTabActive) {
- return;
- }
- navigator.permissions
- .query({
- name: 'camera',
- })
- .then((permissionState) => {
- setCameraPermissionState(permissionState.state);
- if (permissionState.state === 'granted') {
- requestCameraPermission();
- }
- })
- .catch(() => {
- setCameraPermissionState('denied');
- })
- .finally(() => {
- setIsQueriedPermissionState(true);
- });
- return () => {
- setVideoConstraints(undefined);
- };
- }, [isTabActive, requestCameraPermission]);
-
// this effect will pre-fetch location in web if the location permission is already granted to optimize the flow
useEffect(() => {
const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT;
@@ -282,450 +148,56 @@ function IOURequestStepScan({
});
}, [initialTransaction?.amount, iouType]);
- const handleDropReceipt = (e: DragEvent) => {
- const files = Array.from(e?.dataTransfer?.files ?? []);
- if (files.length === 0) {
- return;
- }
- for (const file of files) {
- // eslint-disable-next-line no-param-reassign
- file.uri = URL.createObjectURL(file);
- }
-
- validateFiles(files, Array.from(e.dataTransfer?.items ?? []));
- };
-
- 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 viewfinderLayout = useRef(null);
-
- const getScreenshot = useCallback(() => {
- 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;
- }
-
- if (isMultiScanEnabled) {
- 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 algin 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, filename, source}) => {
- endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
- const transaction =
- isMultiScanEnabled && initialTransaction?.receipt?.source
- ? buildOptimisticTransactionAndCreateDraft({
- initialTransaction,
- currentUserPersonalDetails,
- reportID,
- })
- : initialTransaction;
- const transactionID = transaction?.transactionID ?? initialTransactionID;
- const newReceiptFiles = [...receiptFiles, {file, source, transactionID}];
-
- setMoneyRequestReceipt(transactionID, source, filename, !isEditing, file.type);
- setReceiptFiles(newReceiptFiles);
-
- if (isMultiScanEnabled) {
- return;
- }
-
- if (isEditing) {
- updateScanAndNavigate(file, source);
- return;
- }
-
- submitReceipts(newReceiptFiles);
- });
- }, [
- isMultiScanEnabled,
- initialTransaction,
- currentUserPersonalDetails,
- reportID,
- initialTransactionID,
- receiptFiles,
- isEditing,
- submitReceipts,
- setReceiptFiles,
- requestCameraPermission,
- showBlink,
- updateScanAndNavigate,
- ]);
-
- const clearTorchConstraints = useCallback(() => {
- if (!trackRef.current) {
- return;
- }
- trackRef.current.applyConstraints({
- advanced: [{torch: false}],
- });
- }, []);
-
- const capturePhoto = useCallback(() => {
- if (trackRef.current && isFlashLightOn) {
- trackRef.current
- .applyConstraints({
- advanced: [{torch: true}],
- })
- .then(() => {
- getScreenshotTimeoutRef.current = setTimeout(() => {
- getScreenshot();
- clearTorchConstraints();
- }, CONST.RECEIPT.FLASH_DELAY_MS);
- });
- return;
- }
-
- getScreenshot();
- }, [isFlashLightOn, getScreenshot, clearTorchConstraints]);
-
- const panResponder = useRef(
- PanResponder.create({
- onPanResponderTerminationRequest: () => false,
- }),
- ).current;
-
- useEffect(
- () => () => {
- cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
- cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
- if (!getScreenshotTimeoutRef.current) {
- return;
- }
- clearTimeout(getScreenshotTimeoutRef.current);
- },
- [],
- );
-
- const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = {
- context: 'IOURequestStepScan',
- cameraPermissionState,
- isQueriedPermissionState,
- hasVideoConstraints: !isEmptyObject(videoConstraints),
- };
-
- const mobileCameraView = () => (
+ return (
<>
-
- {PDFValidationComponent}
- {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && (
-
- )}
- {cameraPermissionState !== 'granted' && isQueriedPermissionState && (
-
-
- {translate('receipt.takePhoto')}
- {cameraPermissionState === 'denied' ? (
-
-
-
- ) : (
- {translate('receipt.cameraAccess')}
- )}
-
-
- )}
- {cameraPermissionState === 'granted' && !isEmptyObject(videoConstraints) && (
- (viewfinderLayout.current = e.nativeEvent.layout)}
- >
- setCameraPermissionState('denied')}
- style={{
- ...styles.videoContainer,
- display: cameraPermissionState !== 'granted' ? 'none' : 'block',
- }}
- ref={cameraRef}
- screenshotFormat="image/png"
- videoConstraints={videoConstraints}
- forceScreenshotSourceSize
- audio={false}
- disablePictureInPicture={false}
- imageSmoothing={false}
- mirrored={false}
- screenshotQuality={0}
- />
- {canUseMultiScan && isMobile() ? (
-
-
-
-
-
- ) : null}
-
-
- )}
-
-
-
-
- {({openPicker}) => (
- {
- openPicker({
- onPicked: (data) => validateFiles(data),
- });
- }}
- sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE}
- >
-
-
- )}
-
-
-
-
- {canUseMultiScan && isMobile() ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {canUseMultiScan && isMobile() && shouldShowMultiScanEducationalPopup && (
-
- )}
-
- {canUseMultiScan && (
-
+ ) : (
+
)}
- >
- );
-
- const [containerHeight, setContainerHeight] = useState(0);
- const [desktopUploadViewHeight, setDesktopUploadViewHeight] = useState(0);
- const [alternativeMethodsHeight, setAlternativeMethodsHeight] = useState(0);
- // We use isMobile() here to explicitly hide the alternative methods component on both mobile web and native apps
- const chooseFilesPaddingVertical = Number(styles.chooseFilesView(isSmallScreenWidth).paddingVertical);
- const shouldHideAlternativeMethods = isMobile() || alternativeMethodsHeight + desktopUploadViewHeight + chooseFilesPaddingVertical * 2 > containerHeight;
-
- const desktopUploadView = () => (
- {
- setDesktopUploadViewHeight(e.nativeEvent.layout.height);
- }}
- >
- {PDFValidationComponent}
-
-
- {translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')}
-
- {translate(shouldAcceptMultipleFiles ? 'receipt.desktopSubtitleMultiple' : 'receipt.desktopSubtitleSingle')}
-
-
-
-
- {({openPicker}) => (
-
-
- );
-
- return (
-
- {(isDraggingOverWrapper) => (
- {
- setContainerHeight(event.nativeEvent.layout.height);
- if (!onLayout) {
- return;
- }
- onLayout(setTestReceiptAndNavigate);
+ {ErrorModal}
+ {startLocationPermissionFlow && !!receiptFiles.length && (
+ setStartLocationPermissionFlow(false)}
+ onGrant={() => navigateToConfirmationStep(receiptFiles, true)}
+ onDeny={() => {
+ updateLastLocationPermissionPrompt();
+ navigateToConfirmationStep(receiptFiles, false);
}}
- style={[styles.flex1, !isMobile() && styles.chooseFilesView(isSmallScreenWidth)]}
- >
-
- {!(isDraggingOver ?? isDraggingOverWrapper) && (isMobile() ? mobileCameraView() : desktopUploadView())}
-
-
-
-
- {!shouldHideAlternativeMethods && setAlternativeMethodsHeight(e.nativeEvent.layout.height)} />}
- {ErrorModal}
- {startLocationPermissionFlow && !!receiptFiles.length && (
- setStartLocationPermissionFlow(false)}
- onGrant={() => navigateToConfirmationStep(receiptFiles, true)}
- onDeny={() => {
- updateLastLocationPermissionPrompt();
- navigateToConfirmationStep(receiptFiles, false);
- }}
- />
- )}
-
+ />
)}
-
+ >
);
}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/types.ts b/src/pages/iou/request/step/IOURequestStepScan/types.ts
index 70574048c6671..280107572e1ca 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/types.ts
+++ b/src/pages/iou/request/step/IOURequestStepScan/types.ts
@@ -49,6 +49,32 @@ type UseReceiptScanParams = {
/** Returns a source URL for the file based on platform */
getSource: (file: FileObject) => string;
+};
+
+type UseMobileReceiptScanParams = {
+ /** The initial transaction */
+ initialTransaction: OnyxEntry;
+
+ /** The type of IOU report */
+ iouType: IOUType;
+
+ /** Whether multi-scan is enabled */
+ isMultiScanEnabled?: boolean;
+
+ /** Whether the user is starting a scan request */
+ isStartingScan?: boolean;
+
+ /** The current receipt files being scanned */
+ receiptFiles: ReceiptFile[];
+
+ /** Callback to navigate to the confirmation step */
+ navigateToConfirmationStep: (files: ReceiptFile[], locationPermissionGranted?: boolean, isTestTransaction?: boolean) => void;
+
+ /** Whether the confirmation step should be skipped */
+ shouldSkipConfirmation: boolean;
+
+ /** Callback to start the location permission flow */
+ setStartLocationPermissionFlow: (value: boolean) => void;
/** Callback to update multi-scan enabled state in parent */
setIsMultiScanEnabled: ((value: boolean) => void) | undefined;
@@ -82,4 +108,4 @@ type ReceiptFile = {
};
export default IOURequestStepScanProps;
-export type {ReceiptFile, UseReceiptScanParams};
+export type {ReceiptFile, UseMobileReceiptScanParams, UseReceiptScanParams};
diff --git a/src/selectors/LocationPermission.ts b/src/selectors/LocationPermission.ts
new file mode 100644
index 0000000000000..b9028e51bf923
--- /dev/null
+++ b/src/selectors/LocationPermission.ts
@@ -0,0 +1,17 @@
+import type {OnyxEntry} from 'react-native-onyx';
+import DateUtils from '@libs/DateUtils';
+import CONST from '@src/CONST';
+
+/**
+ * Selector to derive whether we should start the location permission flow from the last prompt timestamp.
+ * Returns true when the user has never been prompted, or when the last prompt was more than LOCATION_PERMISSION_PROMPT_THRESHOLD_DAYS ago.
+ */
+function shouldStartLocationPermissionFlowSelector(lastLocationPermissionPrompt: OnyxEntry): boolean {
+ return (
+ !lastLocationPermissionPrompt ||
+ (DateUtils.isValidDateString(lastLocationPermissionPrompt ?? '') &&
+ DateUtils.getDifferenceInDaysFromNow(new Date(lastLocationPermissionPrompt ?? '')) > CONST.IOU.LOCATION_PERMISSION_PROMPT_THRESHOLD_DAYS)
+ );
+}
+
+export default shouldStartLocationPermissionFlowSelector;
diff --git a/tests/unit/hooks/useMobileReceiptScan.test.ts b/tests/unit/hooks/useMobileReceiptScan.test.ts
new file mode 100644
index 0000000000000..32b230129c0da
--- /dev/null
+++ b/tests/unit/hooks/useMobileReceiptScan.test.ts
@@ -0,0 +1,225 @@
+import {act, renderHook} from '@testing-library/react-native';
+import Onyx from 'react-native-onyx';
+import useMobileReceiptScan from '@pages/iou/request/step/IOURequestStepScan/hooks/useMobileReceiptScan';
+import type {UseMobileReceiptScanParams} from '@pages/iou/request/step/IOURequestStepScan/types';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Transaction} from '@src/types/onyx';
+import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
+
+const mockDismissProductTraining = jest.fn();
+const mockRemoveDraftTransactions = jest.fn();
+const mockRemoveTransactionReceipt = jest.fn();
+
+jest.mock('@libs/actions/Welcome', () => ({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ dismissProductTraining: (...args: unknown[]) => mockDismissProductTraining(...args),
+}));
+
+jest.mock('@userActions/TransactionEdit', () => ({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ removeDraftTransactions: (...args: unknown[]) => mockRemoveDraftTransactions(...args),
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ removeTransactionReceipt: (...args: unknown[]) => mockRemoveTransactionReceipt(...args),
+}));
+
+const INITIAL_TRANSACTION_ID = '987';
+const REPORT_ID = '123';
+
+function createDefaultParams(): UseMobileReceiptScanParams {
+ return {
+ initialTransaction: {transactionID: INITIAL_TRANSACTION_ID, reportID: REPORT_ID, amount: 0} as Transaction,
+ iouType: CONST.IOU.TYPE.REQUEST,
+ isMultiScanEnabled: false,
+ isStartingScan: true,
+ receiptFiles: [],
+ navigateToConfirmationStep: jest.fn(),
+ shouldSkipConfirmation: false,
+ setStartLocationPermissionFlow: jest.fn(),
+ setIsMultiScanEnabled: jest.fn(),
+ };
+}
+
+describe('useMobileReceiptScan', () => {
+ let params: UseMobileReceiptScanParams;
+
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ await Onyx.clear();
+ params = createDefaultParams();
+ });
+
+ describe('canUseMultiScan', () => {
+ it('should return true when isStartingScan is true and iouType is REQUEST', async () => {
+ // Given the hook is rendered with isStartingScan set to true and iouType set to REQUEST
+ const {result} = renderHook(() => useMobileReceiptScan(params));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then canUseMultiScan should be true
+ expect(result.current.canUseMultiScan).toBe(true);
+ });
+
+ it('should return false when iouType is SPLIT', async () => {
+ // Given the hook is rendered with iouType set to SPLIT
+ const splitParams = {...params, iouType: CONST.IOU.TYPE.SPLIT};
+ const {result} = renderHook(() => useMobileReceiptScan(splitParams));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then canUseMultiScan should be false
+ expect(result.current.canUseMultiScan).toBe(false);
+ });
+
+ it('should return false when isStartingScan is false', async () => {
+ // Given the hook is rendered with isStartingScan set to false
+ const paramsWithStartingScanDisabled = {...params, isStartingScan: false};
+ const {result} = renderHook(() => useMobileReceiptScan(paramsWithStartingScanDisabled));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then canUseMultiScan should be false
+ expect(result.current.canUseMultiScan).toBe(false);
+ });
+ });
+
+ describe('toggleMultiScan', () => {
+ it('should set shouldShowMultiScanEducationalPopup to true when the modal has not been dismissed', async () => {
+ // Given the hook is rendered and the multi-scan educational modal has not been previously dismissed
+ const setIsMultiScanEnabled = jest.fn();
+ const toggleParams = {...params, setIsMultiScanEnabled, isMultiScanEnabled: false};
+ const {result} = renderHook(() => useMobileReceiptScan(toggleParams));
+ await waitForBatchedUpdatesWithAct();
+
+ // When toggleMultiScan is called
+ await act(async () => {
+ result.current.toggleMultiScan();
+ });
+
+ // Then shouldShowMultiScanEducationalPopup should be true
+ expect(result.current.shouldShowMultiScanEducationalPopup).toBe(true);
+ expect(setIsMultiScanEnabled).toHaveBeenCalledWith(true);
+ expect(mockRemoveTransactionReceipt).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
+ expect(mockRemoveDraftTransactions).toHaveBeenCalledWith(true);
+ });
+
+ it('should not set shouldShowMultiScanEducationalPopup to true after the modal is dismissed', async () => {
+ // Given the multi-scan educational modal has been previously dismissed
+ Onyx.set(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {
+ [CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]: {timestamp: '2024-01-01', dismissedMethod: 'click'},
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ const setIsMultiScanEnabled = jest.fn();
+ const toggleParams = {...params, setIsMultiScanEnabled, isMultiScanEnabled: false};
+ const {result} = renderHook(() => useMobileReceiptScan(toggleParams));
+ await waitForBatchedUpdatesWithAct();
+
+ // When toggleMultiScan is called
+ await act(async () => {
+ result.current.toggleMultiScan();
+ });
+
+ // Then shouldShowMultiScanEducationalPopup should be false
+ expect(result.current.shouldShowMultiScanEducationalPopup).toBe(false);
+ expect(setIsMultiScanEnabled).toHaveBeenCalledWith(true);
+ expect(mockRemoveTransactionReceipt).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
+ expect(mockRemoveDraftTransactions).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('dismissMultiScanEducationalPopup', () => {
+ it('should set shouldShowMultiScanEducationalPopup to false', async () => {
+ // Given the multi-scan educational modal is currently shown
+ Onyx.set(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {});
+ await waitForBatchedUpdatesWithAct();
+
+ const setIsMultiScanEnabled = jest.fn();
+ const toggleParams = {...params, setIsMultiScanEnabled, isMultiScanEnabled: false};
+ const {result} = renderHook(() => useMobileReceiptScan(toggleParams));
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ result.current.toggleMultiScan();
+ });
+ expect(result.current.shouldShowMultiScanEducationalPopup).toBe(true);
+
+ // When dismissMultiScanEducationalPopup is called
+ await act(async () => {
+ result.current.dismissMultiScanEducationalPopup();
+ });
+
+ // Then shouldShowMultiScanEducationalPopup should be false
+ expect(result.current.shouldShowMultiScanEducationalPopup).toBe(false);
+ expect(mockDismissProductTraining).toHaveBeenCalledWith(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL);
+ });
+ });
+
+ describe('submitReceipts', () => {
+ it('should call navigateToConfirmationStep', async () => {
+ // Given the hook is rendered with navigateToConfirmationStep mock
+ const navigateToConfirmationStep = jest.fn();
+ const {result} = renderHook(() => useMobileReceiptScan({...params, navigateToConfirmationStep}));
+ await waitForBatchedUpdatesWithAct();
+
+ const files = [{file: {uri: 'image.jpg'}, source: 'file://image.jpg', transactionID: INITIAL_TRANSACTION_ID}];
+
+ // When submitReceipts is called with files
+ await act(async () => {
+ result.current.submitReceipts(files);
+ });
+
+ // Then navigateToConfirmationStep should be called with the files
+ expect(navigateToConfirmationStep).toHaveBeenCalledWith(files, false);
+ });
+
+ it('should start the location permission flow when shouldSkipConfirmation is true and the location permission is required', async () => {
+ // Given shouldSkipConfirmation is true and the location permission is required
+ const navigateToConfirmationStep = jest.fn();
+ const setStartLocationPermissionFlow = jest.fn();
+ const {result} = renderHook(() => useMobileReceiptScan({...params, shouldSkipConfirmation: true, navigateToConfirmationStep, setStartLocationPermissionFlow}));
+ await waitForBatchedUpdatesWithAct();
+
+ const files = [{file: {uri: 'image.jpg'}, source: 'file://image.jpg', transactionID: INITIAL_TRANSACTION_ID}];
+
+ // When submitReceipts is called
+ await act(async () => {
+ result.current.submitReceipts(files);
+ });
+
+ // Then setStartLocationPermissionFlow should be called with true
+ expect(setStartLocationPermissionFlow).toHaveBeenCalledWith(true);
+
+ // And navigateToConfirmationStep should not be called
+ expect(navigateToConfirmationStep).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('submitMultiScanReceipts', () => {
+ it('should filter receiptFiles by optimistic transaction IDs', async () => {
+ // Given there are valid and invalid draft transaction IDs in Onyx
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}111`, {transactionID: '111'});
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}222`, {transactionID: '222'});
+ await waitForBatchedUpdatesWithAct();
+
+ const navigateToConfirmationStep = jest.fn();
+ const receiptFiles = [
+ {file: {uri: 'valid-receipt.jpg'}, source: 'file://valid-receipt.jpg', transactionID: '111'},
+ {file: {uri: 'invalid.jpg'}, source: 'file://invalid.jpg', transactionID: '999'},
+ ];
+ const {result} = renderHook(() => useMobileReceiptScan({...params, navigateToConfirmationStep, receiptFiles}));
+ await waitForBatchedUpdatesWithAct();
+
+ // When submitMultiScanReceipts is called
+ await act(async () => {
+ result.current.submitMultiScanReceipts();
+ });
+
+ // Then navigateToConfirmationStep should be called with only the valid receipt file
+ expect(navigateToConfirmationStep).toHaveBeenCalledWith([receiptFiles.at(0)], false);
+ });
+ });
+});
diff --git a/tests/unit/hooks/useReceiptScan.test.ts b/tests/unit/hooks/useReceiptScan.test.ts
index f44a7c28ee6b8..a866b784ec5ef 100644
--- a/tests/unit/hooks/useReceiptScan.test.ts
+++ b/tests/unit/hooks/useReceiptScan.test.ts
@@ -2,14 +2,13 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import {act, renderHook} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
-import useReceiptScan from '@pages/iou/request/step/IOURequestStepScan/useReceiptScan';
+import useReceiptScan from '@pages/iou/request/step/IOURequestStepScan/hooks/useReceiptScan';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report, Transaction} from '@src/types/onyx';
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
const mockHandleMoneyRequestStepScanParticipants = jest.fn();
-const mockDismissProductTraining = jest.fn();
const mockRemoveDraftTransactions = jest.fn();
const mockRemoveTransactionReceipt = jest.fn();
const mockSetMoneyRequestReceipt = jest.fn();
@@ -31,20 +30,10 @@ jest.mock('@hooks/useFilesValidation', () => ({
}),
}));
-jest.mock('@libs/TransactionUtils', () => ({
- getDefaultTaxCode: () => '',
- hasReceipt: (transaction: unknown) => !!(transaction && typeof transaction === 'object' && 'receipt' in transaction && transaction.receipt),
- shouldReuseInitialTransaction: () => true,
-}));
-
jest.mock('@libs/actions/IOU/MoneyRequest', () => ({
handleMoneyRequestStepScanParticipants: (...args: unknown[]) => mockHandleMoneyRequestStepScanParticipants(...args),
}));
-jest.mock('@libs/actions/Welcome', () => ({
- dismissProductTraining: (...args: unknown[]) => mockDismissProductTraining(...args),
-}));
-
jest.mock('@userActions/TransactionEdit', () => ({
removeDraftTransactions: (...args: unknown[]) => mockRemoveDraftTransactions(...args),
removeTransactionReceipt: (...args: unknown[]) => mockRemoveTransactionReceipt(...args),
@@ -55,11 +44,6 @@ jest.mock('@userActions/IOU', () => ({
setMoneyRequestReceipt: (...args: unknown[]) => mockSetMoneyRequestReceipt(...args),
}));
-jest.mock('@hooks/useOptimisticDraftTransactions', () => ({
- __esModule: true,
- default: () => [[], [{transactionID: '111'}, {transactionID: '222'}]],
-}));
-
const REPORT_ID = '123';
const INITIAL_TRANSACTION_ID = '987';
@@ -78,7 +62,6 @@ function createDefaultParams(): Parameters[0] {
backToReport: undefined,
isMultiScanEnabled: false,
isStartingScan: true,
- setIsMultiScanEnabled: undefined,
};
}
@@ -211,21 +194,6 @@ describe('useReceiptScan', () => {
expect(result.current.isEditing).toBe(true);
});
- it('should return canUseMultiScan true when isStartingScan and iouType is REQUEST', async () => {
- const {result} = renderHook(() => useReceiptScan(params));
- await waitForBatchedUpdatesWithAct();
-
- expect(result.current.canUseMultiScan).toBe(true);
- });
-
- it('should return canUseMultiScan false when iouType is SPLIT', async () => {
- const splitParams = {...params, iouType: CONST.IOU.TYPE.SPLIT};
- const {result} = renderHook(() => useReceiptScan(splitParams));
- await waitForBatchedUpdatesWithAct();
-
- expect(result.current.canUseMultiScan).toBe(false);
- });
-
it('should return shouldAcceptMultipleFiles true when not editing', async () => {
const {result} = renderHook(() => useReceiptScan(params));
await waitForBatchedUpdatesWithAct();
@@ -241,14 +209,6 @@ describe('useReceiptScan', () => {
expect(result.current.shouldAcceptMultipleFiles).toBe(false);
});
- it('should return canUseMultiScan false when isStartingScan is false', async () => {
- const paramsWithStartingScanDisabled = {...params, isStartingScan: false};
- const {result} = renderHook(() => useReceiptScan(paramsWithStartingScanDisabled));
- await waitForBatchedUpdatesWithAct();
-
- expect(result.current.canUseMultiScan).toBe(false);
- });
-
it('should return shouldAcceptMultipleFiles false when backTo is set', async () => {
const backToParams = {...params, backTo: 'home' as const};
const {result} = renderHook(() => useReceiptScan(backToParams));
@@ -336,7 +296,7 @@ describe('useReceiptScan', () => {
});
it('should clear receiptFiles when isMultiScanEnabled changes from true to false', async () => {
- const multiScanParams = {...params, isMultiScanEnabled: true, setIsMultiScanEnabled: jest.fn()};
+ const multiScanParams = {...params, isMultiScanEnabled: true};
const {result, rerender} = renderHook((p: Parameters[0]) => useReceiptScan(p), {
initialProps: multiScanParams,
});
@@ -355,72 +315,6 @@ describe('useReceiptScan', () => {
});
});
- describe('multi-scan educational popup', () => {
- it('should initialize shouldShowMultiScanEducationalPopup as false', async () => {
- const {result} = renderHook(() => useReceiptScan(params));
- await waitForBatchedUpdatesWithAct();
-
- expect(result.current.shouldShowMultiScanEducationalPopup).toBe(false);
- });
-
- it('should set shouldShowMultiScanEducationalPopup true when toggleMultiScan is called and modal was not dismissed', async () => {
- const setIsMultiScanEnabled = jest.fn();
- const toggleParams = {...params, setIsMultiScanEnabled, isMultiScanEnabled: false};
- const {result} = renderHook(() => useReceiptScan(toggleParams));
- await waitForBatchedUpdatesWithAct();
-
- await act(async () => {
- result.current.toggleMultiScan();
- });
- await waitForBatchedUpdatesWithAct();
-
- expect(result.current.shouldShowMultiScanEducationalPopup).toBe(true);
- expect(mockDismissProductTraining).not.toHaveBeenCalled();
- });
-
- it('should call setIsMultiScanEnabled and clear receipts when toggleMultiScan is called after modal dismissed', async () => {
- Onyx.set(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {
- [CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]: {timestamp: '2024-01-01', dismissedMethod: 'click'},
- });
- await waitForBatchedUpdatesWithAct();
-
- const setIsMultiScanEnabled = jest.fn();
- const toggleParams = {...params, setIsMultiScanEnabled, isMultiScanEnabled: false};
- const {result} = renderHook(() => useReceiptScan(toggleParams));
- await waitForBatchedUpdatesWithAct();
-
- await act(async () => {
- result.current.toggleMultiScan();
- });
- await waitForBatchedUpdatesWithAct();
-
- expect(setIsMultiScanEnabled).toHaveBeenCalledWith(true);
- expect(mockRemoveTransactionReceipt).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
- expect(mockRemoveDraftTransactions).toHaveBeenCalledWith(true);
- });
-
- it('should set shouldShowMultiScanEducationalPopup false when dismissMultiScanEducationalPopup is called', async () => {
- const setIsMultiScanEnabled = jest.fn();
- const toggleParams = {...params, setIsMultiScanEnabled, isMultiScanEnabled: false};
- const {result} = renderHook(() => useReceiptScan(toggleParams));
- await waitForBatchedUpdatesWithAct();
-
- await act(async () => {
- result.current.toggleMultiScan();
- });
- await waitForBatchedUpdatesWithAct();
- expect(result.current.shouldShowMultiScanEducationalPopup).toBe(true);
-
- await act(async () => {
- result.current.dismissMultiScanEducationalPopup();
- });
- await waitForBatchedUpdatesWithAct();
-
- expect(result.current.shouldShowMultiScanEducationalPopup).toBe(false);
- expect(mockDismissProductTraining).toHaveBeenCalledWith(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL);
- });
- });
-
describe('processReceipts', () => {
it('should do nothing when files array is empty', async () => {
const {result} = renderHook(() => useReceiptScan(params));
@@ -497,39 +391,5 @@ describe('useReceiptScan', () => {
}),
);
});
-
- it('should call handleMoneyRequestStepScanParticipants when submitReceipts is called', async () => {
- const {result} = renderHook(() => useReceiptScan(params));
- await waitForBatchedUpdatesWithAct();
-
- const files = [{file: {uri: 'image.jpg'}, source: 'file://image.jpg', transactionID: INITIAL_TRANSACTION_ID}];
- await act(async () => {
- result.current.submitReceipts(files);
- });
-
- expect(mockHandleMoneyRequestStepScanParticipants).toHaveBeenCalledWith(expect.objectContaining({files}));
- });
-
- it('should filter receiptFiles by optimistic transaction IDs when submitMultiScanReceipts is called', async () => {
- const {result} = renderHook(() => useReceiptScan(params));
- await waitForBatchedUpdatesWithAct();
-
- const validFile = {file: {uri: 'valid-receipt.jpg'}, source: 'file://valid-receipt.jpg', transactionID: '111'};
- const invalidFile = {file: {uri: 'invalid.jpg'}, source: 'file://invalid.jpg', transactionID: '999'};
- await act(async () => {
- result.current.setReceiptFiles([validFile, invalidFile]);
- });
- await waitForBatchedUpdatesWithAct();
-
- await act(async () => {
- result.current.submitMultiScanReceipts();
- });
-
- type HandleMoneyRequestStepScanPayload = {files: Array<{transactionID: string}>};
- const calls = mockHandleMoneyRequestStepScanParticipants.mock.calls as Array<[HandleMoneyRequestStepScanPayload]>;
- const scanParams = calls.at(0)?.at(0);
- expect(scanParams?.files).toHaveLength(1);
- expect(scanParams?.files.at(0)?.transactionID).toBe('111');
- });
});
});