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"