Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
39 changes: 16 additions & 23 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,21 @@ 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")) {
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 userLevelValues = roomToLeave.getMembers().map((m) => m.powerLevel);

const maxUserLevel = Math.max(...(userLevelValues as number[]));
// If the user is the only user with highest power level
if (isOnlyAdmin(roomToLeave, client)) {
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
18 changes: 5 additions & 13 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 All @@ -53,11 +43,13 @@ const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
rejoinWarning = _t("space|leave_dialog_public_rejoin_warning");
}

const isOnlyAdminWrapper = (r: Room): boolean => isOnlyAdmin(r, r.client);

let onlyAdminWarning;
if (isOnlyAdmin(space)) {
if (isOnlyAdmin(space, space.client)) {
onlyAdminWarning = _t("space|leave_dialog_only_admin_warning");
} else {
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdminWrapper).length;
if (numChildrenOnlyAdminIn > 0) {
onlyAdminWarning = _t("space|leave_dialog_only_admin_room_warning");
}
Expand Down
13 changes: 13 additions & 0 deletions src/utils/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,16 @@ export async function waitForMember(
client.removeListener(RoomStateEvent.NewMember, handler);
});
}

export function isOnlyAdmin(room: Room, client: MatrixClient): boolean {
const currentUserLevel = room.getMember(client.getUserId()!)?.powerLevel;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. why not use room.client instead of requiring an extra param
  2. why not use getSafeUserId to avoid !

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For 1, only because the docs said it was for lazy loading members so I thought perhaps better to not use it generally, but happy to do so.


const userLevelValues = room.getMembers().map((m) => m.powerLevel);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assumes all members have already been loaded, which may not be the case due to lazyloading

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this also potentially included invited/left members with high PLs?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fn you replaced used getJoinedMembers instead

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, it's true that lazy loading could potentially be an issue. Fixing this will involve making a leaveRoom async and loading members for every room in a space when we leave it. I'm not sure this is appropriate to do as part of this PR. I have added it to the doc.


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)
);
}
16 changes: 16 additions & 0 deletions test/unit-tests/components/structures/MatrixChat-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,22 @@ describe("<MatrixChat />", () => {
),
).toBeInTheDocument();
});
it("should warn when user is the last admin", async () => {
jest.spyOn(room, "getMembers").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, "getMembers").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