// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useRef, useState, useEffect, ChangeEventHandler, MouseEventHandler, FunctionComponent, } from 'react'; import classNames from 'classnames'; import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; import loadImage, { LoadImageOptions } from 'blueimp-load-image'; import { noop } from 'lodash'; import { LocalizerType } from '../types/Util'; import { Spinner } from './Spinner'; import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer'; type PropsType = { // This ID needs to be globally unique across the app. contextMenuId: string; disabled?: boolean; i18n: LocalizerType; onChange: (value: undefined | ArrayBuffer) => unknown; value: undefined | ArrayBuffer; variant?: AvatarInputVariant; }; enum ImageStatus { Nothing = 'nothing', Loading = 'loading', HasImage = 'has-image', } export enum AvatarInputVariant { Light = 'light', Dark = 'dark', } export const AvatarInput: FunctionComponent = ({ contextMenuId, disabled, i18n, onChange, value, variant = AvatarInputVariant.Light, }) => { const fileInputRef = useRef(null); // Comes from a third-party dependency // eslint-disable-next-line @typescript-eslint/no-explicit-any const menuTriggerRef = useRef(null); const [objectUrl, setObjectUrl] = useState(); useEffect(() => { if (!value) { setObjectUrl(undefined); return noop; } const url = URL.createObjectURL(new Blob([value])); setObjectUrl(url); return () => { URL.revokeObjectURL(url); }; }, [value]); const [processingFile, setProcessingFile] = useState( undefined ); useEffect(() => { if (!processingFile) { return noop; } let shouldCancel = false; (async () => { let newValue: ArrayBuffer; try { newValue = await processFile(processingFile); } catch (err) { // Processing errors should be rare; if they do, we silently fail. In an ideal // world, we may want to show a toast instead. return; } if (shouldCancel) { return; } setProcessingFile(undefined); onChange(newValue); })(); return () => { shouldCancel = true; }; }, [processingFile, onChange]); const buttonLabel = value ? i18n('AvatarInput--change-photo-label') : i18n('AvatarInput--no-photo-label--group'); const startUpload = () => { const fileInput = fileInputRef.current; if (fileInput) { fileInput.click(); } }; const clear = () => { onChange(undefined); }; const onClick: MouseEventHandler = value ? event => { const menuTrigger = menuTriggerRef.current; if (!menuTrigger) { return; } menuTrigger.handleContextClick(event); } : startUpload; const onInputChange: ChangeEventHandler = event => { const file = event.target.files && event.target.files[0]; if (file) { setProcessingFile(file); } }; let imageStatus: ImageStatus; if (processingFile || (value && !objectUrl)) { imageStatus = ImageStatus.Loading; } else if (objectUrl) { imageStatus = ImageStatus.HasImage; } else { imageStatus = ImageStatus.Nothing; } const isLoading = imageStatus === ImageStatus.Loading; return ( <> {i18n('AvatarInput--upload-photo-choice')} {i18n('AvatarInput--remove-photo-choice')} ); }; async function processFile(file: File): Promise { const { image } = await loadImage(file, { canvas: true, cover: true, crop: true, imageSmoothingQuality: 'medium', maxHeight: 512, maxWidth: 512, minHeight: 2, minWidth: 2, // `imageSmoothingQuality` is not present in `loadImage`'s types, but it is // documented and supported. Updating DefinitelyTyped is the long-term solution // here. } as LoadImageOptions); // NOTE: The types for `loadImage` say this can never be a canvas, but it will be if // `canvas: true`, at least in our case. Again, updating DefinitelyTyped should // address this. if (!(image instanceof HTMLCanvasElement)) { throw new Error('Loaded image was not a canvas'); } return canvasToArrayBuffer(image); }