Refactor some logic into common AvatarSetting component (#12544)

* Refactor some logic into common AvatarSetting component

We duplicated some of the logic of setting avatars between profiles &
rooms. This pulls some of that logic into the AvatarSetting component
and hopefully make things a little simpler.

* Unsed import

* Convert JS based hover to CSS

* Remove unnecessary container

* Test avatar-as-file path

* Test file upload

* Unused imports

* Add test for RoomProfileSettings

* Test removing room avatar

* Move upload control CSS too

* Remove commented code

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

* Prettier

* Coments & move style to inline as per PR suggestion

* Better test names

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Fix test

Upload input doesn't have that class anymore

---------

Co-authored-by: Florian Duros <florianduros@element.io>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
David Baker 2024-05-21 11:37:02 +01:00 committed by GitHub
parent f6e919021a
commit 3342aa5ff8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 296 additions and 164 deletions

View File

@ -133,9 +133,7 @@ test.describe("General user settings tab", () => {
test("should support adding and removing a profile picture", async ({ uut }) => {
const profileSettings = uut.locator(".mx_ProfileSettings");
// Upload a picture
await profileSettings
.locator(".mx_ProfileSettings_avatarUpload")
.setInputFiles("playwright/sample-files/riot.png");
await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png");
// Find and click "Remove" link button
await profileSettings.locator(".mx_ProfileSettings_profile").getByRole("button", { name: "Remove" }).click();

View File

@ -23,6 +23,7 @@ limitations under the License.
.mx_AvatarSetting_hover {
transition: opacity var(--hover-transition);
opacity: 0;
/* position to place the hover bg over the entire thing */
position: absolute;
@ -50,14 +51,10 @@ limitations under the License.
}
}
&.mx_AvatarSetting_avatar_hovering .mx_AvatarSetting_hover {
&.mx_AvatarSetting_avatarDisplay:hover .mx_AvatarSetting_hover {
opacity: 1;
}
&:not(.mx_AvatarSetting_avatar_hovering) .mx_AvatarSetting_hover {
opacity: 0;
}
& > * {
box-sizing: border-box;
}

View File

@ -17,10 +17,6 @@ limitations under the License.
.mx_ProfileSettings {
border-bottom: 1px solid $quinary-content;
.mx_ProfileSettings_avatarUpload {
display: none;
}
.mx_ProfileSettings_profile {
display: flex;

View File

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,11 +21,9 @@ import { EventType } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Field from "../elements/Field";
import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AvatarSetting from "../settings/AvatarSetting";
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
interface IProps {
roomId: string;
@ -35,8 +33,9 @@ interface IState {
originalDisplayName: string;
displayName: string;
originalAvatarUrl: string | null;
avatarUrl: string | null;
avatarFile: File | null;
// If true, the user has indicated that they wish to remove the avatar and this should happen on save.
avatarRemovalPending: boolean;
originalTopic: string;
topic: string;
profileFieldsTouched: Record<string, boolean>;
@ -57,8 +56,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`);
const avatarEvent = room.currentState.getStateEvents(EventType.RoomAvatar, "");
let avatarUrl = avatarEvent?.getContent()["url"] ?? null;
if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96);
const avatarUrl = avatarEvent?.getContent()["url"] ?? null;
const topicEvent = room.currentState.getStateEvents(EventType.RoomTopic, "");
const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()["topic"] : "";
@ -71,8 +69,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
originalDisplayName: name,
displayName: name,
originalAvatarUrl: avatarUrl,
avatarUrl: avatarUrl,
avatarFile: null,
avatarRemovalPending: false,
originalTopic: topic,
topic: topic,
profileFieldsTouched: {},
@ -82,16 +80,23 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
};
}
private uploadAvatar = (): void => {
this.avatarUpload.current?.click();
private onAvatarChanged = (file: File): void => {
this.setState({
avatarFile: file,
avatarRemovalPending: false,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
private removeAvatar = (): void => {
// clear file upload field so same file can be selected
if (this.avatarUpload.current) this.avatarUpload.current.value = "";
this.setState({
avatarUrl: null,
avatarFile: null,
avatarRemovalPending: true,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
@ -112,8 +117,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
profileFieldsTouched: {},
displayName: this.state.originalDisplayName,
topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
avatarRemovalPending: false,
});
};
@ -138,11 +143,12 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
if (this.state.avatarFile) {
const { content_uri: uri } = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, { url: uri }, "");
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl;
newState.originalAvatarUrl = uri;
newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
} else if (this.state.avatarRemovalPending) {
await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, {}, "");
newState.avatarRemovalPending = false;
newState.originalAvatarUrl = null;
}
if (this.state.originalTopic !== this.state.topic) {
@ -192,34 +198,6 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
}
};
private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) {
this.setState({
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: false,
},
});
return;
}
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
this.setState({
avatarUrl: String(ev.target?.result),
avatarFile: file,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
reader.readAsDataURL(file);
};
public render(): React.ReactNode {
let profileSettingsButtons;
if (this.state.canSetName || this.state.canSetTopic || this.state.canSetAvatar) {
@ -241,14 +219,6 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
return (
<form onSubmit={this.saveProfile} autoComplete="off" noValidate={true} className="mx_ProfileSettings">
<input
type="file"
ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onClick={chromeFileInputFix}
onChange={this.onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
<div className="mx_ProfileSettings_profile_controls">
<Field
@ -275,11 +245,15 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
/>
</div>
<AvatarSetting
avatarUrl={this.state.avatarUrl ?? undefined}
avatarName={this.state.displayName || this.props.roomId}
avatar={
this.state.avatarRemovalPending
? undefined
: this.state.avatarFile ?? this.state.originalAvatarUrl ?? undefined
}
avatarAltText={_t("room_settings|general|avatar_field_label")}
uploadAvatar={this.state.canSetAvatar ? this.uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this.removeAvatar : undefined}
disabled={!this.state.canSetAvatar}
onChange={this.onAvatarChanged}
removeAvatar={this.removeAvatar}
/>
</div>
{profileSettingsButtons}

View File

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,51 +14,102 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef, useState } from "react";
import classNames from "classnames";
import React, { createRef, useCallback, useEffect, useRef, useState } from "react";
import { _t } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleButton from "../elements/AccessibleButton";
import { mediaFromMxc } from "../../../customisations/Media";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
interface IProps {
avatarUrl?: string;
avatarName: string; // name of user/room the avatar belongs to
uploadAvatar?: (e: ButtonEvent) => void;
removeAvatar?: (e: ButtonEvent) => void;
/**
* 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;
}
const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, removeAvatar }) => {
const [isHovering, setIsHovering] = useState(false);
const hoveringProps = {
onMouseEnter: () => setIsHovering(true),
onMouseLeave: () => setIsHovering(false),
};
/**
* 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 }) => {
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]);
// TODO: Use useId() as soon as we're using React 18.
// Prevents ID collisions when this component is used more than once on the same page.
const a11yId = useRef(`hover-text-${Math.random()}`);
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]);
let avatarElement = (
<AccessibleButton
element="div"
onClick={uploadAvatar ?? null}
className="mx_AvatarSetting_avatarPlaceholder"
aria-labelledby={uploadAvatar ? a11yId.current : undefined}
onClick={uploadAvatar}
className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay"
aria-labelledby={disabled ? undefined : a11yId.current}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
{...hoveringProps}
/>
);
if (avatarUrl) {
if (avatarURL) {
avatarElement = (
<AccessibleButton
element="img"
src={avatarUrl}
className="mx_AvatarSetting_avatarDisplay"
src={avatarURL}
alt={avatarAltText}
onClick={uploadAvatar ?? null}
onClick={uploadAvatar}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
{...hoveringProps}
/>
);
}
@ -67,17 +118,27 @@ const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName,
if (uploadAvatar) {
// insert an empty div to be the host for a css mask containing the upload.svg
uploadAvatarBtn = (
<AccessibleButton
onClick={uploadAvatar}
className="mx_AvatarSetting_uploadButton"
aria-labelledby={a11yId.current}
{...hoveringProps}
/>
<>
<AccessibleButton
onClick={uploadAvatar}
className="mx_AvatarSetting_uploadButton"
aria-labelledby={a11yId.current}
/>
<input
type="file"
style={{ display: "none" }}
ref={fileInputRef}
onClick={chromeFileInputFix}
onChange={onFileChanged}
accept="image/*"
alt={_t("action|upload")}
/>
</>
);
}
let removeAvatarBtn: JSX.Element | undefined;
if (avatarUrl && removeAvatar) {
if (avatarURL && removeAvatar && !disabled) {
removeAvatarBtn = (
<AccessibleButton onClick={removeAvatar} kind="link_sm">
{_t("action|remove")}
@ -85,16 +146,12 @@ const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName,
);
}
const avatarClasses = classNames({
mx_AvatarSetting_avatar: true,
mx_AvatarSetting_avatar_hovering: isHovering && uploadAvatar,
});
return (
<div className={avatarClasses} role="group" aria-label={avatarAltText}>
<div className="mx_AvatarSetting_avatar" role="group" aria-label={avatarAltText}>
{avatarElement}
<div className="mx_AvatarSetting_hover" aria-hidden="true">
<div className="mx_AvatarSetting_hoverBg" />
{uploadAvatar && <span id={a11yId.current}>{_t("action|upload")}</span>}
{!disabled && <span id={a11yId.current}>{_t("action|upload")}</span>}
</div>
{uploadAvatarBtn}
{removeAvatarBtn}

View File

@ -1,5 +1,5 @@
/*
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -23,11 +23,9 @@ import Field from "../elements/Field";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AvatarSetting from "./AvatarSetting";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import PosthogTrackers from "../../../PosthogTrackers";
import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading";
@ -35,8 +33,9 @@ interface IState {
originalDisplayName: string;
displayName: string;
originalAvatarUrl: string | null;
avatarUrl?: string | ArrayBuffer;
avatarFile?: File | null;
// If true, the user has indicated that they wish to remove the avatar and this should happen on save.
avatarRemovalPending: boolean;
enableProfileSave?: boolean;
}
@ -48,20 +47,24 @@ export default class ProfileSettings extends React.Component<{}, IState> {
super(props);
this.userId = MatrixClientPeg.safeGet().getSafeUserId();
let avatarUrl = OwnProfileStore.instance.avatarMxc;
if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96);
const avatarUrl = OwnProfileStore.instance.avatarMxc;
this.state = {
originalDisplayName: OwnProfileStore.instance.displayName ?? "",
displayName: OwnProfileStore.instance.displayName ?? "",
originalAvatarUrl: avatarUrl,
avatarUrl: avatarUrl ?? undefined,
avatarFile: null,
avatarRemovalPending: false,
enableProfileSave: false,
};
}
private uploadAvatar = (): void => {
this.avatarUpload.current?.click();
private onChange = (file: File): void => {
PosthogTrackers.trackInteraction("WebProfileSettingsAvatarUploadButton");
this.setState({
avatarFile: file,
avatarRemovalPending: false,
enableProfileSave: true,
});
};
private removeAvatar = (): void => {
@ -70,8 +73,8 @@ export default class ProfileSettings extends React.Component<{}, IState> {
this.avatarUpload.current.value = "";
}
this.setState({
avatarUrl: undefined,
avatarFile: null,
avatarRemovalPending: true,
enableProfileSave: true,
});
};
@ -84,8 +87,8 @@ export default class ProfileSettings extends React.Component<{}, IState> {
this.setState({
enableProfileSave: false,
displayName: this.state.originalDisplayName,
avatarUrl: this.state.originalAvatarUrl ?? undefined,
avatarFile: null,
avatarRemovalPending: false,
});
};
@ -114,11 +117,12 @@ export default class ProfileSettings extends React.Component<{}, IState> {
);
const { content_uri: uri } = await client.uploadContent(this.state.avatarFile);
await client.setAvatarUrl(uri);
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96) ?? undefined;
newState.originalAvatarUrl = newState.avatarUrl;
newState.originalAvatarUrl = uri;
newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
} else if (this.state.avatarRemovalPending) {
await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined
newState.originalAvatarUrl = null;
newState.avatarRemovalPending = false;
}
} catch (err) {
logger.log("Failed to save profile", err);
@ -138,50 +142,13 @@ export default class ProfileSettings extends React.Component<{}, IState> {
});
};
private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) {
this.setState({
avatarUrl: this.state.originalAvatarUrl ?? undefined,
avatarFile: null,
enableProfileSave: false,
});
return;
}
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
this.setState({
avatarUrl: ev.target?.result ?? undefined,
avatarFile: file,
enableProfileSave: true,
});
};
reader.readAsDataURL(file);
};
public render(): React.ReactNode {
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.userId, {
withDisplayName: true,
});
// False negative result from no-base-to-string rule, doesn't seem to account for Symbol.toStringTag
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const avatarUrl = this.state.avatarUrl?.toString();
return (
<form onSubmit={this.saveProfile} autoComplete="off" noValidate={true} className="mx_ProfileSettings">
<input
type="file"
ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onClick={(ev) => {
chromeFileInputFix(ev);
PosthogTrackers.trackInteraction("WebProfileSettingsAvatarUploadButton", ev);
}}
onChange={this.onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
<div className="mx_ProfileSettings_profile_controls">
<SettingsSubsectionHeading heading={_t("common|profile")} />
@ -199,10 +166,13 @@ export default class ProfileSettings extends React.Component<{}, IState> {
</p>
</div>
<AvatarSetting
avatarUrl={avatarUrl}
avatarName={this.state.displayName || this.userId}
avatar={
this.state.avatarRemovalPending
? undefined
: this.state.avatarFile ?? this.state.originalAvatarUrl ?? undefined
}
avatarAltText={_t("common|user_avatar")}
uploadAvatar={this.uploadAvatar}
onChange={this.onChange}
removeAvatar={this.removeAvatar}
/>
</div>

View File

@ -0,0 +1,105 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { mkStubRoom, stubClient } from "../../../test-utils";
import RoomProfileSettings from "../../../../src/components/views/room_settings/RoomProfileSettings";
const BASE64_GIF = "R0lGODlhAQABAAAAACw=";
const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", {
type: "image/gif",
});
const ROOM_ID = "!floob:itty";
describe("RoomProfileSetting", () => {
let client: MatrixClient;
let room: Room;
beforeEach(() => {
client = stubClient();
room = mkStubRoom(ROOM_ID, "Test room", client);
});
it("handles uploading a room avatar", async () => {
const user = userEvent.setup();
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://matrix.org/1234" });
render(<RoomProfileSettings roomId={ROOM_ID} />);
await user.upload(screen.getByAltText("Upload"), AVATAR_FILE);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(client.uploadContent).toHaveBeenCalledWith(AVATAR_FILE));
await waitFor(() =>
expect(client.sendStateEvent).toHaveBeenCalledWith(
ROOM_ID,
EventType.RoomAvatar,
{
url: "mxc://matrix.org/1234",
},
"",
),
);
});
it("removes a room avatar", async () => {
const user = userEvent.setup();
mocked(client).getRoom.mockReturnValue(room);
mocked(room).currentState.getStateEvents.mockImplementation(
// @ts-ignore
(type: string): MatrixEvent[] | MatrixEvent | null => {
if (type === EventType.RoomAvatar) {
// @ts-ignore
return { getContent: () => ({ url: "mxc://matrix.org/1234" }) };
}
return null;
},
);
render(<RoomProfileSettings roomId="!floob:itty" />);
await user.click(screen.getByRole("button", { name: "Remove" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(client.sendStateEvent).toHaveBeenCalledWith("!floob:itty", EventType.RoomAvatar, {}, ""),
);
});
it("cancels changes", async () => {
const user = userEvent.setup();
render(<RoomProfileSettings roomId="!floob:itty" />);
const roomNameInput = screen.getByLabelText("Room Name");
expect(roomNameInput).toHaveValue("");
await user.type(roomNameInput, "My Room");
expect(roomNameInput).toHaveValue("My Room");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(roomNameInput).toHaveValue("");
});
});

View File

@ -14,18 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AvatarSetting from "../../../../src/components/views/settings/AvatarSetting";
import { stubClient } from "../../../test-utils";
const BASE64_GIF = "R0lGODlhAQABAAAAACw=";
const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", {
type: "image/gif",
});
describe("<AvatarSetting />", () => {
beforeEach(() => {
stubClient();
});
it("renders avatar with specified alt text", async () => {
const { queryByAltText } = render(
<AvatarSetting
avatarName="Peter Fox"
avatarAltText="Avatar of Peter Fox"
avatarUrl="https://avatar.fictional/my-avatar"
/>,
<AvatarSetting avatarAltText="Avatar of Peter Fox" avatar="mxc://example.org/my-avatar" />,
);
const imgElement = queryByAltText("Avatar of Peter Fox");
@ -35,9 +42,8 @@ describe("<AvatarSetting />", () => {
it("renders avatar with remove button", async () => {
const { queryByText } = render(
<AvatarSetting
avatarName="Peter Fox"
avatarAltText="Avatar of Peter Fox"
avatarUrl="https://avatar.fictional/my-avatar"
avatar="mxc://example.org/my-avatar"
removeAvatar={jest.fn()}
/>,
);
@ -47,9 +53,38 @@ describe("<AvatarSetting />", () => {
});
it("renders avatar without remove button", async () => {
const { queryByText } = render(<AvatarSetting avatarName="Peter Fox" avatarAltText="Avatar of Peter Fox" />);
const { queryByText } = render(<AvatarSetting disabled={true} avatarAltText="Avatar of Peter Fox" />);
const removeButton = queryByText("Remove");
expect(removeButton).toBeNull();
});
it("renders a file as the avatar when supplied", async () => {
render(<AvatarSetting avatarAltText="Avatar of Peter Fox" avatar={AVATAR_FILE} />);
const imgElement = await screen.findByRole("button", { name: "Avatar of Peter Fox" });
expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute("src", "data:image/gif;base64," + BASE64_GIF);
});
it("calls onChange when a file is uploaded", async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(
<AvatarSetting
avatar="mxc://example.org/my-avatar"
avatarAltText="Avatar of Peter Fox"
onChange={onChange}
/>,
);
// not really necessary, but to follow the expected user flow as much as possible
await user.click(screen.getByRole("button", { name: "Avatar of Peter Fox" }));
const fileInput = screen.getByAltText("Upload");
await user.upload(fileInput, AVATAR_FILE);
expect(onChange).toHaveBeenCalledWith(AVATAR_FILE);
});
});