Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a8aa4de

Browse files
clarkfandybalaam
andauthored
Member avatars without canvas (#9990)
* Strict typechecking fixes for Base/Member/Avatar Update the core avatar files to pass `--strict --noImplicitAny` typechecks. Signed-off-by: Clark Fischer <[email protected]> * Add tests for Base/Member/Avatar More thoroughly test the core avatar files. Not necessarily the most thorough, but an improvement. Signed-off-by: Clark Fischer <[email protected]> * Extract TextAvatar from BaseAvatar Extracted the fallback/textual avatar into its own component. Signed-off-by: Clark Fischer <[email protected]> * Use standard HTML for non-image avatars Firefox users with `resistFingerprinting` enabled were seeing random noise for rooms and users without avatars. There's no real reason to use data URLs to present flat colors. This converts non-image avatars to inline blocks with background colors. See element-hq/element-web#23936 Signed-off-by: Clark Fischer <[email protected]> * Have pills use solid backgrounds rather than colored images Similar to room and member avatars, pills now use colored pseudo-elements rather than background images. Signed-off-by: Clark Fischer <[email protected]> --------- Signed-off-by: Clark Fischer <[email protected]> Co-authored-by: Andy Balaam <[email protected]>
1 parent 64cec31 commit a8aa4de

File tree

22 files changed

+806
-311
lines changed

22 files changed

+806
-311
lines changed

res/css/views/rooms/_BasicMessageComposer.pcss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ limitations under the License.
7878
min-width: $font-16px; /* ensure the avatar is not compressed */
7979
height: $font-16px;
8080
margin-inline-end: 0.24rem;
81-
background: var(--avatar-background), $background;
81+
background: var(--avatar-background);
8282
color: $avatar-initial-color;
8383
background-repeat: no-repeat;
8484
background-size: $font-16px;

src/Avatar.ts

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2015, 2016 OpenMarket Ltd
2+
Copyright 2015, 2016, 2023 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -24,16 +24,19 @@ import DMRoomMap from "./utils/DMRoomMap";
2424
import { mediaFromMxc } from "./customisations/Media";
2525
import { isLocalRoom } from "./utils/localRoom/isLocalRoom";
2626

27+
const DEFAULT_COLORS: Readonly<string[]> = ["#0DBD8B", "#368bd6", "#ac3ba8"];
28+
2729
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
2830
export function avatarUrlForMember(
29-
member: RoomMember,
31+
member: RoomMember | null | undefined,
3032
width: number,
3133
height: number,
3234
resizeMethod: ResizeMethod,
3335
): string {
34-
let url: string;
35-
if (member?.getMxcAvatarUrl()) {
36-
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
36+
let url: string | undefined;
37+
const mxcUrl = member?.getMxcAvatarUrl();
38+
if (mxcUrl) {
39+
url = mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
3740
}
3841
if (!url) {
3942
// member can be null here currently since on invites, the JS SDK
@@ -44,6 +47,17 @@ export function avatarUrlForMember(
4447
return url;
4548
}
4649

50+
export function getMemberAvatar(
51+
member: RoomMember | null | undefined,
52+
width: number,
53+
height: number,
54+
resizeMethod: ResizeMethod,
55+
): string | undefined {
56+
const mxcUrl = member?.getMxcAvatarUrl();
57+
if (!mxcUrl) return undefined;
58+
return mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
59+
}
60+
4761
export function avatarUrlForUser(
4862
user: Pick<User, "avatarUrl">,
4963
width: number,
@@ -86,18 +100,10 @@ function urlForColor(color: string): string {
86100
// hard to install a listener here, even if there were a clear event to listen to
87101
const colorToDataURLCache = new Map<string, string>();
88102

89-
export function defaultAvatarUrlForString(s: string): string {
103+
export function defaultAvatarUrlForString(s: string | undefined): string {
90104
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
91-
const defaultColors = ["#0DBD8B", "#368bd6", "#ac3ba8"];
92-
let total = 0;
93-
for (let i = 0; i < s.length; ++i) {
94-
total += s.charCodeAt(i);
95-
}
96-
const colorIndex = total % defaultColors.length;
97-
// overwritten color value in custom themes
98-
const cssVariable = `--avatar-background-colors_${colorIndex}`;
99-
const cssValue = document.body.style.getPropertyValue(cssVariable);
100-
const color = cssValue || defaultColors[colorIndex];
105+
106+
const color = getColorForString(s);
101107
let dataUrl = colorToDataURLCache.get(color);
102108
if (!dataUrl) {
103109
// validate color as this can come from account_data
@@ -112,13 +118,23 @@ export function defaultAvatarUrlForString(s: string): string {
112118
return dataUrl;
113119
}
114120

121+
export function getColorForString(input: string): string {
122+
const charSum = [...input].reduce((s, c) => s + c.charCodeAt(0), 0);
123+
const index = charSum % DEFAULT_COLORS.length;
124+
125+
// overwritten color value in custom themes
126+
const cssVariable = `--avatar-background-colors_${index}`;
127+
const cssValue = document.body.style.getPropertyValue(cssVariable);
128+
return cssValue || DEFAULT_COLORS[index]!;
129+
}
130+
115131
/**
116132
* returns the first (non-sigil) character of 'name',
117133
* converted to uppercase
118134
* @param {string} name
119135
* @return {string} the first letter
120136
*/
121-
export function getInitialLetter(name: string): string {
137+
export function getInitialLetter(name: string): string | undefined {
122138
if (!name) {
123139
// XXX: We should find out what causes the name to sometimes be falsy.
124140
console.trace("`name` argument to `getInitialLetter` not supplied");
@@ -134,19 +150,20 @@ export function getInitialLetter(name: string): string {
134150
}
135151

136152
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
137-
return split(name, "", 1)[0].toUpperCase();
153+
return split(name, "", 1)[0]!.toUpperCase();
138154
}
139155

140156
export function avatarUrlForRoom(
141-
room: Room,
157+
room: Room | undefined,
142158
width: number,
143159
height: number,
144160
resizeMethod?: ResizeMethod,
145161
): string | null {
146162
if (!room) return null; // null-guard
147163

148-
if (room.getMxcAvatarUrl()) {
149-
return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
164+
const mxcUrl = room.getMxcAvatarUrl();
165+
if (mxcUrl) {
166+
return mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
150167
}
151168

152169
// space rooms cannot be DMs so skip the rest
@@ -159,8 +176,9 @@ export function avatarUrlForRoom(
159176

160177
// If there are only two members in the DM use the avatar of the other member
161178
const otherMember = room.getAvatarFallbackMember();
162-
if (otherMember?.getMxcAvatarUrl()) {
163-
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
179+
const otherMemberMxc = otherMember?.getMxcAvatarUrl();
180+
if (otherMemberMxc) {
181+
return mediaFromMxc(otherMemberMxc).getThumbnailOfSourceHttp(width, height, resizeMethod);
164182
}
165183
return null;
166184
}

src/components/views/avatars/BaseAvatar.tsx

Lines changed: 77 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
/*
2-
Copyright 2015, 2016 OpenMarket Ltd
3-
Copyright 2018 New Vector Ltd
42
Copyright 2019 Michael Telatynski <[email protected]>
5-
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
3+
Copyright 2015, 2016, 2018, 2019, 2020, 2023 The Matrix.org Foundation C.I.C.
64
75
Licensed under the Apache License, Version 2.0 (the "License");
86
you may not use this file except in compliance with the License.
@@ -21,34 +19,41 @@ import React, { useCallback, useContext, useEffect, useState } from "react";
2119
import classNames from "classnames";
2220
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
2321
import { ClientEvent } from "matrix-js-sdk/src/client";
22+
import { SyncState } from "matrix-js-sdk/src/sync";
2423

2524
import * as AvatarLogic from "../../../Avatar";
26-
import SettingsStore from "../../../settings/SettingsStore";
2725
import AccessibleButton from "../elements/AccessibleButton";
2826
import RoomContext from "../../../contexts/RoomContext";
2927
import MatrixClientContext from "../../../contexts/MatrixClientContext";
3028
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
3129
import { toPx } from "../../../utils/units";
3230
import { _t } from "../../../languageHandler";
31+
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
3332

3433
interface IProps {
35-
name: string; // The name (first initial used as default)
36-
idName?: string; // ID for generating hash colours
37-
title?: string; // onHover title text
38-
url?: string; // highest priority of them all, shortcut to set in urls[0]
39-
urls?: string[]; // [highest_priority, ... , lowest_priority]
34+
/** The name (first initial used as default) */
35+
name: string;
36+
/** ID for generating hash colours */
37+
idName?: string;
38+
/** onHover title text */
39+
title?: string;
40+
/** highest priority of them all, shortcut to set in urls[0] */
41+
url?: string;
42+
/** [highest_priority, ... , lowest_priority] */
43+
urls?: string[];
4044
width?: number;
4145
height?: number;
42-
// XXX: resizeMethod not actually used.
46+
/** @deprecated not actually used */
4347
resizeMethod?: ResizeMethod;
44-
defaultToInitialLetter?: boolean; // true to add default url
45-
onClick?: React.MouseEventHandler;
48+
/** true to add default url */
49+
defaultToInitialLetter?: boolean;
50+
onClick?: React.ComponentPropsWithoutRef<typeof AccessibleTooltipButton>["onClick"];
4651
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
4752
className?: string;
4853
tabIndex?: number;
4954
}
5055

51-
const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): string[] => {
56+
const calculateUrls = (url: string | undefined, urls: string[] | undefined, lowBandwidth: boolean): string[] => {
5257
// work out the full set of urls to try to load. This is formed like so:
5358
// imageUrls: [ props.url, ...props.urls ]
5459

@@ -66,11 +71,26 @@ const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): stri
6671
return Array.from(new Set(_urls));
6772
};
6873

69-
const useImageUrl = ({ url, urls }): [string, () => void] => {
74+
/**
75+
* Hook for cycling through a changing set of images.
76+
*
77+
* The set of images is updated whenever `url` or `urls` change, the user's
78+
* `lowBandwidth` preference changes, or the client reconnects.
79+
*
80+
* Returns `[imageUrl, onError]`. When `onError` is called, the next image in
81+
* the set will be displayed.
82+
*/
83+
const useImageUrl = ({
84+
url,
85+
urls,
86+
}: {
87+
url: string | undefined;
88+
urls: string[] | undefined;
89+
}): [string | undefined, () => void] => {
7090
// Since this is a hot code path and the settings store can be slow, we
7191
// use the cached lowBandwidth value from the room context if it exists
7292
const roomContext = useContext(RoomContext);
73-
const lowBandwidth = roomContext ? roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
93+
const lowBandwidth = roomContext.lowBandwidth;
7494

7595
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
7696
const [urlsIndex, setIndex] = useState<number>(0);
@@ -85,10 +105,10 @@ const useImageUrl = ({ url, urls }): [string, () => void] => {
85105
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
86106

87107
const cli = useContext(MatrixClientContext);
88-
const onClientSync = useCallback((syncState, prevState) => {
108+
const onClientSync = useCallback((syncState: SyncState, prevState: SyncState | null) => {
89109
// Consider the client reconnected if there is no error with syncing.
90110
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
91-
const reconnected = syncState !== "ERROR" && prevState !== syncState;
111+
const reconnected = syncState !== SyncState.Error && prevState !== syncState;
92112
if (reconnected) {
93113
setIndex(0);
94114
}
@@ -108,46 +128,18 @@ const BaseAvatar: React.FC<IProps> = (props) => {
108128
urls,
109129
width = 40,
110130
height = 40,
111-
resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars
112131
defaultToInitialLetter = true,
113132
onClick,
114133
inputRef,
115134
className,
135+
resizeMethod: _unused, // to keep it from being in `otherProps`
116136
...otherProps
117137
} = props;
118138

119139
const [imageUrl, onError] = useImageUrl({ url, urls });
120140

121141
if (!imageUrl && defaultToInitialLetter && name) {
122-
const initialLetter = AvatarLogic.getInitialLetter(name);
123-
const textNode = (
124-
<span
125-
className="mx_BaseAvatar_initial"
126-
aria-hidden="true"
127-
style={{
128-
fontSize: toPx(width * 0.65),
129-
width: toPx(width),
130-
lineHeight: toPx(height),
131-
}}
132-
>
133-
{initialLetter}
134-
</span>
135-
);
136-
const imgNode = (
137-
<img
138-
className="mx_BaseAvatar_image"
139-
src={AvatarLogic.defaultAvatarUrlForString(idName || name)}
140-
alt=""
141-
title={title}
142-
onError={onError}
143-
style={{
144-
width: toPx(width),
145-
height: toPx(height),
146-
}}
147-
aria-hidden="true"
148-
data-testid="avatar-img"
149-
/>
150-
);
142+
const avatar = <TextAvatar name={name} idName={idName} width={width} height={height} title={title} />;
151143

152144
if (onClick) {
153145
return (
@@ -159,9 +151,12 @@ const BaseAvatar: React.FC<IProps> = (props) => {
159151
className={classNames("mx_BaseAvatar", className)}
160152
onClick={onClick}
161153
inputRef={inputRef}
154+
style={{
155+
width: toPx(width),
156+
height: toPx(height),
157+
}}
162158
>
163-
{textNode}
164-
{imgNode}
159+
{avatar}
165160
</AccessibleButton>
166161
);
167162
} else {
@@ -170,10 +165,13 @@ const BaseAvatar: React.FC<IProps> = (props) => {
170165
className={classNames("mx_BaseAvatar", className)}
171166
ref={inputRef}
172167
{...otherProps}
168+
style={{
169+
width: toPx(width),
170+
height: toPx(height),
171+
}}
173172
role="presentation"
174173
>
175-
{textNode}
176-
{imgNode}
174+
{avatar}
177175
</span>
178176
);
179177
}
@@ -220,3 +218,31 @@ const BaseAvatar: React.FC<IProps> = (props) => {
220218

221219
export default BaseAvatar;
222220
export type BaseAvatarType = React.FC<IProps>;
221+
222+
const TextAvatar: React.FC<{
223+
name: string;
224+
idName?: string;
225+
width: number;
226+
height: number;
227+
title?: string;
228+
}> = ({ name, idName, width, height, title }) => {
229+
const initialLetter = AvatarLogic.getInitialLetter(name);
230+
231+
return (
232+
<span
233+
className="mx_BaseAvatar_image mx_BaseAvatar_initial"
234+
aria-hidden="true"
235+
style={{
236+
backgroundColor: AvatarLogic.getColorForString(idName || name),
237+
width: toPx(width),
238+
height: toPx(height),
239+
fontSize: toPx(width * 0.65),
240+
lineHeight: toPx(height),
241+
}}
242+
title={title}
243+
data-testid="avatar-img"
244+
>
245+
{initialLetter}
246+
</span>
247+
);
248+
};

0 commit comments

Comments
 (0)