diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e61713ca69..39c0147da0 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -141,6 +141,7 @@ import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenFor import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; import Markdown from "../../Markdown"; import { sanitizeHtmlParams } from "../../Linkify"; +import { isOnlyAdmin } from "../../utils/membership"; // legacy export export { default as Views } from "../../Views"; @@ -1255,29 +1256,22 @@ export default class MatrixChat extends React.PureComponent { const client = MatrixClientPeg.get(); if (client && roomToLeave) { - const plEvent = roomToLeave.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const plContent = plEvent ? plEvent.getContent() : {}; - const userLevels = plContent.users || {}; - const currentUserLevel = userLevels[client.getUserId()!]; - const userLevelValues = Object.values(userLevels); - if (userLevelValues.every((x) => typeof x === "number")) { + // If the user is the only user with highest power level + if (isOnlyAdmin(roomToLeave)) { + const userLevelValues = roomToLeave.getJoinedMembers().map((m) => m.powerLevel); + const maxUserLevel = Math.max(...(userLevelValues as number[])); - // If the user is the only user with highest power level - if ( - maxUserLevel === currentUserLevel && - userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel) - ) { - const warning = - maxUserLevel >= 100 - ? _t("leave_room_dialog|room_leave_admin_warning") - : _t("leave_room_dialog|room_leave_mod_warning"); - warnings.push( - - {" " /* Whitespace, otherwise the sentences get smashed together */} - {warning} - , - ); - } + + const warning = + maxUserLevel >= 100 + ? _t("leave_room_dialog|room_leave_admin_warning") + : _t("leave_room_dialog|room_leave_mod_warning"); + warnings.push( + + {" " /* Whitespace, otherwise the sentences get smashed together */} + {warning} + , + ); } } diff --git a/src/components/views/dialogs/LeaveSpaceDialog.tsx b/src/components/views/dialogs/LeaveSpaceDialog.tsx index e81606db79..7796bf4f61 100644 --- a/src/components/views/dialogs/LeaveSpaceDialog.tsx +++ b/src/components/views/dialogs/LeaveSpaceDialog.tsx @@ -15,23 +15,13 @@ import BaseDialog from "../dialogs/BaseDialog"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker"; import { filterBoolean } from "../../../utils/arrays"; +import { isOnlyAdmin } from "../../../utils/membership"; interface IProps { space: Room; onFinished(leave: boolean, rooms?: Room[]): void; } -const isOnlyAdmin = (room: Room): boolean => { - const userId = room.client.getSafeUserId(); - if (room.getMember(userId)?.powerLevelNorm !== 100) { - return false; // user is not an admin - } - return room.getJoinedMembers().every((member) => { - // return true if every other member has a lower power level (we are highest) - return member.userId === userId || member.powerLevelNorm < 100; - }); -}; - const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { const spaceChildren = useMemo(() => { const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId)); diff --git a/src/utils/membership.ts b/src/utils/membership.ts index ba67d5a6c3..9880043e3d 100644 --- a/src/utils/membership.ts +++ b/src/utils/membership.ts @@ -131,3 +131,23 @@ export async function waitForMember( client.removeListener(RoomStateEvent.NewMember, handler); }); } + +/** + * Check if the user is the only joined admin in the room + * This function will *not* cause lazy loading of room members, so if these should be included then + * the caller needs to make sure members have been loaded. + * @param room The room to check if the user is the only admin. + * @returns True if the user is the only user with the highest power level, false otherwise + */ +export function isOnlyAdmin(room: Room): boolean { + const currentUserLevel = room.getMember(room.client.getSafeUserId())?.powerLevel; + + const userLevelValues = room.getJoinedMembers().map((m) => m.powerLevel); + + const maxUserLevel = Math.max(...userLevelValues.filter((x) => typeof x === "number")); + // If the user is the only user with highest power level + return ( + maxUserLevel === currentUserLevel && + userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel) + ); +} diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index b33e1b9d45..928b5d7ffd 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -691,6 +691,8 @@ describe("", () => { jest.spyOn(spaceRoom, "isSpaceRoom").mockReturnValue(true); jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(null); + (room as any).client = mockClient; + (spaceRoom as any).client = mockClient; }); describe("forget_room", () => { @@ -775,6 +777,22 @@ describe("", () => { ), ).toBeInTheDocument(); }); + it("should warn when user is the last admin", async () => { + jest.spyOn(room, "getJoinedMembers").mockReturnValue([ + { powerLevel: 100 } as unknown as MatrixJs.RoomMember, + { powerLevel: 0 } as unknown as MatrixJs.RoomMember, + ]); + jest.spyOn(room, "getMember").mockReturnValue({ + powerLevel: 100, + } as unknown as MatrixJs.RoomMember); + dispatchAction(); + await screen.findByRole("dialog"); + expect( + screen.getByText( + "You're the only administrator in this room. If you leave, nobody will be able to change room settings or take other important actions.", + ), + ).toBeInTheDocument(); + }); it("should do nothing on cancel", async () => { dispatchAction(); const dialog = await screen.findByRole("dialog"); diff --git a/test/unit-tests/components/views/dialogs/LeaveSpaceDialog-test.tsx b/test/unit-tests/components/views/dialogs/LeaveSpaceDialog-test.tsx new file mode 100644 index 0000000000..387af00d43 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/LeaveSpaceDialog-test.tsx @@ -0,0 +1,50 @@ +/* +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 React from "react"; +import { render, screen } from "jest-matrix-react"; +import { MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix"; + +import LeaveSpaceDialog from "../../../../../src/components/views/dialogs/LeaveSpaceDialog"; +import { createTestClient, mkStubRoom } from "../../../../test-utils"; + +describe("LeaveSpaceDialog", () => { + it("should warn about not being able to rejoin non-public space", () => { + const mockClient = createTestClient(); + const mockSpace = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; + jest.spyOn(mockSpace.currentState, "getStateEvents").mockReturnValue( + new MatrixEvent({ + type: "m.room.join_rules", + content: { + join_rule: "invite", + }, + }), + ); + + render(); + + expect(screen.getByText(/You won't be able to rejoin unless you are re-invited/)).toBeInTheDocument(); + }); + + it("should warn if user is the only admin", () => { + const mockClient = createTestClient(); + const mockSpace = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; + jest.spyOn(mockSpace, "getJoinedMembers").mockReturnValue([ + { powerLevel: 100 } as unknown as RoomMember, + { powerLevel: 0 } as unknown as RoomMember, + ]); + jest.spyOn(mockSpace, "getMember").mockReturnValue({ + powerLevel: 100, + } as unknown as RoomMember); + + render(); + + expect( + screen.getByText(/You're the only admin of this space. Leaving it will mean no one has control over it./), + ).toBeInTheDocument(); + }); +});