Skip to content

Commit e29b652

Browse files
authored
Merge pull request Expensify#65316 from callstack-internal/feat/59443-multi-files-attachments-carousel
Multi files attachments carousel
2 parents bfeee19 + 2833b84 commit e29b652

7 files changed

Lines changed: 586 additions & 155 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
2+
import type {View} from 'react-native';
3+
import {InteractionManager} from 'react-native';
4+
import {GestureHandlerRootView} from 'react-native-gesture-handler';
5+
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated';
6+
import type {ValueOf} from 'type-fest';
7+
import useFilesValidation from '@hooks/useFilesValidation';
8+
import useLocalize from '@hooks/useLocalize';
9+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
10+
import useThemeStyles from '@hooks/useThemeStyles';
11+
import {cleanFileName, getFileValidationErrorText} from '@libs/fileDownload/FileUtils';
12+
import CONST from '@src/CONST';
13+
import type ModalType from '@src/types/utils/ModalType';
14+
import viewRef from '@src/types/utils/viewRef';
15+
import AttachmentCarouselView from './Attachments/AttachmentCarousel/AttachmentCarouselView';
16+
import useCarouselArrows from './Attachments/AttachmentCarousel/useCarouselArrows';
17+
import useAttachmentErrors from './Attachments/AttachmentView/useAttachmentErrors';
18+
import type {Attachment} from './Attachments/types';
19+
import Button from './Button';
20+
import ConfirmModal from './ConfirmModal';
21+
import HeaderGap from './HeaderGap';
22+
import HeaderWithBackButton from './HeaderWithBackButton';
23+
import Modal from './Modal';
24+
import SafeAreaConsumer from './SafeAreaConsumer';
25+
26+
type ImagePickerResponse = {
27+
height?: number;
28+
name: string;
29+
size?: number | null;
30+
type: string;
31+
uri: string;
32+
width?: number;
33+
};
34+
35+
type FileObject = Partial<File | ImagePickerResponse>;
36+
37+
type ChildrenProps = {
38+
displayFilesInModal: (data: FileObject[]) => void;
39+
show: () => void;
40+
};
41+
42+
type AttachmentComposerModalProps = {
43+
/** Optional callback to fire when we want to preview an image and approve it for use. */
44+
onConfirm: ((file: FileObject | FileObject[]) => void) | null;
45+
46+
/** Title shown in the header of the modal */
47+
headerTitle: string;
48+
49+
/** Optional callback to fire when we want to do something after modal show. */
50+
onModalShow: () => void;
51+
52+
/** Optional callback to fire when we want to do something after modal hide. */
53+
onModalHide: () => void;
54+
55+
/** A function as a child to pass modal launching methods to */
56+
children: React.FC<ChildrenProps>;
57+
58+
/** Should disable send button */
59+
shouldDisableSendButton: boolean;
60+
};
61+
62+
function AttachmentComposerModal({onConfirm, onModalShow = () => {}, onModalHide = () => {}, headerTitle, children, shouldDisableSendButton = false}: AttachmentComposerModalProps) {
63+
const styles = useThemeStyles();
64+
const {translate} = useLocalize();
65+
const {shouldUseNarrowLayout} = useResponsiveLayout();
66+
const {setAttachmentError, clearAttachmentErrors} = useAttachmentErrors();
67+
const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows();
68+
69+
const [isModalOpen, setIsModalOpen] = useState(false);
70+
const [fileError, setFileError] = useState<ValueOf<typeof CONST.FILE_VALIDATION_ERRORS> | null>(null);
71+
const [isFileErrorModalVisible, setIsFileErrorModalVisible] = useState(false);
72+
const [modalType, setModalType] = useState<ModalType>(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE);
73+
const [validFilesToUpload, setValidFilesToUpload] = useState<FileObject[]>([]);
74+
const [attachments, setAttachments] = useState<Attachment[]>([]);
75+
const [page, setPage] = useState<number>(0);
76+
const [currentAttachment, setCurrentAttachment] = useState<Attachment | null>(null);
77+
78+
// TODO: remove unnecessary logic, ideally in a follow-up PR to avoid breaking changes/regressions
79+
/**
80+
* If our attachment is a PDF, return the unswipeable Modal type.
81+
*/
82+
const getModalType = useCallback(
83+
(sourceURL: string, fileObject: FileObject) => {
84+
const fileName = fileObject?.name ?? translate('attachmentView.unknownFilename');
85+
return sourceURL && (sourceURL.includes('.pdf') || fileName.includes('.pdf')) ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.CENTERED;
86+
},
87+
[translate],
88+
);
89+
90+
/**
91+
* Execute the onConfirm callback and close the modal.
92+
*/
93+
const submitAndClose = useCallback(() => {
94+
// If the modal has already been closed
95+
if (!isModalOpen) {
96+
return;
97+
}
98+
99+
if (onConfirm) {
100+
if (validFilesToUpload.length) {
101+
onConfirm(validFilesToUpload);
102+
} else if (currentAttachment) {
103+
onConfirm(currentAttachment.file ?? (currentAttachment as FileObject));
104+
}
105+
}
106+
107+
setIsModalOpen(false);
108+
}, [isModalOpen, onConfirm, validFilesToUpload, currentAttachment]);
109+
110+
const closeConfirmModal = useCallback(() => {
111+
setIsFileErrorModalVisible(false);
112+
}, []);
113+
114+
// TODO: Check if this function is still needed, as it doesn't work.
115+
const isDirectoryCheck = useCallback((data: FileObject) => {
116+
if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) {
117+
setFileError(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED);
118+
setIsFileErrorModalVisible(true);
119+
return false;
120+
}
121+
return true;
122+
}, []);
123+
124+
/**
125+
* Sanitizes file names and ensures proper URI references for file system compatibility
126+
*/
127+
const cleanFileObjectName = useCallback((fileObject: FileObject): FileObject => {
128+
if (fileObject instanceof File) {
129+
const cleanName = cleanFileName(fileObject.name);
130+
if (fileObject.name !== cleanName) {
131+
const updatedFile = new File([fileObject], cleanName, {type: fileObject.type});
132+
const inputSource = URL.createObjectURL(updatedFile);
133+
updatedFile.uri = inputSource;
134+
return updatedFile;
135+
}
136+
if (!fileObject.uri) {
137+
const inputSource = URL.createObjectURL(fileObject);
138+
// eslint-disable-next-line no-param-reassign
139+
fileObject.uri = inputSource;
140+
}
141+
}
142+
return fileObject;
143+
}, []);
144+
145+
const convertFileToAttachment = useCallback((fileObject: FileObject, source: string): Attachment => {
146+
return {
147+
source,
148+
file: fileObject,
149+
};
150+
}, []);
151+
152+
useEffect(() => {
153+
if (!validFilesToUpload.length) {
154+
return;
155+
}
156+
157+
if (validFilesToUpload.length > 0 && !fileError) {
158+
// Convert all files to attachments
159+
const newAttachments = validFilesToUpload.map((fileObject) => {
160+
const source = fileObject.uri ?? '';
161+
return convertFileToAttachment(fileObject, source);
162+
});
163+
164+
const firstAttachment = newAttachments.at(0) ?? null;
165+
setAttachments(newAttachments);
166+
setCurrentAttachment(firstAttachment);
167+
setPage(0);
168+
169+
if (firstAttachment?.file) {
170+
const inputModalType = getModalType(firstAttachment.source as string, firstAttachment.file);
171+
setModalType(inputModalType);
172+
}
173+
174+
setIsModalOpen(true);
175+
}
176+
}, [fileError, validFilesToUpload, convertFileToAttachment, getModalType]);
177+
178+
const {ErrorModal, validateFiles, PDFValidationComponent} = useFilesValidation(setValidFilesToUpload);
179+
180+
const confirmAndContinue = () => {
181+
if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) {
182+
validateFiles(validFilesToUpload);
183+
}
184+
setIsFileErrorModalVisible(false);
185+
InteractionManager.runAfterInteractions(() => {
186+
setFileError(null);
187+
});
188+
};
189+
190+
const validateAndDisplayMultipleFilesToUpload = useCallback(
191+
(data: FileObject[]) => {
192+
if (!data?.length) {
193+
return;
194+
}
195+
196+
const fileObjects = data
197+
.map((item) => {
198+
let fileObject = item;
199+
if ('getAsFile' in item && typeof item.getAsFile === 'function') {
200+
fileObject = item.getAsFile() as FileObject;
201+
}
202+
return cleanFileObjectName(fileObject);
203+
})
204+
.filter((fileObject): fileObject is FileObject => fileObject !== null);
205+
206+
if (!fileObjects.length || fileObjects.some((fileObject) => !isDirectoryCheck(fileObject))) {
207+
return;
208+
}
209+
validateFiles(fileObjects);
210+
},
211+
[cleanFileObjectName, isDirectoryCheck, validateFiles],
212+
);
213+
214+
const closeModal = useCallback(() => {
215+
setIsModalOpen(false);
216+
}, []);
217+
218+
const closeAndResetModal = useCallback(() => {
219+
closeConfirmModal();
220+
closeModal();
221+
InteractionManager.runAfterInteractions(() => {
222+
setFileError(null);
223+
setValidFilesToUpload([]);
224+
});
225+
}, [closeConfirmModal, closeModal]);
226+
227+
const openModal = useCallback(() => {
228+
setIsModalOpen(true);
229+
}, []);
230+
231+
const headerTitleNew = headerTitle ?? translate('reportActionCompose.sendAttachment');
232+
233+
const submitRef = useRef<View | HTMLElement>(null);
234+
235+
return (
236+
<>
237+
{PDFValidationComponent}
238+
<Modal
239+
type={modalType}
240+
onClose={closeModal}
241+
isVisible={isModalOpen}
242+
onModalShow={() => {
243+
onModalShow();
244+
}}
245+
onModalHide={() => {
246+
onModalHide();
247+
clearAttachmentErrors();
248+
setValidFilesToUpload([]);
249+
setAttachments([]);
250+
setCurrentAttachment(null);
251+
setPage(0);
252+
}}
253+
propagateSwipe
254+
initialFocus={() => {
255+
if (!submitRef.current) {
256+
return false;
257+
}
258+
return submitRef.current;
259+
}}
260+
shouldHandleNavigationBack
261+
>
262+
<GestureHandlerRootView style={styles.flex1}>
263+
{shouldUseNarrowLayout && <HeaderGap />}
264+
<HeaderWithBackButton
265+
shouldMinimizeMenuButton
266+
title={headerTitleNew}
267+
shouldShowBorderBottom
268+
shouldShowCloseButton={!shouldUseNarrowLayout}
269+
shouldShowBackButton={shouldUseNarrowLayout}
270+
onBackButtonPress={closeModal}
271+
onCloseButtonPress={closeModal}
272+
shouldSetModalVisibility={false}
273+
shouldDisplayHelpButton={false}
274+
/>
275+
{attachments.length > 0 && !!currentAttachment && (
276+
<AttachmentCarouselView
277+
attachments={attachments}
278+
source={currentAttachment.source}
279+
page={page}
280+
setPage={setPage}
281+
onClose={closeModal}
282+
autoHideArrows={autoHideArrows}
283+
cancelAutoHideArrow={cancelAutoHideArrows}
284+
setShouldShowArrows={setShouldShowArrows}
285+
onAttachmentError={setAttachmentError}
286+
shouldShowArrows={shouldShowArrows}
287+
/>
288+
)}
289+
<LayoutAnimationConfig skipEntering>
290+
{(validFilesToUpload.length > 0 || !!currentAttachment) && (
291+
<SafeAreaConsumer>
292+
{({safeAreaPaddingBottomStyle}) => (
293+
<Animated.View
294+
style={safeAreaPaddingBottomStyle}
295+
entering={FadeIn}
296+
>
297+
<Button
298+
ref={viewRef(submitRef)}
299+
success
300+
large
301+
style={[styles.buttonConfirm, shouldUseNarrowLayout ? {} : styles.attachmentButtonBigScreen]}
302+
textStyles={[styles.buttonConfirmText]}
303+
text={translate('common.send')}
304+
onPress={submitAndClose}
305+
isDisabled={shouldDisableSendButton}
306+
pressOnEnter
307+
/>
308+
</Animated.View>
309+
)}
310+
</SafeAreaConsumer>
311+
)}
312+
</LayoutAnimationConfig>
313+
</GestureHandlerRootView>
314+
</Modal>
315+
<ConfirmModal
316+
title={getFileValidationErrorText(fileError).title}
317+
onConfirm={confirmAndContinue}
318+
onCancel={closeAndResetModal}
319+
isVisible={isFileErrorModalVisible}
320+
prompt={getFileValidationErrorText(fileError).reason}
321+
confirmText={translate(validFilesToUpload.length ? 'common.continue' : 'common.close')}
322+
shouldShowCancelButton={!!validFilesToUpload.length}
323+
cancelText={translate('common.cancel')}
324+
/>
325+
{children?.({
326+
displayFilesInModal: validateAndDisplayMultipleFilesToUpload,
327+
show: openModal,
328+
})}
329+
{ErrorModal}
330+
</>
331+
);
332+
}
333+
334+
AttachmentComposerModal.displayName = 'AttachmentComposerModal';
335+
336+
export default memo(AttachmentComposerModal);
337+
338+
export type {FileObject, ImagePickerResponse};

src/components/AttachmentModal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ function AttachmentModal({
219219
const [currentAttachmentLink, setCurrentAttachmentLink] = useState(attachmentLink);
220220
const {setAttachmentError, isErrorInAttachment, clearAttachmentErrors} = useAttachmentErrors();
221221

222+
// TODO: Remove the logic for validating files, ideally in a follow-up to avoid breaking changes/regressions
223+
222224
const [file, setFile] = useState<FileObject | undefined>(
223225
originalFileName
224226
? {

0 commit comments

Comments
 (0)