element-portable/src/components/views/settings/AvatarSetting.tsx
David Langley 491f0cd08a
Change license (#13)
* Copyright headers 1

* Licence headers 2

* Copyright Headers 3

* Copyright Headers 4

* Copyright Headers 5

* Copyright Headers 6

* Copyright headers 7

* Add copyright headers for html and config file

* Replace license files and update package.json

* Update with CLA

* lint
2024-09-09 13:57:16 +00:00

230 lines
6.8 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";
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;
}
/**
* 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>) => {
if (e.target.files) onChange?.(e.target.files[0]);
},
[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;