Merge branch 'master' into search-collection

This commit is contained in:
abhinav-grd 2021-09-28 16:30:42 +05:30
commit 4941cb514e
21 changed files with 734 additions and 187 deletions

View file

@ -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;

View file

@ -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);

View file

@ -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<string>(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 = () => (
<div
style={{
height: '1px',
marginTop: '40px',
background: '#242424',
width: '100%',
}}
/>
);
return (
<Menu
isOpen={isOpen}
@ -203,23 +212,14 @@ export default function Sidebar(props: Props) {
)}
</div>
</div>
<div
style={{
height: '1px',
marginTop: '40px',
background: '#242424',
width: '100%',
}}
/>
<Divider />
<LinkButton
style={{ marginTop: '30px' }}
onClick={openFeedbackURL}>
{constants.REQUEST_FEATURE}
</LinkButton>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => initiateEmail('contact@ente.io')}>
{constants.SUPPORT}
onClick={() => {
galleryContext.setActiveCollection(ARCHIVE_SECTION);
setIsOpen(false);
}}>
{constants.ARCHIVE}
</LinkButton>
<>
<RecoveryKeyModal
@ -266,6 +266,17 @@ export default function Sidebar(props: Props) {
}}>
{constants.UPDATE_EMAIL}
</LinkButton>
<Divider />
<LinkButton
style={{ marginTop: '30px' }}
onClick={openFeedbackURL}>
{constants.REQUEST_FEATURE}
</LinkButton>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => initiateEmail('contact@ente.io')}>
{constants.SUPPORT}
</LinkButton>
<>
<ExportModal
show={exportModalView}
@ -284,14 +295,7 @@ export default function Sidebar(props: Props) {
</div>
</LinkButton>
</>
<div
style={{
height: '1px',
marginTop: '40px',
background: '#242424',
width: '100%',
}}
/>
<Divider />
<LinkButton
variant="danger"
style={{ marginTop: '30px' }}

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function Archive(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M10 3h4v5h3l-5 5-5-5h3v-5zm8.546 0h-2.344l5.467 9h-4.669l-2.25 3h-5.5l-2.25-3h-4.666l5.46-9h-2.317l-5.477 8.986v9.014h24v-9.014l-5.454-8.986z" />
</svg>
);
}
Archive.defaultProps = {
height: 28,
width: 20,
viewBox: '0 0 24 24',
};

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function SortIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z" />
</svg>
);
}
SortIcon.defaultProps = {
height: 24,
width: 24,
viewBox: '0 0 24 24',
};

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function TickIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
</svg>
);
}
TickIcon.defaultProps = {
height: 28,
width: 20,
viewBox: '0 0 24 24',
};

View file

@ -0,0 +1,20 @@
import React from 'react';
export default function UnArchive(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M24 11.986v9.014h-24v-9.014l5.477-8.986h2.317l-5.46 9h4.666l2.25 3h5.5l2.25-3h4.669l-5.467-9h2.344l5.454 8.986zm-10-3.986h3l-5-5-5 5h3v5h4v-5zm-11.666 4" />
</svg>
);
}
UnArchive.defaultProps = {
height: 28,
width: 20,
viewBox: '0 0 24 24',
};

View file

@ -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<void>;
@ -21,6 +21,28 @@ interface Props {
showCollectionShareModal: () => void;
redirectToAll: () => void;
}
export const MenuLink = ({ children, ...props }: LinkButtonProps) => (
<LinkButton
style={{ fontSize: '14px', fontWeight: 700, padding: '8px 1em' }}
{...props}>
{children}
</LinkButton>
);
export const MenuItem = (props: { children: any }) => (
<ListGroup.Item
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#282828',
padding: 0,
}}>
{props.children}
</ListGroup.Item>
);
const CollectionOptions = (props: Props) => {
const collectionRename = async (
selectedCollection: Collection,
@ -75,23 +97,6 @@ const CollectionOptions = (props: Props) => {
});
};
const MenuLink = (props) => (
<LinkButton
style={{ fontSize: '14px', fontWeight: 700, padding: '8px 1em' }}
{...props}>
{props.children}
</LinkButton>
);
const MenuItem = (props) => (
<ListGroup.Item
style={{
background: '#282828',
padding: 0,
}}>
{props.children}
</ListGroup.Item>
);
return (
<Popover id="collection-options" style={{ borderRadius: '10px' }}>
<Popover.Content style={{ padding: 0, border: 'none' }}>
@ -108,7 +113,7 @@ const CollectionOptions = (props: Props) => {
</MenuItem>
<MenuItem>
<MenuLink
variant="danger"
variant={ButtonVariant.danger}
onClick={confirmDeleteCollection}>
{constants.DELETE}
</MenuLink>

View file

@ -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]);

View file

@ -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 (
<OverlayTrigger
rootClose
trigger="click"
placement="bottom"
overlay={collectionSortOptions}>
<div>
<IconWithMessage message={constants.SORT}>
<IconButton style={{ color: '#fff' }}>
<SortIcon />
</IconButton>
</IconWithMessage>
</div>
</OverlayTrigger>
);
}

View file

@ -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 }) =>
(
<MenuItem>
<Row>
<Label width="20px">
{activeSortBy === props.sortBy && (
<TickWrapper>
<TickIcon />
</TickWrapper>
)}
</Label>
<Value width="165px">
<MenuLink
onClick={() => setCollectionSortBy(props.sortBy)}
variant={
activeSortBy === props.sortBy && 'success'
}>
{props.children}
</MenuLink>
</Value>
</Row>
</MenuItem>
);
const CollectionSortOptions = (props: OptionProps) => {
const SortByOption = SortByOptionCreator(props);
return (
<Popover id="collection-sort-options" style={{ borderRadius: '10px' }}>
<Popover.Content
style={{ padding: 0, border: 'none', width: '185px' }}>
<ListGroup style={{ borderRadius: '8px' }}>
<SortByOption sortBy={COLLECTION_SORT_BY.LATEST_FILE}>
{constants.SORT_BY_LATEST_PHOTO}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.MODIFICATION_TIME}>
{constants.SORT_BY_MODIFICATION_TIME}
</SortByOption>
<SortByOption sortBy={COLLECTION_SORT_BY.NAME}>
{constants.SORT_BY_COLLECTION_NAME}
</SortByOption>
</ListGroup>
</Popover.Content>
</Popover>
);
};
export default CollectionSortOptions;

View file

@ -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<void>;
@ -29,12 +39,11 @@ interface CollectionProps {
collectionFilesCount: Map<number, number>;
}
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<number>(null);
const collectionWrapperRef = useRef<HTMLDivElement>(null);
@ -89,6 +106,8 @@ export default function Collections(props: CollectionProps) {
scrollWidth?: number;
clientWidth?: number;
}>({});
const [collectionSortBy, setCollectionSortBy] =
useState<COLLECTION_SORT_BY>(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 (
<Tooltip
@ -176,73 +195,99 @@ export default function Collections(props: CollectionProps) {
)}
syncWithRemote={props.syncWithRemote}
/>
<Container>
{scrollObj.scrollLeft > 0 && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.LEFT}
onClick={scrollCollection(SCROLL_DIRECTION.LEFT)}
/>
)}
<Wrapper
ref={collectionWrapperRef}
onScroll={updateScrollObj}>
<Chip
active={!selected}
onClick={clickHandler(ALL_SECTION)}>
All
<div
style={{
display: 'inline-block',
width: '24px',
}}
<CollectionBar>
<CollectionContainer>
{scrollObj.scrollLeft > 0 && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.LEFT}
onClick={scrollCollection(
SCROLL_DIRECTION.LEFT
)}
/>
</Chip>
{collections?.map((item) => (
<OverlayTrigger
key={item.id}
placement="top"
delay={{ show: 250, hide: 400 }}
overlay={renderTooltip(item.id)}>
<Chip
ref={collectionChipsRef[item.id]}
active={selected === item.id}
onClick={clickHandler(item.id)}>
{item.name}
{item.type !== CollectionType.favorites &&
item.owner.id === user?.id ? (
<OverlayTrigger
rootClose
trigger="click"
placement="bottom"
overlay={collectionOptions}>
<OptionIcon
onClick={() =>
setSelectedCollectionID(
item.id
)
}
)}
<Wrapper
ref={collectionWrapperRef}
onScroll={updateScrollObj}>
<Chip
active={activeCollection === ALL_SECTION}
onClick={clickHandler(ALL_SECTION)}>
{constants.ALL}
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
</Chip>
{sortCollections(
collections,
props.collectionAndTheirLatestFile,
collectionSortBy
).map((item) => (
<OverlayTrigger
key={item.id}
placement="top"
delay={{ show: 250, hide: 400 }}
overlay={renderTooltip(item.id)}>
<Chip
ref={collectionChipsRef[item.id]}
active={activeCollection === item.id}
onClick={clickHandler(item.id)}>
{item.name}
{item.type !==
CollectionType.favorites &&
item.owner.id === user?.id ? (
<OverlayTrigger
rootClose
trigger="click"
placement="bottom"
overlay={collectionOptions}>
<OptionIcon
onClick={() =>
setSelectedCollectionID(
item.id
)
}
/>
</OverlayTrigger>
) : (
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
</OverlayTrigger>
) : (
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
)}
</Chip>
</OverlayTrigger>
))}
</Wrapper>
{scrollObj.scrollLeft <
scrollObj.scrollWidth - scrollObj.clientWidth && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.RIGHT}
onClick={scrollCollection(SCROLL_DIRECTION.RIGHT)}
/>
)}
</Container>
)}
</Chip>
</OverlayTrigger>
))}
<Chip
active={activeCollection === ARCHIVE_SECTION}
onClick={clickHandler(ARCHIVE_SECTION)}>
{constants.ARCHIVE}
<div
style={{
display: 'inline-block',
width: '24px',
}}
/>
</Chip>
</Wrapper>
{scrollObj.scrollLeft <
scrollObj.scrollWidth - scrollObj.clientWidth && (
<NavigationButton
scrollDirection={SCROLL_DIRECTION.RIGHT}
onClick={scrollCollection(
SCROLL_DIRECTION.RIGHT
)}
/>
)}
</CollectionContainer>
<CollectionSort
setCollectionSortBy={setCollectionSortBy}
activeSortBy={collectionSortBy}
/>
</CollectionBar>
</>
)
);

View file

@ -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 (
<h5
style={{

View file

@ -8,18 +8,31 @@ import CrossIcon from 'components/icons/CrossIcon';
import AddIcon from 'components/icons/AddIcon';
import { IconButton } from 'components/Container';
import constants from 'utils/strings/constants';
import Archive from 'components/icons/Archive';
import MoveIcon from 'components/icons/MoveIcon';
import { COLLECTION_OPS_TYPE } from 'utils/collection';
import { ALL_SECTION, ARCHIVE_SECTION } from './Collections';
import UnArchive from 'components/icons/UnArchive';
import { OverlayTrigger } from 'react-bootstrap';
import { Collection } from 'services/collectionService';
interface Props {
addToCollectionHelper: (collectionName, collection) => 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) => (
<OverlayTrigger
placement="bottom"
overlay={<p style={{ zIndex: 1002 }}>{props.message}</p>}>
{props.children}
</OverlayTrigger>
);
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}
</div>
</SelectionContainer>
{activeCollection !== 0 && (
<IconButton onClick={moveToCollection}>
<MoveIcon />
</IconButton>
{activeCollection === ARCHIVE_SECTION ? (
<IconWithMessage message={constants.UNARCHIVE}>
<IconButton onClick={unArchiveFilesHelper}>
<UnArchive />
</IconButton>
</IconWithMessage>
) : (
<>
{activeCollection === ALL_SECTION && (
<IconWithMessage message={constants.ARCHIVE}>
<IconButton onClick={archiveFilesHelper}>
<Archive />
</IconButton>
</IconWithMessage>
)}
{activeCollection !== ALL_SECTION && (
<IconWithMessage message={constants.MOVE}>
<IconButton onClick={moveToCollection}>
<MoveIcon />
</IconButton>
</IconWithMessage>
)}
<IconWithMessage message={constants.ADD}>
<IconButton onClick={addToCollection}>
<AddIcon />
</IconButton>
</IconWithMessage>
<IconWithMessage message={constants.DELETE}>
<IconButton onClick={deleteHandler}>
<DeleteIcon />
</IconButton>
</IconWithMessage>
</>
)}
<IconButton onClick={addToCollection}>
<AddIcon />
</IconButton>
<IconButton onClick={deleteHandler}>
<DeleteIcon />
</IconButton>
</SelectionBar>
);
};

View file

@ -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<number, string>;
files: Map<number, string>;
showPlanSelectorModal: () => void;
setActiveCollection: (collection: number) => void;
};
const defaultGalleryContext: GalleryContextType = {
thumbs: new Map(),
files: new Map(),
showPlanSelectorModal: () => null,
setActiveCollection: () => null,
};
export const GalleryContext = createContext<GalleryContextType>(
@ -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 (
<GalleryContext.Provider value={defaultGalleryContext}>
<GalleryContext.Provider
value={{
...defaultGalleryContext,
showPlanSelectorModal: () => setPlanModalView(true),
setActiveCollection,
}}>
<FullScreenDropZone
getRootProps={getRootProps}
getInputProps={getInputProps}>
@ -478,8 +532,9 @@ export default function Gallery() {
/>
<Collections
collections={collections}
collectionAndTheirLatestFile={collectionsAndTheirLatestFile}
searchMode={searchMode}
selected={activeCollection}
activeCollection={activeCollection}
setActiveCollection={setActiveCollection}
syncWithRemote={syncWithRemote}
setDialogMessage={setDialogMessage}
@ -530,7 +585,6 @@ export default function Gallery() {
collections={collections}
setDialogMessage={setDialogMessage}
setLoading={setLoading}
showPlanSelectorModal={() => setPlanModalView(true)}
/>
<UploadButton
isFirstFetch={isFirstFetch}
@ -558,6 +612,16 @@ export default function Gallery() {
selected.collectionID === activeCollection && (
<SelectedFileOptions
addToCollectionHelper={addToCollectionHelper}
archiveFilesHelper={() =>
changeFilesVisibilityHelper(
VISIBILITY_STATE.ARCHIVED
)
}
unArchiveFilesHelper={() =>
changeFilesVisibilityHelper(
VISIBILITY_STATE.VISIBLE
)
}
moveToCollectionHelper={moveToCollectionHelper}
showCreateCollectionModal={
showCreateCollectionModal

View file

@ -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 };
}
}

View file

@ -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<File> = (await localForage.getItem<File[]>(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,
});
};

View file

@ -21,8 +21,6 @@ export async function copyOrMoveFromCollection(
setCollectionSelectorView: (value: boolean) => void,
selected: SelectedState,
files: File[],
clearSelection: () => void,
syncWithRemote: () => Promise<void>,
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);
}

View file

@ -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);

View file

@ -420,7 +420,7 @@ const englishConstants = {
</span>
),
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;

View file

@ -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));
}