Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 16 additions & 22 deletions src/components/structures/MatrixChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1255,29 +1256,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {

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(
<strong className="warning" key="last_admin_warning">
{" " /* Whitespace, otherwise the sentences get smashed together */}
{warning}
</strong>,
);
}

const warning =
maxUserLevel >= 100
? _t("leave_room_dialog|room_leave_admin_warning")
: _t("leave_room_dialog|room_leave_mod_warning");
warnings.push(
<strong className="warning" key="last_admin_warning">
{" " /* Whitespace, otherwise the sentences get smashed together */}
{warning}
</strong>,
);
}
}

Expand Down
12 changes: 1 addition & 11 deletions src/components/views/dialogs/LeaveSpaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => {
const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId));
Expand Down
20 changes: 20 additions & 0 deletions src/utils/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,23 @@ export async function waitForMember(
client.removeListener(RoomStateEvent.NewMember, handler);
});
}

/**
* Check if the user is the only 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)
);
}
18 changes: 18 additions & 0 deletions test/unit-tests/components/structures/MatrixChat-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,8 @@ describe("<MatrixChat />", () => {
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", () => {
Expand Down Expand Up @@ -775,6 +777,22 @@ describe("<MatrixChat />", () => {
),
).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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<LeaveSpaceDialog space={mockSpace} onFinished={jest.fn()} />);

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(<LeaveSpaceDialog space={mockSpace} onFinished={jest.fn()} />);

expect(
screen.getByText(/You're the only admin of this space. Leaving it will mean no one has control over it./),
).toBeInTheDocument();
});
});
Loading