Merge pull request #585 from ente-io/collection-share-redesign

Collection share redesign
This commit is contained in:
Abhinav Kumar 2022-06-08 14:36:47 +05:30 committed by GitHub
commit c9558aaa16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 876 additions and 636 deletions

View file

@ -2,7 +2,7 @@ import { Dialog, Slide, styled } from '@mui/material';
import React from 'react';
import PropTypes from 'prop-types';
export const FloatingDrawer = styled(Dialog)(({ theme }) => ({
export const AllCollectionContainer = styled(Dialog)(({ theme }) => ({
'& .MuiDialog-container': {
justifyContent: 'flex-end',
},
@ -17,7 +17,7 @@ export const FloatingDrawer = styled(Dialog)(({ theme }) => ({
},
}));
FloatingDrawer.propTypes = {
AllCollectionContainer.propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
};

View file

@ -4,8 +4,8 @@ import { CollectionType, COLLECTION_SORT_BY } from 'constants/collection';
import { sortCollectionSummaries } from 'services/collectionService';
import {
Transition,
FloatingDrawer,
} from 'components/Collections/FloatingDrawer';
AllCollectionContainer,
} from 'components/Collections/AllCollections/Container';
import { useLocalState } from 'hooks/useLocalState';
import { LS_KEYS } from 'utils/storage/localStorage';
import AllCollectionsHeader from './header';
@ -48,7 +48,7 @@ export default function AllCollections(props: Iprops) {
};
return (
<FloatingDrawer
<AllCollectionContainer
TransitionComponent={LeftSlideTransition}
onClose={close}
open={isOpen}>
@ -64,6 +64,6 @@ export default function AllCollections(props: Iprops) {
collectionSummaries={sortedCollectionSummaries}
onCollectionClick={onCollectionClick}
/>
</FloatingDrawer>
</AllCollectionContainer>
);
}

View file

@ -1,607 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import Select from 'react-select';
import constants from 'utils/strings/constants';
import { Formik, FormikHelpers } from 'formik';
import * as Yup from 'yup';
import Form from 'react-bootstrap/Form';
import FormControl from 'react-bootstrap/FormControl';
import { Button, Col, Table } from 'react-bootstrap';
import { DeadCenter, GalleryContext } from 'pages/gallery';
import { User } from 'types/user';
import {
shareCollection,
unshareCollection,
createShareableURL,
deleteShareableURL,
updateShareableURL,
} from 'services/collectionService';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import SubmitButton from '../SubmitButton';
import DialogBox from '../DialogBox';
import { Collection, PublicURL, UpdatePublicURL } from 'types/collection';
import {
appendCollectionKeyToShareURL,
selectIntOptions,
shareExpiryOptions,
} from 'utils/collection';
import { FlexWrapper, Label, Row, Value } from '../Container';
import CodeBlock from '../CodeBlock';
import { ButtonVariant, getVariantColor } from '../pages/gallery/LinkButton';
import { handleSharingErrors } from 'utils/error';
import { sleep } from 'utils/common';
import { SelectStyles } from '../Search/styles';
import CryptoWorker from 'utils/crypto';
import { dateStringWithMMH } from 'utils/time';
import styled from 'styled-components';
import SingleInputForm from '../SingleInputForm';
import { AppContext } from 'pages/_app';
interface Props {
show: boolean;
onHide: () => void;
collection: Collection;
}
interface formValues {
email: string;
}
interface ShareeProps {
sharee: User;
collectionUnshare: (sharee: User) => void;
}
const DropdownStyle = {
...SelectStyles,
dropdownIndicator: (style) => ({
...style,
margin: '0px',
}),
singleValue: (style) => ({
...style,
color: '#d1d1d1',
width: '240px',
}),
control: (style, { isFocused }) => ({
...style,
...SelectStyles.control(style, { isFocused }),
minWidth: '240px',
}),
};
const linkExpiryStyle = {
...DropdownStyle,
placeholder: (style) => ({
...style,
color: '#d1d1d1',
width: '100%',
textAlign: 'center',
}),
};
const OptionRow = styled(Row)`
flex-wrap: wrap;
justify-content: center;
`;
const OptionLabel = styled(Label)`
flex: 1 1 103px;
@media (min-width: 513px) {
text-align: left;
}
margin: 5px;
`;
const OptionValue = styled(Value)`
flex: 0 0 240px;
justify-content: center;
margin: 5px;
`;
function CollectionShare(props: Props) {
const [loading, setLoading] = useState(false);
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const [sharableLinkError, setSharableLinkError] = useState(null);
const [publicShareUrl, setPublicShareUrl] = useState<string>(null);
const [publicShareProp, setPublicShareProp] = useState<PublicURL>(null);
const [configurePassword, setConfigurePassword] = useState(false);
const deviceLimitOptions = selectIntOptions(50);
const expiryOptions = shareExpiryOptions;
useEffect(() => {
const main = async () => {
if (props.collection?.publicURLs?.[0]?.url) {
const t = await appendCollectionKeyToShareURL(
props.collection?.publicURLs?.[0]?.url,
props.collection.key
);
setPublicShareUrl(t);
setPublicShareProp(
props.collection?.publicURLs?.[0] as PublicURL
);
} else {
setPublicShareUrl(null);
setPublicShareProp(null);
}
};
main();
}, [props.collection]);
const collectionShare = async (
{ email }: formValues,
{ resetForm, setFieldError }: FormikHelpers<formValues>
) => {
try {
setLoading(true);
appContext.startLoading();
const user: User = getData(LS_KEYS.USER);
if (email === user.email) {
setFieldError('email', constants.SHARE_WITH_SELF);
} else if (
props.collection?.sharees?.find(
(value) => value.email === email
)
) {
setFieldError('email', constants.ALREADY_SHARED(email));
} else {
await shareCollection(props.collection, email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
resetForm();
}
} catch (e) {
const errorMessage = handleSharingErrors(e);
setFieldError('email', errorMessage);
} finally {
setLoading(false);
appContext.finishLoading();
}
};
const collectionUnshare = async (sharee) => {
try {
appContext.startLoading();
await unshareCollection(props.collection, sharee.email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
} finally {
appContext.finishLoading();
}
};
const createSharableURLHelper = async () => {
try {
appContext.startLoading();
const publicURL = await createShareableURL(props.collection);
const sharableURL = await appendCollectionKeyToShareURL(
publicURL.url,
props.collection.key
);
setPublicShareUrl(sharableURL);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const disablePublicSharingHelper = async () => {
try {
appContext.startLoading();
await deleteShareableURL(props.collection);
setPublicShareUrl(null);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const savePassword = async (passphrase, setFieldError) => {
if (passphrase && passphrase.trim().length >= 1) {
await enablePublicUrlPassword(passphrase);
setConfigurePassword(false);
publicShareProp.passwordEnabled = true;
} else {
setFieldError('linkPassword', 'can not be empty');
}
};
const handlePasswordChangeSetting = async () => {
if (publicShareProp.passwordEnabled) {
await disablePublicUrlPassword();
} else {
setConfigurePassword(true);
}
};
const disablePublicUrlPassword = async () => {
appContext.setDialogMessage({
title: constants.DISABLE_PASSWORD,
content: constants.DISABLE_PASSWORD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: props.collection.id,
disablePassword: true,
}),
variant: ButtonVariant.danger,
},
});
};
const enablePublicUrlPassword = async (password: string) => {
const cryptoWorker = await new CryptoWorker();
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
const kek = await cryptoWorker.deriveInteractiveKey(password, kekSalt);
return updatePublicShareURLHelper({
collectionID: props.collection.id,
passHash: kek.key,
nonce: kekSalt,
opsLimit: kek.opsLimit,
memLimit: kek.memLimit,
});
};
const disablePublicSharing = () => {
appContext.setDialogMessage({
title: constants.DISABLE_PUBLIC_SHARING,
content: constants.DISABLE_PUBLIC_SHARING_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: disablePublicSharingHelper,
variant: ButtonVariant.danger,
},
});
};
const disableFileDownload = () => {
appContext.setDialogMessage({
title: constants.DISABLE_FILE_DOWNLOAD,
content: constants.DISABLE_FILE_DOWNLOAD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: props.collection.id,
enableDownload: false,
}),
variant: ButtonVariant.danger,
},
});
};
const updatePublicShareURLHelper = async (req: UpdatePublicURL) => {
try {
galleryContext.setBlockingLoad(true);
const response = await updateShareableURL(req);
setPublicShareProp(response);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
galleryContext.setBlockingLoad(false);
}
};
const updateDeviceLimit = async (newLimit: number) => {
return updatePublicShareURLHelper({
collectionID: props.collection.id,
deviceLimit: newLimit,
});
};
const updateDeviceExpiry = async (optionFn) => {
return updatePublicShareURLHelper({
collectionID: props.collection.id,
validTill: optionFn(),
});
};
const handleCollectionPublicSharing = () => {
setSharableLinkError(null);
if (publicShareUrl) {
disablePublicSharing();
} else {
createSharableURLHelper();
}
};
const handleFileDownloadSetting = () => {
if (publicShareProp.enableDownload) {
disableFileDownload();
} else {
updatePublicShareURLHelper({
collectionID: props.collection.id,
enableDownload: true,
});
}
};
const ShareeRow = ({ sharee, collectionUnshare }: ShareeProps) => (
<tr>
<td>{sharee.email}</td>
<td>
<Button
variant="outline-danger"
style={{
height: '25px',
lineHeight: 0,
padding: 0,
width: '25px',
fontSize: '1.2em',
fontWeight: 900,
}}
onClick={() => collectionUnshare(sharee)}>
-
</Button>
</td>
</tr>
);
if (!props.collection) {
return <></>;
}
return (
<DialogBox
open={props.show}
onClose={props.onHide}
attributes={{
title: constants.SHARE_COLLECTION,
staticBackdrop: true,
}}>
<DeadCenter style={{ width: '85%', margin: 'auto' }}>
<h6 style={{ marginTop: '8px' }}>
{constants.SHARE_WITH_PEOPLE}
</h6>
<p />
<Formik<formValues>
initialValues={{ email: '' }}
validationSchema={Yup.object().shape({
email: Yup.string()
.email(constants.EMAIL_ERROR)
.required(constants.REQUIRED),
})}
validateOnChange={false}
validateOnBlur={false}
onSubmit={collectionShare}>
{({
values,
errors,
touched,
handleChange,
handleSubmit,
}) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group
as={Col}
xs={10}
controlId="formHorizontalEmail">
<Form.Control
type="email"
placeholder={constants.ENTER_EMAIL}
value={values.email}
onChange={handleChange('email')}
isInvalid={Boolean(
touched.email && errors.email
)}
autoFocus
disabled={loading}
/>
<FormControl.Feedback type="invalid">
{errors.email}
</FormControl.Feedback>
</Form.Group>
<Form.Group
as={Col}
xs={2}
controlId="formHorizontalEmail">
<SubmitButton
loading={loading}
inline
buttonText="+"
/>
</Form.Group>
</Form.Row>
</Form>
)}
</Formik>
{props.collection.sharees?.length > 0 && (
<>
<p>{constants.SHAREES}</p>
<Table striped bordered hover variant="dark" size="sm">
<tbody>
{props.collection.sharees?.map((sharee) => (
<ShareeRow
key={sharee.email}
sharee={sharee}
collectionUnshare={collectionUnshare}
/>
))}
</tbody>
</Table>
</>
)}
<div
style={{
height: '1px',
marginTop: '10px',
marginBottom: '18px',
background: '#444',
width: '100%',
}}
/>
<div>
<FlexWrapper>
<FlexWrapper
style={{ paddingTop: '5px', color: '#fff' }}>
{constants.PUBLIC_SHARING}
</FlexWrapper>
<Form.Switch
style={{ marginLeft: '20px' }}
checked={!!publicShareUrl}
id="collection-public-sharing-toggler"
className="custom-switch-md"
onChange={handleCollectionPublicSharing}
/>
</FlexWrapper>
{sharableLinkError && (
<FlexWrapper
style={{
marginTop: '10px',
color: getVariantColor(ButtonVariant.danger),
}}>
{sharableLinkError}
</FlexWrapper>
)}
</div>
{publicShareUrl ? (
<>
<CodeBlock
wordBreak={'break-all'}
code={publicShareUrl}
/>
<details style={{ width: '100%' }}>
<summary
onClick={(e) => {
const lastOptionRow: Element =
e.currentTarget.nextElementSibling
.lastElementChild;
const main = async (
lastOptionRow: Element
) => {
await sleep(0);
lastOptionRow.scrollIntoView(true);
};
main(lastOptionRow);
}}
className="manageLinkHeader"
style={{ marginBottom: '20px' }}>
{constants.MANAGE_LINK}
</summary>
<section>
<OptionRow>
<OptionLabel>
{constants.LINK_DEVICE_LIMIT}
</OptionLabel>
<OptionValue>
<Select
menuPosition="fixed"
options={deviceLimitOptions}
isSearchable={false}
value={{
label: publicShareProp?.deviceLimit.toString(),
value: publicShareProp?.deviceLimit,
}}
onChange={(e) =>
updateDeviceLimit(e.value)
}
styles={DropdownStyle}
/>
</OptionValue>
</OptionRow>
<OptionRow>
<OptionLabel
style={{ alignItems: 'center' }}>
{constants.LINK_EXPIRY}
</OptionLabel>
<OptionValue>
<Select
menuPosition="fixed"
options={expiryOptions}
isSearchable={false}
value={null}
placeholder={
publicShareProp?.validTill
? dateStringWithMMH(
publicShareProp?.validTill
)
: 'never'
}
onChange={(e) => {
updateDeviceExpiry(e.value);
}}
styles={linkExpiryStyle}
/>
</OptionValue>
</OptionRow>
<OptionRow>
<OptionLabel>
{constants.FILE_DOWNLOAD}
</OptionLabel>
<OptionValue>
<Form.Switch
style={{ marginLeft: '10px' }}
checked={
publicShareProp?.enableDownload ??
false
}
id="public-sharing-file-download-toggler"
className="custom-switch-md"
onChange={handleFileDownloadSetting}
/>
</OptionValue>
</OptionRow>
<OptionRow>
<OptionLabel>
{constants.LINK_PASSWORD_LOCK}{' '}
</OptionLabel>
<OptionValue>
<Form.Switch
style={{ marginLeft: '10px' }}
checked={
publicShareProp?.passwordEnabled
}
id="public-sharing-file-password-toggler"
className="custom-switch-md"
onChange={
handlePasswordChangeSetting
}
/>
</OptionValue>
</OptionRow>
</section>
<DialogBox
open={configurePassword}
onClose={() => setConfigurePassword(false)}
size="sm"
attributes={{
title: constants.PASSWORD_LOCK,
}}>
<SingleInputForm
callback={savePassword}
placeholder={
constants.RETURN_PASSPHRASE_HINT
}
buttonText={constants.LOCK}
fieldType="password"
/>
</DialogBox>
</details>
</>
) : (
<div
style={{
height: '1px',
marginTop: '28px',
width: '100%',
}}
/>
)}
</DeadCenter>
</DialogBox>
);
}
export default CollectionShare;

View file

@ -0,0 +1,22 @@
import { Dialog, styled } from '@mui/material';
import PropTypes from 'prop-types';
export const CollectionShareContainer = styled(Dialog)(({ theme }) => ({
'& .MuiDialog-container': {
justifyContent: 'flex-end',
},
'& .MuiPaper-root': {
maxWidth: '414px',
},
'& .MuiDialogTitle-root': {
padding: theme.spacing(4, 3, 3, 4),
},
'& .MuiDialogContent-root': {
padding: theme.spacing(3, 4),
},
}));
CollectionShareContainer.propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,46 @@
import SingleInputForm from 'components/SingleInputForm';
import { GalleryContext } from 'pages/gallery';
import React, { useContext } from 'react';
import { shareCollection } from 'services/collectionService';
import { User } from 'types/user';
import { sleep } from 'utils/common';
import { handleSharingErrors } from 'utils/error';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import constants from 'utils/strings/constants';
import { CollectionShareSharees } from './sharees';
import CollectionShareSubmitButton from './submitButton';
export default function EmailShare({ collection }) {
const galleryContext = useContext(GalleryContext);
const collectionShare = async (email, setFieldError) => {
try {
const user: User = getData(LS_KEYS.USER);
if (email === user.email) {
setFieldError('email', constants.SHARE_WITH_SELF);
} else if (
collection?.sharees?.find((value) => value.email === email)
) {
setFieldError('email', constants.ALREADY_SHARED(email));
} else {
await shareCollection(collection, email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
}
} catch (e) {
const errorMessage = handleSharingErrors(e);
setFieldError('email', errorMessage);
}
};
return (
<>
<SingleInputForm
callback={collectionShare}
placeholder={constants.ENTER_EMAIL}
fieldType="email"
buttonText={constants.SHARE}
customSubmitButton={CollectionShareSubmitButton}
/>
<CollectionShareSharees collection={collection} />
</>
);
}

View file

@ -0,0 +1,44 @@
import EmailShare from './emailShare';
import React from 'react';
import constants from 'utils/strings/constants';
import { Collection } from 'types/collection';
import { dialogCloseHandler } from 'components/DialogBox/base';
import DialogTitleWithCloseButton from 'components/DialogBox/titleWithCloseButton';
import DialogContent from '@mui/material/DialogContent';
import { Divider } from '@mui/material';
import { CollectionShareContainer } from './container';
import PublicShare from './publicShare';
interface Props {
show: boolean;
onHide: () => void;
collection: Collection;
}
function CollectionShare(props: Props) {
const handleClose = dialogCloseHandler({
staticBackdrop: true,
onClose: props.onHide,
});
if (!props.collection) {
return <></>;
}
return (
<>
<CollectionShareContainer open={props.show} onClose={handleClose}>
<DialogTitleWithCloseButton onClose={handleClose}>
{constants.SHARE_COLLECTION}
</DialogTitleWithCloseButton>
<DialogContent>
<EmailShare collection={props.collection} />
<Divider />
<PublicShare collection={props.collection} />
</DialogContent>
</CollectionShareContainer>
</>
);
}
export default CollectionShare;

View file

@ -0,0 +1,105 @@
import { Box, Typography } from '@mui/material';
import { FlexWrapper } from 'components/Container';
import { ButtonVariant } from 'components/pages/gallery/LinkButton';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import {
createShareableURL,
deleteShareableURL,
} from 'services/collectionService';
import { appendCollectionKeyToShareURL } from 'utils/collection';
import { handleSharingErrors } from 'utils/error';
import constants from 'utils/strings/constants';
import PublicShareSwitch from './switch';
export default function PublicShareControl({
publicShareUrl,
sharableLinkError,
collection,
setPublicShareUrl,
setSharableLinkError,
}) {
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const createSharableURLHelper = async () => {
try {
appContext.startLoading();
const publicURL = await createShareableURL(collection);
const sharableURL = await appendCollectionKeyToShareURL(
publicURL.url,
collection.key
);
setPublicShareUrl(sharableURL);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const disablePublicSharing = async () => {
try {
appContext.startLoading();
await deleteShareableURL(collection);
setPublicShareUrl(null);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
appContext.finishLoading();
}
};
const confirmDisablePublicSharing = () => {
appContext.setDialogMessage({
title: constants.DISABLE_PUBLIC_SHARING,
content: constants.DISABLE_PUBLIC_SHARING_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: disablePublicSharing,
variant: ButtonVariant.danger,
},
});
};
const handleCollectionPublicSharing = () => {
setSharableLinkError(null);
if (publicShareUrl) {
confirmDisablePublicSharing();
} else {
createSharableURLHelper();
}
};
return (
<Box mt={3}>
<FlexWrapper>
<FlexWrapper>{constants.PUBLIC_SHARING}</FlexWrapper>
<PublicShareSwitch
color="accent"
sx={{
ml: 2,
}}
checked={!!publicShareUrl}
onChange={handleCollectionPublicSharing}
/>
</FlexWrapper>
{sharableLinkError && (
<Typography
variant="body2"
sx={{
color: (theme) => theme.palette.danger.main,
mt: 0.5,
}}>
{sharableLinkError}
</Typography>
)}
</Box>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import { Box, Typography, Divider } from '@mui/material';
import { components } from 'react-select';
const { Option } = components;
export const OptionWithDivider = (props) => (
<Option {...props}>
<LabelWithDivider data={props.data} />
</Option>
);
export const LabelWithDivider = ({ data }) => (
<>
<Box className="main" px={3} py={1}>
<Typography>{data.label}</Typography>
</Box>
<Divider />
</>
);

View file

@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react';
import { PublicURL } from 'types/collection';
import { appendCollectionKeyToShareURL } from 'utils/collection';
import PublicShareControl from './control';
import PublicShareLink from './link';
import PublicShareManage from './manage';
export default function PublicShare({ collection }) {
const [sharableLinkError, setSharableLinkError] = useState(null);
const [publicShareUrl, setPublicShareUrl] = useState<string>(null);
const [publicShareProp, setPublicShareProp] = useState<PublicURL>(null);
useEffect(() => {
const main = async () => {
if (collection?.publicURLs?.[0]?.url) {
const t = await appendCollectionKeyToShareURL(
collection?.publicURLs?.[0]?.url,
collection.key
);
setPublicShareUrl(t);
setPublicShareProp(collection?.publicURLs?.[0] as PublicURL);
} else {
setPublicShareUrl(null);
setPublicShareProp(null);
}
};
main();
}, [collection]);
return (
<>
<PublicShareControl
setPublicShareUrl={setPublicShareUrl}
collection={collection}
publicShareUrl={publicShareUrl}
sharableLinkError={sharableLinkError}
setSharableLinkError={setSharableLinkError}
/>
{publicShareUrl && (
<PublicShareLink publicShareUrl={publicShareUrl} />
)}
{publicShareProp && (
<PublicShareManage
publicShareProp={publicShareProp}
collection={collection}
setPublicShareProp={setPublicShareProp}
setSharableLinkError={setSharableLinkError}
/>
)}
</>
);
}

View file

@ -0,0 +1,11 @@
import { Box } from '@mui/material';
import CodeBlock from 'components/CodeBlock';
import React from 'react';
export default function PublicShareLink({ publicShareUrl }) {
return (
<Box mt={2} mb={3}>
<CodeBlock wordBreak={'break-all'} code={publicShareUrl} />
</Box>
);
}

View file

@ -0,0 +1,39 @@
import { Box, Typography } from '@mui/material';
import React from 'react';
import Select from 'react-select';
import { getDeviceLimitOptions } from 'utils/collection';
import constants from 'utils/strings/constants';
import { DropdownStyle } from '../../styles';
import { OptionWithDivider } from '../customSelectComponents';
export function ManageDeviceLimit({
publicShareProp,
collection,
updatePublicShareURLHelper,
}) {
const updateDeviceLimit = async (newLimit: number) => {
return updatePublicShareURLHelper({
collectionID: collection.id,
deviceLimit: newLimit,
});
};
return (
<Box>
<Typography>{constants.LINK_DEVICE_LIMIT}</Typography>
<Select
menuPosition="fixed"
options={getDeviceLimitOptions()}
components={{
Option: OptionWithDivider,
}}
isSearchable={false}
value={{
label: publicShareProp?.deviceLimit.toString(),
value: publicShareProp?.deviceLimit,
}}
onChange={(e) => updateDeviceLimit(e.value)}
styles={DropdownStyle}
/>
</Box>
);
}

View file

@ -0,0 +1,50 @@
import { Box, Typography } from '@mui/material';
import { ButtonVariant } from 'components/pages/gallery/LinkButton';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import constants from 'utils/strings/constants';
import PublicShareSwitch from '../switch';
export function ManageDownloadAccess({
publicShareProp,
updatePublicShareURLHelper,
collection,
}) {
const appContext = useContext(AppContext);
const handleFileDownloadSetting = () => {
if (publicShareProp.enableDownload) {
disableFileDownload();
} else {
updatePublicShareURLHelper({
collectionID: collection.id,
enableDownload: true,
});
}
};
const disableFileDownload = () => {
appContext.setDialogMessage({
title: constants.DISABLE_FILE_DOWNLOAD,
content: constants.DISABLE_FILE_DOWNLOAD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: collection.id,
enableDownload: false,
}),
variant: ButtonVariant.danger,
},
});
};
return (
<Box>
<Typography>{constants.FILE_DOWNLOAD}</Typography>
<PublicShareSwitch
checked={publicShareProp?.enableDownload ?? false}
onChange={handleFileDownloadSetting}
/>
</Box>
);
}

View file

@ -0,0 +1,105 @@
import { ManageLinkPassword } from './linkPassword';
import { ManageDeviceLimit } from './deviceLimit';
import { ManageLinkExpiry } from './linkExpiry';
import { PublicLinkSetPassword } from '../setPassword';
import { Stack } from '@mui/material';
import { GalleryContext } from 'pages/gallery';
import React, { useContext, useState } from 'react';
import { updateShareableURL } from 'services/collectionService';
import { UpdatePublicURL } from 'types/collection';
import { sleep } from 'utils/common';
import { handleSharingErrors } from 'utils/error';
import constants from 'utils/strings/constants';
import {
ManageSectionLabel,
ManageSectionOptions,
} from '../../styledComponents';
import { ManageDownloadAccess } from './downloadAcess';
export default function PublicShareManage({
publicShareProp,
collection,
setPublicShareProp,
setSharableLinkError,
}) {
const galleryContext = useContext(GalleryContext);
const [changePasswordView, setChangePasswordView] = useState(false);
const closeConfigurePassword = () => setChangePasswordView(false);
const updatePublicShareURLHelper = async (req: UpdatePublicURL) => {
try {
galleryContext.setBlockingLoad(true);
const response = await updateShareableURL(req);
setPublicShareProp(response);
galleryContext.syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
} finally {
galleryContext.setBlockingLoad(false);
}
};
const scrollToEnd = (e) => {
const lastOptionRow: Element =
e.currentTarget.nextElementSibling.lastElementChild;
const main = async (lastOptionRow: Element) => {
await sleep(0);
lastOptionRow.scrollIntoView(true);
};
main(lastOptionRow);
};
return (
<>
<details>
<ManageSectionLabel onClick={scrollToEnd}>
{constants.MANAGE_LINK}
</ManageSectionLabel>
<ManageSectionOptions>
<Stack spacing={1}>
<ManageLinkExpiry
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
<ManageDeviceLimit
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
<ManageDownloadAccess
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
<ManageLinkPassword
setChangePasswordView={setChangePasswordView}
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={
updatePublicShareURLHelper
}
/>
</Stack>
</ManageSectionOptions>
</details>
<PublicLinkSetPassword
open={changePasswordView}
onClose={closeConfigurePassword}
collection={collection}
publicShareProp={publicShareProp}
updatePublicShareURLHelper={updatePublicShareURLHelper}
setChangePasswordView={setChangePasswordView}
/>
</>
);
}

View file

@ -0,0 +1,54 @@
import { Box, Typography } from '@mui/material';
import { DropdownStyle } from 'components/Collections/CollectionShare/styles';
import React from 'react';
import Select from 'react-select';
import { shareExpiryOptions } from 'utils/collection';
import constants from 'utils/strings/constants';
import { dateStringWithMMH } from 'utils/time';
import { OptionWithDivider } from '../customSelectComponents';
const linkExpiryStyle = {
...DropdownStyle,
placeholder: (style) => ({
...style,
color: '#d1d1d1',
width: '100%',
textAlign: 'center',
}),
};
export function ManageLinkExpiry({
publicShareProp,
collection,
updatePublicShareURLHelper,
}) {
const updateDeviceExpiry = async (optionFn) => {
return updatePublicShareURLHelper({
collectionID: collection.id,
validTill: optionFn(),
});
};
return (
<Box>
<Typography>{constants.LINK_EXPIRY}</Typography>
<Select
menuPosition="fixed"
options={shareExpiryOptions}
isSearchable={false}
value={null}
components={{
Option: OptionWithDivider,
}}
placeholder={
publicShareProp?.validTill
? dateStringWithMMH(publicShareProp?.validTill)
: 'never'
}
onChange={(e) => {
updateDeviceExpiry(e.value);
}}
styles={linkExpiryStyle}
/>
</Box>
);
}

View file

@ -0,0 +1,49 @@
import { Box, Typography } from '@mui/material';
import { ButtonVariant } from 'components/pages/gallery/LinkButton';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import constants from 'utils/strings/constants';
import PublicShareSwitch from '../switch';
export function ManageLinkPassword({
collection,
publicShareProp,
updatePublicShareURLHelper,
setChangePasswordView,
}) {
const appContext = useContext(AppContext);
const handlePasswordChangeSetting = async () => {
if (publicShareProp.passwordEnabled) {
await confirmDisablePublicUrlPassword();
} else {
setChangePasswordView(true);
}
};
const confirmDisablePublicUrlPassword = async () => {
appContext.setDialogMessage({
title: constants.DISABLE_PASSWORD,
content: constants.DISABLE_PASSWORD_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
text: constants.DISABLE,
action: () =>
updatePublicShareURLHelper({
collectionID: collection.id,
disablePassword: true,
}),
variant: ButtonVariant.danger,
},
});
};
return (
<Box>
<Typography> {constants.LINK_PASSWORD_LOCK}</Typography>
<PublicShareSwitch
checked={!!publicShareProp?.passwordEnabled}
onChange={handlePasswordChangeSetting}
/>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import DialogBox from 'components/DialogBox';
import SingleInputForm from 'components/SingleInputForm';
import React from 'react';
import CryptoWorker from 'utils/crypto';
import constants from 'utils/strings/constants';
export function PublicLinkSetPassword({
open,
onClose,
collection,
publicShareProp,
updatePublicShareURLHelper,
setChangePasswordView,
}) {
const savePassword = async (passphrase, setFieldError) => {
if (passphrase && passphrase.trim().length >= 1) {
await enablePublicUrlPassword(passphrase);
setChangePasswordView(false);
publicShareProp.passwordEnabled = true;
} else {
setFieldError('linkPassword', 'can not be empty');
}
};
const enablePublicUrlPassword = async (password: string) => {
const cryptoWorker = await new CryptoWorker();
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
const kek = await cryptoWorker.deriveInteractiveKey(password, kekSalt);
return updatePublicShareURLHelper({
collectionID: collection.id,
passHash: kek.key,
nonce: kekSalt,
opsLimit: kek.opsLimit,
memLimit: kek.memLimit,
});
};
return (
<DialogBox
open={open}
onClose={onClose}
PaperProps={{ sx: { maxWidth: '350px' } }}
titleCloseButton
attributes={{
title: constants.PASSWORD_LOCK,
}}>
<SingleInputForm
callback={savePassword}
placeholder={constants.RETURN_PASSPHRASE_HINT}
buttonText={constants.LOCK}
fieldType="password"
/>
</DialogBox>
);
}

View file

@ -0,0 +1,61 @@
import React from 'react';
import { SwitchProps, Switch } from '@mui/material';
import styled from 'styled-components';
const PublicShareSwitch = styled((props: SwitchProps) => (
<Switch
focusVisibleClassName=".Mui-focusVisible"
disableRipple
{...props}
/>
))(({ theme }) => ({
width: 40,
height: 24,
padding: 0,
'& .MuiSwitch-switchBase': {
padding: 0,
margin: 2,
transitionDuration: '300ms',
'&.Mui-checked': {
transform: 'translateX(16px)',
color: '#fff',
'& + .MuiSwitch-track': {
backgroundColor:
theme.palette.mode === 'dark' ? '#2ECA45' : '#65C466',
opacity: 1,
border: 0,
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5,
},
},
'&.Mui-focusVisible .MuiSwitch-thumb': {
color: '#33cf4d',
border: '6px solid #fff',
},
'&.Mui-disabled .MuiSwitch-thumb': {
color:
theme.palette.mode === 'light'
? theme.palette.grey[100]
: theme.palette.grey[600],
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
},
},
'& .MuiSwitch-thumb': {
boxSizing: 'border-box',
width: 20,
height: 20,
},
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
backgroundColor: theme.palette.mode === 'light' ? '#E9E9EA' : '#39393D',
opacity: 1,
transition: theme.transitions.create(['background-color'], {
duration: 500,
}),
},
}));
export default PublicShareSwitch;

View file

@ -0,0 +1,48 @@
import { Box, Typography } from '@mui/material';
import { GalleryContext } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import React, { useContext } from 'react';
import { unshareCollection } from 'services/collectionService';
import { Collection } from 'types/collection';
import { sleep } from 'utils/common';
import constants from 'utils/strings/constants';
import ShareeRow from './row';
interface Iprops {
collection: Collection;
}
export function CollectionShareSharees({ collection }: Iprops) {
const appContext = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const collectionUnshare = async (sharee) => {
try {
appContext.startLoading();
await unshareCollection(collection, sharee.email);
await sleep(2000);
await galleryContext.syncWithRemote(false, true);
} finally {
appContext.finishLoading();
}
};
if (!collection.sharees?.length) {
return <></>;
}
return (
<Box mb={3}>
<Typography variant="body2" color="text.secondary">
{constants.SHAREES}
</Typography>
{collection.sharees?.map((sharee) => (
<ShareeRow
key={sharee.email}
sharee={sharee}
collectionUnshare={collectionUnshare}
/>
))}
</Box>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { IconButton } from '@mui/material';
import { SpaceBetweenFlex } from 'components/Container';
import { User } from 'types/user';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
interface IProps {
sharee: User;
collectionUnshare: (sharee: User) => void;
}
const ShareeRow = ({ sharee, collectionUnshare }: IProps) => (
<SpaceBetweenFlex>
{sharee.email}
<IconButton
sx={{ ml: 2, color: 'text.secondary' }}
onClick={() => collectionUnshare(sharee)}>
<MoreHorizIcon />
</IconButton>
</SpaceBetweenFlex>
);
export default ShareeRow;

View file

@ -0,0 +1,14 @@
import styled from 'styled-components';
export const ManageSectionLabel = styled.summary(
({ theme }) => `
text-align: center;
margin-bottom:${theme.spacing(1)};
`
);
export const ManageSectionOptions = styled.section(
({ theme }) => `
margin-bottom:${theme.spacing(4)};
`
);

View file

@ -0,0 +1,20 @@
import { SelectStyles } from 'components/Search/styles';
export const DropdownStyle = {
...SelectStyles,
dropdownIndicator: (style) => ({
...style,
margin: '0px',
}),
singleValue: (style) => ({
...style,
color: '#d1d1d1',
width: '240px',
}),
control: (style, { isFocused }) => ({
...style,
...SelectStyles.control(style, { isFocused }),
minWidth: '240px',
paddingLeft: '8px',
}),
};

View file

@ -0,0 +1,10 @@
import { FlexWrapper } from 'components/Container';
import SubmitButton, { SubmitButtonProps } from 'components/SubmitButton';
import React from 'react';
export default function CollectionShareSubmitButton(props: SubmitButtonProps) {
return (
<FlexWrapper style={{ justifyContent: 'flex-end' }}>
<SubmitButton {...props} size="medium" inline sx={{ my: 2 }} />
</FlexWrapper>
);
}

View file

@ -27,7 +27,7 @@ export default function Collections(props: Iprops) {
const [allCollectionView, setAllCollectionView] = useState(false);
const [collectionShareModalView, setCollectionShareModalView] =
useState(false);
useState(true);
const collectionsMap = useRef<Map<number, Collection>>(new Map());
const activeCollection = useRef<Collection>(null);

View file

@ -22,7 +22,12 @@ type IProps = React.PropsWithChildren<
}
>;
export default function DialogBox({ attributes, children, ...props }: IProps) {
export default function DialogBox({
attributes,
children,
titleCloseButton,
...props
}: IProps) {
if (!attributes) {
return <Dialog open={false} />;
}
@ -41,7 +46,7 @@ export default function DialogBox({ attributes, children, ...props }: IProps) {
{...props}>
{attributes.title && (
<DialogTitleWithCloseButton
onClose={props.titleCloseButton && handleClose}>
onClose={titleCloseButton && handleClose}>
{attributes.title}
</DialogTitleWithCloseButton>
)}

View file

@ -32,7 +32,7 @@ export const SelectStyles = {
cursor: 'pointer',
},
'& .main': {
backgroundColor: isFocused && '#343434',
backgroundColor: isFocused && '#202020',
},
'&:last-child .MuiDivider-root': {
display: 'none',

View file

@ -5,6 +5,7 @@ import * as Yup from 'yup';
import SubmitButton from './SubmitButton';
import TextField from '@mui/material/TextField';
import ShowHidePassword from './Form/ShowHidePassword';
import { FlexWrapper } from './Container';
interface formValues {
inputValue: string;
@ -17,6 +18,7 @@ export interface SingleInputFormProps {
fieldType: 'text' | 'email' | 'password';
placeholder: string;
buttonText: string;
customSubmitButton?: any;
}
export default function SingleInputForm(props: SingleInputFormProps) {
@ -97,12 +99,19 @@ export default function SingleInputForm(props: SingleInputFormProps) {
),
}}
/>
<SubmitButton
sx={{ mt: 2 }}
buttonText={props.buttonText}
loading={loading}
/>
<FlexWrapper></FlexWrapper>
{props.customSubmitButton ? (
<props.customSubmitButton
buttonText={props.buttonText}
loading={loading}
/>
) : (
<SubmitButton
sx={{ mt: 2 }}
buttonText={props.buttonText}
loading={loading}
/>
)}
</form>
)}
</Formik>

View file

@ -1,13 +1,13 @@
import { Button, ButtonProps, CircularProgress } from '@mui/material';
import React, { FC } from 'react';
interface Props {
export interface SubmitButtonProps {
loading: boolean;
buttonText: string;
inline?: any;
disabled?: boolean;
}
const SubmitButton: FC<ButtonProps<'button', Props>> = ({
const SubmitButton: FC<ButtonProps<'button', SubmitButtonProps>> = ({
loading,
buttonText,
inline,

View file

@ -488,6 +488,3 @@ div.otp-input input:not(:placeholder-shown) , div.otp-input input:focus{
}
}
.manageLinkHeader:hover{
color:#bbb;
}

View file

@ -44,6 +44,12 @@ declare module '@mui/material/Typography' {
}
}
declare module '@mui/material/Switch' {
interface SwitchPropsColorOverrides {
accent: true;
}
}
// Create a theme instance.
const darkThemeOptions = createTheme({
components: {
@ -123,6 +129,13 @@ const darkThemeOptions = createTheme({
},
},
},
MuiTypography: {
styleOverrides: {
body1: {
paddingBottom: '4px',
},
},
},
},
palette: {

View file

@ -129,10 +129,8 @@ const _intSelectOption = (i: number) => {
return { label: i.toString(), value: i };
};
export function selectIntOptions(upperLimit: number) {
return [...Array(upperLimit).reverse().keys()].map((i) =>
_intSelectOption(i + 1)
);
export function getDeviceLimitOptions() {
return [2, 5, 10, 25, 50].map((i) => _intSelectOption(i));
}
export const shareExpiryOptions = [

View file

@ -50,7 +50,7 @@ const englishConstants = {
NAME: 'name',
ENTER_NAME: 'your name',
EMAIL: 'email',
ENTER_EMAIL: 'email',
ENTER_EMAIL: 'Email',
DATA_DISCLAIMER: "we'll never share your data with anyone else.",
SUBMIT: 'submit',
EMAIL_ERROR: 'enter a valid email',
@ -672,7 +672,7 @@ const englishConstants = {
REPORT_SUBMIT_FAILED: 'failed to sent report, try again',
INSTALL: 'install',
ALBUM_URL: 'album url',
PUBLIC_SHARING: 'link sharing',
PUBLIC_SHARING: 'Public link',
NOT_FOUND: '404 - not found',
LINK_EXPIRED: 'this link has either expired or been disabled!',
MANAGE_LINK: 'manage link',
@ -680,8 +680,8 @@ const englishConstants = {
DISABLE_PUBLIC_SHARING: "'disable public sharing",
DISABLE_PUBLIC_SHARING_MESSAGE:
'are you sure you want to disable public sharing?',
FILE_DOWNLOAD: 'file download',
LINK_PASSWORD_LOCK: 'password lock',
FILE_DOWNLOAD: 'Allow downloads',
LINK_PASSWORD_LOCK: 'Password lock',
LINK_DEVICE_LIMIT: 'device limit',
LINK_EXPIRY: 'link expiry',
LINK_EXPIRY_NEVER: 'never',