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/AccountPreferences.tsx b/client/src/components/AccountPreferences.tsx
index da926d0..9355a8b 100644
--- a/client/src/components/AccountPreferences.tsx
+++ b/client/src/components/AccountPreferences.tsx
@@ -39,7 +39,7 @@
 import { useState } from 'react';
 
 import { useAuthContext } from '../contexts/AuthProvider';
-import ConversationAvatar from './ConversationAvatar';
+import ConversationAvatar, { AvatarEditor } from './ConversationAvatar';
 import ConversationsOverviewCard from './ConversationsOverviewCard';
 import JamiIdCard from './JamiIdCard';
 
@@ -100,6 +100,7 @@
         exit: { transition: { staggerChildren: 0.02 } },
       }}
     >
+      <AvatarEditor />
       <motion.div variants={thumbnailVariants}>
         <Typography variant="h2" component="h2" gutterBottom>
           {alias}
diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx
index ddd99e9..601f855 100644
--- a/client/src/components/Button.tsx
+++ b/client/src/components/Button.tsx
@@ -73,6 +73,8 @@
   border: `1px solid ${theme.palette.primary.dark}`,
   color: theme.palette.primary.dark,
   fontSize: '15px',
+  background: 'white',
+  opacity: 1,
   '&:hover': {
     background: theme.palette.primary.light,
   },
@@ -218,7 +220,7 @@
 };
 
 export const EditPictureButton = (props: IconButtonProps) => {
-  return <RoundButton {...props} aria-label="edit picture" Icon={PenIcon} size="large" />;
+  return <RoundButton {...props} aria-label="edit picture" Icon={PenIcon} size="medium" />;
 };
 
 export const UploadPictureButton = (props: IconButtonProps) => {
@@ -465,3 +467,44 @@
     </ClickAwayListener>
   );
 };
+
+const RecordButtonSize = '50px';
+const RecordButtonOutlineWidth = '1px';
+const RecordButtonOutlineOffset = '4px';
+const RecordButtonInnerSize =
+  parseInt(RecordButtonSize) - parseInt(RecordButtonOutlineWidth) * 2 - parseInt(RecordButtonOutlineOffset) * 2 + 'px';
+export const RecordButton = styled((props: IconButtonProps) => (
+  <IconButton {...props} disableRipple={true}>
+    <Box
+      sx={{
+        width: RecordButtonInnerSize,
+        height: RecordButtonInnerSize,
+        borderRadius: '100%',
+        outline: `${RecordButtonOutlineWidth} solid #e57f90`,
+        outlineOffset: RecordButtonOutlineOffset,
+        backgroundColor: '#e57f90',
+        '&:hover': {
+          outlineColor: '#cc0022',
+          backgroundColor: '#cc0022',
+        },
+      }}
+    />
+  </IconButton>
+))({
+  width: RecordButtonSize,
+  height: RecordButtonSize,
+  padding: 0,
+});
+
+export const CornerCloseButton = styled(({ ...props }: IconButtonProps) => (
+  <IconButton {...props}>
+    <SaltireIcon fontSize="inherit" />
+  </IconButton>
+))({
+  position: 'absolute',
+  top: '20px',
+  right: '20px',
+  zIndex: 200,
+  color: 'white',
+  fontSize: '10px',
+});
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>
+  );
+};
diff --git a/client/src/components/Overlay.tsx b/client/src/components/Overlay.tsx
new file mode 100644
index 0000000..5063a5e
--- /dev/null
+++ b/client/src/components/Overlay.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { styled } from '@mui/material';
+
+const OverlayBase = styled('div')({
+  position: 'absolute',
+  width: '100%',
+  height: '100%',
+  zIndex: 100,
+});
+
+export const FileDragOverlay = styled(OverlayBase)({
+  backgroundColor: 'black',
+  opacity: '30%',
+});
+
+type CircleMaskOverlayProps = {
+  // Size in pixels or percentage. The percentage is relative to the length of the diagonal of the box
+  size: string;
+};
+
+export const CircleMaskOverlay = styled(OverlayBase)<CircleMaskOverlayProps>(({ size }) => ({
+  backgroundImage: `radial-gradient(circle at center, rgba(0, 0, 0, 0) ${size}, rgba(0, 0, 0, 0.5) ${size})`,
+}));
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 921a1da..9634028 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -1,5 +1,6 @@
 {
   "severity": "",
+  "change_picture": "Change the picture",
   "ongoing_call_unmuted": "Ongoing call",
   "ongoing_call_muted": "Ongoing call (muted)",
   "connecting_call": "Connecting...",
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index 7250e0e..44d0517 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -1,5 +1,6 @@
 {
   "severity": "",
+  "change_picture": "Modifier l'image",
   "ongoing_call_unmuted": "Appel en cours",
   "ongoing_call_muted": "Appel en cours (muet)",
   "connecting_call": "Connexion...",
diff --git a/client/src/pages/ChatInterface.tsx b/client/src/pages/ChatInterface.tsx
index 0db7171..37e75e2 100644
--- a/client/src/pages/ChatInterface.tsx
+++ b/client/src/pages/ChatInterface.tsx
@@ -15,7 +15,7 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Box, Divider, Stack } from '@mui/material';
+import { Divider, Stack } from '@mui/material';
 import { ConversationMessage, Message, WebSocketMessageType } from 'jami-web-common';
 import { useCallback, useContext, useEffect, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
@@ -23,6 +23,7 @@
 import { FilePreviewRemovable } from '../components/FilePreview';
 import LoadingPage from '../components/Loading';
 import MessageList from '../components/MessageList';
+import { FileDragOverlay } from '../components/Overlay';
 import SendMessageForm from '../components/SendMessageForm';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import { WebSocketContext } from '../contexts/WebSocketProvider';
@@ -107,19 +108,7 @@
 
   return (
     <Stack flex={1} overflow="hidden" {...getRootProps()} paddingBottom="16px">
-      {isDragActive && (
-        // dark overlay when the user is dragging a file
-        <Box
-          sx={{
-            position: 'absolute',
-            width: '100%',
-            height: '100%',
-            backgroundColor: 'black',
-            opacity: '30%',
-            zIndex: 100,
-          }}
-        />
-      )}
+      {isDragActive && <FileDragOverlay />}
       <input {...getInputProps()} />
       <MessageList messages={messages} />
       <Divider