diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index bf199f23..e0a4c7b4 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -8,6 +8,7 @@ import { UploadedFileItemActionType, } from '@Screens/UploadedFilesList/UploadedFileItemAction'; import { + ButtonType, ChallengeReason, ContentType, SNFile, @@ -16,6 +17,7 @@ import { import { useCustomActionSheet } from '@Style/custom_action_sheet'; import { useCallback, useContext, useEffect, useState } from 'react'; import { Platform } from 'react-native'; +import FileViewer from 'react-native-file-viewer'; import RNFS, { exists } from 'react-native-fs'; import RNShare from 'react-native-share'; import Toast from 'react-native-toast-message'; @@ -23,6 +25,20 @@ import Toast from 'react-native-toast-message'; type Props = { note: SNNote; }; +type TDownloadFileAndReturnLocalPathParams = { + file: SNFile; + saveInTempLocation?: boolean; +}; + +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 = useContext(ApplicationContext); @@ -66,49 +82,11 @@ export const useFiles = ({ note }: Props) => { } catch (err) {} }, []); - const shareFile = useCallback( - (file: SNFile, path: string) => { - if (!application) { - return; - } - 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://${path}`, - 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. - if (Platform.OS === 'android') { - await deleteFileAtPath(path); - } - if (shareDialogResponse.success) { - Toast.show({ - type: Success, - text1: 'Successfully exported file', - onPress: Toast.hide, - }); - } - } catch (error) { - Toast.show({ - type: Error, - text1: 'An error occurred while trying to share this file', - onPress: Toast.hide, - }); - } - }); - }, - [Error, Success, application, deleteFileAtPath] - ); - - const downloadFile = useCallback( - async (file: SNFile, showShareScreen = false) => { + const downloadFileAndReturnLocalPath = useCallback( + async ({ + file, + saveInTempLocation = false, + }: TDownloadFileAndReturnLocalPathParams): Promise => { if (isDownloading || !application) { return; } @@ -129,28 +107,22 @@ export const useFiles = ({ note }: Props) => { onPress: Toast.hide, }); - const path = filesService.getDestinationPath( - file.name, - showShareScreen - ); + const path = filesService.getDestinationPath({ + fileName: file.name, + saveInTempLocation, + }); await deleteFileAtPath(path); - await filesService.downloadFileInChunks(file, path); - Toast.hide(); - - if (showShareScreen) { - await shareFile(file, path); - return; - } - Toast.show({ type: Success, text1: 'Success', text2: 'Successfully downloaded file', onPress: Toast.hide, }); + + return path; } catch (error) { Toast.show({ type: Error, @@ -162,14 +134,68 @@ export const useFiles = ({ note }: Props) => { setIsDownloading(false); } }, + [Error, Info, Success, application, deleteFileAtPath, isDownloading] + ); + + const cleanupTempFileOnAndroid = useCallback( + async (downloadedFilePath: string) => { + if (Platform.OS === 'android') { + await deleteFileAtPath(downloadedFilePath); + } + }, + [deleteFileAtPath] + ); + + const shareFile = useCallback( + async (file: SNFile) => { + if (!application) { + return; + } + const downloadedFilePath = await downloadFileAndReturnLocalPath({ + file, + saveInTempLocation: true, + }); + if (!downloadedFilePath) { + return; + } + 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 file', + onPress: Toast.hide, + }); + } + } catch (error) { + Toast.show({ + type: Error, + text1: 'An error occurred while trying to share this file', + onPress: Toast.hide, + }); + } + }); + }, [ Error, - Info, Success, application, - deleteFileAtPath, - isDownloading, - shareFile, + cleanupTempFileOnAndroid, + downloadFileAndReturnLocalPath, ] ); @@ -250,6 +276,55 @@ export const useFiles = ({ note }: Props) => { [application] ); + const previewFile = useCallback( + async (file: SNFile) => { + if (!application) { + return; + } + + 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, + }); + + 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 handleFileAction = useCallback( async (action: UploadedFileItemAction) => { if (!application) { @@ -284,10 +359,10 @@ export const useFiles = ({ note }: Props) => { await detachFileFromNote(file); break; case UploadedFileItemActionType.ShareFile: - await downloadFile(file, true); + await shareFile(file); break; case UploadedFileItemActionType.DownloadFile: - await downloadFile(file); + await downloadFileAndReturnLocalPath({ file }); break; case UploadedFileItemActionType.ToggleFileProtection: { await toggleFileProtection(file); @@ -296,6 +371,11 @@ export const useFiles = ({ note }: Props) => { case UploadedFileItemActionType.RenameFile: await renameFile(file, action.payload.name); break; + case UploadedFileItemActionType.PreviewFile: + await previewFile(file); + break; + default: + break; } application.sync.sync(); @@ -306,8 +386,10 @@ export const useFiles = ({ note }: Props) => { attachFileToNote, authorizeProtectedActionForFile, detachFileFromNote, - downloadFile, + downloadFileAndReturnLocalPath, + previewFile, renameFile, + shareFile, toggleFileProtection, ] ); @@ -360,6 +442,15 @@ export const useFiles = ({ note }: Props) => { }); }, }, + { + text: 'Preview', + callback: () => { + handleFileAction({ + type: UploadedFileItemActionType.PreviewFile, + payload: file, + }); + }, + }, { text: Platform.OS === 'ios' ? 'Export' : 'Share', callback: () => { diff --git a/src/lib/files_service.ts b/src/lib/files_service.ts index 4b4c7dd8..c10a781a 100644 --- a/src/lib/files_service.ts +++ b/src/lib/files_service.ts @@ -2,23 +2,29 @@ import { ApplicationService, SNFile } from '@standardnotes/snjs'; import { Buffer } from 'buffer'; import { PermissionsAndroid, Platform } from 'react-native'; import RNFS, { + CachesDirectoryPath, DocumentDirectoryPath, DownloadDirectoryPath, } from 'react-native-fs'; +type TGetFileDestinationPath = { + fileName: string; + saveInTempLocation?: boolean; +}; + export class FilesService extends ApplicationService { - getDestinationPath(fileName: string, showShareScreen: boolean): string { + getDestinationPath({ + fileName, + saveInTempLocation = false, + }: TGetFileDestinationPath): string { let directory = DocumentDirectoryPath; - let tmpInFileName = ''; if (Platform.OS === 'android') { - directory = DownloadDirectoryPath; - - if (showShareScreen) { - tmpInFileName = 'tmp-'; - } + directory = saveInTempLocation + ? CachesDirectoryPath + : DownloadDirectoryPath; } - return `${directory}/${tmpInFileName}${fileName}`; + return `${directory}/${fileName}`; } async hasStoragePermissionOnAndroid(): Promise { diff --git a/src/screens/Compose/Compose.tsx b/src/screens/Compose/Compose.tsx index d5bb7a8b..9faaa34a 100644 --- a/src/screens/Compose/Compose.tsx +++ b/src/screens/Compose/Compose.tsx @@ -494,7 +494,7 @@ export class Compose extends React.Component<{}, State> { text = 'The local component server has an error.'; break; case ComponentLoadingError.Unknown: - text = 'An unknown error occured.'; + text = 'An unknown error occurred.'; break; default: break; diff --git a/src/screens/ManageSessions/ManageSessions.tsx b/src/screens/ManageSessions/ManageSessions.tsx index 24fdb9ad..14675f6a 100644 --- a/src/screens/ManageSessions/ManageSessions.tsx +++ b/src/screens/ManageSessions/ManageSessions.tsx @@ -33,7 +33,7 @@ const useSessions = (): [ const response = await application?.getSessions(); if (!response) { - setErrorMessage('An unknown error occured while loading sessions.'); + setErrorMessage('An unknown error occurred while loading sessions.'); return; } @@ -41,7 +41,7 @@ const useSessions = (): [ if (response.error?.message) { setErrorMessage(response.error.message); } else { - setErrorMessage('An unknown error occured while loading sessions.'); + setErrorMessage('An unknown error occurred while loading sessions.'); } } else { const newSessions = response.data as RemoteSession[]; @@ -66,7 +66,9 @@ const useSessions = (): [ if (response.error?.message) { setErrorMessage(response.error?.message); } else { - setErrorMessage('An unknown error occured while revoking the session.'); + setErrorMessage( + 'An unknown error occurred while revoking the session.' + ); } } else { setSessions(sessions.filter(session => session.uuid !== uuid)); diff --git a/src/screens/SideMenu/Files.tsx b/src/screens/SideMenu/Files.tsx index 3996ad19..b201f1dd 100644 --- a/src/screens/SideMenu/Files.tsx +++ b/src/screens/SideMenu/Files.tsx @@ -48,7 +48,7 @@ export const Files: FC = ({ note }) => { ); return ( - +