Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9191e55
Re-land in-app VisionCamera for chat attachment photos
MelvinBot Jul 19, 2026
6bd1224
Fix React Compiler divergence in useTapToFocusGesture
MelvinBot Jul 19, 2026
d43b5da
Constrain iOS preview resolution for in-app attachment camera
MelvinBot Aug 16, 2026
f702d47
Merge main into claude-inAppCameraAttachments-relandV2
MelvinBot Aug 16, 2026
71c2d26
Fix typecheck: drop removed reasonAttributes prop, add Greek flipCame…
MelvinBot Aug 16, 2026
771a9a4
Use VisionCamera default temp dir for chat photos instead of receipts…
MelvinBot Aug 18, 2026
f8d21e0
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot Aug 18, 2026
10a2f5c
Hide camera flip control when the device has only one camera position
MelvinBot Aug 19, 2026
c74f438
Inline fill-rule/clip-rule on camera-flip.svg to drop the <style>-blo…
MelvinBot Aug 19, 2026
a9823bc
Fully minify camera-flip.svg so it passes the Compress SVG check
MelvinBot Aug 19, 2026
37a88eb
Fix: add missing useCameraDevices export to vision-camera jest mock
MelvinBot Aug 19, 2026
6d5d9e4
Fix: add useCameraDevices to inline vision-camera mocks in Scan tests
MelvinBot Aug 19, 2026
0d57176
Add header comment to AttachmentCamera describing the in-app VisionCa…
MelvinBot Aug 24, 2026
ecaa885
Address review: extract platform videoResolution helper, name modal d…
MelvinBot Aug 24, 2026
f2fafd8
Run npm run fmt
MelvinBot Aug 25, 2026
3cbe381
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot Aug 31, 2026
3a84f53
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot Aug 31, 2026
37a6cb7
Apply review patch: landscape support, shutter sound, camera error ha…
MelvinBot Sep 6, 2026
828a392
Apply Polyglot Parrot translations for lowercased flipCamera label
MelvinBot Sep 6, 2026
cb6a3cc
Merge remote-tracking branch 'origin/main' into claude-inAppCameraAtt…
MelvinBot Sep 9, 2026
30c2452
Merge origin/main into claude-inAppCameraAttachments-relandV2
MelvinBot Sep 10, 2026
b6d983f
Remove unnecessary comments and tighten the remaining ones
MelvinBot Sep 13, 2026
adb8b8d
Address review feedback: use app Modal, extract useIsPlatformMuted, d…
MelvinBot Sep 13, 2026
5c5f805
Apply oxfmt formatting
MelvinBot Sep 13, 2026
e681ed5
Move camera videoResolution filter into a platform-split module
MelvinBot Sep 13, 2026
58576a8
Address review feedback: reuse permission logic, getFileName, Sentry-…
MelvinBot Sep 14, 2026
a2eb5fd
Merge origin/main into claude-inAppCameraAttachments-relandV2
MelvinBot Sep 14, 2026
fd7c3b9
Merge origin/main into claude-inAppCameraAttachments-relandV2
MelvinBot Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions __mocks__/react-native-vision-camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const useCameraDevice = jest.fn(() => null);
const useCameraDevices = jest.fn(() => []);
const useCameraFormat = jest.fn(() => null);
const useCameraPermission = jest.fn(() => ({hasPermission: false, requestPermission: jest.fn(() => Promise.resolve(false))}));

const Camera = Object.assign(
jest.fn(() => null),
{
getCameraPermissionStatus: jest.fn(() => 'not-determined'),
requestCameraPermission: jest.fn(() => Promise.resolve('granted')),
},
);

export {Camera, useCameraDevice, useCameraDevices, useCameraFormat, useCameraPermission};
1 change: 1 addition & 0 deletions assets/images/camera-flip.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,6 @@
"../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3
"../../src/hooks/useMoneyReportHeaderStatusBar.ts" "rulesdir/no-direct-personal-details-list" 1
"../../src/hooks/useNativeCamera.ts" "react-hooks/refs" 1
"../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2
"../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
"../../src/hooks/useOptimisticDraftTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
Expand Down
338 changes: 338 additions & 0 deletions src/components/AttachmentPicker/AttachmentCamera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
/**
* In-app VisionCamera modal used by the native AttachmentPicker.
*/
import ActivityIndicator from '@components/ActivityIndicator';
Comment thread
MelvinBot marked this conversation as resolved.
import Button from '@components/Button';
import Icon from '@components/Icon';
import ImageSVG from '@components/ImageSVG';
import Modal from '@components/Modal';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';

import useIsPlatformMuted from '@hooks/useIsPlatformMuted';
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import {requestCameraPermission, useTapToFocusGesture} from '@hooks/useNativeCamera';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';

import {getFileName} from '@libs/fileDownload/FileUtils';
import getPhotoSource from '@libs/fileDownload/getPhotoSource';
import getVideoResolutionFormatFilter from '@libs/getVideoResolutionFormatFilter';
import isInLandscapeMode from '@libs/isInLandscapeMode';
import {logCameraCaptureFailed, logCameraRuntimeError} from '@libs/telemetry/ReceiptObservability';

import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio';

import variables from '@styles/variables';

import CONST from '@src/CONST';

import type {Camera, CameraRuntimeError, PhotoFile} from 'react-native-vision-camera';

import React, {useEffect, useRef, useState} from 'react';
import {Alert, AppState, View} from 'react-native';
import {GestureDetector} from 'react-native-gesture-handler';
import {RESULTS} from 'react-native-permissions';
import Animated from 'react-native-reanimated';
import {useCameraDevice, useCameraDevices, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera';

type CapturedPhoto = {
uri: string;
fileName: string;
type: string;
width: number;
height: number;
};

type AttachmentCameraProps = {
/** Whether the camera modal is visible */
isVisible: boolean;

/** Callback when a photo is captured */
onCapture: (photos: CapturedPhoto[]) => void;

/** Callback when the camera is closed */
onClose: () => void;

/** Callback fired once the modal has finished its hide animation */
onModalHide: () => void;
};

function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: AttachmentCameraProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const insets = useSafeAreaInsets();
const StyleUtils = useStyleUtils();
const {windowWidth, windowHeight} = useWindowDimensions();
const isLandscape = isInLandscapeMode(windowWidth, windowHeight);
const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip', 'Close']);
const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']);
const isPlatformMuted = useIsPlatformMuted();

const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back');
const [flash, setFlash] = useState(false);
const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null);
const isCapturing = useRef(false);
const isActiveRef = useRef(false);
const cameraRef = useRef<Camera>(null);

const device = useCameraDevice(cameraPosition, {
physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
});
Comment thread
MelvinBot marked this conversation as resolved.

const cameraDevices = useCameraDevices();
const canFlipCamera = cameraDevices.some((d) => d.position === 'front') && cameraDevices.some((d) => d.position === 'back');

const format = useCameraFormat(device, [
{photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO},
{photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}},
getVideoResolutionFormatFilter(windowWidth, windowHeight),
]);
Comment thread
MelvinBot marked this conversation as resolved.
const hasFlash = !!device?.hasFlash;
const cameraAspectRatio = getCameraAspectRatio(format, isLandscape);

const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, device?.supportsFocus ?? false);

const askForPermissions = () => requestCameraPermission(translate, setCameraPermissionStatus);

useEffect(() => {
isActiveRef.current = isVisible;
}, [isVisible]);

// Refresh permissions when modal becomes visible or when returning from app settings
useEffect(() => {
if (!isVisible) {
return;
}

let ignore = false;
const refreshCameraPermissionStatus = (autoRequest = false) => {
CameraPermission?.getCameraPermissionStatus?.()
.then((status: string) => {
if (ignore) {
return;
}
setCameraPermissionStatus(status);
if (autoRequest && status === RESULTS.DENIED) {
requestCameraPermission(translate, setCameraPermissionStatus);
}
})
.catch(() => {
if (ignore) {
return;
}
setCameraPermissionStatus(RESULTS.UNAVAILABLE);
});
};

refreshCameraPermissionStatus(true);

const subscription = AppState.addEventListener('change', (appState) => {
if (appState !== 'active') {
return;
}
refreshCameraPermissionStatus();
});

return () => {
ignore = true;
subscription.remove();
};
}, [isVisible, translate]);

const capturePhoto = () => {
if (cameraPermissionStatus !== RESULTS.GRANTED) {
askForPermissions();
return;
}

if (!cameraRef.current || isCapturing.current) {
return;
}

isCapturing.current = true;

cameraRef.current
.takePhoto({
flash: flash && hasFlash ? 'on' : 'off',
Comment thread
MelvinBot marked this conversation as resolved.
enableShutterSound: !isPlatformMuted,
})
.then((photo: PhotoFile) => {
// Discard capture if the camera was closed while takePhoto was in-flight
if (!isActiveRef.current) {
return;
Comment thread
MelvinBot marked this conversation as resolved.
}
const uri = getPhotoSource(photo.path);
const fileName = getFileName(photo.path) || `photo_${Date.now()}.jpg`;

onCapture([
{
uri,
fileName,
type: 'image/jpeg',
width: photo.width,
height: photo.height,
},
]);
})
.catch((error: Error) => {
Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
logCameraCaptureFailed(error);
})
.finally(() => {
isCapturing.current = false;
});
};

const handleCameraError = (error: CameraRuntimeError) => {
Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
logCameraRuntimeError({code: error.code, message: error.message});
};

const handleClose = () => {
isCapturing.current = false;
setFlash(false);
setCameraPosition('back');
onClose();
};

return (
<Modal
Comment thread
MelvinBot marked this conversation as resolved.
isVisible={isVisible}
onClose={handleClose}
onModalHide={onModalHide}
type={CONST.MODAL.MODAL_TYPE.FULLSCREEN}
style={styles.appBG}
innerContainerStyle={styles.flex1}
>
<View style={[styles.flex1, styles.appBG, StyleUtils.getPlatformSafeAreaPadding(insets)]}>
<View style={[styles.flexRow, styles.justifyContentEnd, styles.ph3, styles.pv2]}>
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('common.close')}
onPress={handleClose}
sentryLabel="AttachmentCamera-Close"
>
<Icon
height={variables.iconSizeNormal}
width={variables.iconSizeNormal}
src={lazyIcons.Close}
fill={theme.icon}
/>
</PressableWithFeedback>
</View>

<View style={[styles.flex1]}>
{cameraPermissionStatus !== RESULTS.GRANTED && (
<View style={[styles.cameraView, styles.permissionView, styles.userSelectNone]}>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Hand}
width={CONST.RECEIPT.HAND_ICON_WIDTH}
height={CONST.RECEIPT.HAND_ICON_HEIGHT}
style={styles.pb5}
/>
<Text style={[styles.textFileUpload]}>{translate('receipt.takePhoto')}</Text>
<Text style={[styles.subTextFileUpload]}>{translate('receipt.cameraAccess')}</Text>
<Button
variant={CONST.BUTTON_VARIANT.SUCCESS}
accessibilityLabel={translate('common.continue')}
style={[styles.p9, styles.pt5]}
onPress={askForPermissions}
>
<Button.Text>{translate('common.continue')}</Button.Text>
</Button>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device == null && (
<View style={[styles.cameraView, styles.justifyContentCenter, styles.alignItemsCenter]}>
<ActivityIndicator
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
style={[styles.flex1]}
color={theme.textSupporting}
/>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device != null && (
<View style={[styles.cameraView, styles.alignItemsCenter]}>
<GestureDetector gesture={tapGesture}>
<View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio, isLandscape)}>
<VisionCamera
ref={cameraRef}
device={device}
format={format ?? undefined}
style={styles.flex1}
zoom={device.neutralZoom}
photo
isActive={isVisible}
photoQualityBalance="quality"
onError={handleCameraError}
/>
<Animated.View style={[styles.cameraFocusIndicator, cameraFocusIndicatorAnimatedStyle]} />
</View>
</GestureDetector>
</View>
)}
</View>

<View style={[styles.flexRow, styles.justifyContentAround, styles.alignItemsCenter, styles.pv3]}>
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flash')}
style={[styles.alignItemsStart, !hasFlash && styles.opacity0]}
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !hasFlash}
onPress={() => setFlash((prevFlash) => !prevFlash)}
sentryLabel="AttachmentCamera-Flash"
>
<Icon
height={variables.iconSizeMenuItem}
width={variables.iconSizeMenuItem}
src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
fill={theme.textSupporting}
/>
</PressableWithFeedback>

<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.shutter')}
style={[styles.alignItemsCenter]}
onPress={capturePhoto}
sentryLabel="AttachmentCamera-Shutter"
>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Shutter}
width={CONST.RECEIPT.SHUTTER_SIZE}
height={CONST.RECEIPT.SHUTTER_SIZE}
/>
</PressableWithFeedback>

<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flipCamera')}
style={[styles.alignItemsEnd, !canFlipCamera && styles.opacity0]}
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !canFlipCamera}
onPress={() => setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))}
sentryLabel="AttachmentCamera-FlipCamera"
>
<Icon
height={variables.iconSizeMenuItem}
width={variables.iconSizeMenuItem}
src={lazyIcons.CameraFlip}
fill={theme.textSupporting}
/>
</PressableWithFeedback>
</View>
</View>
</Modal>
);
}

export default AttachmentCamera;
export type {CapturedPhoto};
Loading
Loading