mirror of
https://github.com/standardnotes/mobile.git
synced 2026-08-02 08:17:25 -04:00
feat: preview files (#580)
* fix: when sharing the file on Android, keep it in cache instead of Downloads folder * feat: preview files * feat: show an alert when the file is possibly non-previewable * fix: correct wording * fix: add period at the end of string * fix: remove title from the alert modal
This commit is contained in:
@@ -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<string | undefined> => {
|
||||
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: () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -48,7 +48,7 @@ export const Files: FC<Props> = ({ note }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<FileItemContainer>
|
||||
<FileItemContainer key={file.uuid}>
|
||||
<SideMenuCellStyled
|
||||
text={file.name}
|
||||
key={file.uuid}
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum UploadedFileItemActionType {
|
||||
DownloadFile,
|
||||
RenameFile,
|
||||
ToggleFileProtection,
|
||||
PreviewFile,
|
||||
}
|
||||
|
||||
export type UploadedFileItemAction =
|
||||
|
||||
Reference in New Issue
Block a user