diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 2682b535..c41eeca1 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -281,6 +281,8 @@ PODS:
- React-Core
- react-native-fingerprint-scanner (5.0.0):
- React-Core
+ - react-native-image-picker (4.7.3):
+ - React-Core
- react-native-mail (4.1.0):
- React-Core
- react-native-pager-view (5.4.15):
@@ -488,6 +490,7 @@ DEPENDENCIES:
- "react-native-aes (from `../node_modules/@standardnotes/react-native-aes`)"
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
- react-native-fingerprint-scanner (from `../node_modules/react-native-fingerprint-scanner`)
+ - react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-mail (from `../node_modules/react-native-mail`)
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
@@ -591,6 +594,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-document-picker"
react-native-fingerprint-scanner:
:path: "../node_modules/react-native-fingerprint-scanner"
+ react-native-image-picker:
+ :path: "../node_modules/react-native-image-picker"
react-native-mail:
:path: "../node_modules/react-native-mail"
react-native-pager-view:
@@ -707,6 +712,7 @@ SPEC CHECKSUMS:
react-native-aes: 420f829e2c4ff32e3deb2aa056094a6a29fcf992
react-native-document-picker: 5663fe4bcdb646200683a41790464d2793307ac8
react-native-fingerprint-scanner: be63e626b31fb951780a5fac5328b065a61a3d6e
+ react-native-image-picker: 4e6008ad8c2321622affa2c85432a5ebd02d480c
react-native-mail: 5fe7239a5b5c1e858d425501c03d1ab977434122
react-native-pager-view: b1914469643f40042e65d78cbf3d3dfebd6fb0d9
react-native-safe-area-context: da2d11bd7df9bf7779e9bdc85081c141cfa544f4
diff --git a/ios/StandardNotes/Info.plist b/ios/StandardNotes/Info.plist
index ef6bc88c..b4c3eab4 100644
--- a/ios/StandardNotes/Info.plist
+++ b/ios/StandardNotes/Info.plist
@@ -98,6 +98,8 @@
Not used by application; required in configuration because API exists in build dependencies.
NSPhotoLibraryUsageDescription
Photo library is optionally used to select files to upload or QR code images from your photo library.
+ NSMicrophoneUsageDescription
+ Microphone is optionally used to capture videos.
UIAppFonts
AntDesign.ttf
diff --git a/package.json b/package.json
index 04a11e06..6aec3df9 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "standardnotes-mobile",
- "version": "3.15.0",
- "user-version": "3.15.0",
+ "version": "3.16.0",
+ "user-version": "3.16.0",
"private": true,
"license": "AGPL-3.0-or-later",
"scripts": {
@@ -32,7 +32,7 @@
"@react-navigation/native": "^6.0.10",
"@react-navigation/stack": "^6.2.1",
"@standardnotes/components": "^1.7.14",
- "@standardnotes/filepicker": "^1.10.5",
+ "@standardnotes/filepicker": "^1.10.6",
"@standardnotes/react-native-aes": "^1.4.3",
"@standardnotes/react-native-textview": "1.0.2",
"@standardnotes/react-native-utils": "1.0.1",
@@ -53,6 +53,7 @@
"react-native-flag-secure-android": "standardnotes/react-native-flag-secure-android#cb08e74",
"react-native-fs": "^2.19.0",
"react-native-gesture-handler": "2.3.2",
+ "react-native-image-picker": "^4.7.3",
"react-native-keychain": "^8.0.0",
"react-native-mail": "standardnotes/react-native-mail#fd26119e67a2ffc5eaa95a9c17049743e39ce2d3",
"react-native-privacy-snapshot": "standardnotes/react-native-privacy-snapshot#653e904",
diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts
index 03bf03fa..bc4f53ce 100644
--- a/src/hooks/useFiles.ts
+++ b/src/hooks/useFiles.ts
@@ -10,15 +10,30 @@ import {
import {
ButtonType,
ChallengeReason,
+ ClientDisplayableError,
ContentType,
SNFile,
SNNote,
} from '@standardnotes/snjs';
-import { useCustomActionSheet } from '@Style/custom_action_sheet';
+import {
+ CustomActionSheetOption,
+ useCustomActionSheet,
+} from '@Style/custom_action_sheet';
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';
@@ -30,6 +45,11 @@ type TDownloadFileAndReturnLocalPathParams = {
saveInTempLocation?: boolean;
};
+type TUploadFileFromCameraOrImageGalleryParams = {
+ uploadFromGallery?: boolean;
+ mediaType?: MediaType;
+};
+
export const isFileTypePreviewable = (fileType: string) => {
const isImage = fileType.startsWith('image/');
const isVideo = fileType.startsWith('video/');
@@ -52,6 +72,8 @@ export const useFiles = ({ note }: Props) => {
const { Success, Info, Error } = ToastType;
+ const filesService = application.getFilesService();
+
const reloadAttachedFiles = useCallback(() => {
setAttachedFiles(
application.items
@@ -84,7 +106,6 @@ export const useFiles = ({ note }: Props) => {
if (isDownloading) {
return;
}
- const filesService = application.getFilesService();
const isGrantedStoragePermissionOnAndroid =
await filesService.hasStoragePermissionOnAndroid();
@@ -128,7 +149,7 @@ export const useFiles = ({ note }: Props) => {
setIsDownloading(false);
}
},
- [Error, Info, Success, application, deleteFileAtPath, isDownloading]
+ [Error, Info, Success, deleteFileAtPath, filesService, isDownloading]
);
const cleanupTempFileOnAndroid = useCallback(
@@ -191,13 +212,16 @@ export const useFiles = ({ note }: Props) => {
);
const attachFileToNote = useCallback(
- async (file: SNFile) => {
+ async (file: SNFile, showToastAfterAction = true) => {
await application.items.associateFileWithNote(file, note);
- Toast.show({
- type: Success,
- text1: 'Successfully attached file to note',
- onPress: Toast.hide,
- });
+
+ if (showToastAfterAction) {
+ Toast.show({
+ type: Success,
+ text1: 'Successfully attached file to note',
+ onPress: Toast.hide,
+ });
+ }
},
[Success, application, note]
);
@@ -297,6 +321,194 @@ export const useFiles = ({ note }: Props) => {
},
[application, cleanupTempFileOnAndroid, downloadFileAndReturnLocalPath]
);
+
+ const deleteFile = useCallback(
+ async (file: SNFile) => {
+ 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,
+ text2: `Deleting "${file.name}"...`,
+ });
+ const response = await application.files.deleteFile(file);
+
+ if (response instanceof ClientDisplayableError) {
+ Toast.show({
+ type: Error,
+ text1: response.text,
+ });
+ return;
+ }
+
+ Toast.show({
+ type: Success,
+ text2: `Successfully deleted "${file.name}"`,
+ });
+ }
+ },
+ [Error, 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 () => {
+ try {
+ const selectedFiles = await pickMultiple();
+
+ return selectedFiles;
+ } catch (error) {
+ handlePickFilesError(error);
+ }
+ };
+
+ const uploadSingleFile = async (file: DocumentPickerResponse | Asset) => {
+ try {
+ const fileName = filesService.getFileName(file);
+ Toast.show({
+ type: Info,
+ text1: `Uploading "${fileName}"...`,
+ autoHide: false,
+ });
+
+ const operation = await application.files.beginNewFileUpload();
+
+ if (operation instanceof ClientDisplayableError) {
+ Toast.show({
+ type: Error,
+ text1: operation.text,
+ });
+ return;
+ }
+
+ const onChunk = async (
+ chunk: Uint8Array,
+ index: number,
+ isLast: boolean
+ ) => {
+ await application.files.pushBytesForUpload(
+ operation,
+ chunk,
+ index,
+ isLast
+ );
+ };
+
+ const fileResult = await filesService.readFile(file, onChunk);
+ const fileObj = await application.files.finishUpload(
+ operation,
+ fileResult
+ );
+ if (fileObj instanceof ClientDisplayableError) {
+ Toast.show({
+ type: Error,
+ text1: fileObj.text,
+ });
+ return;
+ }
+ return fileObj;
+ } catch (error) {
+ handleUploadError();
+ }
+ };
+
+ const uploadFiles = async (): Promise => {
+ try {
+ const selectedFiles = await pickFiles();
+ if (!selectedFiles || selectedFiles.length === 0) {
+ return;
+ }
+ const uploadedFiles: SNFile[] = [];
+ for (const file of selectedFiles) {
+ if (!file.uri || !file.size) {
+ continue;
+ }
+ const fileObject = await uploadSingleFile(file);
+ 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) {
+ handleUploadError();
+ }
+ };
+
+ const uploadFileFromCameraOrImageGallery = async ({
+ uploadFromGallery = false,
+ mediaType = 'photo',
+ }: TUploadFileFromCameraOrImageGalleryParams): Promise<
+ SNFile | undefined
+ > => {
+ 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);
+ 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) {
+ handleUploadError();
+ }
+ };
+
const handleFileAction = useCallback(
async (action: UploadedFileItemAction) => {
const file =
@@ -342,6 +554,9 @@ export const useFiles = ({ note }: Props) => {
case UploadedFileItemActionType.PreviewFile:
await previewFile(file);
break;
+ case UploadedFileItemActionType.DeleteFile:
+ await deleteFile(file);
+ break;
default:
break;
}
@@ -350,9 +565,10 @@ export const useFiles = ({ note }: Props) => {
return true;
},
[
- application,
+ application.sync,
attachFileToNote,
authorizeProtectedActionForFile,
+ deleteFile,
detachFileFromNote,
downloadFileAndReturnLocalPath,
previewFile,
@@ -383,7 +599,7 @@ export const useFiles = ({ note }: Props) => {
}
const isAttachedToNote = attachedFiles.includes(file);
- const actions = [
+ const actions: CustomActionSheetOption[] = [
{
text: isAttachedToNote ? 'Detach from note' : 'Attach to note',
callback: isAttachedToNote
@@ -442,6 +658,16 @@ export const useFiles = ({ note }: Props) => {
handleFileAction,
}),
},
+ {
+ text: 'Delete permanently',
+ callback: () => {
+ handleFileAction({
+ type: UploadedFileItemActionType.DeleteFile,
+ payload: file,
+ });
+ },
+ destructive: true,
+ },
];
const osDependentActions =
Platform.OS === 'ios'
@@ -456,5 +682,8 @@ export const useFiles = ({ note }: Props) => {
showActionsMenu,
attachedFiles,
allFiles,
+ uploadFiles,
+ uploadFileFromCameraOrImageGallery,
+ attachFileToNote,
};
};
diff --git a/src/lib/files_service.ts b/src/lib/files_service.ts
index c10a781a..be03fc81 100644
--- a/src/lib/files_service.ts
+++ b/src/lib/files_service.ts
@@ -1,11 +1,20 @@
+import {
+ ByteChunker,
+ FileSelectionResponse,
+ OnChunkCallback,
+} from '@standardnotes/filepicker';
import { ApplicationService, SNFile } from '@standardnotes/snjs';
import { Buffer } from 'buffer';
+import { Base64 } from 'js-base64';
import { PermissionsAndroid, Platform } from 'react-native';
+import { DocumentPickerResponse } from 'react-native-document-picker';
import RNFS, {
CachesDirectoryPath,
DocumentDirectoryPath,
DownloadDirectoryPath,
+ read,
} from 'react-native-fs';
+import { Asset } from 'react-native-image-picker';
type TGetFileDestinationPath = {
fileName: string;
@@ -13,6 +22,8 @@ type TGetFileDestinationPath = {
};
export class FilesService extends ApplicationService {
+ private fileChunkSizeForReading = 2_000_000;
+
getDestinationPath({
fileName,
saveInTempLocation = false,
@@ -53,4 +64,51 @@ export class FilesService extends ApplicationService {
}
);
}
+
+ getFileName(file: DocumentPickerResponse | Asset) {
+ if ('name' in file) {
+ return file.name;
+ }
+ return file.fileName as string;
+ }
+
+ async readFile(
+ file: DocumentPickerResponse | Asset,
+ onChunk: OnChunkCallback
+ ): Promise {
+ const fileUri = (
+ Platform.OS === 'ios' ? decodeURI(file.uri!) : file.uri
+ ) as string;
+
+ let positionShift = 0;
+ let filePortion = '';
+
+ const chunker = new ByteChunker(
+ this.application.files.minimumChunkSize(),
+ onChunk
+ );
+ let isFinalChunk = false;
+
+ do {
+ filePortion = await read(
+ fileUri,
+ this.fileChunkSizeForReading,
+ positionShift,
+ 'base64'
+ );
+ const bytes = Base64.toUint8Array(filePortion);
+ isFinalChunk = bytes.length < this.fileChunkSizeForReading;
+
+ await chunker.addBytes(bytes, isFinalChunk);
+
+ positionShift += this.fileChunkSizeForReading;
+ } while (!isFinalChunk);
+
+ const fileName = this.getFileName(file);
+
+ return {
+ name: fileName,
+ mimeType: file.type || '',
+ };
+ }
}
diff --git a/src/screens/Root.tsx b/src/screens/Root.tsx
index eadb3f19..1228e8b7 100644
--- a/src/screens/Root.tsx
+++ b/src/screens/Root.tsx
@@ -5,7 +5,7 @@ import {
} from '@Lib/application_state';
import { useHasEditor, useIsLocked } from '@Lib/snjs_helper_hooks';
import { ApplicationContext } from '@Root/ApplicationContext';
-import { UuidString } from '@standardnotes/snjs/dist/@types';
+import { UuidString } from '@standardnotes/snjs';
import { ThemeService } from '@Style/theme_service';
import { hexToRGBA } from '@Style/utils';
import React, { useContext, useEffect, useMemo, useState } from 'react';
diff --git a/src/screens/UploadedFilesList/UploadedFilesList.styled.ts b/src/screens/UploadedFilesList/UploadedFilesList.styled.ts
index 63b076f4..d11b6582 100644
--- a/src/screens/UploadedFilesList/UploadedFilesList.styled.ts
+++ b/src/screens/UploadedFilesList/UploadedFilesList.styled.ts
@@ -30,17 +30,6 @@ export const useUploadedFilesListStyles = () => {
marginTop: 24,
marginBottom: 24,
},
- button: {
- borderRadius: 20,
- padding: 10,
- elevation: 2,
- },
- buttonOpen: {
- backgroundColor: '#F194FF',
- },
- buttonClose: {
- backgroundColor: '#2196F3',
- },
});
};
diff --git a/src/screens/UploadedFilesList/UploadedFilesList.tsx b/src/screens/UploadedFilesList/UploadedFilesList.tsx
index 4bb0c234..0084d1af 100644
--- a/src/screens/UploadedFilesList/UploadedFilesList.tsx
+++ b/src/screens/UploadedFilesList/UploadedFilesList.tsx
@@ -12,17 +12,27 @@ import {
useUploadedFilesListStyles,
} from '@Screens/UploadedFilesList/UploadedFilesList.styled';
import { SNFile } from '@standardnotes/snjs';
+import {
+ CustomActionSheetOption,
+ useCustomActionSheet,
+} from '@Style/custom_action_sheet';
+import { ICON_ATTACH } from '@Style/icons';
+import { ThemeService } from '@Style/theme_service';
import React, {
FC,
useCallback,
+ useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
-import { FlatList, ListRenderItem, Text, View } from 'react-native';
+import { FlatList, ListRenderItem, Platform, Text, View } from 'react-native';
+import FAB from 'react-native-fab';
import IosSearchBar from 'react-native-search-bar';
import AndroidSearchBar from 'react-native-search-box';
+import Icon from 'react-native-vector-icons/Ionicons';
+import { ThemeContext } from 'styled-components';
enum Tabs {
AttachedFiles,
@@ -32,8 +42,11 @@ enum Tabs {
type Props = ModalStackNavigationProp;
export const UploadedFilesList: FC = props => {
+ const theme = useContext(ThemeContext);
+
const styles = useUploadedFilesListStyles();
const navigation = useNavigation();
+ const { showActionSheet } = useCustomActionSheet();
const [currentTab, setCurrentTab] = useState(Tabs.AttachedFiles);
const [searchString, setSearchString] = useState('');
@@ -45,7 +58,15 @@ export const UploadedFilesList: FC = props => {
const note = props.route.params.note;
- const { attachedFiles, allFiles } = useFiles({ note });
+ const {
+ attachedFiles,
+ allFiles,
+ uploadFiles,
+ uploadFileFromCameraOrImageGallery,
+ attachFileToNote,
+ } = useFiles({
+ note,
+ });
const filesList =
currentTab === Tabs.AttachedFiles ? attachedFiles : allFiles;
@@ -100,6 +121,79 @@ export const UploadedFilesList: FC = props => {
setFilesListScrolled(true);
};
+ const handleAttachFromCamera = () => {
+ const options = [
+ {
+ text: 'Photo',
+ callback: async () => {
+ const uploadedFile = await uploadFileFromCameraOrImageGallery({
+ mediaType: 'photo',
+ });
+ if (!uploadedFile) {
+ return;
+ }
+ attachFileToNote(uploadedFile, false);
+ },
+ },
+ {
+ text: 'Video',
+ callback: async () => {
+ const uploadedFile = await uploadFileFromCameraOrImageGallery({
+ mediaType: 'video',
+ });
+ if (!uploadedFile) {
+ return;
+ }
+ attachFileToNote(uploadedFile, false);
+ },
+ },
+ ];
+ showActionSheet('Choose file type', options);
+ };
+
+ const handlePressAttach = () => {
+ const options: CustomActionSheetOption[] = [
+ {
+ text: 'Attach from files',
+ key: 'files',
+ callback: async () => {
+ const uploadedFiles = await uploadFiles();
+ if (!uploadedFiles) {
+ return;
+ }
+ if (currentTab === AttachedFiles) {
+ uploadedFiles.forEach(file => attachFileToNote(file, false));
+ }
+ },
+ },
+ {
+ text: 'Attach from Photo Library',
+ key: 'library',
+ callback: async () => {
+ const uploadedFile = await uploadFileFromCameraOrImageGallery({
+ uploadFromGallery: true,
+ });
+ if (!uploadedFile) {
+ return;
+ }
+ attachFileToNote(uploadedFile, false);
+ },
+ },
+ {
+ text: 'Attach from Camera',
+ key: 'camera',
+ callback: async () => {
+ handleAttachFromCamera();
+ },
+ },
+ ];
+ const osSpecificOptions =
+ Platform.OS === 'android'
+ ? options.filter(option => option.key !== 'library')
+ : options;
+ showActionSheet('Choose action', osSpecificOptions);
+ };
+
const renderItem: ListRenderItem = ({ item }) => {
return (
= props => {
)}
+
+ }
+ />
);
diff --git a/yarn.lock b/yarn.lock
index 7917de9a..423186bf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1898,10 +1898,10 @@
"@standardnotes/auth" "^3.18.5"
"@standardnotes/common" "^1.19.3"
-"@standardnotes/filepicker@^1.10.5":
- version "1.10.5"
- resolved "https://registry.yarnpkg.com/@standardnotes/filepicker/-/filepicker-1.10.5.tgz#28c7cc68cb3337940a52e19b1dea76af343f2852"
- integrity sha512-Tl9mCW/qAzupOtQAYlbPHgZU16ljtRGV0awOHT7+uulPYoyKJJ4cqlKRzFaaONTAN6GpICEeSqidcB/lb5e5uQ==
+"@standardnotes/filepicker@^1.10.6":
+ version "1.10.6"
+ resolved "https://registry.yarnpkg.com/@standardnotes/filepicker/-/filepicker-1.10.6.tgz#73fcf1f455b88fbede4bbdff53ea657fefed1249"
+ integrity sha512-cYQ/0ccYpwsH/sZMLUC6v2TlKUlBvqJFSroM70LANlIIWWQe9aoPNqrR2kPGlbuzz9FkBcadsHgpshfFR8o/nA==
"@standardnotes/models@^1.2.2":
version "1.2.2"
@@ -8175,6 +8175,11 @@ react-native-gesture-handler@2.3.2:
lodash "^4.17.21"
prop-types "^15.7.2"
+react-native-image-picker@^4.7.3:
+ version "4.7.3"
+ resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-4.7.3.tgz#f5863b27bf2e76c83649dd8efea4598426bd04e8"
+ integrity sha512-eRKm4wlsmZHmsWFyv77kYc2F+ZyEVqe0m7mqhsMzWk6TQT4FBDtEDxmRDDFq+ivCu/1QD+EPhmYcAIpeGr7Ekg==
+
react-native-keychain@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/react-native-keychain/-/react-native-keychain-8.0.0.tgz#ff708e4dc2a5440df717179bf9b7cd50f78b61d7"