You've already forked element-web
mirror of
https://github.com/element-hq/element-web.git
synced 2025-08-09 14:42:51 +03:00
Mvvm split user info, create powerlevels component (#30005)
* feat: mvvm user info powerlevels * chore: remove unecesssary comments and add new * chore: fix lint and rebase * fix: lint error
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useContext, useEffect, useState, useCallback } from "react";
|
||||||
|
import { logger } from "@sentry/browser";
|
||||||
|
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import Modal from "../../../Modal";
|
||||||
|
import ErrorDialog from "../../views/dialogs/ErrorDialog";
|
||||||
|
import QuestionDialog from "../../views/dialogs/QuestionDialog";
|
||||||
|
import { warnSelfDemote } from "../../views/right_panel/UserInfo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export interface UserInfoPowerLevelState {
|
||||||
|
/**
|
||||||
|
* default power level value of the selected user
|
||||||
|
*/
|
||||||
|
powerLevelUsersDefault: number;
|
||||||
|
/**
|
||||||
|
* The new power level to apply
|
||||||
|
*/
|
||||||
|
selectedPowerLevel: number;
|
||||||
|
/**
|
||||||
|
* Method to call When power level selection change
|
||||||
|
*/
|
||||||
|
onPowerChange: (powerLevel: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserInfoPowerlevelViewModel = (user: RoomMember, room: Room): UserInfoPowerLevelState => {
|
||||||
|
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPowerLevel(user.powerLevel);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const onPowerChange = useCallback(
|
||||||
|
async (powerLevel: number) => {
|
||||||
|
setSelectedPowerLevel(powerLevel);
|
||||||
|
|
||||||
|
const applyPowerChange = (roomId: string, target: string, powerLevel: number): Promise<unknown> => {
|
||||||
|
return cli.setPowerLevel(roomId, target, powerLevel).then(
|
||||||
|
function () {
|
||||||
|
logger.info("Power change success");
|
||||||
|
},
|
||||||
|
function (err) {
|
||||||
|
logger.error("Failed to change power level " + err);
|
||||||
|
Modal.createDialog(ErrorDialog, {
|
||||||
|
title: _t("common|error"),
|
||||||
|
description: _t("error|update_power_level"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomId = user.roomId;
|
||||||
|
const target = user.userId;
|
||||||
|
|
||||||
|
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||||
|
if (!powerLevelEvent) return;
|
||||||
|
|
||||||
|
const myUserId = cli.getUserId();
|
||||||
|
const myPower = powerLevelEvent.getContent().users[myUserId || ""];
|
||||||
|
if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
|
||||||
|
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||||
|
title: _t("common|warning"),
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
{_t("user_info|promote_warning")}
|
||||||
|
<br />
|
||||||
|
{_t("common|are_you_sure")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
button: _t("action|continue"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [confirmed] = await finished;
|
||||||
|
if (!confirmed) return;
|
||||||
|
} else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
|
||||||
|
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||||
|
try {
|
||||||
|
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to warn about self demotion: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyPowerChange(roomId, target, powerLevel);
|
||||||
|
},
|
||||||
|
[user.roomId, user.userId, cli, room],
|
||||||
|
);
|
||||||
|
|
||||||
|
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||||
|
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
powerLevelUsersDefault,
|
||||||
|
onPowerChange,
|
||||||
|
selectedPowerLevel,
|
||||||
|
};
|
||||||
|
};
|
@@ -43,7 +43,6 @@ import { type ButtonEvent } from "../elements/AccessibleButton";
|
|||||||
import SdkConfig from "../../../SdkConfig";
|
import SdkConfig from "../../../SdkConfig";
|
||||||
import MultiInviter from "../../../utils/MultiInviter";
|
import MultiInviter from "../../../utils/MultiInviter";
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
import { textualPowerLevel } from "../../../Roles";
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
import EncryptionPanel from "./EncryptionPanel";
|
import EncryptionPanel from "./EncryptionPanel";
|
||||||
@@ -54,7 +53,6 @@ import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
|||||||
import BaseCard from "./BaseCard";
|
import BaseCard from "./BaseCard";
|
||||||
import ImageView from "../elements/ImageView";
|
import ImageView from "../elements/ImageView";
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import PowerSelector from "../elements/PowerSelector";
|
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
import PresenceLabel from "../rooms/PresenceLabel";
|
import PresenceLabel from "../rooms/PresenceLabel";
|
||||||
import { ShareDialog } from "../dialogs/ShareDialog";
|
import { ShareDialog } from "../dialogs/ShareDialog";
|
||||||
@@ -76,6 +74,7 @@ import { Flex } from "../../utils/Flex";
|
|||||||
import CopyableText from "../elements/CopyableText";
|
import CopyableText from "../elements/CopyableText";
|
||||||
import { useUserTimezone } from "../../../hooks/useUserTimezone";
|
import { useUserTimezone } from "../../../hooks/useUserTimezone";
|
||||||
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
|
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
|
||||||
|
import { PowerLevelSection } from "./user_info/UserInfoPowerLevels";
|
||||||
|
|
||||||
export interface IDevice extends Device {
|
export interface IDevice extends Device {
|
||||||
ambiguous?: boolean;
|
ambiguous?: boolean;
|
||||||
@@ -437,7 +436,7 @@ const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IRoomPermissions {
|
export interface IRoomPermissions {
|
||||||
modifyLevelMax: number;
|
modifyLevelMax: number;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canInvite: boolean;
|
canInvite: boolean;
|
||||||
@@ -492,112 +491,6 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
|
|||||||
return roomPermissions;
|
return roomPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PowerLevelSection: React.FC<{
|
|
||||||
user: RoomMember;
|
|
||||||
room: Room;
|
|
||||||
roomPermissions: IRoomPermissions;
|
|
||||||
powerLevels: IPowerLevelsContent;
|
|
||||||
}> = ({ user, room, roomPermissions, powerLevels }) => {
|
|
||||||
if (roomPermissions.canEdit) {
|
|
||||||
return <PowerLevelEditor user={user} room={room} roomPermissions={roomPermissions} />;
|
|
||||||
} else {
|
|
||||||
const powerLevelUsersDefault = powerLevels.users_default || 0;
|
|
||||||
const powerLevel = user.powerLevel;
|
|
||||||
const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
|
|
||||||
return (
|
|
||||||
<div className="mx_UserInfo_profileField">
|
|
||||||
<div className="mx_UserInfo_roleDescription">{role}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PowerLevelEditor: React.FC<{
|
|
||||||
user: RoomMember;
|
|
||||||
room: Room;
|
|
||||||
roomPermissions: IRoomPermissions;
|
|
||||||
}> = ({ user, room, roomPermissions }) => {
|
|
||||||
const cli = useContext(MatrixClientContext);
|
|
||||||
|
|
||||||
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedPowerLevel(user.powerLevel);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const onPowerChange = useCallback(
|
|
||||||
async (powerLevel: number) => {
|
|
||||||
setSelectedPowerLevel(powerLevel);
|
|
||||||
|
|
||||||
const applyPowerChange = (roomId: string, target: string, powerLevel: number): Promise<unknown> => {
|
|
||||||
return cli.setPowerLevel(roomId, target, powerLevel).then(
|
|
||||||
function () {
|
|
||||||
// NO-OP; rely on the m.room.member event coming down else we could
|
|
||||||
// get out of sync if we force setState here!
|
|
||||||
logger.log("Power change success");
|
|
||||||
},
|
|
||||||
function (err) {
|
|
||||||
logger.error("Failed to change power level " + err);
|
|
||||||
Modal.createDialog(ErrorDialog, {
|
|
||||||
title: _t("common|error"),
|
|
||||||
description: _t("error|update_power_level"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const roomId = user.roomId;
|
|
||||||
const target = user.userId;
|
|
||||||
|
|
||||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
|
||||||
if (!powerLevelEvent) return;
|
|
||||||
|
|
||||||
const myUserId = cli.getUserId();
|
|
||||||
const myPower = powerLevelEvent.getContent().users[myUserId || ""];
|
|
||||||
if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
|
|
||||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
|
||||||
title: _t("common|warning"),
|
|
||||||
description: (
|
|
||||||
<div>
|
|
||||||
{_t("user_info|promote_warning")}
|
|
||||||
<br />
|
|
||||||
{_t("common|are_you_sure")}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
button: _t("action|continue"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [confirmed] = await finished;
|
|
||||||
if (!confirmed) return;
|
|
||||||
} else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
|
|
||||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
|
||||||
try {
|
|
||||||
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to warn about self demotion: ", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyPowerChange(roomId, target, powerLevel);
|
|
||||||
},
|
|
||||||
[user.roomId, user.userId, cli, room],
|
|
||||||
);
|
|
||||||
|
|
||||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
|
||||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx_UserInfo_profileField">
|
|
||||||
<PowerSelector
|
|
||||||
label={undefined}
|
|
||||||
value={selectedPowerLevel}
|
|
||||||
maxValue={roomPermissions.modifyLevelMax}
|
|
||||||
usersDefault={powerLevelUsersDefault}
|
|
||||||
onChange={onPowerChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getUserDeviceInfo(
|
async function getUserDeviceInfo(
|
||||||
userId: string,
|
userId: string,
|
||||||
cli: MatrixClient,
|
cli: MatrixClient,
|
||||||
@@ -820,12 +713,7 @@ const BasicUserInfo: React.FC<{
|
|||||||
// hide the Roles section for DMs as it doesn't make sense there
|
// hide the Roles section for DMs as it doesn't make sense there
|
||||||
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
|
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
|
||||||
memberDetails = (
|
memberDetails = (
|
||||||
<PowerLevelSection
|
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={roomPermissions} />
|
||||||
powerLevels={powerLevels}
|
|
||||||
user={member as RoomMember}
|
|
||||||
room={room}
|
|
||||||
roomPermissions={roomPermissions}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { textualPowerLevel } from "../../../../Roles";
|
||||||
|
import PowerSelector from "../../elements/PowerSelector";
|
||||||
|
import { type IRoomPermissions } from "../UserInfo";
|
||||||
|
import {
|
||||||
|
type UserInfoPowerLevelState,
|
||||||
|
useUserInfoPowerlevelViewModel,
|
||||||
|
} from "../../../viewmodels/right_panel/UserInfoPowerlevelViewModel";
|
||||||
|
|
||||||
|
export const PowerLevelSection: React.FC<{
|
||||||
|
user: RoomMember;
|
||||||
|
room: Room;
|
||||||
|
roomPermissions: IRoomPermissions;
|
||||||
|
}> = ({ user, room, roomPermissions }) => {
|
||||||
|
const vm = useUserInfoPowerlevelViewModel(user, room);
|
||||||
|
|
||||||
|
if (roomPermissions.canEdit) {
|
||||||
|
return <PowerLevelEditor vm={vm} roomPermissions={roomPermissions} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const powerLevel = user.powerLevel;
|
||||||
|
const role = textualPowerLevel(powerLevel, vm.powerLevelUsersDefault);
|
||||||
|
return (
|
||||||
|
<div className="mx_UserInfo_profileField">
|
||||||
|
<div className="mx_UserInfo_roleDescription">{role}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PowerLevelEditor: React.FC<{
|
||||||
|
vm: UserInfoPowerLevelState;
|
||||||
|
roomPermissions: IRoomPermissions;
|
||||||
|
}> = ({ vm, roomPermissions }) => {
|
||||||
|
return (
|
||||||
|
<div className="mx_UserInfo_profileField">
|
||||||
|
<PowerSelector
|
||||||
|
label={undefined}
|
||||||
|
value={vm.selectedPowerLevel}
|
||||||
|
maxValue={roomPermissions.modifyLevelMax}
|
||||||
|
usersDefault={vm.powerLevelUsersDefault}
|
||||||
|
onChange={vm.onPowerChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -0,0 +1,222 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook } from "jest-matrix-react";
|
||||||
|
import { type Mocked, mocked } from "jest-mock";
|
||||||
|
import { RoomMember, MatrixEvent, type Room, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||||
|
import { useUserInfoPowerlevelViewModel } from "../../../../../../src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel";
|
||||||
|
import { withClientContextRenderOptions } from "../../../../../test-utils";
|
||||||
|
import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
|
||||||
|
import Modal from "../../../../../../src/Modal";
|
||||||
|
import { warnSelfDemote } from "../../../../../../src/components/views/right_panel/UserInfo";
|
||||||
|
|
||||||
|
jest.mock("../../../../../../src/Modal", () => ({
|
||||||
|
createDialog: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../../../../src/components/views/right_panel/UserInfo", () => ({
|
||||||
|
warnSelfDemote: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("UserInfoAdminPowerlevelViewModel", () => {
|
||||||
|
const defaultRoomId = "!fkfk";
|
||||||
|
const defaultUserId = "@user:example.com";
|
||||||
|
const defaultMeId = "@me:example.com";
|
||||||
|
const selfUser = new RoomMember(defaultRoomId, defaultMeId);
|
||||||
|
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||||
|
const startPowerLevel = 50;
|
||||||
|
const changedPowerLevel = 100;
|
||||||
|
|
||||||
|
let mockClient: Mocked<MatrixClient>;
|
||||||
|
let mockRoom: Mocked<Room>;
|
||||||
|
let defaultProps: {
|
||||||
|
user: RoomMember;
|
||||||
|
room: Room;
|
||||||
|
roomPermissions: IRoomPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
defaultProps = {
|
||||||
|
user: defaultMember,
|
||||||
|
room: mockRoom,
|
||||||
|
roomPermissions: {
|
||||||
|
modifyLevelMax: 100,
|
||||||
|
canEdit: false,
|
||||||
|
canInvite: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRoom = mocked({
|
||||||
|
roomId: defaultRoomId,
|
||||||
|
getType: jest.fn().mockReturnValue(undefined),
|
||||||
|
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||||
|
getMember: jest.fn().mockReturnValue(undefined),
|
||||||
|
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||||
|
name: "test room",
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
currentState: {
|
||||||
|
getStateEvents: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
},
|
||||||
|
getEventReadUpTo: jest.fn(),
|
||||||
|
} as unknown as Room);
|
||||||
|
|
||||||
|
mockClient = mocked({
|
||||||
|
getUser: jest.fn(),
|
||||||
|
isGuest: jest.fn().mockReturnValue(false),
|
||||||
|
isUserIgnored: jest.fn(),
|
||||||
|
getIgnoredUsers: jest.fn(),
|
||||||
|
setIgnoredUsers: jest.fn(),
|
||||||
|
getUserId: jest.fn(),
|
||||||
|
getSafeUserId: jest.fn(),
|
||||||
|
getDomain: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||||
|
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||||
|
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||||
|
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||||
|
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
currentState: {
|
||||||
|
on: jest.fn(),
|
||||||
|
},
|
||||||
|
getRoom: jest.fn(),
|
||||||
|
credentials: {},
|
||||||
|
setPowerLevel: jest.fn().mockResolvedValueOnce({ event_id: "123" }),
|
||||||
|
} as unknown as MatrixClient);
|
||||||
|
|
||||||
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||||
|
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||||
|
|
||||||
|
(Modal.createDialog as jest.Mock).mockImplementation(() => ({
|
||||||
|
finished: Promise.resolve([true]),
|
||||||
|
}));
|
||||||
|
(warnSelfDemote as jest.Mock).mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponentHook = (props = defaultProps, client = mockClient) => {
|
||||||
|
return renderHook(
|
||||||
|
() => useUserInfoPowerlevelViewModel(props.user, props.room),
|
||||||
|
withClientContextRenderOptions(client),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should give default power level", () => {
|
||||||
|
const defaultPowerLevel = 1;
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: { users: { [defaultUserId]: defaultPowerLevel }, users_default: defaultPowerLevel },
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
|
||||||
|
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom });
|
||||||
|
|
||||||
|
expect(result.current.powerLevelUsersDefault).toBe(defaultPowerLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles successful power level change", async () => {
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
|
||||||
|
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
|
||||||
|
|
||||||
|
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
|
||||||
|
|
||||||
|
await result.current.onPowerChange(changedPowerLevel);
|
||||||
|
|
||||||
|
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows warning when promoting user to higher power level", async () => {
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: {
|
||||||
|
users: {
|
||||||
|
[defaultUserId]: startPowerLevel,
|
||||||
|
[defaultMeId]: startPowerLevel,
|
||||||
|
},
|
||||||
|
users_default: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
mockClient.getUserId.mockReturnValue(defaultMeId);
|
||||||
|
|
||||||
|
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
|
||||||
|
|
||||||
|
await result.current.onPowerChange(changedPowerLevel);
|
||||||
|
|
||||||
|
expect(Modal.createDialog).toHaveBeenCalled();
|
||||||
|
expect(mockClient.setPowerLevel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows warning when self-demoting", async () => {
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: {
|
||||||
|
users: { [defaultMeId]: changedPowerLevel },
|
||||||
|
users_default: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
mockClient.getUserId.mockReturnValue(defaultMeId);
|
||||||
|
|
||||||
|
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom, user: selfUser }, mockClient);
|
||||||
|
|
||||||
|
await result.current.onPowerChange(startPowerLevel);
|
||||||
|
|
||||||
|
expect(warnSelfDemote).toHaveBeenCalled();
|
||||||
|
expect(mockClient.setPowerLevel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels power level change when user declines warning", async () => {
|
||||||
|
(Modal.createDialog as jest.Mock).mockImplementation(() => ({
|
||||||
|
finished: Promise.resolve([false]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: {
|
||||||
|
users: {
|
||||||
|
[defaultUserId]: startPowerLevel,
|
||||||
|
"@me:example.com": startPowerLevel,
|
||||||
|
},
|
||||||
|
users_default: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
mockClient.getUserId.mockReturnValue(defaultMeId);
|
||||||
|
|
||||||
|
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
|
||||||
|
|
||||||
|
await result.current.onPowerChange(changedPowerLevel);
|
||||||
|
|
||||||
|
expect(Modal.createDialog).toHaveBeenCalled();
|
||||||
|
expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing power level event", async () => {
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(null);
|
||||||
|
|
||||||
|
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
|
||||||
|
|
||||||
|
await result.current.onPowerChange(changedPowerLevel);
|
||||||
|
|
||||||
|
expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@@ -7,18 +7,10 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { fireEvent, render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
|
import { render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { type Mocked, mocked } from "jest-mock";
|
import { type Mocked, mocked } from "jest-mock";
|
||||||
import {
|
import { type Room, User, type MatrixClient, RoomMember, Device } from "matrix-js-sdk/src/matrix";
|
||||||
type Room,
|
|
||||||
User,
|
|
||||||
type MatrixClient,
|
|
||||||
RoomMember,
|
|
||||||
MatrixEvent,
|
|
||||||
EventType,
|
|
||||||
Device,
|
|
||||||
} from "matrix-js-sdk/src/matrix";
|
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import {
|
import {
|
||||||
UserVerificationStatus,
|
UserVerificationStatus,
|
||||||
@@ -31,7 +23,6 @@ import {
|
|||||||
import UserInfo, {
|
import UserInfo, {
|
||||||
disambiguateDevices,
|
disambiguateDevices,
|
||||||
getPowerLevels,
|
getPowerLevels,
|
||||||
PowerLevelEditor,
|
|
||||||
UserInfoHeader,
|
UserInfoHeader,
|
||||||
UserOptionsSection,
|
UserOptionsSection,
|
||||||
} from "../../../../../src/components/views/right_panel/UserInfo";
|
} from "../../../../../src/components/views/right_panel/UserInfo";
|
||||||
@@ -717,65 +708,6 @@ describe("<UserOptionsSection />", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("<PowerLevelEditor />", () => {
|
|
||||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
|
||||||
|
|
||||||
let defaultProps: Parameters<typeof PowerLevelEditor>[0];
|
|
||||||
beforeEach(() => {
|
|
||||||
defaultProps = {
|
|
||||||
user: defaultMember,
|
|
||||||
room: mockRoom,
|
|
||||||
roomPermissions: {
|
|
||||||
modifyLevelMax: 100,
|
|
||||||
canEdit: false,
|
|
||||||
canInvite: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderComponent = (props = {}) => {
|
|
||||||
const Wrapper = (wrapperProps = {}) => {
|
|
||||||
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return render(<PowerLevelEditor {...defaultProps} {...props} />, {
|
|
||||||
wrapper: Wrapper,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
it("renders a power level combobox", () => {
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a combobox and attempts to change power level on change of the combobox", async () => {
|
|
||||||
const startPowerLevel = 999;
|
|
||||||
const powerLevelEvent = new MatrixEvent({
|
|
||||||
type: EventType.RoomPowerLevels,
|
|
||||||
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
|
|
||||||
});
|
|
||||||
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
|
||||||
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
|
|
||||||
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
|
|
||||||
mockClient.setPowerLevel.mockResolvedValueOnce({ event_id: "123" });
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
const changedPowerLevel = 100;
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), {
|
|
||||||
target: { value: changedPowerLevel },
|
|
||||||
});
|
|
||||||
|
|
||||||
await screen.findByText("Demote", { exact: true });
|
|
||||||
|
|
||||||
// firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it
|
|
||||||
await userEvent.click(await screen.findByText("Demote", { exact: true }));
|
|
||||||
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("disambiguateDevices", () => {
|
describe("disambiguateDevices", () => {
|
||||||
it("does not add ambiguous key to unique names", () => {
|
it("does not add ambiguous key to unique names", () => {
|
||||||
const initialDevices = [
|
const initialDevices = [
|
||||||
|
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { fireEvent, render, screen } from "jest-matrix-react";
|
||||||
|
import { type Mocked, mocked } from "jest-mock";
|
||||||
|
import { MatrixEvent, type MatrixClient, RoomMember, type Room, EventType } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||||
|
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||||
|
import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
|
||||||
|
import { PowerLevelSection } from "../../../../../../src/components/views/right_panel/user_info/UserInfoPowerLevels";
|
||||||
|
|
||||||
|
describe("<PowerLevelEditor />", () => {
|
||||||
|
const defaultRoomId = "!fkfk";
|
||||||
|
const defaultUserId = "@user:example.com";
|
||||||
|
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||||
|
|
||||||
|
let mockClient: Mocked<MatrixClient>;
|
||||||
|
let mockRoom: Mocked<Room>;
|
||||||
|
let defaultProps: {
|
||||||
|
user: RoomMember;
|
||||||
|
room: Room;
|
||||||
|
roomPermissions: IRoomPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
defaultProps = {
|
||||||
|
user: defaultMember,
|
||||||
|
room: mockRoom,
|
||||||
|
roomPermissions: {
|
||||||
|
modifyLevelMax: 100,
|
||||||
|
canEdit: false,
|
||||||
|
canInvite: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRoom = mocked({
|
||||||
|
roomId: defaultRoomId,
|
||||||
|
getType: jest.fn().mockReturnValue(undefined),
|
||||||
|
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||||
|
getMember: jest.fn().mockReturnValue(undefined),
|
||||||
|
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||||
|
name: "test room",
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
currentState: {
|
||||||
|
getStateEvents: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
},
|
||||||
|
getEventReadUpTo: jest.fn(),
|
||||||
|
} as unknown as Room);
|
||||||
|
|
||||||
|
mockClient = mocked({
|
||||||
|
getUser: jest.fn(),
|
||||||
|
isGuest: jest.fn().mockReturnValue(false),
|
||||||
|
isUserIgnored: jest.fn(),
|
||||||
|
getIgnoredUsers: jest.fn(),
|
||||||
|
setIgnoredUsers: jest.fn(),
|
||||||
|
getUserId: jest.fn(),
|
||||||
|
getSafeUserId: jest.fn(),
|
||||||
|
getDomain: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||||
|
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||||
|
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||||
|
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||||
|
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
currentState: {
|
||||||
|
on: jest.fn(),
|
||||||
|
},
|
||||||
|
getRoom: jest.fn(),
|
||||||
|
credentials: {},
|
||||||
|
setPowerLevel: jest.fn().mockResolvedValueOnce({ event_id: "123" }),
|
||||||
|
} as unknown as MatrixClient);
|
||||||
|
|
||||||
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||||
|
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
defaultProps = {
|
||||||
|
user: defaultMember,
|
||||||
|
room: mockRoom,
|
||||||
|
roomPermissions: {
|
||||||
|
modifyLevelMax: 100,
|
||||||
|
canEdit: false,
|
||||||
|
canInvite: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (props = defaultProps) => {
|
||||||
|
const Wrapper = (wrapperProps = {}) => {
|
||||||
|
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(<PowerLevelSection {...props} />, {
|
||||||
|
wrapper: Wrapper,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders a power level combobox if can edit is true", () => {
|
||||||
|
const startPowerLevel = 999;
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
|
||||||
|
renderComponent({
|
||||||
|
...defaultProps,
|
||||||
|
room: mockRoom,
|
||||||
|
roomPermissions: { ...defaultProps.roomPermissions, canEdit: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a user role if can edit is false", () => {
|
||||||
|
const member = new RoomMember(defaultRoomId, defaultUserId);
|
||||||
|
member.powerLevel = 100;
|
||||||
|
renderComponent({ ...defaultProps, user: member });
|
||||||
|
|
||||||
|
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a combobox and attempts to change power level on change of the combobox", async () => {
|
||||||
|
const startPowerLevel = 999;
|
||||||
|
const powerLevelEvent = new MatrixEvent({
|
||||||
|
type: EventType.RoomPowerLevels,
|
||||||
|
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
|
||||||
|
});
|
||||||
|
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
|
||||||
|
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
|
||||||
|
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
|
||||||
|
renderComponent({
|
||||||
|
...defaultProps,
|
||||||
|
room: mockRoom,
|
||||||
|
roomPermissions: { ...defaultProps.roomPermissions, canEdit: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const changedPowerLevel = 100;
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), {
|
||||||
|
target: { value: changedPowerLevel },
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("Demote", { exact: true });
|
||||||
|
|
||||||
|
// firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it
|
||||||
|
await userEvent.click(await screen.findByText("Demote", { exact: true }));
|
||||||
|
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
|
||||||
|
});
|
||||||
|
});
|
Reference in New Issue
Block a user