diff --git a/src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel.tsx b/src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel.tsx new file mode 100644 index 00000000000..f56356ff411 --- /dev/null +++ b/src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel.tsx @@ -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, + }; +}; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 542e6421dee..970f8a22786 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -43,7 +43,6 @@ import { type ButtonEvent } from "../elements/AccessibleButton"; import SdkConfig from "../../../SdkConfig"; import MultiInviter from "../../../utils/MultiInviter"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; -import { textualPowerLevel } from "../../../Roles"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import EncryptionPanel from "./EncryptionPanel"; @@ -54,7 +53,6 @@ import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; import ImageView from "../elements/ImageView"; import Spinner from "../elements/Spinner"; -import PowerSelector from "../elements/PowerSelector"; import MemberAvatar from "../avatars/MemberAvatar"; import PresenceLabel from "../rooms/PresenceLabel"; import { ShareDialog } from "../dialogs/ShareDialog"; @@ -76,6 +74,7 @@ import { Flex } from "../../utils/Flex"; import CopyableText from "../elements/CopyableText"; import { useUserTimezone } from "../../../hooks/useUserTimezone"; import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer"; +import { PowerLevelSection } from "./user_info/UserInfoPowerLevels"; export interface IDevice extends Device { ambiguous?: boolean; @@ -437,7 +436,7 @@ const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => { ); }; -interface IRoomPermissions { +export interface IRoomPermissions { modifyLevelMax: number; canEdit: boolean; canInvite: boolean; @@ -492,112 +491,6 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR 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( userId: string, cli: MatrixClient, @@ -820,12 +713,7 @@ const BasicUserInfo: React.FC<{ // hide the Roles section for DMs as it doesn't make sense there if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) { memberDetails = ( - <PowerLevelSection - powerLevels={powerLevels} - user={member as RoomMember} - room={room} - roomPermissions={roomPermissions} - /> + <PowerLevelSection user={member as RoomMember} room={room} roomPermissions={roomPermissions} /> ); } diff --git a/src/components/views/right_panel/user_info/UserInfoPowerLevels.tsx b/src/components/views/right_panel/user_info/UserInfoPowerLevels.tsx new file mode 100644 index 00000000000..c7c42da559d --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoPowerLevels.tsx @@ -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> + ); +}; diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoPowerLevelsViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoPowerLevelsViewModel-test.tsx new file mode 100644 index 00000000000..d69dc127798 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoPowerLevelsViewModel-test.tsx @@ -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(); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index ce0edca0ac0..d019e2147b5 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -7,18 +7,10 @@ Please see LICENSE files in the repository root for full details. */ 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 { type Mocked, mocked } from "jest-mock"; -import { - type Room, - User, - type MatrixClient, - RoomMember, - MatrixEvent, - EventType, - Device, -} from "matrix-js-sdk/src/matrix"; +import { type Room, User, type MatrixClient, RoomMember, Device } from "matrix-js-sdk/src/matrix"; import { EventEmitter } from "events"; import { UserVerificationStatus, @@ -31,7 +23,6 @@ import { import UserInfo, { disambiguateDevices, getPowerLevels, - PowerLevelEditor, UserInfoHeader, UserOptionsSection, } 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", () => { it("does not add ambiguous key to unique names", () => { const initialDevices = [ diff --git a/test/unit-tests/components/views/right_panel/user_info/UserInfoPowerLevels-test.tsx b/test/unit-tests/components/views/right_panel/user_info/UserInfoPowerLevels-test.tsx new file mode 100644 index 00000000000..2b871dd4693 --- /dev/null +++ b/test/unit-tests/components/views/right_panel/user_info/UserInfoPowerLevels-test.tsx @@ -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); + }); +});