mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-29 14:31:22 +01:00
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:
parent
c313c720de
commit
6fc3dd4628
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||||
super(props);
|
}, [room]);
|
||||||
|
|
||||||
this.state = {
|
const urls = useMemo(() => {
|
||||||
urls: RoomAvatar.getImageUrls(this.props),
|
const myMembership = room?.getMyMembership();
|
||||||
};
|
if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) {
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount(): void {
|
|
||||||
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
|
||||||
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getDerivedStateFromProps(nextProps: IProps): IState {
|
|
||||||
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.
|
// The user has opted out of showing avatars, so return no urls here.
|
||||||
return [];
|
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 {
|
|
||||||
if (!props.room) return null;
|
|
||||||
|
|
||||||
return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<BaseAvatar
|
<BaseAvatar
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
type={(room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space ? "square" : "round"}
|
size={size}
|
||||||
|
type={(room?.getType() ?? oobData?.roomType) === RoomType.Space ? "square" : "round"}
|
||||||
name={roomName}
|
name={roomName}
|
||||||
idName={this.roomIdName}
|
idName={roomIdName}
|
||||||
urls={this.state.urls}
|
urls={urls}
|
||||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
onClick={viewAvatarOnClick && urls[0] ? onRoomAvatarClick : onClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
export default RoomAvatar;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
32
src/hooks/room/useRoomIdName.ts
Normal file
32
src/hooks/room/useRoomIdName.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user