mirror of https://github.com/vector-im/riot-web
				
				
				
			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>pull/28217/head
							parent
							
								
									f6e919021a
								
							
						
					
					
						commit
						3342aa5ff8
					
				|  | @ -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(); | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
|  |  | |||
|  | @ -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; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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(""); | ||||
|     }); | ||||
| }); | ||||
|  | @ -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); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 David Baker
						David Baker