simon | 26e79f7 | 2022-10-05 22:16:08 -0400 | [diff] [blame] | 1 | /* |
| 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 | */ |
idillon | b0ec86a | 2022-12-06 17:54:55 -0500 | [diff] [blame] | 18 | import { Check, RefreshOutlined } from '@mui/icons-material'; |
| 19 | import { Avatar, AvatarProps, Box, Dialog, Stack, Typography } from '@mui/material'; |
| 20 | import { useCallback, useEffect, useState } from 'react'; |
| 21 | import { useDropzone } from 'react-dropzone'; |
| 22 | import { useTranslation } from 'react-i18next'; |
| 23 | |
| 24 | import CallPermissionDenied from '../pages/CallPermissionDenied'; |
| 25 | import { FileHandler } from '../utils/files'; |
| 26 | import { |
| 27 | CancelPictureButton, |
| 28 | CloseButton, |
| 29 | CornerCloseButton, |
| 30 | EditPictureButton, |
| 31 | RecordButton, |
| 32 | RoundButton, |
| 33 | TakePictureButton, |
| 34 | UploadPictureButton, |
| 35 | } from './Button'; |
| 36 | import { InfosDialog, useDialogHandler } from './Dialog'; |
| 37 | import { CircleMaskOverlay, FileDragOverlay } from './Overlay'; |
simon | 6b9ddfb | 2022-10-03 00:04:50 -0400 | [diff] [blame] | 38 | |
| 39 | type ConversationAvatarProps = AvatarProps & { |
| 40 | displayName?: string; |
| 41 | }; |
| 42 | export default function ConversationAvatar({ displayName, ...props }: ConversationAvatarProps) { |
idillon | b0ec86a | 2022-12-06 17:54:55 -0500 | [diff] [blame] | 43 | const src = props.src ?? '/broken'; |
| 44 | delete props.src; |
| 45 | return <Avatar {...props} alt={displayName} src={src} />; |
simon | 6b9ddfb | 2022-10-03 00:04:50 -0400 | [diff] [blame] | 46 | } |
idillon | b0ec86a | 2022-12-06 17:54:55 -0500 | [diff] [blame] | 47 | |
| 48 | export 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 | |
| 131 | type PhotoTakerProps = { |
| 132 | open: boolean; |
| 133 | onClose: () => void; |
| 134 | onConfirm: (photoHandler: FileHandler) => void; |
| 135 | }; |
| 136 | |
| 137 | const circleLengthPercentage = '50%'; |
| 138 | const 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 | }; |