diff --git a/src/components/Container.ts b/src/components/Container.ts index 5c9175e31..88e183559 100644 --- a/src/components/Container.ts +++ b/src/components/Container.ts @@ -57,6 +57,7 @@ export const Value = styled.div<{ width?: string }>` `; export const FlexWrapper = styled.div` + width: 100%; display: flex; text-align: center; justify-content: center; diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx index eb788676e..faeae4cff 100644 --- a/src/components/PhotoFrame.tsx +++ b/src/components/PhotoFrame.tsx @@ -27,6 +27,8 @@ import { MIN_COLUMNS, SPACE_BTW_DATES, } from 'types'; +import { fileIsArchived } from 'utils/file'; +import { ALL_SECTION, ARCHIVE_SECTION } from './pages/gallery/Collections'; import { isSharedFile } from 'utils/file'; const NO_OF_PAGES = 2; @@ -404,12 +406,20 @@ const PhotoFrame = ({ ) { return false; } + if (activeCollection === ALL_SECTION && fileIsArchived(item)) { + return false; + } + if (activeCollection === ARCHIVE_SECTION && !fileIsArchived(item)) { + return false; + } + if (isSharedFile(item) && !isSharedCollection) { return false; } if (!idSet.has(item.id)) { if ( - !activeCollection || + activeCollection === ALL_SECTION || + activeCollection === ARCHIVE_SECTION || activeCollection === item.collectionID ) { idSet.add(item.id); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 64c35de7e..8e1ac23e3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,12 +32,11 @@ import InProgressIcon from './icons/InProgressIcon'; import exportService from 'services/exportService'; import { Subscription } from 'services/billingService'; import { PAGES } from 'types'; - +import { ARCHIVE_SECTION } from 'components/pages/gallery/Collections'; interface Props { collections: Collection[]; setDialogMessage: SetDialogMessage; setLoading: SetLoading; - showPlanSelectorModal: () => void; } export default function Sidebar(props: Props) { const [usage, SetUsage] = useState(null); @@ -52,7 +51,6 @@ export default function Sidebar(props: Props) { const [twoFactorModalView, setTwoFactorModalView] = useState(false); const [exportModalView, setExportModalView] = useState(false); const galleryContext = useContext(GalleryContext); - galleryContext.showPlanSelectorModal = props.showPlanSelectorModal; useEffect(() => { const main = async () => { if (!isOpen) { @@ -110,8 +108,19 @@ export default function Sidebar(props: Props) { const router = useRouter(); function onManageClick() { setIsOpen(false); - props.showPlanSelectorModal(); + galleryContext.showPlanSelectorModal(); } + + const Divider = () => ( +
+ ); return (
-
+ - {constants.REQUEST_FEATURE} - - initiateEmail('contact@ente.io')}> - {constants.SUPPORT} + onClick={() => { + galleryContext.setActiveCollection(ARCHIVE_SECTION); + setIsOpen(false); + }}> + {constants.ARCHIVE} <> {constants.UPDATE_EMAIL} + + + {constants.REQUEST_FEATURE} + + initiateEmail('contact@ente.io')}> + {constants.SUPPORT} + <> -
+ + + + ); +} + +Archive.defaultProps = { + height: 28, + width: 20, + viewBox: '0 0 24 24', +}; diff --git a/src/components/icons/SortIcon.tsx b/src/components/icons/SortIcon.tsx new file mode 100644 index 000000000..07c2c0460 --- /dev/null +++ b/src/components/icons/SortIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function SortIcon(props) { + return ( + + + + + ); +} + +SortIcon.defaultProps = { + height: 24, + width: 24, + viewBox: '0 0 24 24', +}; diff --git a/src/components/icons/TickIcon.tsx b/src/components/icons/TickIcon.tsx new file mode 100644 index 000000000..8bd76968f --- /dev/null +++ b/src/components/icons/TickIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function TickIcon(props) { + return ( + + + + ); +} + +TickIcon.defaultProps = { + height: 28, + width: 20, + viewBox: '0 0 24 24', +}; diff --git a/src/components/icons/UnArchive.tsx b/src/components/icons/UnArchive.tsx new file mode 100644 index 000000000..e5f86943c --- /dev/null +++ b/src/components/icons/UnArchive.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function UnArchive(props) { + return ( + + + + ); +} + +UnArchive.defaultProps = { + height: 28, + width: 20, + viewBox: '0 0 24 24', +}; diff --git a/src/components/pages/gallery/CollectionOptions.tsx b/src/components/pages/gallery/CollectionOptions.tsx index cd8bb56f9..041d58f07 100644 --- a/src/components/pages/gallery/CollectionOptions.tsx +++ b/src/components/pages/gallery/CollectionOptions.tsx @@ -9,7 +9,7 @@ import { import { getSelectedCollection } from 'utils/collection'; import constants from 'utils/strings/constants'; import { SetCollectionNamerAttributes } from './CollectionNamer'; -import LinkButton from './LinkButton'; +import LinkButton, { ButtonVariant, LinkButtonProps } from './LinkButton'; interface Props { syncWithRemote: () => Promise; @@ -21,6 +21,28 @@ interface Props { showCollectionShareModal: () => void; redirectToAll: () => void; } + +export const MenuLink = ({ children, ...props }: LinkButtonProps) => ( + + {children} + +); + +export const MenuItem = (props: { children: any }) => ( + + {props.children} + +); + const CollectionOptions = (props: Props) => { const collectionRename = async ( selectedCollection: Collection, @@ -75,23 +97,6 @@ const CollectionOptions = (props: Props) => { }); }; - const MenuLink = (props) => ( - - {props.children} - - ); - - const MenuItem = (props) => ( - - {props.children} - - ); return ( @@ -108,7 +113,7 @@ const CollectionOptions = (props: Props) => { {constants.DELETE} diff --git a/src/components/pages/gallery/CollectionSelector.tsx b/src/components/pages/gallery/CollectionSelector.tsx index 2556ed26f..ee9e27925 100644 --- a/src/components/pages/gallery/CollectionSelector.tsx +++ b/src/components/pages/gallery/CollectionSelector.tsx @@ -7,6 +7,8 @@ import { } from 'services/collectionService'; import AddCollectionButton from './AddCollectionButton'; import PreviewCard from './PreviewCard'; +import { getData, LS_KEYS } from 'utils/storage/localStorage'; +import { User } from 'services/userService'; export const CollectionIcon = styled.div` width: 200px; @@ -53,14 +55,18 @@ function CollectionSelector({ if (!attributes) { return; } - const collectionsOtherThanFrom = collectionsAndTheirLatestFile?.filter( - (item) => !(item.collection.id === attributes.fromCollection) - ); - if (collectionsOtherThanFrom.length === 0) { + const user: User = getData(LS_KEYS.USER); + const personalCollectionsOtherThanFrom = + collectionsAndTheirLatestFile?.filter( + (item) => + item.collection.id !== attributes.fromCollection && + item.collection.owner.id === user?.id + ); + if (personalCollectionsOtherThanFrom.length === 0) { props.onHide(); attributes.showNextModal(); } else { - setCollectionToShow(collectionsOtherThanFrom); + setCollectionToShow(personalCollectionsOtherThanFrom); } }, [props.show]); diff --git a/src/components/pages/gallery/CollectionSort.tsx b/src/components/pages/gallery/CollectionSort.tsx new file mode 100644 index 000000000..9d0c2a68c --- /dev/null +++ b/src/components/pages/gallery/CollectionSort.tsx @@ -0,0 +1,31 @@ +import { IconButton } from 'components/Container'; +import SortIcon from 'components/icons/SortIcon'; +import React from 'react'; +import { OverlayTrigger } from 'react-bootstrap'; +import { COLLECTION_SORT_BY } from 'services/collectionService'; +import constants from 'utils/strings/constants'; +import CollectionSortOptions from './CollectionSortOptions'; +import { IconWithMessage } from './SelectedFileOptions'; + +interface Props { + setCollectionSortBy: (sortBy: COLLECTION_SORT_BY) => void; + activeSortBy: COLLECTION_SORT_BY; +} +export default function CollectionSort(props: Props) { + const collectionSortOptions = CollectionSortOptions(props); + return ( + +
+ + + + + +
+
+ ); +} diff --git a/src/components/pages/gallery/CollectionSortOptions.tsx b/src/components/pages/gallery/CollectionSortOptions.tsx new file mode 100644 index 000000000..73241c145 --- /dev/null +++ b/src/components/pages/gallery/CollectionSortOptions.tsx @@ -0,0 +1,69 @@ +import { Label, Value } from 'components/Container'; +import TickIcon from 'components/icons/TickIcon'; +import React from 'react'; +import { ListGroup, Popover, Row } from 'react-bootstrap'; +import { COLLECTION_SORT_BY } from 'services/collectionService'; +import styled from 'styled-components'; +import constants from 'utils/strings/constants'; +import { MenuItem, MenuLink } from './CollectionOptions'; + +interface OptionProps { + activeSortBy: COLLECTION_SORT_BY; + setCollectionSortBy: (sortBy: COLLECTION_SORT_BY) => void; +} + +const TickWrapper = styled.span` + color: #aaa; + margin-left: 5px; +`; + +const SortByOptionCreator = + ({ setCollectionSortBy, activeSortBy }: OptionProps) => + (props: { sortBy: COLLECTION_SORT_BY; children: any }) => + ( + + + + + setCollectionSortBy(props.sortBy)} + variant={ + activeSortBy === props.sortBy && 'success' + }> + {props.children} + + + + + ); + +const CollectionSortOptions = (props: OptionProps) => { + const SortByOption = SortByOptionCreator(props); + + return ( + + + + + {constants.SORT_BY_LATEST_PHOTO} + + + {constants.SORT_BY_MODIFICATION_TIME} + + + {constants.SORT_BY_COLLECTION_NAME} + + + + + ); +}; + +export default CollectionSortOptions; diff --git a/src/components/pages/gallery/Collections.tsx b/src/components/pages/gallery/Collections.tsx index 6d55c829f..e2b5b0383 100644 --- a/src/components/pages/gallery/Collections.tsx +++ b/src/components/pages/gallery/Collections.tsx @@ -5,21 +5,31 @@ import NavigationButton, { } from 'components/NavigationButton'; import React, { useEffect, useRef, useState } from 'react'; import { OverlayTrigger, Tooltip } from 'react-bootstrap'; -import { Collection, CollectionType } from 'services/collectionService'; +import { + Collection, + CollectionAndItsLatestFile, + CollectionType, + COLLECTION_SORT_BY, + sortCollections, +} from 'services/collectionService'; import { User } from 'services/userService'; import styled from 'styled-components'; import { IMAGE_CONTAINER_MAX_WIDTH } from 'types'; import { getSelectedCollection } from 'utils/collection'; import { getData, LS_KEYS } from 'utils/storage/localStorage'; +import constants from 'utils/strings/constants'; import { SetCollectionNamerAttributes } from './CollectionNamer'; import CollectionOptions from './CollectionOptions'; +import CollectionSort from './CollectionSort'; import OptionIcon, { OptionIconWrapper } from './OptionIcon'; +export const ARCHIVE_SECTION = -1; export const ALL_SECTION = 0; interface CollectionProps { collections: Collection[]; - selected?: number; + collectionAndTheirLatestFile: CollectionAndItsLatestFile[]; + activeCollection?: number; setActiveCollection: (id?: number) => void; setDialogMessage: SetDialogMessage; syncWithRemote: () => Promise; @@ -29,12 +39,11 @@ interface CollectionProps { collectionFilesCount: Map; } -const Container = styled.div` - margin: 10px auto; +const CollectionContainer = styled.div` overflow-y: hidden; height: 40px; display: flex; - width: 100%; + width: calc(100% - 80px); position: relative; padding: 0 24px; @@ -52,6 +61,14 @@ const Wrapper = styled.div` scroll-behavior: smooth; `; +const CollectionBar = styled.div` + width: 100%; + margin: 10px auto; + display: flex; + align-items: center; + justify-content: flex-start; +`; + const Chip = styled.button<{ active: boolean }>` border-radius: 8px; padding: 4px; @@ -71,7 +88,7 @@ const Chip = styled.button<{ active: boolean }>` `; export default function Collections(props: CollectionProps) { - const { selected, collections, setActiveCollection } = props; + const { activeCollection, collections, setActiveCollection } = props; const [selectedCollectionID, setSelectedCollectionID] = useState(null); const collectionWrapperRef = useRef(null); @@ -89,6 +106,8 @@ export default function Collections(props: CollectionProps) { scrollWidth?: number; clientWidth?: number; }>({}); + const [collectionSortBy, setCollectionSortBy] = + useState(COLLECTION_SORT_BY.MODIFICATION_TIME); const updateScrollObj = () => { if (collectionWrapperRef.current) { @@ -110,10 +129,10 @@ export default function Collections(props: CollectionProps) { }, [collections]); useEffect(() => { - collectionChipsRef[selected]?.current.scrollIntoView({ + collectionChipsRef[activeCollection]?.current.scrollIntoView({ inline: 'center', }); - }, [selected]); + }, [activeCollection]); const clickHandler = (collectionID?: number) => () => { setSelectedCollectionID(collectionID); @@ -140,7 +159,7 @@ export default function Collections(props: CollectionProps) { const scrollCollection = (direction: SCROLL_DIRECTION) => () => { collectionWrapperRef.current.scrollBy(250 * direction, 0); }; - const renderTooltip = (collectionID) => { + const renderTooltip = (collectionID: number) => { const fileCount = props.collectionFilesCount?.get(collectionID) ?? 0; return ( - - {scrollObj.scrollLeft > 0 && ( - - )} - - - All -
+ + {scrollObj.scrollLeft > 0 && ( + - - {collections?.map((item) => ( - - - {item.name} - {item.type !== CollectionType.favorites && - item.owner.id === user?.id ? ( - - - setSelectedCollectionID( - item.id - ) - } + )} + + + {constants.ALL} +
+ + {sortCollections( + collections, + props.collectionAndTheirLatestFile, + collectionSortBy + ).map((item) => ( + + + {item.name} + {item.type !== + CollectionType.favorites && + item.owner.id === user?.id ? ( + + + setSelectedCollectionID( + item.id + ) + } + /> + + ) : ( +
- - ) : ( -
- )} - - - ))} - - {scrollObj.scrollLeft < - scrollObj.scrollWidth - scrollObj.clientWidth && ( - - )} - + )} + + + ))} + + {constants.ARCHIVE} +
+ + + {scrollObj.scrollLeft < + scrollObj.scrollWidth - scrollObj.clientWidth && ( + + )} + + + ) ); diff --git a/src/components/pages/gallery/LinkButton.tsx b/src/components/pages/gallery/LinkButton.tsx index eb4765852..6316402fb 100644 --- a/src/components/pages/gallery/LinkButton.tsx +++ b/src/components/pages/gallery/LinkButton.tsx @@ -6,10 +6,10 @@ export enum ButtonVariant { secondary = 'secondary', warning = 'warning', } -type Props = React.PropsWithChildren<{ - onClick: any; +export type LinkButtonProps = React.PropsWithChildren<{ + onClick: () => void; variant?: string; - style?: any; + style?: React.CSSProperties; }>; export function getVariantColor(variant: string) { @@ -26,7 +26,7 @@ export function getVariantColor(variant: string) { return '#d1d1d1'; } } -export default function LinkButton(props: Props) { +export default function LinkButton(props: LinkButtonProps) { return (
void; - moveToCollectionHelper: (collectionName, collection) => void; + addToCollectionHelper: ( + collectionName: string, + collection: Collection + ) => void; + moveToCollectionHelper: ( + collectionName: string, + collection: Collection + ) => void; showCreateCollectionModal: (opsType: COLLECTION_OPS_TYPE) => () => void; setDialogMessage: SetDialogMessage; setCollectionSelectorAttributes: SetCollectionSelectorAttributes; deleteFileHelper: () => void; count: number; clearSelection: () => void; + archiveFilesHelper: () => void; + unArchiveFilesHelper: () => void; activeCollection: number; } @@ -29,6 +42,7 @@ const SelectionBar = styled(Navbar)` color: #fff; z-index: 1001; width: 100%; + padding: 0 16px; `; const SelectionContainer = styled.div` @@ -37,6 +51,14 @@ const SelectionContainer = styled.div` display: flex; `; +export const IconWithMessage = (props) => ( + {props.message}

}> + {props.children} +
+); + const SelectedFileOptions = ({ addToCollectionHelper, moveToCollectionHelper, @@ -46,6 +68,8 @@ const SelectedFileOptions = ({ deleteFileHelper, count, clearSelection, + archiveFilesHelper, + unArchiveFilesHelper, activeCollection, }: Props) => { const addToCollection = () => @@ -88,17 +112,40 @@ const SelectedFileOptions = ({ {count} {constants.SELECTED}
- {activeCollection !== 0 && ( - - - + {activeCollection === ARCHIVE_SECTION ? ( + + + + + + ) : ( + <> + {activeCollection === ALL_SECTION && ( + + + + + + )} + {activeCollection !== ALL_SECTION && ( + + + + + + )} + + + + + + + + + + + )} - - - - - - ); }; diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index af74ea3e6..8d0fa68e9 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -12,6 +12,8 @@ import { getLocalFiles, deleteFiles, syncFiles, + updateMagicMetadata, + VISIBILITY_STATE, } from 'services/fileService'; import styled from 'styled-components'; import LoadingBar from 'react-top-loading-bar'; @@ -43,7 +45,11 @@ import { useDropzone } from 'react-dropzone'; import EnteSpinner from 'components/EnteSpinner'; import { LoadingOverlay } from 'components/LoadingOverlay'; import PhotoFrame from 'components/PhotoFrame'; -import { getSelectedFileIds, sortFilesIntoCollections } from 'utils/file'; +import { + changeFilesVisibility, + getSelectedFileIds, + sortFilesIntoCollections, +} from 'utils/file'; import SearchBar, { DateValue } from 'components/SearchBar'; import { Bbox } from 'services/searchService'; import SelectedFileOptions from 'components/pages/gallery/SelectedFileOptions'; @@ -106,12 +112,14 @@ type GalleryContextType = { thumbs: Map; files: Map; showPlanSelectorModal: () => void; + setActiveCollection: (collection: number) => void; }; const defaultGalleryContext: GalleryContextType = { thumbs: new Map(), files: new Map(), showPlanSelectorModal: () => null, + setActiveCollection: () => null, }; export const GalleryContext = createContext( @@ -318,12 +326,12 @@ export default function Gallery() { setCollectionSelectorView, selected, files, - clearSelection, - syncWithRemote, + setActiveCollection, collectionName, collection ); + clearSelection(); } catch (e) { setDialogMessage({ title: constants.ERROR, @@ -331,6 +339,9 @@ export default function Gallery() { close: { variant: 'danger' }, content: constants.UNKNOWN_ERROR, }); + } finally { + await syncWithRemote(false, true); + loadingBar.current.complete(); } }; @@ -345,12 +356,12 @@ export default function Gallery() { setCollectionSelectorView, selected, files, - clearSelection, - syncWithRemote, + setActiveCollection, collectionName, collection ); + clearSelection(); } catch (e) { setDialogMessage({ title: constants.ERROR, @@ -358,6 +369,43 @@ export default function Gallery() { close: { variant: 'danger' }, content: constants.UNKNOWN_ERROR, }); + } finally { + await syncWithRemote(false, true); + loadingBar.current.complete(); + } + }; + const changeFilesVisibilityHelper = async ( + visibility: VISIBILITY_STATE + ) => { + loadingBar.current?.continuousStart(); + try { + const updatedFiles = await changeFilesVisibility( + files, + selected, + visibility + ); + await updateMagicMetadata(updatedFiles); + clearSelection(); + } catch (e) { + switch (e.status?.toString()) { + case ServerErrorCodes.FORBIDDEN: + setDialogMessage({ + title: constants.ERROR, + staticBackdrop: true, + close: { variant: 'danger' }, + content: constants.NOT_FILE_OWNER, + }); + return; + } + setDialogMessage({ + title: constants.ERROR, + staticBackdrop: true, + close: { variant: 'danger' }, + content: constants.UNKNOWN_ERROR, + }); + } finally { + await syncWithRemote(false, true); + loadingBar.current.complete(); } }; @@ -401,10 +449,10 @@ export default function Gallery() { loadingBar.current?.continuousStart(); try { const fileIds = getSelectedFileIds(selected); - await deleteFiles(fileIds, clearSelection, syncWithRemote); + await deleteFiles(fileIds); setDeleted([...deleted, ...fileIds]); + clearSelection(); } catch (e) { - loadingBar.current.complete(); switch (e.status?.toString()) { case ServerErrorCodes.FORBIDDEN: setDialogMessage({ @@ -413,8 +461,6 @@ export default function Gallery() { close: { variant: 'danger' }, content: constants.NOT_FILE_OWNER, }); - loadingBar.current.complete(); - return; } setDialogMessage({ title: constants.ERROR, @@ -422,6 +468,9 @@ export default function Gallery() { close: { variant: 'danger' }, content: constants.UNKNOWN_ERROR, }); + } finally { + await syncWithRemote(false, true); + loadingBar.current.complete(); } }; @@ -438,7 +487,12 @@ export default function Gallery() { }; return ( - + setPlanModalView(true), + setActiveCollection, + }}> @@ -478,8 +532,9 @@ export default function Gallery() { /> setPlanModalView(true)} /> + changeFilesVisibilityHelper( + VISIBILITY_STATE.ARCHIVED + ) + } + unArchiveFilesHelper={() => + changeFilesVisibilityHelper( + VISIBILITY_STATE.VISIBLE + ) + } moveToCollectionHelper={moveToCollectionHelper} showCreateCollectionModal={ showCreateCollectionModal diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index ea6b69ee9..4364db20a 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -69,6 +69,12 @@ export interface CollectionAndItsLatestFile { file: File; } +export enum COLLECTION_SORT_BY { + LATEST_FILE, + MODIFICATION_TIME, + NAME, +} + const getCollectionWithSecrets = async ( collection: Collection, masterKey: string @@ -219,13 +225,9 @@ export const getCollectionsAndTheirLatestFile = ( } }); const collectionsAndTheirLatestFile: CollectionAndItsLatestFile[] = []; - const userID = getData(LS_KEYS.USER)?.id; for (const collection of collections) { - if ( - collection.owner.id !== userID || - collection.type === CollectionType.favorites - ) { + if (collection.type === CollectionType.favorites) { continue; } collectionsAndTheirLatestFile.push({ @@ -591,3 +593,62 @@ export const getNonEmptyCollections = ( nonEmptyCollectionsIds.has(collection.id) ); }; + +export function sortCollections( + collections: Collection[], + collectionAndTheirLatestFile: CollectionAndItsLatestFile[], + sortBy: COLLECTION_SORT_BY +) { + return collections.sort((collectionA, collectionB) => { + switch (sortBy) { + case COLLECTION_SORT_BY.LATEST_FILE: + return compareCollectionsLatestFile( + collectionAndTheirLatestFile, + collectionA, + collectionB + ); + case COLLECTION_SORT_BY.MODIFICATION_TIME: + return collectionB.updationTime - collectionA.updationTime; + case COLLECTION_SORT_BY.NAME: + return collectionA.name.localeCompare(collectionB.name); + } + }); +} + +function compareCollectionsLatestFile( + collectionAndTheirLatestFile: CollectionAndItsLatestFile[], + collectionA: Collection, + collectionB: Collection +) { + const CollectionALatestFile = getCollectionLatestFile( + collectionAndTheirLatestFile, + collectionA + ); + const CollectionBLatestFile = getCollectionLatestFile( + collectionAndTheirLatestFile, + collectionB + ); + + return ( + CollectionBLatestFile.updationTime - CollectionALatestFile.updationTime + ); +} + +function getCollectionLatestFile( + collectionAndTheirLatestFile: CollectionAndItsLatestFile[], + collection: Collection +) { + const collectionAndItsLatestFile = collectionAndTheirLatestFile.filter( + (collectionAndItsLatestFile) => + collectionAndItsLatestFile.collection.id === collection.id + ); + if (collectionAndItsLatestFile.length === 1) { + return collectionAndItsLatestFile[0].file; + } else { + logError( + Error('collection missing from collectionLatestFile list'), + '' + ); + return { updationTime: 0 }; + } +} diff --git a/src/services/fileService.ts b/src/services/fileService.ts index 002763998..f3fc24ddf 100644 --- a/src/services/fileService.ts +++ b/src/services/fileService.ts @@ -37,6 +37,19 @@ export const FORMAT_MISSED_BY_FILE_TYPE_LIB = [ { fileType: FILE_TYPE.VIDEO, exactType: 'webm' }, ]; +export enum VISIBILITY_STATE { + VISIBLE, + ARCHIVED, +} +export interface MagicMetadataProps { + visibility?: VISIBILITY_STATE; +} +export interface MagicMetadata { + version: number; + count: number; + data: string | MagicMetadataProps; + header: string; +} export interface File { id: number; collectionID: number; @@ -44,6 +57,7 @@ export interface File { file: fileAttribute; thumbnail: fileAttribute; metadata: MetadataObject; + magicMetadata: MagicMetadata; encryptedKey: string; keyDecryptionNonce: string; key: string; @@ -57,6 +71,21 @@ export interface File { updationTime: number; } +interface UpdateMagicMetadataRequest { + metadataList: UpdateMagicMetadata[]; +} +interface UpdateMagicMetadata { + id: number; + magicMetadata: MagicMetadata; +} + +export const NEW_MAGIC_METADATA: MagicMetadata = { + version: 0, + data: {}, + header: null, + count: 0, +}; + export const getLocalFiles = async () => { const files: Array = (await localForage.getItem(FILES)) || []; return files; @@ -203,11 +232,7 @@ const removeDeletedCollectionFiles = async ( return files; }; -export const deleteFiles = async ( - filesToDelete: number[], - clearSelection: Function, - syncWithRemote: Function -) => { +export const deleteFiles = async (filesToDelete: number[]) => { try { const token = getToken(); if (!token) { @@ -221,10 +246,25 @@ export const deleteFiles = async ( 'X-Auth-Token': token, } ); - clearSelection(); - syncWithRemote(); } catch (e) { logError(e, 'delete failed'); throw e; } }; + +export const updateMagicMetadata = async (files: File[]) => { + const token = getToken(); + if (!token) { + return; + } + const reqBody: UpdateMagicMetadataRequest = { metadataList: [] }; + for (const file of files) { + reqBody.metadataList.push({ + id: file.id, + magicMetadata: file.magicMetadata, + }); + } + await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, { + 'X-Auth-Token': token, + }); +}; diff --git a/src/utils/collection/index.ts b/src/utils/collection/index.ts index 1c13c40c1..2683236a5 100644 --- a/src/utils/collection/index.ts +++ b/src/utils/collection/index.ts @@ -21,8 +21,6 @@ export async function copyOrMoveFromCollection( setCollectionSelectorView: (value: boolean) => void, selected: SelectedState, files: File[], - clearSelection: () => void, - syncWithRemote: () => Promise, setActiveCollection: (id: number) => void, collectionName: string, existingCollection: Collection @@ -52,8 +50,6 @@ export async function copyOrMoveFromCollection( default: throw Error(CustomError.INVALID_COLLECTION_OPERATION); } - clearSelection(); - await syncWithRemote(); setActiveCollection(collection.id); } diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts index 8ecde6b3c..a9186ac1f 100644 --- a/src/utils/file/index.ts +++ b/src/utils/file/index.ts @@ -1,7 +1,17 @@ +import { SelectedState } from 'pages/gallery'; import { Collection } from 'services/collectionService'; -import { File, FILE_TYPE } from 'services/fileService'; +import { + File, + fileAttribute, + FILE_TYPE, + MagicMetadataProps, + NEW_MAGIC_METADATA, + VISIBILITY_STATE, +} from 'services/fileService'; import { decodeMotionPhoto } from 'services/motionPhotoService'; import { getMimeTypeFromBlob } from 'services/upload/readFileService'; +import { EncryptionResult } from 'services/upload/uploadService'; +import { logError } from 'utils/sentry'; import { User } from 'services/userService'; import CryptoWorker from 'utils/crypto'; import { getData, LS_KEYS } from 'utils/storage/localStorage'; @@ -44,7 +54,7 @@ export function sortFilesIntoCollections(files: File[]) { return collectionWiseFiles; } -export function getSelectedFileIds(selectedFiles) { +export function getSelectedFileIds(selectedFiles: SelectedState) { const filesIDs: number[] = []; for (const [key, val] of Object.entries(selectedFiles)) { if (typeof val === 'boolean' && val) { @@ -53,7 +63,10 @@ export function getSelectedFileIds(selectedFiles) { } return filesIDs; } -export function getSelectedFiles(selectedFiles, files: File[]): File[] { +export function getSelectedFiles( + selectedFiles: SelectedState, + files: File[] +): File[] { const filesIDs = new Set(getSelectedFileIds(selectedFiles)); const filesToDelete: File[] = []; for (const file of files) { @@ -124,14 +137,30 @@ export function sortFiles(files: File[]) { } export async function decryptFile(file: File, collection: Collection) { - const worker = await new CryptoWorker(); - file.key = await worker.decryptB64( - file.encryptedKey, - file.keyDecryptionNonce, - collection.key - ); - file.metadata = await worker.decryptMetadata(file); - return file; + try { + const worker = await new CryptoWorker(); + file.key = await worker.decryptB64( + file.encryptedKey, + file.keyDecryptionNonce, + collection.key + ); + const encryptedMetadata = file.metadata as unknown as fileAttribute; + file.metadata = await worker.decryptMetadata( + encryptedMetadata.encryptedData, + encryptedMetadata.decryptionHeader, + file.key + ); + if (file.magicMetadata?.data) { + file.magicMetadata.data = await worker.decryptMetadata( + file.magicMetadata.data, + file.magicMetadata.header, + file.key + ); + } + return file; + } catch (e) { + logError(e, 'file decryption failed'); + } } export function removeUnnecessaryFileProps(files: File[]): File[] { @@ -188,6 +217,56 @@ export async function convertForPreview(file: File, fileBlob: Blob) { return fileBlob; } +export function fileIsArchived(file: File) { + if ( + !file || + !file.magicMetadata || + !file.magicMetadata.data || + typeof file.magicMetadata.data === 'string' || + typeof file.magicMetadata.data.visibility === 'undefined' + ) { + return false; + } + return file.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED; +} + +export async function changeFilesVisibility( + files: File[], + selected: SelectedState, + visibility: VISIBILITY_STATE +) { + const worker = await new CryptoWorker(); + const selectedFiles = getSelectedFiles(selected, files); + const updatedFiles: File[] = []; + for (const file of selectedFiles) { + if (!file.magicMetadata) { + file.magicMetadata = NEW_MAGIC_METADATA; + } + if (typeof file.magicMetadata.data === 'string') { + logError(Error('magic metadata not decrypted'), ''); + return; + } + // copies the existing magic metadata properties of the files and updates the visibility value + // The expected behaviour while updating magic metadata is to let the existing property as it is and update/add the property you want + const updatedMagicMetadataProps: MagicMetadataProps = { + ...file.magicMetadata.data, + visibility, + }; + const encryptedMagicMetadata: EncryptionResult = + await worker.encryptMetadata(updatedMagicMetadataProps, file.key); + updatedFiles.push({ + ...file, + magicMetadata: { + version: file.magicMetadata.version, + count: Object.keys(updatedMagicMetadataProps).length, + data: encryptedMagicMetadata.file + .encryptedData as unknown as string, + header: encryptedMagicMetadata.file.decryptionHeader, + }, + }); + } + return updatedFiles; +} export function isSharedFile(file: File) { const user: User = getData(LS_KEYS.USER); diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx index 8dce165e8..29aeda2e8 100644 --- a/src/utils/strings/englishConstants.tsx +++ b/src/utils/strings/englishConstants.tsx @@ -420,7 +420,7 @@ const englishConstants = { ), NOT_FILE_OWNER: 'you cannot delete files in a shared album', - ADD_TO_COLLECTION: 'add to collection', + ADD_TO_COLLECTION: 'add to album', SELECTED: 'selected', VIDEO_PLAYBACK_FAILED: 'video format not supported', VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD: @@ -542,7 +542,16 @@ 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', + ARCHIVE: 'archive', + ALL: 'all', + MOVE_TO_COLLECTION: 'move to album', + UNARCHIVE: 'un-archive', + MOVE: 'move', + ADD: 'add', + SORT: 'sort', + SORT_BY_LATEST_PHOTO: 'most recent photo', + SORT_BY_MODIFICATION_TIME: 'last modified', + SORT_BY_COLLECTION_NAME: 'album title', }; export default englishConstants; diff --git a/src/worker/crypto.worker.js b/src/worker/crypto.worker.js index 71b3373de..e4da90107 100644 --- a/src/worker/crypto.worker.js +++ b/src/worker/crypto.worker.js @@ -3,11 +3,11 @@ import * as libsodium from 'utils/crypto/libsodium'; import { convertHEIC2JPEG } from 'utils/file/convertHEIC'; export class Crypto { - async decryptMetadata(file) { + async decryptMetadata(encryptedMetadata, header, key) { const encodedMetadata = await libsodium.decryptChaChaOneShot( - await libsodium.fromB64(file.metadata.encryptedData), - await libsodium.fromB64(file.metadata.decryptionHeader), - file.key + await libsodium.fromB64(encryptedMetadata), + await libsodium.fromB64(header), + key ); return JSON.parse(new TextDecoder().decode(encodedMetadata)); }