Skip to content

Commit 6fc3dd4

Browse files
authored
Refactor RoomAvatar into a functional component. (#29743)
* Refactor RoomAvatar into a functional component * Add useRoomAvatar hook * Remove useRoomAvatar hook and fix RoomAvatarEvents not using thumbnails. * lint * Ensure stable version of roomIdName * Use new hook * lint * remove unused param * Fixup tests * remove console * Update test
1 parent c313c72 commit 6fc3dd4

File tree

8 files changed

+130
-135
lines changed

8 files changed

+130
-135
lines changed

src/Avatar.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,12 @@ export function avatarUrlForRoom(
147147
width?: number,
148148
height?: number,
149149
resizeMethod?: ResizeMethod,
150+
avatarMxcOverride?: string,
150151
): string | null {
151152
if (!room) return null; // null-guard
152-
153-
if (room.getMxcAvatarUrl()) {
154-
const media = mediaFromMxc(room.getMxcAvatarUrl() ?? undefined);
153+
const mxc = avatarMxcOverride ?? room.getMxcAvatarUrl();
154+
if (mxc) {
155+
const media = mediaFromMxc(mxc);
155156
if (width !== undefined && height !== undefined) {
156157
return media.getThumbnailOfSourceHttp(width, height, resizeMethod);
157158
}

src/components/views/avatars/RoomAvatar.tsx

Lines changed: 57 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -6,156 +6,91 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
66
Please see LICENSE files in the repository root for full details.
77
*/
88

9-
import React, { type ComponentProps } from "react";
10-
import {
11-
type Room,
12-
RoomStateEvent,
13-
type MatrixEvent,
14-
EventType,
15-
RoomType,
16-
KnownMembership,
17-
} from "matrix-js-sdk/src/matrix";
9+
import React, { useCallback, useMemo, type ComponentProps } from "react";
10+
import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix";
11+
import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
1812

1913
import BaseAvatar from "./BaseAvatar";
2014
import ImageView from "../elements/ImageView";
21-
import { MatrixClientPeg } from "../../../MatrixClientPeg";
2215
import Modal from "../../../Modal";
2316
import * as Avatar from "../../../Avatar";
24-
import DMRoomMap from "../../../utils/DMRoomMap";
2517
import { mediaFromMxc } from "../../../customisations/Media";
2618
import { type IOOBData } from "../../../stores/ThreepidInviteStore";
27-
import { LocalRoom } from "../../../models/LocalRoom";
2819
import { filterBoolean } from "../../../utils/arrays";
29-
import SettingsStore from "../../../settings/SettingsStore";
20+
import { useSettingValue } from "../../../hooks/useSettings";
21+
import { useRoomState } from "../../../hooks/useRoomState";
22+
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
3023

31-
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
24+
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
3225
// Room may be left unset here, but if it is,
3326
// oobData.avatarUrl should be set (else there
3427
// would be nowhere to get the avatar from)
3528
room?: Room;
36-
oobData: IOOBData & {
29+
// Optional here.
30+
size?: ComponentProps<typeof BaseAvatar>["size"];
31+
oobData?: IOOBData & {
3732
roomId?: string;
3833
};
3934
viewAvatarOnClick?: boolean;
4035
onClick?(): void;
4136
}
4237

43-
interface IState {
44-
urls: string[];
45-
}
46-
47-
export function idNameForRoom(room: Room): string {
48-
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
49-
// If the room is a DM, we use the other user's ID for the color hash
50-
// in order to match the room avatar with their avatar
51-
if (dmMapUserId) return dmMapUserId;
52-
53-
if (room instanceof LocalRoom && room.targets.length === 1) {
54-
return room.targets[0].userId;
55-
}
56-
57-
return room.roomId;
58-
}
59-
60-
export default class RoomAvatar extends React.Component<IProps, IState> {
61-
public static defaultProps = {
62-
size: "36px",
63-
oobData: {},
64-
};
65-
66-
public constructor(props: IProps) {
67-
super(props);
68-
69-
this.state = {
70-
urls: RoomAvatar.getImageUrls(this.props),
71-
};
72-
}
38+
const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
39+
const roomName = room?.name ?? oobData?.name ?? "?";
40+
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
41+
const roomIdName = useRoomIdName(room, oobData);
7342

74-
public componentDidMount(): void {
75-
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents);
76-
}
43+
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
7744

78-
public componentWillUnmount(): void {
79-
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
80-
}
81-
82-
public static getDerivedStateFromProps(nextProps: IProps): IState {
83-
return {
84-
urls: RoomAvatar.getImageUrls(nextProps),
85-
};
86-
}
87-
88-
private onRoomStateEvents = (ev: MatrixEvent): void => {
89-
if (ev.getRoomId() !== this.props.room?.roomId || ev.getType() !== EventType.RoomAvatar) return;
90-
91-
this.setState({
92-
urls: RoomAvatar.getImageUrls(this.props),
93-
});
94-
};
95-
96-
private static getImageUrls(props: IProps): string[] {
97-
const myMembership = props.room?.getMyMembership();
98-
if (myMembership === KnownMembership.Invite || !myMembership) {
99-
if (SettingsStore.getValue("showAvatarsOnInvites") === false) {
100-
// The user has opted out of showing avatars, so return no urls here.
101-
return [];
102-
}
103-
}
104-
let oobAvatar: string | null = null;
105-
if (props.oobData.avatarUrl) {
106-
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
107-
parseInt(props.size, 10),
108-
parseInt(props.size, 10),
109-
"crop",
110-
);
111-
}
112-
113-
return filterBoolean([
114-
oobAvatar, // highest priority
115-
RoomAvatar.getRoomAvatarUrl(props),
116-
]);
117-
}
118-
119-
private static getRoomAvatarUrl(props: IProps): string | null {
120-
if (!props.room) return null;
121-
122-
return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop");
123-
}
124-
125-
private onRoomAvatarClick = (): void => {
126-
const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined);
45+
const onRoomAvatarClick = useCallback(() => {
46+
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
12747
if (!avatarUrl) return;
12848
const params = {
12949
src: avatarUrl,
130-
name: this.props.room?.name,
50+
name: room?.name,
13151
};
13252

13353
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
134-
};
54+
}, [room]);
13555

136-
private get roomIdName(): string | undefined {
137-
const room = this.props.room;
138-
139-
if (room) {
140-
return idNameForRoom(room);
141-
} else {
142-
return this.props.oobData?.roomId;
56+
const urls = useMemo(() => {
57+
const myMembership = room?.getMyMembership();
58+
if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) {
59+
// The user has opted out of showing avatars, so return no urls here.
60+
return [];
14361
}
144-
}
14562

146-
public render(): React.ReactNode {
147-
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
148-
const roomName = room?.name ?? oobData.name ?? "?";
63+
// parseInt ignores suffixes.
64+
const sizeInt = parseInt(size, 10);
65+
let oobAvatar: string | null = null;
14966

150-
return (
151-
<BaseAvatar
152-
{...otherProps}
153-
type={(room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space ? "square" : "round"}
154-
name={roomName}
155-
idName={this.roomIdName}
156-
urls={this.state.urls}
157-
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
158-
/>
159-
);
160-
}
161-
}
67+
if (oobData?.avatarUrl) {
68+
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
69+
}
70+
71+
return filterBoolean([
72+
oobAvatar, // highest priority
73+
Avatar.avatarUrlForRoom(
74+
room ?? null,
75+
sizeInt,
76+
sizeInt,
77+
"crop",
78+
avatarEvent?.getContent<RoomAvatarEventContent>().url,
79+
),
80+
]);
81+
}, [showAvatarsOnInvites, room, size, avatarEvent, oobData]);
82+
83+
return (
84+
<BaseAvatar
85+
{...otherProps}
86+
size={size}
87+
type={(room?.getType() ?? oobData?.roomType) === RoomType.Space ? "square" : "round"}
88+
name={roomName}
89+
idName={roomIdName}
90+
urls={urls}
91+
onClick={viewAvatarOnClick && urls[0] ? onRoomAvatarClick : onClick}
92+
/>
93+
);
94+
};
95+
96+
export default RoomAvatar;

src/components/views/avatars/WithPresenceIndicator.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ function getDmMember(room: Room): RoomMember | null {
5252
return otherUserId ? room.getMember(otherUserId) : null;
5353
}
5454

55-
export const useDmMember = (room: Room): RoomMember | null => {
56-
const [dmMember, setDmMember] = useState<RoomMember | null>(getDmMember(room));
55+
export const useDmMember = (room?: Room): RoomMember | null => {
56+
const [dmMember, setDmMember] = useState<RoomMember | null>(room ? getDmMember(room) : null);
5757
const updateDmMember = (): void => {
58-
setDmMember(getDmMember(room));
58+
setDmMember(room ? getDmMember(room) : null);
5959
};
6060

61-
useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember);
62-
useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember);
61+
useEventEmitter(room?.currentState, RoomStateEvent.Members, updateDmMember);
62+
useEventEmitter(room?.client, ClientEvent.AccountData, updateDmMember);
6363
useEffect(updateDmMember, [room]);
6464

6565
return dmMember;

src/components/views/messages/RoomAvatarEvent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export default class RoomAvatarEvent extends React.Component<IProps> {
7070
className="mx_RoomAvatarEvent_avatar"
7171
onClick={this.onAvatarClick}
7272
>
73-
<RoomAvatar size="14px" oobData={oobData} />
73+
<RoomAvatar room={room ?? undefined} size="14px" oobData={oobData} />
7474
</AccessibleButton>
7575
),
7676
},

src/components/views/room_settings/RoomProfileSettings.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ Please see LICENSE files in the repository root for full details.
77

88
import React, { createRef } from "react";
99
import classNames from "classnames";
10-
import { ContentHelpers, EventType } from "matrix-js-sdk/src/matrix";
10+
import { ContentHelpers, EventType, type Room } from "matrix-js-sdk/src/matrix";
1111

1212
import { _t } from "../../../languageHandler";
1313
import { MatrixClientPeg } from "../../../MatrixClientPeg";
1414
import Field from "../elements/Field";
1515
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
1616
import AvatarSetting from "../settings/AvatarSetting";
1717
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
18-
import { idNameForRoom } from "../avatars/RoomAvatar";
18+
import DMRoomMap from "../../../utils/DMRoomMap";
19+
import { LocalRoom } from "../../../models/LocalRoom";
1920

2021
interface IProps {
2122
roomId: string;
@@ -36,6 +37,19 @@ interface IState {
3637
canSetAvatar: boolean;
3738
}
3839

40+
function idNameForRoom(room: Room): string {
41+
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
42+
// If the room is a DM, we use the other user's ID for the color hash
43+
// in order to match the room avatar with their avatar
44+
if (dmMapUserId) return dmMapUserId;
45+
46+
if (room instanceof LocalRoom && room.targets.length === 1) {
47+
return room.targets[0].userId;
48+
}
49+
50+
return room.roomId;
51+
}
52+
3953
// TODO: Merge with ProfileSettings?
4054
export default class RoomProfileSettings extends React.Component<IProps, IState> {
4155
private avatarUpload = createRef<HTMLInputElement>();

src/hooks/room/useRoomIdName.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type Room } from "matrix-js-sdk/src/matrix";
9+
10+
import { useDmMember } from "../../components/views/avatars/WithPresenceIndicator.tsx";
11+
import { LocalRoom } from "../../models/LocalRoom.ts";
12+
13+
/**
14+
* Determine a stable ID for generating hash colours. If the room
15+
* is a DM (or local room), then the other user's ID will be used.
16+
* @param oobData - out-of-band information about the room
17+
* @returns An ID string, or undefined if the room and oobData are undefined.
18+
*/
19+
export function useRoomIdName(room?: Room, oobData?: { roomId?: string }): string | undefined {
20+
const dmMember = useDmMember(room);
21+
if (dmMember) {
22+
// If the room is a DM, we use the other user's ID for the color hash
23+
// in order to match the room avatar with their avatar
24+
return dmMember.userId;
25+
} else if (room instanceof LocalRoom && room.targets.length === 1) {
26+
return room.targets[0].userId;
27+
} else if (room) {
28+
return room.roomId;
29+
} else {
30+
return oobData?.roomId;
31+
}
32+
}

test/unit-tests/components/views/avatars/DecoratedRoomAvatar-test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
88

99
import { render, waitFor } from "jest-matrix-react";
1010
import { mocked } from "jest-mock";
11-
import { JoinRule, type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
11+
import { JoinRule, type MatrixClient, PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix";
1212
import React from "react";
1313
import userEvent from "@testing-library/user-event";
1414

@@ -79,6 +79,7 @@ describe("DecoratedRoomAvatar", () => {
7979
} as unknown as DMRoomMap;
8080
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
8181
jest.spyOn(DecoratedRoomAvatar.prototype as any, "getPresenceIcon").mockImplementation(() => "ONLINE");
82+
jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, DM_USER_ID));
8283

8384
const { container, asFragment } = renderComponent();
8485

test/unit-tests/components/views/avatars/RoomAvatar-test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
88

99
import React from "react";
1010
import { render } from "jest-matrix-react";
11-
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
11+
import { EventType, type MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
1212
import { mocked } from "jest-mock";
1313

1414
import RoomAvatar from "../../../../../src/components/views/avatars/RoomAvatar";
@@ -60,6 +60,7 @@ describe("RoomAvatar", () => {
6060
it("should render as expected for a DM room", () => {
6161
const userId = "@[email protected]";
6262
const room = new Room("!room:example.com", client, client.getSafeUserId());
63+
room.getMember = jest.fn().mockImplementation(() => new RoomMember(room.roomId, userId));
6364
room.name = "DM room";
6465
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId);
6566
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
@@ -78,6 +79,17 @@ describe("RoomAvatar", () => {
7879
jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar");
7980
room.name = "test room";
8081
room.updateMyMembership("invite");
82+
room.currentState.setStateEvents([
83+
new MatrixEvent({
84+
sender: "@sender:server",
85+
room_id: room.roomId,
86+
type: EventType.RoomAvatar,
87+
state_key: "",
88+
content: {
89+
url: "mxc://example.com/foobar",
90+
},
91+
}),
92+
]);
8193
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
8294
});
8395
it("should not render an invite avatar if the user has disabled it", () => {

0 commit comments

Comments
 (0)