blob: d7bd9543120b15687dade394215eb35f9421167a [file] [log] [blame]
/*
* 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 Check from '@mui/icons-material/Check';
import RefreshOutlined from '@mui/icons-material/RefreshOutlined';
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) {
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>
);
};