Files
element-web/src/components/views/settings/AvatarSetting.tsx
Michael Telatynski 9b316e8e7f Check that the file the user chose has a MIME type of image/* (#28467)
* Check that the file the user chose has a MIME type of `image/*`

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* i18n

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Optional

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* DRY

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update src/components/views/settings/AvatarSetting.tsx

Co-authored-by: Florian Duros <florianduros@element.io>

* prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Florian Duros <florianduros@element.io>
2024-11-18 10:30:31 +00:00

248 lines
7.3 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode, createRef, useCallback, useEffect, useState } from "react";
import EditIcon from "@vector-im/compound-design-tokens/assets/web/icons/edit";
import UploadIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import { Menu, MenuItem } from "@vector-im/compound-web";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { mediaFromMxc } from "../../../customisations/Media";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import { useId } from "../../../utils/useId";
import AccessibleButton from "../elements/AccessibleButton";
import BaseAvatar from "../avatars/BaseAvatar";
import Modal from "../../../Modal.tsx";
import ErrorDialog from "../dialogs/ErrorDialog.tsx";
interface MenuProps {
trigger: ReactNode;
onUploadSelect: () => void;
onRemoveSelect?: () => void;
menuOpen: boolean;
onOpenChange: (newOpen: boolean) => void;
}
const AvatarSettingContextMenu: React.FC<MenuProps> = ({
trigger,
onUploadSelect,
onRemoveSelect,
menuOpen,
onOpenChange,
}) => {
return (
<Menu
trigger={trigger}
title={_t("action|set_avatar")}
showTitle={false}
open={menuOpen}
onOpenChange={onOpenChange}
>
<MenuItem
as="div"
Icon={<UploadIcon width="24px" height="24px" />}
label={_t("action|upload_file")}
onSelect={onUploadSelect}
/>
{onRemoveSelect && (
<MenuItem
as="div"
Icon={<DeleteIcon width="24px" height="24px" />}
className="mx_AvatarSetting_removeMenuItem"
label={_t("action|remove")}
onSelect={onRemoveSelect}
/>
)}
</Menu>
);
};
interface IProps {
/**
* The current value of the avatar URL, as an mxc URL or a File.
* Generally, an mxc URL would be specified until the user selects a file, then
* the file supplied by the onChange callback would be supplied here until it's
* saved.
*/
avatar?: string | File;
/**
* If true, the user cannot change the avatar
*/
disabled?: boolean;
/**
* Called when the user has selected a new avatar
* The callback is passed a File object for the new avatar data
*/
onChange?: (f: File) => void;
/**
* Called when the user wishes to remove the avatar
*/
removeAvatar?: () => void;
/**
* The alt text for the avatar
*/
avatarAltText: string;
/**
* String to use for computing the colour of the placeholder avatar if no avatar is set
*/
placeholderId: string;
/**
* String to use for the placeholder display if no avatar is set
*/
placeholderName: string;
}
export function getFileChanged(e: React.ChangeEvent<HTMLInputElement>): File | null {
if (!e.target.files?.length) return null;
const file = e.target.files[0];
if (file.type.startsWith("image/")) return file;
Modal.createDialog(ErrorDialog, {
title: _t("upload_failed_title"),
description: _t("upload_file|not_image"),
});
return null;
}
/**
* Component for setting or removing an avatar on something (eg. a user or a room)
*/
const AvatarSetting: React.FC<IProps> = ({
avatar,
avatarAltText,
onChange,
removeAvatar,
disabled,
placeholderId,
placeholderName,
}) => {
const fileInputRef = createRef<HTMLInputElement>();
// Real URL that we can supply to the img element, either a data URL or whatever mediaFromMxc gives
// This represents whatever avatar the user has chosen at the time
const [avatarURL, setAvatarURL] = useState<string | undefined>(undefined);
useEffect(() => {
if (avatar instanceof File) {
const reader = new FileReader();
reader.onload = () => {
setAvatarURL(reader.result as string);
};
reader.readAsDataURL(avatar);
} else if (avatar) {
setAvatarURL(mediaFromMxc(avatar).getSquareThumbnailHttp(96) ?? undefined);
} else {
setAvatarURL(undefined);
}
}, [avatar]);
// Prevents ID collisions when this component is used more than once on the same page.
const a11yId = useId();
const onFileChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = getFileChanged(e);
if (file) {
onChange?.(file);
}
},
[onChange],
);
const uploadAvatar = useCallback((): void => {
fileInputRef.current?.click();
}, [fileInputRef]);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback((newOpen: boolean) => {
setMenuOpen(newOpen);
}, []);
let avatarElement = (
<AccessibleButton
element="div"
onClick={uploadAvatar}
className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay"
aria-labelledby={disabled ? undefined : a11yId}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
disabled={disabled}
>
<BaseAvatar idName={placeholderId} name={placeholderName} size="90px" />
</AccessibleButton>
);
if (avatarURL) {
avatarElement = (
<AccessibleButton
element="img"
className="mx_AvatarSetting_avatarDisplay"
src={avatarURL}
alt={avatarAltText}
onClick={uploadAvatar}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
disabled={disabled}
/>
);
}
let uploadAvatarBtn: JSX.Element | undefined;
if (!disabled) {
const uploadButtonClasses = classNames("mx_AvatarSetting_uploadButton", {
mx_AvatarSetting_uploadButton_active: menuOpen,
});
uploadAvatarBtn = (
<div className={uploadButtonClasses}>
<EditIcon width="20px" height="20px" />
</div>
);
}
const content = (
<div className="mx_AvatarSetting_avatar" role="group" aria-label={avatarAltText}>
{avatarElement}
{uploadAvatarBtn}
</div>
);
if (disabled) {
return content;
}
return (
<>
<AvatarSettingContextMenu
trigger={content}
onUploadSelect={uploadAvatar}
onRemoveSelect={removeAvatar}
menuOpen={menuOpen}
onOpenChange={onOpenChange}
/>
<input
type="file"
style={{ display: "none" }}
ref={fileInputRef}
onClick={chromeFileInputFix}
onChange={onFileChanged}
accept="image/*"
alt={_t("action|upload")}
/>
</>
);
};
export default AvatarSetting;