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