This commit is contained in:
Manav Rathi
2024-11-09 17:17:45 +05:30
parent c6bdd4bd8f
commit dc07bd9c74
6 changed files with 893 additions and 963 deletions

View File

@@ -3,43 +3,65 @@ import {
MenuItemGroup,
MenuSectionTitle,
} from "@/base/components/Menu";
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { LoadingButton } from "@/base/components/mui/LoadingButton";
import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer";
import { Titlebar } from "@/base/components/Titlebar";
import { useModalVisibility } from "@/base/components/utils/modal";
import { sharedCryptoWorker } from "@/base/crypto";
import log from "@/base/log";
import type {
Collection,
PublicURL,
UpdatePublicURL,
} from "@/media/collection";
import { COLLECTION_ROLE } from "@/media/collection";
import { COLLECTION_ROLE, type CollectionUser } from "@/media/collection";
import { PublicLinkCreated } from "@/new/photos/components/share/PublicLinkCreated";
import type { CollectionSummary } from "@/new/photos/services/collection/ui";
import { useAppContext } from "@/new/photos/types/context";
import { AppContext, useAppContext } from "@/new/photos/types/context";
import { FlexWrapper } from "@ente/shared/components/Container";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
import { formatDateTime } from "@ente/shared/time/format";
import { default as Add, default as AddIcon } from "@mui/icons-material/Add";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import BlockIcon from "@mui/icons-material/Block";
import ChevronRightIcon, {
default as ChevronRight,
} from "@mui/icons-material/ChevronRight";
import ContentCopyIcon from "@mui/icons-material/ContentCopyOutlined";
import DoneIcon from "@mui/icons-material/Done";
import DownloadSharp from "@mui/icons-material/DownloadSharp";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import LinkIcon from "@mui/icons-material/Link";
import ModeEditIcon from "@mui/icons-material/ModeEdit";
import Photo from "@mui/icons-material/Photo";
import Photo, { default as PhotoIcon } from "@mui/icons-material/Photo";
import PublicIcon from "@mui/icons-material/Public";
import RemoveCircleOutline from "@mui/icons-material/RemoveCircleOutline";
import { Dialog, DialogProps, Stack, Typography } from "@mui/material";
import Workspaces from "@mui/icons-material/Workspaces";
import {
Dialog,
DialogProps,
FormHelperText,
Stack,
styled,
Typography,
} from "@mui/material";
import NumberAvatar from "@mui/material/Avatar";
import TextField from "@mui/material/TextField";
import Avatar from "components/pages/gallery/Avatar";
import { Formik, type FormikHelpers } from "formik";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import React, { useContext, useEffect, useMemo, useState } from "react";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Trans } from "react-i18next";
import {
createShareableURL,
deleteShareableURL,
shareCollection,
unshareCollection,
updateShareableURL,
} from "services/collectionService";
import { SetPublicShareProp } from "types/publicCollection";
@@ -49,7 +71,7 @@ import {
shareExpiryOptions,
} from "utils/collection";
import { handleSharingErrors } from "utils/error/ui";
import EmailShare from "./emailShare";
import * as Yup from "yup";
interface CollectionShareProps {
open: boolean;
@@ -297,6 +319,870 @@ const EnablePublicShareOptions: React.FC<EnablePublicShareOptionsProps> = ({
);
};
export default function EmailShare({
collection,
onRootClose,
}: {
collection: Collection;
onRootClose: () => void;
}) {
const [addParticipantView, setAddParticipantView] = useState(false);
const [manageEmailShareView, setManageEmailShareView] = useState(false);
const closeAddParticipant = () => setAddParticipantView(false);
const openAddParticipant = () => setAddParticipantView(true);
const closeManageEmailShare = () => setManageEmailShareView(false);
const openManageEmailShare = () => setManageEmailShareView(true);
const participantType = useRef<
COLLECTION_ROLE.COLLABORATOR | COLLECTION_ROLE.VIEWER
>();
const openAddCollab = () => {
participantType.current = COLLECTION_ROLE.COLLABORATOR;
openAddParticipant();
};
const openAddViewer = () => {
participantType.current = COLLECTION_ROLE.VIEWER;
openAddParticipant();
};
return (
<>
<Stack>
<MenuSectionTitle
title={t("shared_with_people_count", {
count: collection.sharees?.length ?? 0,
})}
icon={<Workspaces />}
/>
<MenuItemGroup>
{collection.sharees.length > 0 ? (
<>
<EnteMenuItem
fontWeight={"normal"}
startIcon={
<AvatarGroup sharees={collection.sharees} />
}
onClick={openManageEmailShare}
label={
collection.sharees.length === 1
? collection.sharees[0]?.email
: null
}
endIcon={<ChevronRight />}
/>
<MenuItemDivider hasIcon />
</>
) : null}
<EnteMenuItem
startIcon={<AddIcon />}
onClick={openAddViewer}
label={t("ADD_VIEWERS")}
/>
<MenuItemDivider hasIcon />
<EnteMenuItem
startIcon={<AddIcon />}
onClick={openAddCollab}
label={t("ADD_COLLABORATORS")}
/>
</MenuItemGroup>
</Stack>
<AddParticipant
open={addParticipantView}
onClose={closeAddParticipant}
onRootClose={onRootClose}
collection={collection}
type={participantType.current}
/>
<ManageEmailShare
peopleCount={collection.sharees.length}
open={manageEmailShareView}
onClose={closeManageEmailShare}
onRootClose={onRootClose}
collection={collection}
/>
</>
);
}
const AvatarContainer = styled("div")({
position: "relative",
display: "flex",
alignItems: "center",
marginLeft: -5,
});
const AvatarContainerOuter = styled("div")({
position: "relative",
display: "flex",
alignItems: "center",
marginLeft: 8,
});
const AvatarCounter = styled(NumberAvatar)({
height: 20,
width: 20,
fontSize: 10,
color: "#fff",
});
const SHAREE_AVATAR_LIMIT = 6;
const AvatarGroup = ({ sharees }: { sharees: Collection["sharees"] }) => {
const hasShareesOverLimit = sharees?.length > SHAREE_AVATAR_LIMIT;
const countOfShareesOverLimit = sharees?.length - SHAREE_AVATAR_LIMIT;
return (
<AvatarContainerOuter>
{sharees?.slice(0, 6).map((sharee) => (
<AvatarContainer key={sharee.email}>
<Avatar
key={sharee.email}
email={sharee.email}
opacity={100}
/>
</AvatarContainer>
))}
{hasShareesOverLimit && (
<AvatarContainer key="extra-count">
<AvatarCounter>+{countOfShareesOverLimit}</AvatarCounter>
</AvatarContainer>
)}
</AvatarContainerOuter>
);
};
interface AddParticipantProps {
collection: Collection;
open: boolean;
onClose: () => void;
onRootClose: () => void;
type: COLLECTION_ROLE.VIEWER | COLLECTION_ROLE.COLLABORATOR;
}
const AddParticipant: React.FC<AddParticipantProps> = ({
open,
collection,
onClose,
onRootClose,
type,
}) => {
const { user, syncWithRemote, emailList } = useContext(GalleryContext);
const nonSharedEmails = useMemo(
() =>
emailList.filter(
(email) =>
!collection.sharees?.find((value) => value.email === email),
),
[emailList, collection.sharees],
);
const collectionShare: AddParticipantFormProps["callback"] = async ({
email,
emails,
}) => {
// if email is provided, means user has custom entered email, so, will need to validate for self sharing
// and already shared
if (email) {
if (email === user.email) {
throw new Error(t("SHARE_WITH_SELF"));
} else if (
collection?.sharees?.find((value) => value.email === email)
) {
throw new Error(t("ALREADY_SHARED", { email: email }));
}
// set emails to array of one email
emails = [email];
}
for (const email of emails) {
if (
email === user.email ||
collection?.sharees?.find((value) => value.email === email)
) {
// can just skip this email
continue;
}
try {
await shareCollection(collection, email, type);
await syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
throw new Error(errorMessage);
}
}
};
const handleRootClose = () => {
onClose();
onRootClose();
};
const handleDrawerClose: DialogProps["onClose"] = (_, reason) => {
if (reason === "backdropClick") {
handleRootClose();
} else {
onClose();
}
};
return (
<SidebarDrawer anchor="right" open={open} onClose={handleDrawerClose}>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={
type === COLLECTION_ROLE.VIEWER
? t("ADD_VIEWERS")
: t("ADD_COLLABORATORS")
}
onRootClose={handleRootClose}
caption={collection.name}
/>
<AddParticipantForm
onClose={onClose}
callback={collectionShare}
optionsList={nonSharedEmails}
placeholder={t("ENTER_EMAIL")}
fieldType="email"
buttonText={
type === COLLECTION_ROLE.VIEWER
? t("ADD_VIEWERS")
: t("ADD_COLLABORATORS")
}
submitButtonProps={{
size: "large",
sx: { mt: 1, mb: 2 },
}}
disableAutoFocus
/>
</Stack>
</SidebarDrawer>
);
};
interface AddParticipantFormValues {
inputValue: string;
selectedOptions: string[];
}
export interface AddParticipantFormProps {
callback: (props: { email?: string; emails?: string[] }) => Promise<void>;
fieldType: "text" | "email" | "password";
placeholder: string;
buttonText: string;
submitButtonProps?: any;
initialValue?: string;
secondaryButtonAction?: () => void;
disableAutoFocus?: boolean;
hiddenPreInput?: any;
caption?: any;
hiddenPostInput?: any;
autoComplete?: string;
blockButton?: boolean;
hiddenLabel?: boolean;
onClose?: () => void;
optionsList?: string[];
}
const AddParticipantForm: React.FC<AddParticipantFormProps> = (props) => {
const { submitButtonProps } = props;
const { sx: buttonSx, ...restSubmitButtonProps } = submitButtonProps ?? {};
const [loading, SetLoading] = useState(false);
const submitForm = async (
values: AddParticipantFormValues,
{ setFieldError, resetForm }: FormikHelpers<AddParticipantFormValues>,
) => {
try {
SetLoading(true);
if (values.inputValue !== "") {
await props.callback({ email: values.inputValue });
} else if (values.selectedOptions.length !== 0) {
await props.callback({ emails: values.selectedOptions });
}
SetLoading(false);
props.onClose();
resetForm();
} catch (e) {
setFieldError("inputValue", e?.message);
SetLoading(false);
}
};
const validationSchema = useMemo(() => {
switch (props.fieldType) {
case "text":
return Yup.object().shape({
inputValue: Yup.string().required(t("required")),
});
case "email":
return Yup.object().shape({
inputValue: Yup.string().email(t("EMAIL_ERROR")),
});
}
}, [props.fieldType]);
const handleInputFieldClick = (setFieldValue) => {
setFieldValue("selectedOptions", []);
};
return (
<Formik<AddParticipantFormValues>
initialValues={{
inputValue: props.initialValue ?? "",
selectedOptions: [],
}}
onSubmit={submitForm}
validationSchema={validationSchema}
validateOnChange={false}
validateOnBlur={false}
>
{({
values,
errors,
handleChange,
handleSubmit,
setFieldValue,
}) => (
<form noValidate onSubmit={handleSubmit}>
<Stack spacing={"24px"} py={"20px"} px={"12px"}>
{props.hiddenPreInput}
<Stack>
<MenuSectionTitle title={t("ADD_NEW_EMAIL")} />
<TextField
sx={{ marginTop: 0 }}
hiddenLabel={props.hiddenLabel}
fullWidth
type={props.fieldType}
id={props.fieldType}
onChange={handleChange("inputValue")}
onClick={() =>
handleInputFieldClick(setFieldValue)
}
name={props.fieldType}
{...(props.hiddenLabel
? { placeholder: props.placeholder }
: { label: props.placeholder })}
error={Boolean(errors.inputValue)}
helperText={errors.inputValue}
value={values.inputValue}
disabled={loading}
autoFocus={!props.disableAutoFocus}
autoComplete={props.autoComplete}
/>
</Stack>
{props.optionsList.length > 0 && (
<Stack>
<MenuSectionTitle
title={t("OR_ADD_EXISTING")}
/>
<MenuItemGroup>
{props.optionsList.map((item, index) => (
<>
<EnteMenuItem
fontWeight="normal"
key={item}
onClick={() => {
if (
values.selectedOptions.includes(
item,
)
) {
setFieldValue(
"selectedOptions",
values.selectedOptions.filter(
(
selectedOption,
) =>
selectedOption !==
item,
),
);
} else {
setFieldValue(
"selectedOptions",
[
...values.selectedOptions,
item,
],
);
}
}}
label={item}
startIcon={
<Avatar email={item} />
}
endIcon={
values.selectedOptions.includes(
item,
) ? (
<DoneIcon />
) : null
}
/>
{index !==
props.optionsList.length -
1 && <MenuItemDivider />}
</>
))}
</MenuItemGroup>
</Stack>
)}
<FormHelperText
sx={{
position: "relative",
top: errors.inputValue ? "-22px" : "0",
float: "right",
padding: "0 8px",
}}
>
{props.caption}
</FormHelperText>
{props.hiddenPostInput}
</Stack>
<FlexWrapper
px={"8px"}
justifyContent={"center"}
flexWrap={props.blockButton ? "wrap-reverse" : "nowrap"}
>
<Stack direction={"column"} px={"8px"} width={"100%"}>
{props.secondaryButtonAction && (
<FocusVisibleButton
onClick={props.secondaryButtonAction}
size="large"
color="secondary"
sx={{
"&&&": {
mt: !props.blockButton ? 2 : 0.5,
mb: !props.blockButton ? 4 : 0,
mr: !props.blockButton ? 1 : 0,
...buttonSx,
},
}}
{...restSubmitButtonProps}
>
{t("cancel")}
</FocusVisibleButton>
)}
<LoadingButton
type="submit"
color="accent"
fullWidth
buttonText={props.buttonText}
loading={loading}
sx={{ mt: 2, mb: 4 }}
{...restSubmitButtonProps}
>
{props.buttonText}
</LoadingButton>
</Stack>
</FlexWrapper>
</form>
)}
</Formik>
);
};
interface ManageEmailShareProps {
collection: Collection;
open: boolean;
onClose: () => void;
onRootClose: () => void;
peopleCount: number;
}
const ManageEmailShare: React.FC<ManageEmailShareProps> = ({
open,
collection,
onClose,
onRootClose,
peopleCount,
}) => {
const { showLoadingBar, hideLoadingBar } = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const [addParticipantView, setAddParticipantView] = useState(false);
const [manageParticipantView, setManageParticipantView] = useState(false);
const closeAddParticipant = () => setAddParticipantView(false);
const openAddParticipant = () => setAddParticipantView(true);
const participantType = useRef<
COLLECTION_ROLE.COLLABORATOR | COLLECTION_ROLE.VIEWER
>();
const selectedParticipant = useRef<CollectionUser>();
const openAddCollab = () => {
participantType.current = COLLECTION_ROLE.COLLABORATOR;
openAddParticipant();
};
const openAddViewer = () => {
participantType.current = COLLECTION_ROLE.VIEWER;
openAddParticipant();
};
const handleRootClose = () => {
onClose();
onRootClose();
};
const handleDrawerClose: DialogProps["onClose"] = (_, reason) => {
if (reason === "backdropClick") {
handleRootClose();
} else {
onClose();
}
};
const collectionUnshare = async (email: string) => {
try {
showLoadingBar();
await unshareCollection(collection, email);
await galleryContext.syncWithRemote(false, true);
} finally {
hideLoadingBar();
}
};
const ownerEmail =
galleryContext.user.id === collection.owner?.id
? galleryContext.user.email
: collection.owner?.email;
const isOwner = galleryContext.user.id === collection.owner?.id;
const collaborators = collection.sharees
?.filter((sharee) => sharee.role === COLLECTION_ROLE.COLLABORATOR)
.map((sharee) => sharee.email);
const viewers =
collection.sharees
?.filter((sharee) => sharee.role === COLLECTION_ROLE.VIEWER)
.map((sharee) => sharee.email) || [];
const openManageParticipant = (email) => {
selectedParticipant.current = collection.sharees.find(
(sharee) => sharee.email === email,
);
setManageParticipantView(true);
};
const closeManageParticipant = () => {
setManageParticipantView(false);
};
return (
<>
<SidebarDrawer
anchor="right"
open={open}
onClose={handleDrawerClose}
>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={collection.name}
onRootClose={handleRootClose}
caption={t("participants_count", {
count: peopleCount,
})}
/>
<Stack py={"20px"} px={"12px"} spacing={"24px"}>
<Stack>
<MenuSectionTitle
title={t("OWNER")}
icon={<AdminPanelSettingsIcon />}
/>
<MenuItemGroup>
<EnteMenuItem
fontWeight="normal"
onClick={() => {}}
label={isOwner ? t("you") : ownerEmail}
startIcon={<Avatar email={ownerEmail} />}
/>
</MenuItemGroup>
</Stack>
<Stack>
<MenuSectionTitle
title={t("COLLABORATORS")}
icon={<ModeEditIcon />}
/>
<MenuItemGroup>
{collaborators.map((item) => (
<>
<EnteMenuItem
fontWeight={"normal"}
key={item}
onClick={() =>
openManageParticipant(item)
}
label={item}
startIcon={<Avatar email={item} />}
endIcon={<ChevronRightIcon />}
/>
<MenuItemDivider hasIcon />
</>
))}
<EnteMenuItem
startIcon={<Add />}
onClick={openAddCollab}
label={
collaborators?.length
? t("ADD_MORE")
: t("ADD_COLLABORATORS")
}
/>
</MenuItemGroup>
</Stack>
<Stack>
<MenuSectionTitle
title={t("VIEWERS")}
icon={<Photo />}
/>
<MenuItemGroup>
{viewers.map((item) => (
<>
<EnteMenuItem
fontWeight={"normal"}
key={item}
onClick={() =>
openManageParticipant(item)
}
label={item}
startIcon={<Avatar email={item} />}
endIcon={<ChevronRightIcon />}
/>
<MenuItemDivider hasIcon />
</>
))}
<EnteMenuItem
startIcon={<Add />}
fontWeight={"bold"}
onClick={openAddViewer}
label={
viewers?.length
? t("ADD_MORE")
: t("ADD_VIEWERS")
}
/>
</MenuItemGroup>
</Stack>
</Stack>
</Stack>
</SidebarDrawer>
<ManageParticipant
collectionUnshare={collectionUnshare}
open={manageParticipantView}
collection={collection}
onRootClose={onRootClose}
onClose={closeManageParticipant}
selectedParticipant={selectedParticipant.current}
/>
<AddParticipant
open={addParticipantView}
onClose={closeAddParticipant}
onRootClose={onRootClose}
collection={collection}
type={participantType.current}
/>
</>
);
};
interface ManageParticipantProps {
open: boolean;
collection: Collection;
onClose: () => void;
onRootClose: () => void;
selectedParticipant: CollectionUser;
collectionUnshare: (email: string) => Promise<void>;
}
const ManageParticipant: React.FC<ManageParticipantProps> = ({
collection,
open,
onClose,
onRootClose,
selectedParticipant,
collectionUnshare,
}) => {
const { showMiniDialog } = useAppContext();
const galleryContext = useContext(GalleryContext);
const handleDrawerClose: DialogProps["onClose"] = (_, reason) => {
if (reason === "backdropClick") {
onRootClose();
} else {
onClose();
}
};
const handleRemove = () => {
collectionUnshare(selectedParticipant.email);
onClose();
};
const handleRoleChange = (role: string) => () => {
if (role !== selectedParticipant.role) {
changeRolePermission(selectedParticipant.email, role);
}
};
const updateCollectionRole = async (selectedEmail, newRole) => {
try {
await shareCollection(collection, selectedEmail, newRole);
selectedParticipant.role = newRole;
await galleryContext.syncWithRemote(false, true);
} catch (e) {
log.error(handleSharingErrors(e), e);
}
};
const changeRolePermission = (selectedEmail, newRole) => {
let contentText;
let buttonText;
if (newRole === "VIEWER") {
contentText = (
<Trans
i18nKey="CHANGE_PERMISSIONS_TO_VIEWER"
values={{
selectedEmail: `${selectedEmail}`,
}}
/>
);
buttonText = t("CONVERT_TO_VIEWER");
} else if (newRole === "COLLABORATOR") {
contentText = t("CHANGE_PERMISSIONS_TO_COLLABORATOR", {
selectedEmail: selectedEmail,
});
buttonText = t("CONVERT_TO_COLLABORATOR");
}
showMiniDialog({
title: t("CHANGE_PERMISSION"),
message: contentText,
continue: {
text: buttonText,
color: "critical",
action: () => updateCollectionRole(selectedEmail, newRole),
},
});
};
const removeParticipant = () => {
showMiniDialog({
title: t("REMOVE_PARTICIPANT"),
message: (
<Trans
i18nKey="REMOVE_PARTICIPANT_MESSAGE"
values={{
selectedEmail: `${selectedParticipant.email}`,
}}
/>
),
continue: {
text: t("CONFIRM_REMOVE"),
color: "critical",
action: handleRemove,
},
});
};
if (!selectedParticipant) {
return <></>;
}
return (
<SidebarDrawer anchor="right" open={open} onClose={handleDrawerClose}>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("MANAGE")}
onRootClose={onRootClose}
caption={selectedParticipant.email}
/>
<Stack py={"20px"} px={"8px"} spacing={"32px"}>
<Stack>
<Typography
color="text.muted"
variant="small"
padding={1}
>
{t("ADDED_AS")}
</Typography>
<MenuItemGroup>
<EnteMenuItem
fontWeight="normal"
onClick={handleRoleChange("COLLABORATOR")}
label={"Collaborator"}
startIcon={<ModeEditIcon />}
endIcon={
selectedParticipant.role ===
"COLLABORATOR" && <DoneIcon />
}
/>
<MenuItemDivider hasIcon />
<EnteMenuItem
fontWeight="normal"
onClick={handleRoleChange("VIEWER")}
label={"Viewer"}
startIcon={<PhotoIcon />}
endIcon={
selectedParticipant.role === "VIEWER" && (
<DoneIcon />
)
}
/>
</MenuItemGroup>
<Typography
color="text.muted"
variant="small"
padding={1}
>
{t("COLLABORATOR_RIGHTS")}
</Typography>
<Stack py={"30px"}>
<Typography
color="text.muted"
variant="small"
padding={1}
>
{t("REMOVE_PARTICIPANT_HEAD")}
</Typography>
<MenuItemGroup>
<EnteMenuItem
color="critical"
fontWeight="normal"
onClick={removeParticipant}
label={"Remove"}
startIcon={<BlockIcon />}
/>
</MenuItemGroup>
</Stack>
</Stack>
</Stack>
</Stack>
</SidebarDrawer>
);
};
interface PublicShareProps {
collection: Collection;
onRootClose: () => void;

View File

@@ -1,122 +0,0 @@
import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer";
import { Titlebar } from "@/base/components/Titlebar";
import { COLLECTION_ROLE, type Collection } from "@/media/collection";
import { DialogProps, Stack } from "@mui/material";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import { useContext, useMemo } from "react";
import { shareCollection } from "services/collectionService";
import { handleSharingErrors } from "utils/error/ui";
import AddParticipantForm, {
AddParticipantFormProps,
} from "./AddParticipantForm";
interface Iprops {
collection: Collection;
open: boolean;
onClose: () => void;
onRootClose: () => void;
type: COLLECTION_ROLE.VIEWER | COLLECTION_ROLE.COLLABORATOR;
}
export default function AddParticipant({
open,
collection,
onClose,
onRootClose,
type,
}: Iprops) {
const { user, syncWithRemote, emailList } = useContext(GalleryContext);
const nonSharedEmails = useMemo(
() =>
emailList.filter(
(email) =>
!collection.sharees?.find((value) => value.email === email),
),
[emailList, collection.sharees],
);
const collectionShare: AddParticipantFormProps["callback"] = async ({
email,
emails,
}) => {
// if email is provided, means user has custom entered email, so, will need to validate for self sharing
// and already shared
if (email) {
if (email === user.email) {
throw new Error(t("SHARE_WITH_SELF"));
} else if (
collection?.sharees?.find((value) => value.email === email)
) {
throw new Error(t("ALREADY_SHARED", { email: email }));
}
// set emails to array of one email
emails = [email];
}
for (const email of emails) {
if (
email === user.email ||
collection?.sharees?.find((value) => value.email === email)
) {
// can just skip this email
continue;
}
try {
await shareCollection(collection, email, type);
await syncWithRemote(false, true);
} catch (e) {
const errorMessage = handleSharingErrors(e);
throw new Error(errorMessage);
}
}
};
const handleRootClose = () => {
onClose();
onRootClose();
};
const handleDrawerClose: DialogProps["onClose"] = (_, reason) => {
if (reason === "backdropClick") {
handleRootClose();
} else {
onClose();
}
};
return (
<SidebarDrawer anchor="right" open={open} onClose={handleDrawerClose}>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={
type === COLLECTION_ROLE.VIEWER
? t("ADD_VIEWERS")
: t("ADD_COLLABORATORS")
}
onRootClose={handleRootClose}
caption={collection.name}
/>
<AddParticipantForm
onClose={onClose}
callback={collectionShare}
optionsList={nonSharedEmails}
placeholder={t("ENTER_EMAIL")}
fieldType="email"
buttonText={
type === COLLECTION_ROLE.VIEWER
? t("ADD_VIEWERS")
: t("ADD_COLLABORATORS")
}
submitButtonProps={{
size: "large",
sx: { mt: 1, mb: 2 },
}}
disableAutoFocus
/>
</Stack>
</SidebarDrawer>
);
}

View File

@@ -1,243 +0,0 @@
import {
MenuItemDivider,
MenuItemGroup,
MenuSectionTitle,
} from "@/base/components/Menu";
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { LoadingButton } from "@/base/components/mui/LoadingButton";
import { FlexWrapper } from "@ente/shared/components/Container";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import DoneIcon from "@mui/icons-material/Done";
import { FormHelperText, Stack } from "@mui/material";
import TextField from "@mui/material/TextField";
import Avatar from "components/pages/gallery/Avatar";
import { Formik, type FormikHelpers } from "formik";
import { t } from "i18next";
import { useMemo, useState } from "react";
import * as Yup from "yup";
interface formValues {
inputValue: string;
selectedOptions: string[];
}
export interface AddParticipantFormProps {
callback: (props: { email?: string; emails?: string[] }) => Promise<void>;
fieldType: "text" | "email" | "password";
placeholder: string;
buttonText: string;
submitButtonProps?: any;
initialValue?: string;
secondaryButtonAction?: () => void;
disableAutoFocus?: boolean;
hiddenPreInput?: any;
caption?: any;
hiddenPostInput?: any;
autoComplete?: string;
blockButton?: boolean;
hiddenLabel?: boolean;
onClose?: () => void;
optionsList?: string[];
}
export default function AddParticipantForm(props: AddParticipantFormProps) {
const { submitButtonProps } = props;
const { sx: buttonSx, ...restSubmitButtonProps } = submitButtonProps ?? {};
const [loading, SetLoading] = useState(false);
const submitForm = async (
values: formValues,
{ setFieldError, resetForm }: FormikHelpers<formValues>,
) => {
try {
SetLoading(true);
if (values.inputValue !== "") {
await props.callback({ email: values.inputValue });
} else if (values.selectedOptions.length !== 0) {
await props.callback({ emails: values.selectedOptions });
}
SetLoading(false);
props.onClose();
resetForm();
} catch (e) {
setFieldError("inputValue", e?.message);
SetLoading(false);
}
};
const validationSchema = useMemo(() => {
switch (props.fieldType) {
case "text":
return Yup.object().shape({
inputValue: Yup.string().required(t("required")),
});
case "email":
return Yup.object().shape({
inputValue: Yup.string().email(t("EMAIL_ERROR")),
});
}
}, [props.fieldType]);
const handleInputFieldClick = (setFieldValue) => {
setFieldValue("selectedOptions", []);
};
return (
<Formik<formValues>
initialValues={{
inputValue: props.initialValue ?? "",
selectedOptions: [],
}}
onSubmit={submitForm}
validationSchema={validationSchema}
validateOnChange={false}
validateOnBlur={false}
>
{({
values,
errors,
handleChange,
handleSubmit,
setFieldValue,
}) => (
<form noValidate onSubmit={handleSubmit}>
<Stack spacing={"24px"} py={"20px"} px={"12px"}>
{props.hiddenPreInput}
<Stack>
<MenuSectionTitle title={t("ADD_NEW_EMAIL")} />
<TextField
sx={{ marginTop: 0 }}
hiddenLabel={props.hiddenLabel}
fullWidth
type={props.fieldType}
id={props.fieldType}
onChange={handleChange("inputValue")}
onClick={() =>
handleInputFieldClick(setFieldValue)
}
name={props.fieldType}
{...(props.hiddenLabel
? { placeholder: props.placeholder }
: { label: props.placeholder })}
error={Boolean(errors.inputValue)}
helperText={errors.inputValue}
value={values.inputValue}
disabled={loading}
autoFocus={!props.disableAutoFocus}
autoComplete={props.autoComplete}
/>
</Stack>
{props.optionsList.length > 0 && (
<Stack>
<MenuSectionTitle
title={t("OR_ADD_EXISTING")}
/>
<MenuItemGroup>
{props.optionsList.map((item, index) => (
<>
<EnteMenuItem
fontWeight="normal"
key={item}
onClick={() => {
if (
values.selectedOptions.includes(
item,
)
) {
setFieldValue(
"selectedOptions",
values.selectedOptions.filter(
(
selectedOption,
) =>
selectedOption !==
item,
),
);
} else {
setFieldValue(
"selectedOptions",
[
...values.selectedOptions,
item,
],
);
}
}}
label={item}
startIcon={
<Avatar email={item} />
}
endIcon={
values.selectedOptions.includes(
item,
) ? (
<DoneIcon />
) : null
}
/>
{index !==
props.optionsList.length -
1 && <MenuItemDivider />}
</>
))}
</MenuItemGroup>
</Stack>
)}
<FormHelperText
sx={{
position: "relative",
top: errors.inputValue ? "-22px" : "0",
float: "right",
padding: "0 8px",
}}
>
{props.caption}
</FormHelperText>
{props.hiddenPostInput}
</Stack>
<FlexWrapper
px={"8px"}
justifyContent={"center"}
flexWrap={props.blockButton ? "wrap-reverse" : "nowrap"}
>
<Stack direction={"column"} px={"8px"} width={"100%"}>
{props.secondaryButtonAction && (
<FocusVisibleButton
onClick={props.secondaryButtonAction}
size="large"
color="secondary"
sx={{
"&&&": {
mt: !props.blockButton ? 2 : 0.5,
mb: !props.blockButton ? 4 : 0,
mr: !props.blockButton ? 1 : 0,
...buttonSx,
},
}}
{...restSubmitButtonProps}
>
{t("cancel")}
</FocusVisibleButton>
)}
<LoadingButton
type="submit"
color="accent"
fullWidth
buttonText={props.buttonText}
loading={loading}
sx={{ mt: 2, mb: 4 }}
{...restSubmitButtonProps}
>
{props.buttonText}
</LoadingButton>
</Stack>
</FlexWrapper>
</form>
)}
</Formik>
);
}

View File

@@ -1,235 +0,0 @@
import {
MenuItemDivider,
MenuItemGroup,
MenuSectionTitle,
} from "@/base/components/Menu";
import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer";
import { Titlebar } from "@/base/components/Titlebar";
import {
COLLECTION_ROLE,
type Collection,
type CollectionUser,
} from "@/media/collection";
import { AppContext } from "@/new/photos/types/context";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import Add from "@mui/icons-material/Add";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ModeEditIcon from "@mui/icons-material/ModeEdit";
import Photo from "@mui/icons-material/Photo";
import { DialogProps, Stack } from "@mui/material";
import Avatar from "components/pages/gallery/Avatar";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import { useContext, useRef, useState } from "react";
import { unshareCollection } from "services/collectionService";
import AddParticipant from "./AddParticipant";
import ManageParticipant from "./ManageParticipant";
interface Iprops {
collection: Collection;
open: boolean;
onClose: () => void;
onRootClose: () => void;
peopleCount: number;
}
export default function ManageEmailShare({
open,
collection,
onClose,
onRootClose,
peopleCount,
}: Iprops) {
const { showLoadingBar, hideLoadingBar } = useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const [addParticipantView, setAddParticipantView] = useState(false);
const [manageParticipantView, setManageParticipantView] = useState(false);
const closeAddParticipant = () => setAddParticipantView(false);
const openAddParticipant = () => setAddParticipantView(true);
const participantType = useRef<
COLLECTION_ROLE.COLLABORATOR | COLLECTION_ROLE.VIEWER
>();
const selectedParticipant = useRef<CollectionUser>();
const openAddCollab = () => {
participantType.current = COLLECTION_ROLE.COLLABORATOR;
openAddParticipant();
};
const openAddViewer = () => {
participantType.current = COLLECTION_ROLE.VIEWER;
openAddParticipant();
};
const handleRootClose = () => {
onClose();
onRootClose();
};
const handleDrawerClose: DialogProps["onClose"] = (_, reason) => {
if (reason === "backdropClick") {
handleRootClose();
} else {
onClose();
}
};
const collectionUnshare = async (email: string) => {
try {
showLoadingBar();
await unshareCollection(collection, email);
await galleryContext.syncWithRemote(false, true);
} finally {
hideLoadingBar();
}
};
const ownerEmail =
galleryContext.user.id === collection.owner?.id
? galleryContext.user.email
: collection.owner?.email;
const isOwner = galleryContext.user.id === collection.owner?.id;
const collaborators = collection.sharees
?.filter((sharee) => sharee.role === COLLECTION_ROLE.COLLABORATOR)
.map((sharee) => sharee.email);
const viewers =
collection.sharees
?.filter((sharee) => sharee.role === COLLECTION_ROLE.VIEWER)
.map((sharee) => sharee.email) || [];
const openManageParticipant = (email) => {
selectedParticipant.current = collection.sharees.find(
(sharee) => sharee.email === email,
);
setManageParticipantView(true);
};
const closeManageParticipant = () => {
setManageParticipantView(false);
};
return (
<>
<SidebarDrawer
anchor="right"
open={open}
onClose={handleDrawerClose}
>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={collection.name}
onRootClose={handleRootClose}
caption={t("participants_count", {
count: peopleCount,
})}
/>
<Stack py={"20px"} px={"12px"} spacing={"24px"}>
<Stack>
<MenuSectionTitle
title={t("OWNER")}
icon={<AdminPanelSettingsIcon />}
/>
<MenuItemGroup>
<EnteMenuItem
fontWeight="normal"
onClick={() => {}}
label={isOwner ? t("you") : ownerEmail}
startIcon={<Avatar email={ownerEmail} />}
/>
</MenuItemGroup>
</Stack>
<Stack>
<MenuSectionTitle
title={t("COLLABORATORS")}
icon={<ModeEditIcon />}
/>
<MenuItemGroup>
{collaborators.map((item) => (
<>
<EnteMenuItem
fontWeight={"normal"}
key={item}
onClick={() =>
openManageParticipant(item)
}
label={item}
startIcon={<Avatar email={item} />}
endIcon={<ChevronRightIcon />}
/>
<MenuItemDivider hasIcon />
</>
))}
<EnteMenuItem
startIcon={<Add />}
onClick={openAddCollab}
label={
collaborators?.length
? t("ADD_MORE")
: t("ADD_COLLABORATORS")
}
/>
</MenuItemGroup>
</Stack>
<Stack>
<MenuSectionTitle
title={t("VIEWERS")}
icon={<Photo />}
/>
<MenuItemGroup>
{viewers.map((item) => (
<>
<EnteMenuItem
fontWeight={"normal"}
key={item}
onClick={() =>
openManageParticipant(item)
}
label={item}
startIcon={<Avatar email={item} />}
endIcon={<ChevronRightIcon />}
/>
<MenuItemDivider hasIcon />
</>
))}
<EnteMenuItem
startIcon={<Add />}
fontWeight={"bold"}
onClick={openAddViewer}
label={
viewers?.length
? t("ADD_MORE")
: t("ADD_VIEWERS")
}
/>
</MenuItemGroup>
</Stack>
</Stack>
</Stack>
</SidebarDrawer>
<ManageParticipant
collectionUnshare={collectionUnshare}
open={manageParticipantView}
collection={collection}
onRootClose={onRootClose}
onClose={closeManageParticipant}
selectedParticipant={selectedParticipant.current}
/>
<AddParticipant
open={addParticipantView}
onClose={closeAddParticipant}
onRootClose={onRootClose}
collection={collection}
type={participantType.current}
/>
</>
);
}

View File

@@ -1,203 +0,0 @@
import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu";
import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer";
import { Titlebar } from "@/base/components/Titlebar";
import log from "@/base/log";
import type { Collection, CollectionUser } from "@/media/collection";
import { useAppContext } from "@/new/photos/types/context";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import BlockIcon from "@mui/icons-material/Block";
import DoneIcon from "@mui/icons-material/Done";
import ModeEditIcon from "@mui/icons-material/ModeEdit";
import PhotoIcon from "@mui/icons-material/Photo";
import { DialogProps, Stack, Typography } from "@mui/material";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import { useContext } from "react";
import { Trans } from "react-i18next";
import { shareCollection } from "services/collectionService";
import { handleSharingErrors } from "utils/error/ui";
interface Iprops {
open: boolean;
collection: Collection;
onClose: () => void;
onRootClose: () => void;
selectedParticipant: CollectionUser;
collectionUnshare: (email: string) => Promise<void>;
}
export default function ManageParticipant({
collection,
open,
onClose,
onRootClose,
selectedParticipant,
collectionUnshare,
}: Iprops) {
const { showMiniDialog } = useAppContext();
const galleryContext = useContext(GalleryContext);
const handleDrawerClose: DialogProps["onClose"] = (_, reason) => {
if (reason === "backdropClick") {
onRootClose();
} else {
onClose();
}
};
const handleRemove = () => {
collectionUnshare(selectedParticipant.email);
onClose();
};
const handleRoleChange = (role: string) => () => {
if (role !== selectedParticipant.role) {
changeRolePermission(selectedParticipant.email, role);
}
};
const updateCollectionRole = async (selectedEmail, newRole) => {
try {
await shareCollection(collection, selectedEmail, newRole);
selectedParticipant.role = newRole;
await galleryContext.syncWithRemote(false, true);
} catch (e) {
log.error(handleSharingErrors(e), e);
}
};
const changeRolePermission = (selectedEmail, newRole) => {
let contentText;
let buttonText;
if (newRole === "VIEWER") {
contentText = (
<Trans
i18nKey="CHANGE_PERMISSIONS_TO_VIEWER"
values={{
selectedEmail: `${selectedEmail}`,
}}
/>
);
buttonText = t("CONVERT_TO_VIEWER");
} else if (newRole === "COLLABORATOR") {
contentText = t("CHANGE_PERMISSIONS_TO_COLLABORATOR", {
selectedEmail: selectedEmail,
});
buttonText = t("CONVERT_TO_COLLABORATOR");
}
showMiniDialog({
title: t("CHANGE_PERMISSION"),
message: contentText,
continue: {
text: buttonText,
color: "critical",
action: () => updateCollectionRole(selectedEmail, newRole),
},
});
};
const removeParticipant = () => {
showMiniDialog({
title: t("REMOVE_PARTICIPANT"),
message: (
<Trans
i18nKey="REMOVE_PARTICIPANT_MESSAGE"
values={{
selectedEmail: `${selectedParticipant.email}`,
}}
/>
),
continue: {
text: t("CONFIRM_REMOVE"),
color: "critical",
action: handleRemove,
},
});
};
if (!selectedParticipant) {
return <></>;
}
return (
<SidebarDrawer anchor="right" open={open} onClose={handleDrawerClose}>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={t("MANAGE")}
onRootClose={onRootClose}
caption={selectedParticipant.email}
/>
<Stack py={"20px"} px={"8px"} spacing={"32px"}>
<Stack>
<Typography
color="text.muted"
variant="small"
padding={1}
>
{t("ADDED_AS")}
</Typography>
<MenuItemGroup>
<EnteMenuItem
fontWeight="normal"
onClick={handleRoleChange("COLLABORATOR")}
label={"Collaborator"}
startIcon={<ModeEditIcon />}
endIcon={
selectedParticipant.role ===
"COLLABORATOR" && <DoneIcon />
}
/>
<MenuItemDivider hasIcon />
<EnteMenuItem
fontWeight="normal"
onClick={handleRoleChange("VIEWER")}
label={"Viewer"}
startIcon={<PhotoIcon />}
endIcon={
selectedParticipant.role === "VIEWER" && (
<DoneIcon />
)
}
/>
</MenuItemGroup>
<Typography
color="text.muted"
variant="small"
padding={1}
>
{t("COLLABORATOR_RIGHTS")}
</Typography>
<Stack py={"30px"}>
<Typography
color="text.muted"
variant="small"
padding={1}
>
{t("REMOVE_PARTICIPANT_HEAD")}
</Typography>
<MenuItemGroup>
<EnteMenuItem
color="critical"
fontWeight="normal"
onClick={removeParticipant}
label={"Remove"}
startIcon={<BlockIcon />}
/>
</MenuItemGroup>
</Stack>
</Stack>
</Stack>
</Stack>
</SidebarDrawer>
);
}

View File

@@ -1,153 +0,0 @@
import { COLLECTION_ROLE, type Collection } from "@/media/collection";
import { useRef, useState } from "react";
import {
MenuItemDivider,
MenuItemGroup,
MenuSectionTitle,
} from "@/base/components/Menu";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import AddIcon from "@mui/icons-material/Add";
import ChevronRight from "@mui/icons-material/ChevronRight";
import Workspaces from "@mui/icons-material/Workspaces";
import { Stack, styled } from "@mui/material";
import NumberAvatar from "@mui/material/Avatar";
import Avatar from "components/pages/gallery/Avatar";
import { t } from "i18next";
import AddParticipant from "./AddParticipant";
import ManageEmailShare from "./ManageEmailShare";
export default function EmailShare({
collection,
onRootClose,
}: {
collection: Collection;
onRootClose: () => void;
}) {
const [addParticipantView, setAddParticipantView] = useState(false);
const [manageEmailShareView, setManageEmailShareView] = useState(false);
const closeAddParticipant = () => setAddParticipantView(false);
const openAddParticipant = () => setAddParticipantView(true);
const closeManageEmailShare = () => setManageEmailShareView(false);
const openManageEmailShare = () => setManageEmailShareView(true);
const participantType = useRef<
COLLECTION_ROLE.COLLABORATOR | COLLECTION_ROLE.VIEWER
>();
const openAddCollab = () => {
participantType.current = COLLECTION_ROLE.COLLABORATOR;
openAddParticipant();
};
const openAddViewer = () => {
participantType.current = COLLECTION_ROLE.VIEWER;
openAddParticipant();
};
return (
<>
<Stack>
<MenuSectionTitle
title={t("shared_with_people_count", {
count: collection.sharees?.length ?? 0,
})}
icon={<Workspaces />}
/>
<MenuItemGroup>
{collection.sharees.length > 0 ? (
<>
<EnteMenuItem
fontWeight={"normal"}
startIcon={
<AvatarGroup sharees={collection.sharees} />
}
onClick={openManageEmailShare}
label={
collection.sharees.length === 1
? collection.sharees[0]?.email
: null
}
endIcon={<ChevronRight />}
/>
<MenuItemDivider hasIcon />
</>
) : null}
<EnteMenuItem
startIcon={<AddIcon />}
onClick={openAddViewer}
label={t("ADD_VIEWERS")}
/>
<MenuItemDivider hasIcon />
<EnteMenuItem
startIcon={<AddIcon />}
onClick={openAddCollab}
label={t("ADD_COLLABORATORS")}
/>
</MenuItemGroup>
</Stack>
<AddParticipant
open={addParticipantView}
onClose={closeAddParticipant}
onRootClose={onRootClose}
collection={collection}
type={participantType.current}
/>
<ManageEmailShare
peopleCount={collection.sharees.length}
open={manageEmailShareView}
onClose={closeManageEmailShare}
onRootClose={onRootClose}
collection={collection}
/>
</>
);
}
const AvatarContainer = styled("div")({
position: "relative",
display: "flex",
alignItems: "center",
marginLeft: -5,
});
const AvatarContainerOuter = styled("div")({
position: "relative",
display: "flex",
alignItems: "center",
marginLeft: 8,
});
const AvatarCounter = styled(NumberAvatar)({
height: 20,
width: 20,
fontSize: 10,
color: "#fff",
});
const SHAREE_AVATAR_LIMIT = 6;
const AvatarGroup = ({ sharees }: { sharees: Collection["sharees"] }) => {
const hasShareesOverLimit = sharees?.length > SHAREE_AVATAR_LIMIT;
const countOfShareesOverLimit = sharees?.length - SHAREE_AVATAR_LIMIT;
return (
<AvatarContainerOuter>
{sharees?.slice(0, 6).map((sharee) => (
<AvatarContainer key={sharee.email}>
<Avatar
key={sharee.email}
email={sharee.email}
opacity={100}
/>
</AvatarContainer>
))}
{hasShareesOverLimit && (
<AvatarContainer key="extra-count">
<AvatarCounter>+{countOfShareesOverLimit}</AvatarCounter>
</AvatarContainer>
)}
</AvatarContainerOuter>
);
};