import { ErrorMessage } from '@Lib/constants' import { ToastType } from '@Lib/Types' import { useNavigation } from '@react-navigation/native' import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext' import { SCREEN_INPUT_MODAL_FILE_NAME } from '@Root/Screens/screens' import { TAppStackNavigationProp } from '@Root/Screens/UploadedFilesList/UploadedFileItem' import { UploadedFileItemAction, UploadedFileItemActionType, } from '@Root/Screens/UploadedFilesList/UploadedFileItemAction' import { Tabs } from '@Screens/UploadedFilesList/UploadedFilesList' import { FileDownloadProgress } from '@standardnotes/files/dist/Domain/Types/FileDownloadProgress' import { ButtonType, ChallengeReason, ClientDisplayableError, ContentType, FileItem, SNNote } from '@standardnotes/snjs' import { CustomActionSheetOption, useCustomActionSheet } from '@Style/CustomActionSheet' import { useCallback, useEffect, useState } from 'react' import { Platform } from 'react-native' import DocumentPicker, { DocumentPickerResponse, isInProgress, pickMultiple } from 'react-native-document-picker' import FileViewer from 'react-native-file-viewer' import RNFS, { exists } from 'react-native-fs' import { Asset, launchCamera, launchImageLibrary, MediaType } from 'react-native-image-picker' import RNShare from 'react-native-share' import Toast from 'react-native-toast-message' type Props = { note: SNNote } type TDownloadFileAndReturnLocalPathParams = { file: FileItem saveInTempLocation?: boolean showSuccessToast?: boolean } type TUploadFileFromCameraOrImageGalleryParams = { uploadFromGallery?: boolean mediaType?: MediaType } export const isFileTypePreviewable = (fileType: string) => { const isImage = fileType.startsWith('image/') const isVideo = fileType.startsWith('video/') const isAudio = fileType.startsWith('audio/') const isPdf = fileType === 'application/pdf' const isText = fileType === 'text/plain' return isImage || isVideo || isAudio || isPdf || isText } export const useFiles = ({ note }: Props) => { const application = useSafeApplicationContext() const { showActionSheet } = useCustomActionSheet() const navigation = useNavigation() const [attachedFiles, setAttachedFiles] = useState([]) const [allFiles, setAllFiles] = useState([]) const [isDownloading, setIsDownloading] = useState(false) const { GeneralText } = ErrorMessage const { Success, Info, Error } = ToastType const filesService = application.getFilesService() const reloadAttachedFiles = useCallback(() => { setAttachedFiles(application.items.getFilesForNote(note).sort(filesService.sortByName)) }, [application.items, filesService.sortByName, note]) const reloadAllFiles = useCallback(() => { setAllFiles(application.items.getItems(ContentType.File).sort(filesService.sortByName) as FileItem[]) }, [application.items, filesService.sortByName]) const deleteFileAtPath = useCallback(async (path: string) => { try { if (await exists(path)) { await RNFS.unlink(path) } } catch (err) { console.error(err) } }, []) const showDownloadToastWithProgressBar = useCallback( (percentComplete: number | undefined) => { const percentCompleteFormatted = filesService.formatCompletedPercent(percentComplete) Toast.show({ type: Info, text1: `Downloading and decrypting file... (${percentCompleteFormatted}%)`, props: { percentComplete: percentCompleteFormatted, }, autoHide: false, }) }, [Info, filesService], ) const showUploadToastWithProgressBar = useCallback( (fileName: string, percentComplete: number | undefined) => { const percentCompleteFormatted = filesService.formatCompletedPercent(percentComplete) Toast.show({ type: Info, text1: `Uploading "${fileName}"... (${percentCompleteFormatted}%)`, autoHide: false, props: { percentComplete: percentCompleteFormatted, }, }) }, [Info, filesService], ) const resetProgressState = useCallback(() => { Toast.show({ type: Info, props: { percentComplete: 0, }, onShow: Toast.hide, }) }, [Info]) const updateProgressPercentOnDownload = useCallback( (progress: FileDownloadProgress | undefined) => { showDownloadToastWithProgressBar(progress?.percentComplete) }, [showDownloadToastWithProgressBar], ) const downloadFileAndReturnLocalPath = useCallback( async ({ file, saveInTempLocation = false, showSuccessToast = true, }: TDownloadFileAndReturnLocalPathParams): Promise => { if (isDownloading) { return } const isGrantedStoragePermissionOnAndroid = await filesService.hasStoragePermissionOnAndroid() if (!isGrantedStoragePermissionOnAndroid) { return } setIsDownloading(true) try { showDownloadToastWithProgressBar(0) const path = filesService.getDestinationPath({ fileName: file.name, saveInTempLocation, }) await deleteFileAtPath(path) const response = await filesService.downloadFileInChunks(file, path, updateProgressPercentOnDownload) resetProgressState() if (response instanceof ClientDisplayableError) { Toast.show({ type: Error, text1: 'Error', text2: response.text || GeneralText, }) return } if (showSuccessToast) { Toast.show({ type: Success, text1: 'Success', text2: 'Successfully downloaded. Press here to open the file.', position: 'bottom', onPress: async () => { await FileViewer.open(path, { showOpenWithDialog: true }) }, }) } else { Toast.hide() } return path } catch (error) { Toast.show({ type: Error, text1: 'Error', text2: 'An error occurred while downloading the file', onPress: Toast.hide, }) return } finally { setIsDownloading(false) } }, [ Error, GeneralText, Success, deleteFileAtPath, filesService, isDownloading, resetProgressState, showDownloadToastWithProgressBar, updateProgressPercentOnDownload, ], ) const cleanupTempFileOnAndroid = useCallback( async (downloadedFilePath: string) => { if (Platform.OS === 'android') { await deleteFileAtPath(downloadedFilePath) } }, [deleteFileAtPath], ) const shareFile = useCallback( async (file: FileItem) => { const downloadedFilePath = await downloadFileAndReturnLocalPath({ file, saveInTempLocation: true, showSuccessToast: false, }) if (!downloadedFilePath) { return } await application.getAppState().performActionWithoutStateChangeImpact(async () => { try { // On Android this response always returns {success: false}, there is an open issue for that: // https://github.com/react-native-share/react-native-share/issues/1059 const shareDialogResponse = await RNShare.open({ url: `file://${downloadedFilePath}`, failOnCancel: false, }) // On iOS the user can store files locally from "Share" screen, so we don't show "Download" option there. // For Android the user has a separate "Download" action for the file, therefore after the file is shared, // it's not needed anymore and we remove it from the storage. await cleanupTempFileOnAndroid(downloadedFilePath) if (shareDialogResponse.success) { Toast.show({ type: Success, text1: 'Successfully exported. Press here to open the file.', position: 'bottom', onPress: async () => { await FileViewer.open(downloadedFilePath) }, }) } } catch (error) { Toast.show({ type: Error, text1: 'An error occurred while trying to share this file', onPress: Toast.hide, }) } }) }, [Error, Success, application, cleanupTempFileOnAndroid, downloadFileAndReturnLocalPath], ) const attachFileToNote = useCallback( async (file: FileItem, showToastAfterAction = true) => { await application.items.associateFileWithNote(file, note) void application.sync.sync() if (showToastAfterAction) { Toast.show({ type: Success, text1: 'Successfully attached file to note', onPress: Toast.hide, }) } }, [Success, application, note], ) const detachFileFromNote = useCallback( async (file: FileItem) => { await application.items.disassociateFileWithNote(file, note) void application.sync.sync() Toast.show({ type: Success, text1: 'Successfully detached file from note', onPress: Toast.hide, }) }, [Success, application, note], ) const toggleFileProtection = useCallback( async (file: FileItem) => { try { let result: FileItem | undefined if (file.protected) { result = await application.mutator.unprotectFile(file) } else { result = await application.mutator.protectFile(file) } const isProtected = result ? result.protected : file.protected return isProtected } catch (error) { console.error('An error occurred: ', error) return file.protected } }, [application], ) const authorizeProtectedActionForFile = useCallback( async (file: FileItem, challengeReason: ChallengeReason) => { const authorizedFiles = await application.protections.authorizeProtectedActionForItems([file], challengeReason) return authorizedFiles.length > 0 && authorizedFiles.includes(file) }, [application], ) const renameFile = useCallback( async (file: FileItem, fileName: string) => { await application.items.renameFile(file, fileName) }, [application], ) const previewFile = useCallback( async (file: FileItem) => { let downloadedFilePath: string | undefined = '' try { const isPreviewable = isFileTypePreviewable(file.mimeType) if (!isPreviewable) { const tryToPreview = await application.alertService.confirm( 'This file may not be previewable. Do you wish to try anyway?', '', 'Try to preview', ButtonType.Info, 'Cancel', ) if (!tryToPreview) { return } } downloadedFilePath = await downloadFileAndReturnLocalPath({ file, saveInTempLocation: true, showSuccessToast: false, }) if (!downloadedFilePath) { return } await FileViewer.open(downloadedFilePath, { onDismiss: async () => { await cleanupTempFileOnAndroid(downloadedFilePath as string) }, }) return true } catch (error) { await cleanupTempFileOnAndroid(downloadedFilePath as string) await application.alertService.alert('An error occurred while previewing the file.') return false } }, [application, cleanupTempFileOnAndroid, downloadFileAndReturnLocalPath], ) const deleteFile = useCallback( async (file: FileItem) => { const shouldDelete = await application.alertService.confirm( `Are you sure you want to permanently delete "${file.name}"?`, undefined, 'Confirm', ButtonType.Danger, 'Cancel', ) if (shouldDelete) { Toast.show({ type: Info, text1: `Deleting "${file.name}"...`, }) const response = await application.files.deleteFile(file) if (response instanceof ClientDisplayableError) { Toast.show({ type: Error, text1: 'Error', text2: response.text || GeneralText, }) return } Toast.show({ type: Success, text1: `Successfully deleted "${file.name}"`, }) } }, [Error, GeneralText, Info, Success, application.alertService, application.files], ) const handlePickFilesError = async (error: unknown) => { if (DocumentPicker.isCancel(error)) { // User canceled the picker, exit any dialogs or menus and move on } else if (isInProgress(error)) { Toast.show({ type: Info, text2: 'Multiple pickers were opened; only the last one will be considered.', }) } else { Toast.show({ type: Error, text1: 'An error occurred while attempting to select files.', }) } } const handleUploadError = async () => { Toast.show({ type: Error, text1: 'Error', text2: 'An error occurred while uploading file(s).', }) } const pickFiles = async (): Promise => { try { const selectedFiles = await pickMultiple() return selectedFiles } catch (error) { await handlePickFilesError(error) } } const uploadSingleFile = async (file: DocumentPickerResponse | Asset, size: number): Promise => { try { const fileName = filesService.getFileName(file) const operation = await application.files.beginNewFileUpload(size) if (operation instanceof ClientDisplayableError) { Toast.show({ type: Error, text1: operation.text, }) return } const initialPercentComplete = operation.getProgress().percentComplete showUploadToastWithProgressBar(fileName, initialPercentComplete) const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => { await application.files.pushBytesForUpload(operation, chunk, index, isLast) showUploadToastWithProgressBar(fileName, operation.getProgress().percentComplete) } const fileResult = await filesService.readFile(file, onChunk) const fileObj = await application.files.finishUpload(operation, fileResult) resetProgressState() if (fileObj instanceof ClientDisplayableError) { Toast.show({ type: Error, text1: fileObj.text, }) return } return fileObj } catch (error) { await handleUploadError() } } const uploadFiles = async (): Promise => { try { const selectedFiles = await pickFiles() if (!selectedFiles || selectedFiles.length === 0) { return } const uploadedFiles: FileItem[] = [] for (const file of selectedFiles) { if (!file.uri || !file.size) { continue } const fileObject = await uploadSingleFile(file, file.size) if (!fileObject) { Toast.show({ type: Error, text1: 'Error', text2: `An error occurred while uploading ${file.name}.`, }) continue } uploadedFiles.push(fileObject) Toast.show({ text1: `Successfully uploaded ${fileObject.name}` }) } if (selectedFiles.length > 1) { Toast.show({ text1: 'Successfully uploaded' }) } return uploadedFiles } catch (error) { await handleUploadError() } } const handleAttachFromCamera = (currentTab: Tabs | undefined) => { const options = [ { text: 'Photo', callback: async () => { const uploadedFile = await uploadFileFromCameraOrImageGallery({ mediaType: 'photo', }) if (!uploadedFile) { return } if (shouldAttachToNote(currentTab)) { await attachFileToNote(uploadedFile, false) } }, }, { text: 'Video', callback: async () => { const uploadedFile = await uploadFileFromCameraOrImageGallery({ mediaType: 'video', }) if (!uploadedFile) { return } await attachFileToNote(uploadedFile, false) }, }, ] showActionSheet({ title: 'Choose file type', options, }) } const shouldAttachToNote = (currentTab: Tabs | undefined) => { return currentTab === undefined || currentTab === Tabs.AttachedFiles } const handlePressAttachFile = (currentTab?: Tabs) => { const options: CustomActionSheetOption[] = [ { text: 'Attach from files', key: 'files', callback: async () => { const uploadedFiles = await uploadFiles() if (!uploadedFiles) { return } if (shouldAttachToNote(currentTab)) { uploadedFiles.forEach((file) => attachFileToNote(file, false)) } }, }, { text: 'Attach from Photo Library', key: 'library', callback: async () => { const uploadedFile = await uploadFileFromCameraOrImageGallery({ uploadFromGallery: true, }) if (!uploadedFile) { return } if (shouldAttachToNote(currentTab)) { await attachFileToNote(uploadedFile, false) } }, }, { text: 'Attach from Camera', key: 'camera', callback: async () => { handleAttachFromCamera(currentTab) }, }, ] const osSpecificOptions = Platform.OS === 'android' ? options.filter((option) => option.key !== 'library') : options showActionSheet({ title: 'Choose action', options: osSpecificOptions, }) } const uploadFileFromCameraOrImageGallery = async ({ uploadFromGallery = false, mediaType = 'photo', }: TUploadFileFromCameraOrImageGalleryParams): Promise => { try { const result = uploadFromGallery ? await launchImageLibrary({ mediaType: 'mixed' }) : await launchCamera({ mediaType }) if (result.didCancel || !result.assets) { return } const file = result.assets[0] const fileObject = await uploadSingleFile(file, file.fileSize || 0) if (!file.uri || !file.fileSize) { return } if (!fileObject) { Toast.show({ type: Error, text1: 'Error', text2: `An error occurred while uploading ${file.fileName}.`, }) return } Toast.show({ text1: `Successfully uploaded ${fileObject.name}` }) return fileObject } catch (error) { await handleUploadError() } } const handleFileAction = useCallback( async (action: UploadedFileItemAction) => { const file = action.payload let isAuthorizedForAction = true if (file.protected && action.type !== UploadedFileItemActionType.ToggleFileProtection) { isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile) } if (!isAuthorizedForAction) { return false } switch (action.type) { case UploadedFileItemActionType.AttachFileToNote: await attachFileToNote(file) break case UploadedFileItemActionType.DetachFileToNote: await detachFileFromNote(file) break case UploadedFileItemActionType.ShareFile: await shareFile(file) break case UploadedFileItemActionType.DownloadFile: await downloadFileAndReturnLocalPath({ file }) break case UploadedFileItemActionType.ToggleFileProtection: { await toggleFileProtection(file) break } case UploadedFileItemActionType.RenameFile: navigation.navigate(SCREEN_INPUT_MODAL_FILE_NAME, { file, renameFile, }) break case UploadedFileItemActionType.PreviewFile: await previewFile(file) break case UploadedFileItemActionType.DeleteFile: await deleteFile(file) break default: break } await application.sync.sync() return true }, [ application.sync, attachFileToNote, authorizeProtectedActionForFile, deleteFile, detachFileFromNote, downloadFileAndReturnLocalPath, navigation, previewFile, renameFile, shareFile, toggleFileProtection, ], ) useEffect(() => { const unregisterFileStream = application.streamItems(ContentType.File, () => { reloadAttachedFiles() reloadAllFiles() }) return () => { unregisterFileStream() } }, [application, reloadAllFiles, reloadAttachedFiles]) const showActionsMenu = useCallback( (file: FileItem | undefined) => { if (!file) { return } const isAttachedToNote = attachedFiles.includes(file) const actions: CustomActionSheetOption[] = [ { text: 'Preview', callback: async () => { await handleFileAction({ type: UploadedFileItemActionType.PreviewFile, payload: file, }) }, }, { text: isAttachedToNote ? 'Detach from note' : 'Attach to note', callback: isAttachedToNote ? async () => { await handleFileAction({ type: UploadedFileItemActionType.DetachFileToNote, payload: file, }) } : async () => { await handleFileAction({ type: UploadedFileItemActionType.AttachFileToNote, payload: file, }) }, }, { text: `${file.protected ? 'Disable' : 'Enable'} password protection`, callback: async () => { await handleFileAction({ type: UploadedFileItemActionType.ToggleFileProtection, payload: file, }) }, }, { text: Platform.OS === 'ios' ? 'Export' : 'Share', callback: async () => { await handleFileAction({ type: UploadedFileItemActionType.ShareFile, payload: file, }) }, }, { text: 'Download', callback: async () => { await handleFileAction({ type: UploadedFileItemActionType.DownloadFile, payload: file, }) }, }, { text: 'Rename', callback: async () => { await handleFileAction({ type: UploadedFileItemActionType.RenameFile, payload: file, }) }, }, { text: 'Delete permanently', callback: async () => { await handleFileAction({ type: UploadedFileItemActionType.DeleteFile, payload: file, }) }, destructive: true, }, ] const osDependentActions = Platform.OS === 'ios' ? actions.filter((action) => action.text !== 'Download') : [...actions] showActionSheet({ title: file.name, options: osDependentActions, styles: { titleTextStyle: { fontWeight: 'bold', }, }, }) }, [attachedFiles, handleFileAction, showActionSheet], ) return { showActionsMenu, attachedFiles, allFiles, handleFileAction, handlePressAttachFile, uploadFileFromCameraOrImageGallery, attachFileToNote, } }