blob: 2e2d31c146bec86b83c573fb6018509052e90daa [file] [log] [blame]
simon26e79f72022-10-05 22:16:08 -04001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
idillonb0ec86a2022-12-06 17:54:55 -050018import { Check, RefreshOutlined } from '@mui/icons-material';
19import { Avatar, AvatarProps, Box, Dialog, Stack, Typography } from '@mui/material';
20import { useCallback, useEffect, useState } from 'react';
21import { useDropzone } from 'react-dropzone';
22import { useTranslation } from 'react-i18next';
23
24import CallPermissionDenied from '../pages/CallPermissionDenied';
25import { FileHandler } from '../utils/files';
26import {
27 CancelPictureButton,
28 CloseButton,
29 CornerCloseButton,
30 EditPictureButton,
31 RecordButton,
32 RoundButton,
33 TakePictureButton,
34 UploadPictureButton,
35} from './Button';
36import { InfosDialog, useDialogHandler } from './Dialog';
37import { CircleMaskOverlay, FileDragOverlay } from './Overlay';
simon6b9ddfb2022-10-03 00:04:50 -040038
39type ConversationAvatarProps = AvatarProps & {
40 displayName?: string;
41};
42export default function ConversationAvatar({ displayName, ...props }: ConversationAvatarProps) {
idillonb0ec86a2022-12-06 17:54:55 -050043 const src = props.src ?? '/broken';
44 delete props.src;
45 return <Avatar {...props} alt={displayName} src={src} />;
simon6b9ddfb2022-10-03 00:04:50 -040046}
idillonb0ec86a2022-12-06 17:54:55 -050047
48export const AvatarEditor = () => {
49 const { t } = useTranslation();
50 const [editionToolsIsOpen, setEditionToolsIsOpen] = useState(false);
51 const [avatarHandler, setAvatarHandler] = useState<FileHandler | null>(null);
52 const photoTakerHandler = useDialogHandler();
53
54 const onFilesDrop = useCallback(
55 (acceptedFiles: File[]) => {
56 const fileHandler = new FileHandler(acceptedFiles[0]);
57 setAvatarHandler(fileHandler);
58 setEditionToolsIsOpen(false);
59 },
60 [setAvatarHandler, setEditionToolsIsOpen]
61 );
62
63 const {
64 getRootProps,
65 getInputProps,
66 open: openFilePicker,
67 isDragActive,
68 } = useDropzone({
69 accept: {
70 'image/png': ['.png'],
71 'image/jpeg': ['.jpeg', '.jpg'],
72 },
73 multiple: false,
74 noClick: true,
75 noKeyboard: true,
76 onDrop: onFilesDrop,
77 });
78
79 const openPhotoTaker = useCallback(() => {
80 photoTakerHandler.openDialog();
81 setEditionToolsIsOpen(false);
82 }, [photoTakerHandler]);
83
84 const removeAvatar = useCallback(() => {
85 setAvatarHandler(null);
86 setEditionToolsIsOpen(false);
87 }, []);
88
89 const closeTools = useCallback(() => {
90 setEditionToolsIsOpen(false);
91 }, []);
92
93 return (
94 <Stack alignItems="center" position="relative" {...getRootProps()}>
95 {isDragActive && <FileDragOverlay />}
96 <input {...getInputProps()} />
97
98 {/* Avatar preview */}
99 <Stack height="150px" width="150px" position="relative">
100 <Box sx={{ position: 'absolute', right: '19.89px', zIndex: 10 }}>
101 <EditPictureButton onClick={() => setEditionToolsIsOpen(true)} sx={{ width: '30px', height: '30px' }} />
102 </Box>
103 <ConversationAvatar src={avatarHandler?.url} sx={{ width: '100%', height: '100%' }} />
104 </Stack>
105
106 {editionToolsIsOpen && (
107 <Stack
108 alignItems="center"
109 justifyContent="center"
110 position="absolute"
111 height="100%"
112 zIndex={10}
113 spacing="26px"
114 sx={{ backgroundColor: 'white' }}
115 >
116 <Typography variant="h3">{t('change_picture')}</Typography>
117 <Stack direction="row" alignItems="center" spacing="9px">
118 <TakePictureButton onClick={openPhotoTaker} />
119 <UploadPictureButton onClick={openFilePicker} />
120 {avatarHandler && <CancelPictureButton onClick={removeAvatar} />}
121 <CloseButton onClick={closeTools} />
122 </Stack>
123 </Stack>
124 )}
125
126 <PhotoTakerDialog {...photoTakerHandler.props} onConfirm={setAvatarHandler} />
127 </Stack>
128 );
129};
130
131type PhotoTakerProps = {
132 open: boolean;
133 onClose: () => void;
134 onConfirm: (photoHandler: FileHandler) => void;
135};
136
137const circleLengthPercentage = '50%';
138const PhotoTakerDialog = ({ open, onClose, onConfirm }: PhotoTakerProps) => {
139 const [isPaused, setIsPaused] = useState(false);
140 const [video, setVideo] = useState<HTMLVideoElement | null>(null);
141 const [stream, setStream] = useState<MediaStream | null>(null); // video.srcObject is not reliable
142 const [permissionState, setPermissionState] = useState<PermissionState>('prompt');
143
144 const videoRef = useCallback((node: HTMLVideoElement) => {
145 setVideo(node);
146 }, []);
147
148 useEffect(() => {
149 navigator.permissions
150 // @ts-ignore
151 // Firefox can't query camera permissions. Hopefully this will work soon.
152 .query({ name: 'camera' })
153 .then((permissionStatus) => {
154 setPermissionState(permissionStatus.state);
155 permissionStatus.onchange = () => {
156 setPermissionState(permissionStatus.state);
157 setStream(null);
158 };
159 })
160 .catch(() => {
161 // Expected behavior on Firefox
162 });
163 }, []);
164
165 useEffect(() => {
166 // Start stream when dialog opens, stop it when dialog closes
167 if (open && !stream) {
168 navigator.mediaDevices
169 .getUserMedia({ video: true })
170 .then((stream) => {
171 setStream(stream);
172 // This would be useless if we could query 'camera' permission on Firefox
173 setPermissionState('granted');
174 })
175 .catch(() => {
176 setStream(null);
177 setPermissionState('denied');
178 });
179 }
180 if (!open) {
181 stream?.getTracks()?.forEach((track) => track.stop());
182 setStream(null);
183 }
184 }, [open, stream, permissionState]);
185
186 useEffect(() => {
187 if (video) {
188 if (stream) {
189 video.srcObject = stream;
190 video.play();
191 setIsPaused(false);
192 } else {
193 video.srcObject = null;
194 }
195 }
196 }, [video, stream]);
197
198 const takePhoto = useCallback(() => {
199 video?.pause();
200 setIsPaused(true);
201 }, [video]);
202
203 const cancelPhoto = useCallback(() => {
204 video?.play();
205 setIsPaused(false);
206 }, [video]);
207
208 const savePhoto = useCallback(async () => {
209 if (video) {
210 // The PhotoTaker shows a circle in the middle of the camera's image.
211 // We have to cut the picture, otherwise the avatar would be larger to what the user saw in boundaries of the circle
212 // The circle's diameter is a percentage of the image's diagonal length
213 const diagonalLength = Math.sqrt(Math.pow(video.offsetWidth, 2) + Math.pow(video.offsetHeight, 2));
214 const avatarSize = diagonalLength * (parseInt(circleLengthPercentage) / 100);
215 const xMargin = (video.offsetWidth - avatarSize) / 2;
216 const yMargin = (video.offsetHeight - avatarSize) / 2;
217
218 // @ts-ignore
219 // Seems like Typescript does not know yet about OffscreenCanvas. Lets pretend it is HTMLCanvasElement for now
220 const offscreenCanvas: HTMLCanvasElement = new OffscreenCanvas(avatarSize, avatarSize);
221 const context = offscreenCanvas.getContext('2d');
222 if (context) {
223 context.setTransform(-1, 0, 0, 1, avatarSize, 0); // mirror
224 context.drawImage(video, -xMargin, -yMargin, video.offsetWidth, video.offsetHeight);
225 // @ts-ignore
226 // convertToBlob is only on OffscreenCanvas, not HTMLCanvasElement, which has "toBlob" instead
227 const blob: Blob = await offscreenCanvas.convertToBlob();
228 const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
229 const fileHandler = new FileHandler(file);
230 onConfirm(fileHandler);
231 onClose();
232 }
233 }
234 }, [onConfirm, onClose, video]);
235
236 if (permissionState === 'denied') {
237 // TODO: UI needs to be improved
238 return <InfosDialog title="" open={open} onClose={onClose} content={<CallPermissionDenied />} />;
239 }
240
241 return (
242 <Dialog open={open} onClose={onClose}>
243 <Stack>
244 <Stack position="relative">
245 <CornerCloseButton onClick={onClose} />
246 {stream && <CircleMaskOverlay size={circleLengthPercentage} />}
247 <video ref={videoRef} style={{ transform: 'rotateY(180deg)' /* mirror */ }} />
248 </Stack>
249 <Stack height="89px" direction="row" justifyContent="center" alignItems="center" spacing="10px">
250 {isPaused ? (
251 <>
252 <RoundButton
253 Icon={RefreshOutlined}
254 aria-label="cancel photo"
255 size="large"
256 sx={{ fontSize: '40px' }}
257 onClick={cancelPhoto}
258 />
259 <RoundButton
260 Icon={Check}
261 aria-label="save photo"
262 size="large"
263 sx={{ fontSize: '40px' }}
264 onClick={savePhoto}
265 />
266 </>
267 ) : (
268 <RecordButton onClick={takePhoto} />
269 )}
270 </Stack>
271 </Stack>
272 </Dialog>
273 );
274};