diff --git a/src/components/FullScreenDropZone.tsx b/src/components/FullScreenDropZone.tsx index 09d936b90..82b44364f 100644 --- a/src/components/FullScreenDropZone.tsx +++ b/src/components/FullScreenDropZone.tsx @@ -39,7 +39,6 @@ const Overlay = styled.div` type Props = React.PropsWithChildren<{ getRootProps: any; getInputProps: any; - showCollectionSelector; }>; export default function FullScreenDropZone(props: Props) { @@ -58,10 +57,6 @@ export default function FullScreenDropZone(props: Props) { { - e.preventDefault(); - props.showCollectionSelector(); - }, })}> {isDragActive && ( diff --git a/src/components/icons/MoveIcon.tsx b/src/components/icons/MoveIcon.tsx new file mode 100644 index 000000000..66338bd21 --- /dev/null +++ b/src/components/icons/MoveIcon.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export default function MoveIcon(props) { + return ( + + + + ); +} + +MoveIcon.defaultProps = { + height: 24, + width: 24, + viewBox: '0 0 24 24', +}; diff --git a/src/components/pages/gallery/CollectionSelector.tsx b/src/components/pages/gallery/CollectionSelector.tsx index bd30a6956..f115814ac 100644 --- a/src/components/pages/gallery/CollectionSelector.tsx +++ b/src/components/pages/gallery/CollectionSelector.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Card, Modal } from 'react-bootstrap'; import styled from 'styled-components'; import { @@ -23,6 +23,7 @@ export interface CollectionSelectorAttributes { callback: (collection: Collection) => void; showNextModal: () => void; title: string; + fromCollection?: number; } export type SetCollectionSelectorAttributes = React.Dispatch< React.SetStateAction @@ -45,30 +46,46 @@ function CollectionSelector({ collectionsAndTheirLatestFile, ...props }: Props) { + const [collectionToShow, setCollectionToShow] = useState< + CollectionAndItsLatestFile[] + >([]); + useEffect(() => { + if (!attributes) { + return; + } + const collectionsOtherThanFrom = collectionsAndTheirLatestFile?.filter( + (item) => !(item.collection.id === attributes.fromCollection) + ); + if (collectionsOtherThanFrom.length === 0) { + props.onHide(); + attributes.showNextModal(); + } else { + setCollectionToShow(collectionsOtherThanFrom); + } + }, [props.show]); + if (!attributes) { return ; } - const CollectionIcons: JSX.Element[] = collectionsAndTheirLatestFile?.map( - (item) => ( - { - attributes.callback(item.collection); - props.onHide(); - }}> - - {}} - forcedEnable - /> - - {item.collection.name} - - - - ) - ); + const CollectionIcons: JSX.Element[] = collectionToShow?.map((item) => ( + { + attributes.callback(item.collection); + props.onHide(); + }}> + + {}} + forcedEnable + /> + + {item.collection.name} + + + + )); return ( void; - showCreateCollectionModal: () => void; + moveToCollectionHelper: (collectionName, collection) => void; + showCreateCollectionModal: (opsType: COLLECTION_OPS_TYPE) => () => void; setDialogMessage: SetDialogMessage; setCollectionSelectorAttributes: SetCollectionSelectorAttributes; deleteFileHelper: () => void; count: number; clearSelection: () => void; + activeCollection: number; } const SelectionBar = styled(Navbar)` @@ -35,18 +39,21 @@ const SelectionContainer = styled.div` const SelectedFileOptions = ({ addToCollectionHelper, + moveToCollectionHelper, showCreateCollectionModal, setDialogMessage, setCollectionSelectorAttributes, deleteFileHelper, count, clearSelection, + activeCollection, }: Props) => { const addToCollection = () => setCollectionSelectorAttributes({ callback: (collection) => addToCollectionHelper(null, collection), - showNextModal: showCreateCollectionModal, + showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.ADD), title: constants.ADD_TO_COLLECTION, + fromCollection: activeCollection, }); const deleteHandler = () => @@ -62,6 +69,15 @@ const SelectedFileOptions = ({ close: { text: constants.CANCEL }, }); + const moveToCollection = () => { + setCollectionSelectorAttributes({ + callback: (collection) => moveToCollectionHelper(null, collection), + showNextModal: showCreateCollectionModal(COLLECTION_OPS_TYPE.MOVE), + title: constants.MOVE_TO_COLLECTION, + fromCollection: activeCollection, + }); + }; + return ( @@ -72,6 +88,11 @@ const SelectedFileOptions = ({ {count} {constants.SELECTED} + {activeCollection !== 0 && ( + + + + )} diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index 25ccc190a..a20dc80c9 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -45,7 +45,6 @@ import EnteSpinner from 'components/EnteSpinner'; import { LoadingOverlay } from 'components/LoadingOverlay'; import PhotoFrame from 'components/PhotoFrame'; import { getSelectedFileIds, sortFilesIntoCollections } from 'utils/file'; -import { addFilesToCollection } from 'utils/collection'; import SearchBar, { DateValue } from 'components/SearchBar'; import { Bbox } from 'services/searchService'; import SelectedFileOptions from 'components/pages/gallery/SelectedFileOptions'; @@ -63,6 +62,11 @@ import Collections from 'components/pages/gallery/Collections'; import { AppContext } from 'pages/_app'; import { CustomError, ServerErrorCodes } from 'utils/common/errorUtil'; import { PAGES } from 'types'; +import { + copyOrMoveFromCollection, + COLLECTION_OPS_TYPE, +} from 'utils/collection'; +import { logError } from 'utils/sentry'; export const DeadCenter = styled.div` flex: 1; @@ -295,7 +299,8 @@ export default function Gallery() { ) => { loadingBar.current?.continuousStart(); try { - await addFilesToCollection( + await copyOrMoveFromCollection( + COLLECTION_OPS_TYPE.ADD, setCollectionSelectorView, selected, files, @@ -315,14 +320,68 @@ export default function Gallery() { } }; - const showCreateCollectionModal = () => - setCollectionNamerAttributes({ - title: constants.CREATE_COLLECTION, - buttonText: constants.CREATE, - autoFilledName: '', - callback: (collectionName) => - addToCollectionHelper(collectionName, null), - }); + const moveToCollectionHelper = async ( + collectionName: string, + collection: Collection + ) => { + loadingBar.current?.continuousStart(); + try { + await copyOrMoveFromCollection( + COLLECTION_OPS_TYPE.MOVE, + setCollectionSelectorView, + selected, + files, + clearSelection, + syncWithRemote, + setActiveCollection, + collectionName, + collection + ); + } catch (e) { + setDialogMessage({ + title: constants.ERROR, + staticBackdrop: true, + close: { variant: 'danger' }, + content: constants.UNKNOWN_ERROR, + }); + } + }; + + const showCreateCollectionModal = (opsType: COLLECTION_OPS_TYPE) => { + try { + let callback = null; + switch (opsType) { + case COLLECTION_OPS_TYPE.ADD: + callback = (collectionName: string) => + addToCollectionHelper(collectionName, null); + break; + case COLLECTION_OPS_TYPE.MOVE: + callback = (collectionName: string) => + moveToCollectionHelper(collectionName, null); + break; + default: + throw Error(CustomError.INVALID_COLLECTION_OPERATION); + } + return () => + setCollectionNamerAttributes({ + title: constants.CREATE_COLLECTION, + buttonText: constants.CREATE, + autoFilledName: '', + callback, + }); + } catch (e) { + logError( + e, + 'showCreateCollectionModal called with incorrect attributes' + ); + setDialogMessage({ + title: constants.ERROR, + staticBackdrop: true, + close: { variant: 'danger' }, + content: constants.UNKNOWN_ERROR, + }); + } + }; const deleteFileHelper = async () => { loadingBar.current?.continuousStart(); @@ -368,11 +427,7 @@ export default function Gallery() { + getInputProps={getInputProps}> {loading && ( @@ -488,6 +543,7 @@ export default function Gallery() { selected.collectionID === activeCollection && ( )} diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 1bb5c8562..570786302 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -41,6 +41,23 @@ export interface Collection { isDeleted: boolean; } +interface EncryptedFileKey { + id: number; + encryptedKey: string; + keyDecryptionNonce: string; +} + +interface AddToCollectionRequest { + collectionID: number; + files: EncryptedFileKey[]; +} + +interface MoveToCollectionRequest { + fromCollectionID: number; + toCollectionID: number; + files: EncryptedFileKey[]; +} + interface collectionAttributes { encryptedPath?: string; pathDecryptionNonce?: string; @@ -344,39 +361,78 @@ export const addToCollection = async ( files: File[] ) => { try { - const params = {}; - const worker = await new CryptoWorker(); const token = getToken(); - params['collectionID'] = collection.id; - await Promise.all( - files.map(async (file) => { - file.collectionID = collection.id; - const newEncryptedKey: B64EncryptionResult = - await worker.encryptToB64(file.key, collection.key); - file.encryptedKey = newEncryptedKey.encryptedData; - file.keyDecryptionNonce = newEncryptedKey.nonce; - if (params['files'] === undefined) { - params['files'] = []; - } - params['files'].push({ - id: file.id, - encryptedKey: file.encryptedKey, - keyDecryptionNonce: file.keyDecryptionNonce, - }); - return file; - }) - ); + const fileKeysEncryptedWithNewCollection = + await encryptWithNewCollectionKey(collection, files); + + const requestBody: AddToCollectionRequest = { + collectionID: collection.id, + files: fileKeysEncryptedWithNewCollection, + }; await HTTPService.post( `${ENDPOINT}/collections/add-files`, - params, + requestBody, null, - { 'X-Auth-Token': token } + { + 'X-Auth-Token': token, + } ); } catch (e) { logError(e, 'Add to collection Failed '); throw e; } }; +export const moveToCollection = async ( + fromCollectionID: number, + toCollection: Collection, + files: File[] +) => { + try { + const token = getToken(); + const fileKeysEncryptedWithNewCollection = + await encryptWithNewCollectionKey(toCollection, files); + + const requestBody: MoveToCollectionRequest = { + fromCollectionID: fromCollectionID, + toCollectionID: toCollection.id, + files: fileKeysEncryptedWithNewCollection, + }; + await HTTPService.post( + `${ENDPOINT}/collections/move-files`, + requestBody, + null, + { + 'X-Auth-Token': token, + } + ); + } catch (e) { + logError(e, 'move to collection Failed '); + throw e; + } +}; + +const encryptWithNewCollectionKey = async ( + newCollection: Collection, + files: File[] +): Promise => { + const fileKeysEncryptedWithNewCollection: EncryptedFileKey[] = []; + const worker = await new CryptoWorker(); + for (const file of files) { + const newEncryptedKey: B64EncryptionResult = await worker.encryptToB64( + file.key, + newCollection.key + ); + file.encryptedKey = newEncryptedKey.encryptedData; + file.keyDecryptionNonce = newEncryptedKey.nonce; + + fileKeysEncryptedWithNewCollection.push({ + id: file.id, + encryptedKey: file.encryptedKey, + keyDecryptionNonce: file.keyDecryptionNonce, + }); + } + return fileKeysEncryptedWithNewCollection; +}; const removeFromCollection = async (collection: Collection, files: File[]) => { try { const params = {}; diff --git a/src/utils/collection/index.ts b/src/utils/collection/index.ts index 7da63e9d4..5a13cadb8 100644 --- a/src/utils/collection/index.ts +++ b/src/utils/collection/index.ts @@ -3,13 +3,21 @@ import { Collection, CollectionType, createCollection, + moveToCollection, } from 'services/collectionService'; import { getSelectedFiles } from 'utils/file'; import { File } from 'services/fileService'; +import { CustomError } from 'utils/common/errorUtil'; +import { SelectedState } from 'pages/gallery'; -export async function addFilesToCollection( +export enum COLLECTION_OPS_TYPE { + ADD, + MOVE, +} +export async function copyOrMoveFromCollection( + type: COLLECTION_OPS_TYPE, setCollectionSelectorView: (value: boolean) => void, - selected: any, + selected: SelectedState, files: File[], clearSelection: () => void, syncWithRemote: () => Promise, @@ -28,7 +36,20 @@ export async function addFilesToCollection( collection = existingCollection; } const selectedFiles = getSelectedFiles(selected, files); - await addToCollection(collection, selectedFiles); + switch (type) { + case COLLECTION_OPS_TYPE.ADD: + await addToCollection(collection, selectedFiles); + break; + case COLLECTION_OPS_TYPE.MOVE: + await moveToCollection( + selected.collectionID, + collection, + selectedFiles + ); + break; + default: + throw Error(CustomError.INVALID_COLLECTION_OPERATION); + } clearSelection(); await syncWithRemote(); setActiveCollection(collection.id); diff --git a/src/utils/common/errorUtil.ts b/src/utils/common/errorUtil.ts index 26630e709..9bca94c37 100644 --- a/src/utils/common/errorUtil.ts +++ b/src/utils/common/errorUtil.ts @@ -26,6 +26,7 @@ export enum CustomError { TYPE_DETECTION_FAILED = 'type detection failed', SIGNUP_FAILED = 'signup failed', FAV_COLLECTION_MISSING = 'favorite collection missing', + INVALID_COLLECTION_OPERATION = 'invalid collection operation', } function parseUploadError(error: AxiosResponse) { diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx index 725d2eab3..8dce165e8 100644 --- a/src/utils/strings/englishConstants.tsx +++ b/src/utils/strings/englishConstants.tsx @@ -542,6 +542,7 @@ const englishConstants = { TOO_LARGE_INFO: 'these files were not uploaded as they exceed the maximum size limit for your storage plan', UPLOAD_TO_COLLECTION: 'upload to album', + MOVE_TO_COLLECTION: 'move to collection', }; export default englishConstants;