-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat: Add thumbnail support for receipts and improve image handling #84919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d6fa27a
35be2b4
23bb98f
6c74437
90397b1
af737d4
ddff150
8b0918c
f4cf69d
ecc9d6c
336ecb9
05b4b40
fd0e6fe
946ddbb
f8cac08
a3e143e
cbfb4f1
8e8bcf0
f24b70f
5beb1fa
6acd919
3b1ef08
49d3c60
512c481
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -873,6 +873,7 @@ | |
| "zxldvw", | ||
| "مثال", | ||
| "Airwallex", | ||
| "deprioritizes", | ||
| "AMRO", | ||
| "Bancorporation", | ||
| "Banque", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import {useEffect, useRef, useState, useTransition} from 'react'; | ||
| import {Image} from 'react-native'; | ||
| import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; | ||
|
|
||
| const thumbnailCache = new Map<string, string>(); | ||
| /** Track how many mounted hook instances reference each sourceUri */ | ||
| const thumbnailRefCount = new Map<string, number>(); | ||
|
|
||
| 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<string | undefined> { | ||
| 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<string | undefined>(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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | undefined> { | ||
| return ImageManipulator.manipulate(sourceUri) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We definitely should check this on Android and iOS both. I think I saw on my crappy Galaxy S8 that the image preview on confirmation page takes about 1 second to load without any resizing. But, also I think we can control the overall file size by tweaking the vision camera settings e.g. if we use |
||
| .resize({width: maxWidth}) | ||
| .renderAsync() | ||
| .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) | ||
| .then((result) => result.uri) | ||
Julesssss marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .catch((error) => { | ||
| Log.warn(`Failed to generate thumbnail: ${error}`); | ||
| return undefined; | ||
| }); | ||
| } | ||
|
|
||
| export type {ImageObject}; | ||
| export {calculateCropRect, cropImageToAspectRatio}; | ||
| export {calculateCropRect, cropImageToAspectRatio, generateThumbnail}; | ||
Uh oh!
There was an error while loading. Please reload this page.