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
This commit is contained in:
Will Hunt 2025-04-15 10:23:26 +01:00 committed by GitHub
parent c313c720de
commit 6fc3dd4628
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 128 additions and 133 deletions

View File

@ -147,11 +147,12 @@ export function avatarUrlForRoom(
width?: number, width?: number,
height?: number, height?: number,
resizeMethod?: ResizeMethod, resizeMethod?: ResizeMethod,
avatarMxcOverride?: string,
): string | null { ): string | null {
if (!room) return null; // null-guard if (!room) return null; // null-guard
const mxc = avatarMxcOverride ?? room.getMxcAvatarUrl();
if (room.getMxcAvatarUrl()) { if (mxc) {
const media = mediaFromMxc(room.getMxcAvatarUrl() ?? undefined); const media = mediaFromMxc(mxc);
if (width !== undefined && height !== undefined) { if (width !== undefined && height !== undefined) {
return media.getThumbnailOfSourceHttp(width, height, resizeMethod); return media.getThumbnailOfSourceHttp(width, height, resizeMethod);
} }

View File

@ -6,156 +6,91 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ComponentProps } from "react"; import React, { useCallback, useMemo, type ComponentProps } from "react";
import { import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix";
type Room, import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
RoomStateEvent,
type MatrixEvent,
EventType,
RoomType,
KnownMembership,
} from "matrix-js-sdk/src/matrix";
import BaseAvatar from "./BaseAvatar"; import BaseAvatar from "./BaseAvatar";
import ImageView from "../elements/ImageView"; import ImageView from "../elements/ImageView";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import * as Avatar from "../../../Avatar"; import * as Avatar from "../../../Avatar";
import DMRoomMap from "../../../utils/DMRoomMap";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import { type IOOBData } from "../../../stores/ThreepidInviteStore"; import { type IOOBData } from "../../../stores/ThreepidInviteStore";
import { LocalRoom } from "../../../models/LocalRoom";
import { filterBoolean } from "../../../utils/arrays"; import { filterBoolean } from "../../../utils/arrays";
import SettingsStore from "../../../settings/SettingsStore"; import { useSettingValue } from "../../../hooks/useSettings";
import { useRoomState } from "../../../hooks/useRoomState";
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> { interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
// Room may be left unset here, but if it is, // Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there // oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from) // would be nowhere to get the avatar from)
room?: Room; room?: Room;
oobData: IOOBData & { // Optional here.
size?: ComponentProps<typeof BaseAvatar>["size"];
oobData?: IOOBData & {
roomId?: string; roomId?: string;
}; };
viewAvatarOnClick?: boolean; viewAvatarOnClick?: boolean;
onClick?(): void; onClick?(): void;
} }
interface IState { const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
urls: string[]; const roomName = room?.name ?? oobData?.name ?? "?";
} const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
const roomIdName = useRoomIdName(room, oobData);
export function idNameForRoom(room: Room): string { const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
if (dmMapUserId) return dmMapUserId;
if (room instanceof LocalRoom && room.targets.length === 1) { const onRoomAvatarClick = useCallback(() => {
return room.targets[0].userId; const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
} if (!avatarUrl) return;
const params = {
return room.roomId; src: avatarUrl,
} name: room?.name,
export default class RoomAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
size: "36px",
oobData: {},
};
public constructor(props: IProps) {
super(props);
this.state = {
urls: RoomAvatar.getImageUrls(this.props),
}; };
}
public componentDidMount(): void { Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents); }, [room]);
}
public componentWillUnmount(): void { const urls = useMemo(() => {
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); const myMembership = room?.getMyMembership();
} if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) {
// The user has opted out of showing avatars, so return no urls here.
public static getDerivedStateFromProps(nextProps: IProps): IState { return [];
return {
urls: RoomAvatar.getImageUrls(nextProps),
};
}
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getRoomId() !== this.props.room?.roomId || ev.getType() !== EventType.RoomAvatar) return;
this.setState({
urls: RoomAvatar.getImageUrls(this.props),
});
};
private static getImageUrls(props: IProps): string[] {
const myMembership = props.room?.getMyMembership();
if (myMembership === KnownMembership.Invite || !myMembership) {
if (SettingsStore.getValue("showAvatarsOnInvites") === false) {
// The user has opted out of showing avatars, so return no urls here.
return [];
}
} }
// parseInt ignores suffixes.
const sizeInt = parseInt(size, 10);
let oobAvatar: string | null = null; let oobAvatar: string | null = null;
if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( if (oobData?.avatarUrl) {
parseInt(props.size, 10), oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
parseInt(props.size, 10),
"crop",
);
} }
return filterBoolean([ return filterBoolean([
oobAvatar, // highest priority oobAvatar, // highest priority
RoomAvatar.getRoomAvatarUrl(props), Avatar.avatarUrlForRoom(
room ?? null,
sizeInt,
sizeInt,
"crop",
avatarEvent?.getContent<RoomAvatarEventContent>().url,
),
]); ]);
} }, [showAvatarsOnInvites, room, size, avatarEvent, oobData]);
private static getRoomAvatarUrl(props: IProps): string | null { return (
if (!props.room) return null; <BaseAvatar
{...otherProps}
size={size}
type={(room?.getType() ?? oobData?.roomType) === RoomType.Space ? "square" : "round"}
name={roomName}
idName={roomIdName}
urls={urls}
onClick={viewAvatarOnClick && urls[0] ? onRoomAvatarClick : onClick}
/>
);
};
return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop"); export default RoomAvatar;
}
private onRoomAvatarClick = (): void => {
const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined);
if (!avatarUrl) return;
const params = {
src: avatarUrl,
name: this.props.room?.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
};
private get roomIdName(): string | undefined {
const room = this.props.room;
if (room) {
return idNameForRoom(room);
} else {
return this.props.oobData?.roomId;
}
}
public render(): React.ReactNode {
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
const roomName = room?.name ?? oobData.name ?? "?";
return (
<BaseAvatar
{...otherProps}
type={(room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space ? "square" : "round"}
name={roomName}
idName={this.roomIdName}
urls={this.state.urls}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
/>
);
}
}

View File

@ -52,14 +52,14 @@ function getDmMember(room: Room): RoomMember | null {
return otherUserId ? room.getMember(otherUserId) : null; return otherUserId ? room.getMember(otherUserId) : null;
} }
export const useDmMember = (room: Room): RoomMember | null => { export const useDmMember = (room?: Room): RoomMember | null => {
const [dmMember, setDmMember] = useState<RoomMember | null>(getDmMember(room)); const [dmMember, setDmMember] = useState<RoomMember | null>(room ? getDmMember(room) : null);
const updateDmMember = (): void => { const updateDmMember = (): void => {
setDmMember(getDmMember(room)); setDmMember(room ? getDmMember(room) : null);
}; };
useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); useEventEmitter(room?.currentState, RoomStateEvent.Members, updateDmMember);
useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember); useEventEmitter(room?.client, ClientEvent.AccountData, updateDmMember);
useEffect(updateDmMember, [room]); useEffect(updateDmMember, [room]);
return dmMember; return dmMember;

View File

@ -70,7 +70,7 @@ export default class RoomAvatarEvent extends React.Component<IProps> {
className="mx_RoomAvatarEvent_avatar" className="mx_RoomAvatarEvent_avatar"
onClick={this.onAvatarClick} onClick={this.onAvatarClick}
> >
<RoomAvatar size="14px" oobData={oobData} /> <RoomAvatar room={room ?? undefined} size="14px" oobData={oobData} />
</AccessibleButton> </AccessibleButton>
), ),
}, },

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import React, { createRef } from "react"; import React, { createRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { ContentHelpers, EventType } from "matrix-js-sdk/src/matrix"; import { ContentHelpers, EventType, type Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -15,7 +15,8 @@ import Field from "../elements/Field";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import AvatarSetting from "../settings/AvatarSetting"; import AvatarSetting from "../settings/AvatarSetting";
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize"; import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
import { idNameForRoom } from "../avatars/RoomAvatar"; import DMRoomMap from "../../../utils/DMRoomMap";
import { LocalRoom } from "../../../models/LocalRoom";
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -36,6 +37,19 @@ interface IState {
canSetAvatar: boolean; canSetAvatar: boolean;
} }
function idNameForRoom(room: Room): string {
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
if (dmMapUserId) return dmMapUserId;
if (room instanceof LocalRoom && room.targets.length === 1) {
return room.targets[0].userId;
}
return room.roomId;
}
// TODO: Merge with ProfileSettings? // TODO: Merge with ProfileSettings?
export default class RoomProfileSettings extends React.Component<IProps, IState> { export default class RoomProfileSettings extends React.Component<IProps, IState> {
private avatarUpload = createRef<HTMLInputElement>(); private avatarUpload = createRef<HTMLInputElement>();

View File

@ -0,0 +1,32 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { useDmMember } from "../../components/views/avatars/WithPresenceIndicator.tsx";
import { LocalRoom } from "../../models/LocalRoom.ts";
/**
* Determine a stable ID for generating hash colours. If the room
* is a DM (or local room), then the other user's ID will be used.
* @param oobData - out-of-band information about the room
* @returns An ID string, or undefined if the room and oobData are undefined.
*/
export function useRoomIdName(room?: Room, oobData?: { roomId?: string }): string | undefined {
const dmMember = useDmMember(room);
if (dmMember) {
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
return dmMember.userId;
} else if (room instanceof LocalRoom && room.targets.length === 1) {
return room.targets[0].userId;
} else if (room) {
return room.roomId;
} else {
return oobData?.roomId;
}
}

View File

@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { render, waitFor } from "jest-matrix-react"; import { render, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { JoinRule, type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; import { JoinRule, type MatrixClient, PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import React from "react"; import React from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@ -79,6 +79,7 @@ describe("DecoratedRoomAvatar", () => {
} as unknown as DMRoomMap; } as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(DecoratedRoomAvatar.prototype as any, "getPresenceIcon").mockImplementation(() => "ONLINE"); jest.spyOn(DecoratedRoomAvatar.prototype as any, "getPresenceIcon").mockImplementation(() => "ONLINE");
jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, DM_USER_ID));
const { container, asFragment } = renderComponent(); const { container, asFragment } = renderComponent();

View File

@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react"; import React from "react";
import { render } from "jest-matrix-react"; import { render } from "jest-matrix-react";
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { EventType, type MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import RoomAvatar from "../../../../../src/components/views/avatars/RoomAvatar"; import RoomAvatar from "../../../../../src/components/views/avatars/RoomAvatar";
@ -60,6 +60,7 @@ describe("RoomAvatar", () => {
it("should render as expected for a DM room", () => { it("should render as expected for a DM room", () => {
const userId = "@dm_user@example.com"; const userId = "@dm_user@example.com";
const room = new Room("!room:example.com", client, client.getSafeUserId()); const room = new Room("!room:example.com", client, client.getSafeUserId());
room.getMember = jest.fn().mockImplementation(() => new RoomMember(room.roomId, userId));
room.name = "DM room"; room.name = "DM room";
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId); mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId);
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
@ -78,6 +79,17 @@ describe("RoomAvatar", () => {
jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar"); jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar");
room.name = "test room"; room.name = "test room";
room.updateMyMembership("invite"); room.updateMyMembership("invite");
room.currentState.setStateEvents([
new MatrixEvent({
sender: "@sender:server",
room_id: room.roomId,
type: EventType.RoomAvatar,
state_key: "",
content: {
url: "mxc://example.com/foobar",
},
}),
]);
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
}); });
it("should not render an invite avatar if the user has disabled it", () => { it("should not render an invite avatar if the user has disabled it", () => {