diff --git a/cspell.json b/cspell.json index 0485fc658882c..21b805ebe87b2 100644 --- a/cspell.json +++ b/cspell.json @@ -873,6 +873,7 @@ "zxldvw", "مثال", "Airwallex", + "deprioritizes", "AMRO", "Bancorporation", "Banque", diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 2927be7e3cbf6..1ea1bc03d494a 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1,4 +1,4 @@ -import {emailSelector} from '@selectors/Session'; +import {accountIDSelector, emailSelector} from '@selectors/Session'; import {format} from 'date-fns'; import {Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; @@ -10,6 +10,7 @@ import type {ValueOf} from 'type-fest'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useLocalReceiptThumbnail from '@hooks/useLocalReceiptThumbnail'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; @@ -365,9 +366,7 @@ function MoneyRequestConfirmationListFooter({ const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, { - selector: (session) => session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - }); + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK; @@ -536,6 +535,19 @@ function MoneyRequestConfirmationListFooter({ const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); + const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile); + + // For local files: use the pre-generated thumbnail if it was ready by first render. + // If the thumbnail arrives late we keep showing the full-res image to avoid a + // visible source swap (flash). For remote files: existing behavior unchanged. + const initialLocalSourceRef = useRef(undefined); + if (isLocalFile && initialLocalSourceRef.current === undefined) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + initialLocalSourceRef.current = thumbnailUri || String(resolvedReceiptImage ?? '') || ''; + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const effectiveReceiptSource = isLocalFile ? initialLocalSourceRef.current || '' : resolvedThumbnail || resolvedReceiptImage || ''; + const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate const shouldShowTimeRequestFields = isTimeRequest && action === CONST.IOU.ACTION.CREATE; @@ -1428,7 +1440,7 @@ function MoneyRequestConfirmationListFooter({ (); +/** Track how many mounted hook instances reference each sourceUri */ +const thumbnailRefCount = new Map(); + +function retainUri(uri: string) { + thumbnailRefCount.set(uri, (thumbnailRefCount.get(uri) ?? 0) + 1); +} + +function releaseUri(uri: string) { + const count = (thumbnailRefCount.get(uri) ?? 1) - 1; + if (count <= 0) { + thumbnailRefCount.delete(uri); + thumbnailCache.delete(uri); + } else { + thumbnailRefCount.set(uri, count); + } +} + +/** + * Pre-populate the thumbnail cache so the confirm screen can use it + * synchronously on first render, avoiding any source swap / flash. + */ +function pregenerateThumbnail(sourceUri: string): Promise { + if (thumbnailCache.has(sourceUri)) { + return Promise.resolve(thumbnailCache.get(sourceUri)); + } + return generateThumbnail(sourceUri).then((uri) => { + if (uri) { + thumbnailCache.set(sourceUri, uri); + // Pre-decode the thumbnail in the native image pipeline so the + // confirmation screen can display it instantly without decode latency. + Image.prefetch(uri); + } + return uri; + }); +} + +/** + * Returns a cached low-resolution thumbnail for a local receipt image. + * The thumbnail should be pre-generated via `pregenerateThumbnail` before + * navigating to the confirm screen. If it wasn't, this hook generates it + * as a fallback, but in that case a source swap (flash) may occur. + */ +function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean): {thumbnailUri: string | undefined; isGenerating: boolean} { + const [asyncResult, setAsyncResult] = useState<{source: string; uri?: string; done: boolean} | undefined>(); + const [, startTransition] = useTransition(); + const retainedUriRef = useRef(undefined); + + // Resolve cached thumbnails synchronously during render (fast path) + const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; + const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; + const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri; + + const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri; + const isGenerating = shouldGenerate && !resultForCurrentSource?.done; + + // Retain / release the cache entry so it lives as long as at least one + // mounted hook instance references it, and is cleaned up after the last + // consumer unmounts. + useEffect(() => { + if (!sourceUri || !isLocalFile) { + return; + } + + retainUri(sourceUri); + retainedUriRef.current = sourceUri; + + return () => { + releaseUri(sourceUri); + retainedUriRef.current = undefined; + }; + }, [sourceUri, isLocalFile]); + + // Fallback: generate if not already in cache (e.g. gallery pick path) + useEffect(() => { + if (!sourceUri || !isLocalFile || thumbnailCache.has(sourceUri)) { + return; + } + + let cancelled = false; + generateThumbnail(sourceUri) + .then((uri) => { + if (cancelled) { + return; + } + if (uri) { + thumbnailCache.set(sourceUri, uri); + } + startTransition(() => { + setAsyncResult({source: sourceUri, uri: uri ?? undefined, done: true}); + }); + }) + .catch(() => { + if (cancelled) { + return; + } + setAsyncResult({source: sourceUri, done: true}); + }); + + return () => { + cancelled = true; + }; + }, [sourceUri, isLocalFile, startTransition]); + + return {thumbnailUri, isGenerating}; +} + +export {pregenerateThumbnail}; +export default useLocalReceiptThumbnail; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 71c9c694af6f2..31a0a1ee5ef58 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -801,6 +801,7 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin reservationList: 'string', isTestReceipt: 'boolean', isTestDriveReceipt: 'boolean', + thumbnail: 'string', }); case 'childRecentReceiptTransactionIDs': return validateObject>(value, {}, 'string'); @@ -1152,6 +1153,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) reservationList: 'array', isTestReceipt: 'boolean', isTestDriveReceipt: 'boolean', + thumbnail: 'string', }); case 'taxRate': return validateObject>(value, { diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index dceabfe0ca4f6..35c21bbae440e 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -62,9 +62,9 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPa return {image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction, filename}; } - // For local files, we won't have a thumbnail yet + // For local files, use the pre-generated thumbnail if available for fast preview if ((isReceiptImage || isReceiptPDF) && typeof path === 'string' && (path.startsWith('blob:') || path.startsWith('file:'))) { - return {image: path, isLocalFile: true, filename}; + return {thumbnail: transaction?.receipt?.thumbnail, image: path, isLocalFile: true, filename}; } if (isReceiptImage) { diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index d109df8060971..60392d123a91d 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -290,10 +290,19 @@ function replaceReceipt({transactionID, file, source, state, transactionPolicy, API.write(WRITE_COMMANDS.REPLACE_RECEIPT, parameters, {optimisticData, successData, failureData}); } -function setMoneyRequestReceipt(transactionID: string, source: string, filename: string, isDraft: boolean, type?: string, isTestReceipt = false, isTestDriveReceipt = false) { +function setMoneyRequestReceipt( + transactionID: string, + source: string, + filename: string, + isDraft: boolean, + type?: string, + isTestReceipt = false, + isTestDriveReceipt = false, + thumbnail?: string, +) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { // isTestReceipt = false and isTestDriveReceipt = false are being converted to null because we don't really need to store it in Onyx in those cases - receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null}, + receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, thumbnail}, }); } diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index bc26a58747ce1..1bb619b4bc007 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -903,5 +903,6 @@ export { getFilesFromClipboardEvent, cleanFileObject, cleanFileObjectName, + JPEG_QUALITY, }; export type {FileValidationError}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts index 8e7a457283b77..7b889fb616a90 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts @@ -1,7 +1,10 @@ +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; import type {Orientation} from 'react-native-vision-camera'; import cropOrRotateImage from '@libs/cropOrRotateImage'; import getDeviceOrientationAwareImageSize from '@libs/cropOrRotateImage/getDeviceOrientationAwareImageSize'; +import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; +import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; type ImageObject = { @@ -78,5 +81,23 @@ function cropImageToAspectRatio( .catch(() => image); } +const THUMBNAIL_MAX_WIDTH = 256; +/** + * Generate a low-resolution thumbnail from an image URI. + * Used on native to avoid decoding the full 12MP camera photo on the confirmation page. + * 256px is sufficient for the confirmation screen preview and decodes ~4x faster than 512px. + */ +function generateThumbnail(sourceUri: string, maxWidth = THUMBNAIL_MAX_WIDTH): Promise { + return ImageManipulator.manipulate(sourceUri) + .resize({width: maxWidth}) + .renderAsync() + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) + .then((result) => result.uri) + .catch((error) => { + Log.warn(`Failed to generate thumbnail: ${error}`); + return undefined; + }); +} + export type {ImageObject}; -export {calculateCropRect, cropImageToAspectRatio}; +export {calculateCropRect, cropImageToAspectRatio, generateThumbnail}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 38343b2750231..97587d7a4d0bb 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -21,6 +21,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import {pregenerateThumbnail} from '@hooks/useLocalReceiptThumbnail'; import useNativeCamera from '@hooks/useNativeCamera'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; @@ -178,6 +179,10 @@ function IOURequestStepScan({ } endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + // Preload the confirmation screen module so its JS is parsed and ready + // when we navigate after capture — eliminates cold-start module load cost. + require('../IOURequestStepConfirmation'); + // Pre-create upload directory to avoid latency during capture const path = getReceiptsUploadFolderPath(); ReactNativeBlobUtil.fs @@ -325,6 +330,7 @@ function IOURequestStepScan({ const transactionID = transaction?.transactionID ?? initialTransactionID; const source = getPhotoSource(photo.path); const filename = photo.path; + endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); const cameraFile = { @@ -334,9 +340,8 @@ function IOURequestStepScan({ source, }; - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); - if (isEditing) { + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); updateScanAndNavigate(cameraFile as FileObject, source); return; } @@ -345,14 +350,18 @@ function IOURequestStepScan({ setReceiptFiles(newReceiptFiles); if (isMultiScanEnabled) { + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); setDidCapturePhoto(false); isCapturingPhoto.current = false; return; } - // Defer navigation by one frame so React renders the frozen camera - // state (didCapturePhoto=true) before the screen transitions away. - requestAnimationFrame(() => submitReceipts(newReceiptFiles)); + // Fire Onyx merge immediately (non-blocking) while we await thumbnail generation. + // Both run in parallel — navigation proceeds once the thumbnail is cached. + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); + pregenerateThumbnail(source).then(() => { + submitReceipts(newReceiptFiles); + }); }) .catch((error: string) => { isCapturingPhoto.current = false; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 7079127d0df3b..11e420acf93b3 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -239,6 +239,9 @@ type Receipt = { /** Receipt is Test Drive testing receipt */ isTestDriveReceipt?: true; + + /** Local thumbnail URI for fast preview on confirmation page */ + thumbnail?: string; }; /** Model of route */