Set frontend's logic to modify the avatar

This commit does not set the logic to modify the avatar in the backend. It does not even set the logic to retrieve the avatar from the backend.

Change-Id: I1a787742a956218d150d69cc5ccc90b59e296b1f
diff --git a/client/src/components/ConversationAvatar.tsx b/client/src/components/ConversationAvatar.tsx
index 9af1d8b..2e2d31c 100644
--- a/client/src/components/ConversationAvatar.tsx
+++ b/client/src/components/ConversationAvatar.tsx
@@ -15,11 +15,260 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Avatar, AvatarProps } from '@mui/material';
+import { Check, RefreshOutlined } from '@mui/icons-material';
+import { Avatar, AvatarProps, Box, Dialog, Stack, Typography } from '@mui/material';
+import { useCallback, useEffect, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
+
+import CallPermissionDenied from '../pages/CallPermissionDenied';
+import { FileHandler } from '../utils/files';
+import {
+  CancelPictureButton,
+  CloseButton,
+  CornerCloseButton,
+  EditPictureButton,
+  RecordButton,
+  RoundButton,
+  TakePictureButton,
+  UploadPictureButton,
+} from './Button';
+import { InfosDialog, useDialogHandler } from './Dialog';
+import { CircleMaskOverlay, FileDragOverlay } from './Overlay';
 
 type ConversationAvatarProps = AvatarProps & {
   displayName?: string;
 };
 export default function ConversationAvatar({ displayName, ...props }: ConversationAvatarProps) {
-  return <Avatar {...props} alt={displayName} src="/broken" />;
+  const src = props.src ?? '/broken';
+  delete props.src;
+  return <Avatar {...props} alt={displayName} src={src} />;
 }
+
+export const AvatarEditor = () => {
+  const { t } = useTranslation();
+  const [editionToolsIsOpen, setEditionToolsIsOpen] = useState(false);
+  const [avatarHandler, setAvatarHandler] = useState<FileHandler | null>(null);
+  const photoTakerHandler = useDialogHandler();
+
+  const onFilesDrop = useCallback(
+    (acceptedFiles: File[]) => {
+      const fileHandler = new FileHandler(acceptedFiles[0]);
+      setAvatarHandler(fileHandler);
+      setEditionToolsIsOpen(false);
+    },
+    [setAvatarHandler, setEditionToolsIsOpen]
+  );
+
+  const {
+    getRootProps,
+    getInputProps,
+    open: openFilePicker,
+    isDragActive,
+  } = useDropzone({
+    accept: {
+      'image/png': ['.png'],
+      'image/jpeg': ['.jpeg', '.jpg'],
+    },
+    multiple: false,
+    noClick: true,
+    noKeyboard: true,
+    onDrop: onFilesDrop,
+  });
+
+  const openPhotoTaker = useCallback(() => {
+    photoTakerHandler.openDialog();
+    setEditionToolsIsOpen(false);
+  }, [photoTakerHandler]);
+
+  const removeAvatar = useCallback(() => {
+    setAvatarHandler(null);
+    setEditionToolsIsOpen(false);
+  }, []);
+
+  const closeTools = useCallback(() => {
+    setEditionToolsIsOpen(false);
+  }, []);
+
+  return (
+    <Stack alignItems="center" position="relative" {...getRootProps()}>
+      {isDragActive && <FileDragOverlay />}
+      <input {...getInputProps()} />
+
+      {/* Avatar preview */}
+      <Stack height="150px" width="150px" position="relative">
+        <Box sx={{ position: 'absolute', right: '19.89px', zIndex: 10 }}>
+          <EditPictureButton onClick={() => setEditionToolsIsOpen(true)} sx={{ width: '30px', height: '30px' }} />
+        </Box>
+        <ConversationAvatar src={avatarHandler?.url} sx={{ width: '100%', height: '100%' }} />
+      </Stack>
+
+      {editionToolsIsOpen && (
+        <Stack
+          alignItems="center"
+          justifyContent="center"
+          position="absolute"
+          height="100%"
+          zIndex={10}
+          spacing="26px"
+          sx={{ backgroundColor: 'white' }}
+        >
+          <Typography variant="h3">{t('change_picture')}</Typography>
+          <Stack direction="row" alignItems="center" spacing="9px">
+            <TakePictureButton onClick={openPhotoTaker} />
+            <UploadPictureButton onClick={openFilePicker} />
+            {avatarHandler && <CancelPictureButton onClick={removeAvatar} />}
+            <CloseButton onClick={closeTools} />
+          </Stack>
+        </Stack>
+      )}
+
+      <PhotoTakerDialog {...photoTakerHandler.props} onConfirm={setAvatarHandler} />
+    </Stack>
+  );
+};
+
+type PhotoTakerProps = {
+  open: boolean;
+  onClose: () => void;
+  onConfirm: (photoHandler: FileHandler) => void;
+};
+
+const circleLengthPercentage = '50%';
+const PhotoTakerDialog = ({ open, onClose, onConfirm }: PhotoTakerProps) => {
+  const [isPaused, setIsPaused] = useState(false);
+  const [video, setVideo] = useState<HTMLVideoElement | null>(null);
+  const [stream, setStream] = useState<MediaStream | null>(null); // video.srcObject is not reliable
+  const [permissionState, setPermissionState] = useState<PermissionState>('prompt');
+
+  const videoRef = useCallback((node: HTMLVideoElement) => {
+    setVideo(node);
+  }, []);
+
+  useEffect(() => {
+    navigator.permissions
+      // @ts-ignore
+      // Firefox can't query camera permissions. Hopefully this will work soon.
+      .query({ name: 'camera' })
+      .then((permissionStatus) => {
+        setPermissionState(permissionStatus.state);
+        permissionStatus.onchange = () => {
+          setPermissionState(permissionStatus.state);
+          setStream(null);
+        };
+      })
+      .catch(() => {
+        // Expected behavior on Firefox
+      });
+  }, []);
+
+  useEffect(() => {
+    // Start stream when dialog opens, stop it when dialog closes
+    if (open && !stream) {
+      navigator.mediaDevices
+        .getUserMedia({ video: true })
+        .then((stream) => {
+          setStream(stream);
+          // This would be useless if we could query 'camera' permission on Firefox
+          setPermissionState('granted');
+        })
+        .catch(() => {
+          setStream(null);
+          setPermissionState('denied');
+        });
+    }
+    if (!open) {
+      stream?.getTracks()?.forEach((track) => track.stop());
+      setStream(null);
+    }
+  }, [open, stream, permissionState]);
+
+  useEffect(() => {
+    if (video) {
+      if (stream) {
+        video.srcObject = stream;
+        video.play();
+        setIsPaused(false);
+      } else {
+        video.srcObject = null;
+      }
+    }
+  }, [video, stream]);
+
+  const takePhoto = useCallback(() => {
+    video?.pause();
+    setIsPaused(true);
+  }, [video]);
+
+  const cancelPhoto = useCallback(() => {
+    video?.play();
+    setIsPaused(false);
+  }, [video]);
+
+  const savePhoto = useCallback(async () => {
+    if (video) {
+      // The PhotoTaker shows a circle in the middle of the camera's image.
+      // We have to cut the picture, otherwise the avatar would be larger to what the user saw in boundaries of the circle
+      // The circle's diameter is a percentage of the image's diagonal length
+      const diagonalLength = Math.sqrt(Math.pow(video.offsetWidth, 2) + Math.pow(video.offsetHeight, 2));
+      const avatarSize = diagonalLength * (parseInt(circleLengthPercentage) / 100);
+      const xMargin = (video.offsetWidth - avatarSize) / 2;
+      const yMargin = (video.offsetHeight - avatarSize) / 2;
+
+      // @ts-ignore
+      // Seems like Typescript does not know yet about OffscreenCanvas. Lets pretend it is HTMLCanvasElement for now
+      const offscreenCanvas: HTMLCanvasElement = new OffscreenCanvas(avatarSize, avatarSize);
+      const context = offscreenCanvas.getContext('2d');
+      if (context) {
+        context.setTransform(-1, 0, 0, 1, avatarSize, 0); // mirror
+        context.drawImage(video, -xMargin, -yMargin, video.offsetWidth, video.offsetHeight);
+        // @ts-ignore
+        // convertToBlob is only on OffscreenCanvas, not HTMLCanvasElement, which has "toBlob" instead
+        const blob: Blob = await offscreenCanvas.convertToBlob();
+        const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
+        const fileHandler = new FileHandler(file);
+        onConfirm(fileHandler);
+        onClose();
+      }
+    }
+  }, [onConfirm, onClose, video]);
+
+  if (permissionState === 'denied') {
+    // TODO: UI needs to be improved
+    return <InfosDialog title="" open={open} onClose={onClose} content={<CallPermissionDenied />} />;
+  }
+
+  return (
+    <Dialog open={open} onClose={onClose}>
+      <Stack>
+        <Stack position="relative">
+          <CornerCloseButton onClick={onClose} />
+          {stream && <CircleMaskOverlay size={circleLengthPercentage} />}
+          <video ref={videoRef} style={{ transform: 'rotateY(180deg)' /* mirror */ }} />
+        </Stack>
+        <Stack height="89px" direction="row" justifyContent="center" alignItems="center" spacing="10px">
+          {isPaused ? (
+            <>
+              <RoundButton
+                Icon={RefreshOutlined}
+                aria-label="cancel photo"
+                size="large"
+                sx={{ fontSize: '40px' }}
+                onClick={cancelPhoto}
+              />
+              <RoundButton
+                Icon={Check}
+                aria-label="save photo"
+                size="large"
+                sx={{ fontSize: '40px' }}
+                onClick={savePhoto}
+              />
+            </>
+          ) : (
+            <RecordButton onClick={takePhoto} />
+          )}
+        </Stack>
+      </Stack>
+    </Dialog>
+  );
+};