Change avatar to use Compound implementation (#11448)

* Move avatar to new compound implementation

* Make space avatars square

* Remove reference to the avatar initial CSS class

* remove references to mx_BaseAvatar_image

* Fixe test suites

* Fix accessbility violations

* Add ConfirmUserActionDialog test

* Fix tests

* Add FacePile test

* Fix items clipping in members list

* Fix user info avatar sizing

* Fix tests
This commit is contained in:
Germain 2023-08-24 04:48:35 +01:00 committed by GitHub
parent e34920133e
commit 09c5e06d12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 936 additions and 1413 deletions

View file

@ -19,34 +19,29 @@ limitations under the License.
import React, { useCallback, useContext, useEffect, useState } from "react";
import classNames from "classnames";
import { ResizeMethod, ClientEvent } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import { Avatar } from "@vector-im/compound-web";
import * as AvatarLogic from "../../../Avatar";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ButtonEvent } from "../elements/AccessibleButton";
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units";
import { _t } from "../../../languageHandler";
interface IProps {
name?: string; // The name (first initial used as default)
idName?: string; // ID for generating hash colours
name?: React.ComponentProps<typeof Avatar>["name"]; // The name (first initial used as default)
idName?: React.ComponentProps<typeof Avatar>["id"]; // ID for generating hash colours
title?: string; // onHover title text
url?: string | null; // highest priority of them all, shortcut to set in urls[0]
urls?: string[]; // [highest_priority, ... , lowest_priority]
width: number;
height: number;
// XXX: resizeMethod not actually used.
resizeMethod?: ResizeMethod;
defaultToInitialLetter?: boolean; // true to add default url
type?: React.ComponentProps<typeof Avatar>["type"];
size: string;
onClick?: (ev: ButtonEvent) => void;
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
inputRef?: React.RefObject<HTMLSpanElement>;
className?: string;
tabIndex?: number;
altText?: string;
ariaLabel?: string;
}
const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => {
@ -107,120 +102,47 @@ const BaseAvatar: React.FC<IProps> = (props) => {
title,
url,
urls,
width = 40,
height = 40,
resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars
defaultToInitialLetter = true,
size = "40px",
onClick,
inputRef,
className,
type = "round",
altText = _t("Avatar"),
ariaLabel = _t("Avatar"),
...otherProps
} = props;
const [imageUrl, onError] = useImageUrl({ url, urls });
if (!imageUrl && defaultToInitialLetter && name) {
const initialLetter = AvatarLogic.getInitialLetter(name);
const textNode = (
<span
className="mx_BaseAvatar_initial"
aria-hidden="true"
style={{
fontSize: toPx(width * 0.65),
width: toPx(width),
lineHeight: toPx(height),
}}
>
{initialLetter}
</span>
);
const imgNode = (
<img
loading="lazy"
className="mx_BaseAvatar_image"
src={AvatarLogic.defaultAvatarUrlForString(idName || name)}
alt=""
title={title}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
aria-hidden="true"
data-testid="avatar-img"
/>
);
if (onClick) {
return (
<AccessibleButton
aria-label={ariaLabel}
aria-live="off"
{...otherProps}
element="span"
className={classNames("mx_BaseAvatar", className)}
onClick={onClick}
inputRef={inputRef}
>
{textNode}
{imgNode}
</AccessibleButton>
);
} else {
return (
<span
className={classNames("mx_BaseAvatar", className)}
ref={inputRef}
{...otherProps}
role="presentation"
>
{textNode}
{imgNode}
</span>
);
}
}
const extraProps: Partial<React.ComponentProps<typeof Avatar>> = {};
if (onClick) {
return (
<AccessibleButton
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
element="img"
src={imageUrl}
onClick={onClick}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
title={title}
alt={altText}
inputRef={inputRef}
data-testid="avatar-img"
{...otherProps}
/>
);
extraProps["aria-live"] = "off";
extraProps["role"] = "button";
} else if (!imageUrl) {
extraProps["role"] = "presentation";
extraProps["aria-label"] = undefined;
} else {
return (
<img
loading="lazy"
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
src={imageUrl}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
title={title}
alt=""
ref={inputRef}
data-testid="avatar-img"
{...otherProps}
/>
);
extraProps["role"] = undefined;
}
return (
<Avatar
ref={inputRef}
src={imageUrl}
id={idName ?? ""}
name={name ?? ""}
type={type}
size={size}
className={classNames("mx_BaseAvatar", className)}
aria-label={altText}
onError={onError}
title={title}
onClick={onClick}
{...extraProps}
{...otherProps}
data-testid="avatar-img"
/>
);
};
export default BaseAvatar;

View file

@ -33,7 +33,7 @@ import TooltipTarget from "../elements/TooltipTarget";
interface IProps {
room: Room;
avatarSize: number;
size: string;
displayBadge?: boolean;
forceCount?: boolean;
oobData?: IOOBData;
@ -207,8 +207,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
<div className={classes}>
<RoomAvatar
room={this.props.room}
width={this.props.avatarSize}
height={this.props.avatarSize}
size={this.props.size}
oobData={this.props.oobData}
viewAvatarOnClick={this.props.viewAvatarOnClick}
/>

View file

@ -30,8 +30,7 @@ import { _t } from "../../../languageHandler";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember | null;
fallbackUserId?: string;
width: number;
height: number;
size: string;
resizeMethod?: ResizeMethod;
// Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean;
@ -44,8 +43,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
}
export default function MemberAvatar({
width,
height,
size,
resizeMethod = "crop",
viewUserOnClick,
forceHistorical,
@ -68,8 +66,8 @@ export default function MemberAvatar({
if (member?.name) {
if (member.getMxcAvatarUrl()) {
imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "").getThumbnailOfSourceHttp(
width,
height,
parseInt(size, 10),
parseInt(size, 10),
resizeMethod,
);
}
@ -85,9 +83,7 @@ export default function MemberAvatar({
return (
<BaseAvatar
{...props}
width={width}
height={height}
resizeMethod={resizeMethod}
size={size}
name={name ?? ""}
title={hideTitle ? undefined : title}
idName={member?.userId ?? fallbackUserId}
@ -104,7 +100,6 @@ export default function MemberAvatar({
: props.onClick
}
altText={_t("Profile picture")}
ariaLabel={_t("Profile picture")}
/>
);
}

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, { ComponentProps } from "react";
import { Room, RoomStateEvent, MatrixEvent, EventType, RoomType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import BaseAvatar from "./BaseAvatar";
import ImageView from "../elements/ImageView";
@ -47,9 +46,7 @@ interface IState {
export default class RoomAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
width: 36,
height: 36,
resizeMethod: "crop",
size: "36px",
oobData: {},
};
@ -87,9 +84,9 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
let oobAvatar: string | null = null;
if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
props.width,
props.height,
props.resizeMethod,
parseInt(props.size, 10),
parseInt(props.size, 10),
"crop",
);
}
@ -102,7 +99,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
private static getRoomAvatarUrl(props: IProps): string | null {
if (!props.room) return null;
return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop");
}
private onRoomAvatarClick = (): void => {
@ -140,9 +137,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
return (
<BaseAvatar
{...otherProps}
className={classNames(className, {
mx_RoomAvatar_isSpaceRoom: (room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space,
})}
type={(room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space ? "square" : "round"}
name={roomName}
idName={this.roomIdName}
urls={this.state.urls}

View file

@ -24,7 +24,7 @@ import BaseAvatar from "./BaseAvatar";
interface SearchResultAvatarProps {
user: Member | RoomMember;
size: number;
size: string;
}
export function SearchResultAvatar({ user, size }: SearchResultAvatarProps): JSX.Element {
@ -46,11 +46,10 @@ export function SearchResultAvatar({ user, size }: SearchResultAvatarProps): JSX
return (
<BaseAvatar
className="mx_SearchResultAvatar"
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(size) : null}
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(parseInt(size, 10)) : null}
name={user.name}
idName={user.userId}
width={size}
height={size}
size={size}
/>
);
}

View file

@ -22,13 +22,12 @@ import { IApp, isAppWidget } from "../../../stores/WidgetStore";
import BaseAvatar, { BaseAvatarType } from "./BaseAvatar";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls" | "height" | "width"> {
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp | IWidget;
height?: number;
width?: number;
size: string;
}
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
const WidgetAvatar: React.FC<IProps> = ({ app, className, size = "20px", ...props }) => {
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg").default];
// heuristics for some better icons until Widgets support their own icons
if (app.type.includes("jitsi")) {
@ -49,8 +48,7 @@ const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 2
// MSC2765
url={isAppWidget(app) && app.avatar_url ? mediaFromMxc(app.avatar_url).getSquareThumbnailHttp(20) : null}
urls={iconUrls}
width={width}
height={height}
size={size}
/>
);
};