diff --git a/src/components/Search/SearchActionOptionsUtils.desktop.tsx b/src/components/Search/SearchActionOptionsUtils.desktop.tsx deleted file mode 100644 index 1e59543721e0..000000000000 --- a/src/components/Search/SearchActionOptionsUtils.desktop.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import type {SearchHeaderOptionValue} from './SearchPageHeader'; - -function getDownloadOption(): DropdownOption | undefined { - return undefined; -} - -export default getDownloadOption; diff --git a/src/components/Search/SearchActionOptionsUtils.native.tsx b/src/components/Search/SearchActionOptionsUtils.native.tsx deleted file mode 100644 index 1e59543721e0..000000000000 --- a/src/components/Search/SearchActionOptionsUtils.native.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import type {SearchHeaderOptionValue} from './SearchPageHeader'; - -function getDownloadOption(): DropdownOption | undefined { - return undefined; -} - -export default getDownloadOption; diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx index 60f278998f0d..e4702734fcd0 100644 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx @@ -92,6 +92,7 @@ function ExpenseItemHeaderNarrow({ action={action} goToItem={onButtonPress} isLargeScreenWidth={false} + isSelected={isSelected} /> diff --git a/src/libs/fileDownload/DownloadUtils.ts b/src/libs/fileDownload/DownloadUtils.ts new file mode 100644 index 000000000000..a09b0aa38c75 --- /dev/null +++ b/src/libs/fileDownload/DownloadUtils.ts @@ -0,0 +1,73 @@ +import * as ApiUtils from '@libs/ApiUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; + +const createDownloadLink = (href: string, fileName: string) => { + // creating anchor tag to initiate download + const link = document.createElement('a'); + // adding href to anchor + link.href = href; + link.style.display = 'none'; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)` + link.download = fileName; + + // Append to html link element page + document.body.appendChild(link); + + // Start download + link.click(); + + // Clean up and remove the link + URL.revokeObjectURL(link.href); + link.parentNode?.removeChild(link); +}; + +/** + * The function downloads an attachment on web/desktop platforms. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const fetchFileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false, formData = undefined, requestType = 'get', onDownloadFailed?: () => void) => { + const resolvedUrl = tryResolveUrlFromApiRoot(url); + + const isApiUrl = resolvedUrl.startsWith(ApiUtils.getApiRoot()); + const isAttachmentUrl = CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix)); + const isSageUrl = url === CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT; + if ( + // We have two file download cases that we should allow: 1. downloading attachments 2. downloading Expensify package for Sage Intacct + shouldOpenExternalLink || + (!isApiUrl && !isAttachmentUrl && !isSageUrl) + ) { + // Different origin URLs might pose a CORS issue during direct downloads. + // Opening in a new tab avoids this limitation, letting the browser handle the download. + Link.openExternalLink(url); + return Promise.resolve(); + } + + const fetchOptions: RequestInit = { + method: requestType, + body: formData, + }; + + return fetch(url, fetchOptions) + .then((response) => response.blob()) + .then((blob) => { + // Create blob link to download + const href = URL.createObjectURL(new Blob([blob])); + const completeFileName = FileUtils.appendTimeToFileName(fileName ?? FileUtils.getFileName(url)); + createDownloadLink(href, completeFileName); + }) + .catch(() => { + if (onDownloadFailed) { + onDownloadFailed(); + } else { + // file could not be downloaded, open sourceURL in new tab + Link.openExternalLink(url); + } + }); +}; + +export default fetchFileDownload; diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index abd116565922..83255231d26b 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -1,6 +1,8 @@ import {PermissionsAndroid, Platform} from 'react-native'; import type {FetchBlobResponse} from 'react-native-blob-util'; import RNFetchBlob from 'react-native-blob-util'; +import RNFS from 'react-native-fs'; +import CONST from '@src/CONST'; import * as FileUtils from './FileUtils'; import type {FileDownload} from './types'; @@ -94,14 +96,59 @@ function handleDownload(url: string, fileName?: string, successMessage?: string) }); } +const postDownloadFile = (url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void): Promise => { + const fetchOptions: RequestInit = { + method: 'POST', + body: formData, + }; + + return fetch(url, fetchOptions) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to download file'); + } + return response.text(); + }) + .then((fileData) => { + const finalFileName = FileUtils.appendTimeToFileName(fileName ?? 'Expensify'); + const downloadPath = `${RNFS.DownloadDirectoryPath}/Expensify/${finalFileName}`; + + return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath); + }) + .then((downloadPath) => + RNFetchBlob.MediaCollection.copyToMediaStore( + { + name: FileUtils.getFileName(downloadPath), + parentFolder: 'Expensify', + mimeType: null, + }, + 'Download', + downloadPath, + ).then(() => downloadPath), + ) + .then((downloadPath) => { + RNFetchBlob.fs.unlink(downloadPath); + FileUtils.showSuccessAlert(); + }) + .catch(() => { + if (!onDownloadFailed) { + FileUtils.showGeneralErrorAlert(); + } + onDownloadFailed?.(); + }); +}; + /** * Checks permission and downloads the file for Android */ -const fileDownload: FileDownload = (url, fileName, successMessage) => +const fileDownload: FileDownload = (url, fileName, successMessage, _, formData, requestType, onDownloadFailed) => new Promise((resolve) => { hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { + if (requestType === CONST.NETWORK.METHOD.POST) { + return postDownloadFile(url, fileName, formData, onDownloadFailed); + } return handleDownload(url, fileName, successMessage); } FileUtils.showPermissionErrorAlert(); diff --git a/src/libs/fileDownload/index.desktop.ts b/src/libs/fileDownload/index.desktop.ts index 8e682225b79a..de000f61b41b 100644 --- a/src/libs/fileDownload/index.desktop.ts +++ b/src/libs/fileDownload/index.desktop.ts @@ -1,12 +1,18 @@ import ELECTRON_EVENTS from '@desktop/ELECTRON_EVENTS'; import type Options from '@desktop/electronDownloadManagerType'; import CONST from '@src/CONST'; +import fetchFileDownload from './DownloadUtils'; import type {FileDownload} from './types'; /** * The function downloads an attachment on desktop platforms. */ -const fileDownload: FileDownload = (url, fileName) => { +const fileDownload: FileDownload = (url, fileName, successMessage, shouldOpenExternalLink, formData, requestType) => { + if (requestType === CONST.NETWORK.METHOD.POST) { + window.electron.send(ELECTRON_EVENTS.DOWNLOAD); + return fetchFileDownload(url, fileName, successMessage, shouldOpenExternalLink, formData, requestType); + } + const options: Options = { filename: fileName, saveAs: true, diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index b1617bb440d0..1fff9fb998e6 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -1,6 +1,7 @@ import {CameraRoll} from '@react-native-camera-roll/camera-roll'; import type {PhotoIdentifier} from '@react-native-camera-roll/camera-roll'; import RNFetchBlob from 'react-native-blob-util'; +import RNFS from 'react-native-fs'; import CONST from '@src/CONST'; import * as FileUtils from './FileUtils'; import type {FileDownload} from './types'; @@ -26,6 +27,39 @@ function downloadFile(fileUrl: string, fileName: string) { }).fetch('GET', fileUrl); } +const postDownloadFile = (url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void) => { + const fetchOptions: RequestInit = { + method: 'POST', + body: formData, + }; + + return fetch(url, fetchOptions) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to download file'); + } + return response.text(); + }) + .then((fileData) => { + const finalFileName = FileUtils.appendTimeToFileName(fileName ?? 'Expensify'); + const expensifyDir = `${RNFS.DocumentDirectoryPath}/Expensify`; + + return RNFS.mkdir(expensifyDir).then(() => { + const localPath = `${expensifyDir}/${finalFileName}`; + return RNFS.writeFile(localPath, fileData, 'utf8').then(() => localPath); + }); + }) + .then(() => { + FileUtils.showSuccessAlert(); + }) + .catch(() => { + if (!onDownloadFailed) { + FileUtils.showGeneralErrorAlert(); + } + onDownloadFailed?.(); + }); +}; + /** * Download the image to photo lib in iOS */ @@ -67,7 +101,7 @@ function downloadVideo(fileUrl: string, fileName: string): Promise +const fileDownload: FileDownload = (fileUrl, fileName, successMessage, _, formData, requestType, onDownloadFailed) => new Promise((resolve) => { let fileDownloadPromise; const fileType = FileUtils.getFileType(fileUrl); @@ -82,6 +116,11 @@ const fileDownload: FileDownload = (fileUrl, fileName, successMessage) => fileDownloadPromise = downloadVideo(fileUrl, attachmentName); break; default: + if (requestType === CONST.NETWORK.METHOD.POST) { + fileDownloadPromise = postDownloadFile(fileUrl, fileName, formData, onDownloadFailed); + break; + } + fileDownloadPromise = downloadFile(fileUrl, attachmentName); break; } diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts index 133a18e146a5..fc1ea6f74d9b 100644 --- a/src/libs/fileDownload/index.ts +++ b/src/libs/fileDownload/index.ts @@ -1,67 +1,10 @@ -import * as ApiUtils from '@libs/ApiUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import * as Link from '@userActions/Link'; -import CONST from '@src/CONST'; -import * as FileUtils from './FileUtils'; +import fetchFileDownload from './DownloadUtils'; import type {FileDownload} from './types'; /** * The function downloads an attachment on web/desktop platforms. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const fileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false, formData = undefined, requestType = 'get', onDownloadFailed?: () => void) => { - const resolvedUrl = tryResolveUrlFromApiRoot(url); - if ( - // we have two file download cases that we should allow 1. dowloading attachments 2. downloading Expensify package for Sage Intacct - shouldOpenExternalLink || - (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && - !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix)) && - url !== CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT) - ) { - // Different origin URLs might pose a CORS issue during direct downloads. - // Opening in a new tab avoids this limitation, letting the browser handle the download. - Link.openExternalLink(url); - return Promise.resolve(); - } - - const fetchOptions: RequestInit = { - method: requestType, - body: formData, - }; - - return fetch(url, fetchOptions) - .then((response) => response.blob()) - .then((blob) => { - // Create blob link to download - const href = URL.createObjectURL(new Blob([blob])); - - // creating anchor tag to initiate download - const link = document.createElement('a'); - - // adding href to anchor - link.href = href; - link.style.display = 'none'; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)` - link.download = FileUtils.appendTimeToFileName(fileName || FileUtils.getFileName(url)); - - // Append to html link element page - document.body.appendChild(link); - - // Start download - link.click(); - - // Clean up and remove the link - URL.revokeObjectURL(link.href); - link.parentNode?.removeChild(link); - }) - .catch(() => { - if (onDownloadFailed) { - onDownloadFailed(); - } else { - // file could not be downloaded, open sourceURL in new tab - Link.openExternalLink(url); - } - }); -}; +const fileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false, formData = undefined, requestType = 'get', onDownloadFailed?: () => void) => + fetchFileDownload(url, fileName, successMessage, shouldOpenExternalLink, formData, requestType, onDownloadFailed); export default fileDownload;