Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Prevent user from accidentally double clicking user info admin actions #11254

Merged
merged 6 commits into from
Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
132 changes: 84 additions & 48 deletions src/components/views/right_panel/UserInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,13 +605,15 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsC

interface IBaseProps {
member: RoomMember;
isUpdating: boolean;
startUpdating(): void;
stopUpdating(): void;
}

export const RoomKickButton = ({
room,
member,
isUpdating,
startUpdating,
stopUpdating,
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
Expand All @@ -621,6 +623,9 @@ export const RoomKickButton = ({
if (member.membership !== "invite" && member.membership !== "join") return <></>;

const onKick = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();

const commonProps = {
member,
action: room.isSpaceRoom()
Expand Down Expand Up @@ -669,9 +674,10 @@ export const RoomKickButton = ({
}

const [proceed, reason, rooms = []] = await finished;
if (!proceed) return;

startUpdating();
if (!proceed) {
stopUpdating();
return;
}

bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
.then(
Expand Down Expand Up @@ -702,7 +708,12 @@ export const RoomKickButton = ({
: _t("Remove from room");

return (
<AccessibleButton kind="link" className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onKick}>
<AccessibleButton
kind="link"
className="mx_UserInfo_field mx_UserInfo_destructive"
onClick={onKick}
disabled={isUpdating}
>
{kickLabel}
</AccessibleButton>
);
Expand Down Expand Up @@ -736,13 +747,17 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
export const BanToggleButton = ({
room,
member,
isUpdating,
startUpdating,
stopUpdating,
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
const cli = useContext(MatrixClientContext);

const isBanned = member.membership === "ban";
const onBanOrUnban = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();

const commonProps = {
member,
action: room.isSpaceRoom()
Expand Down Expand Up @@ -809,9 +824,10 @@ export const BanToggleButton = ({
}

const [proceed, reason, rooms = []] = await finished;
if (!proceed) return;

startUpdating();
if (!proceed) {
stopUpdating();
return;
}

const fn = (roomId: string): Promise<unknown> => {
if (isBanned) {
Expand Down Expand Up @@ -851,7 +867,7 @@ export const BanToggleButton = ({
});

return (
<AccessibleButton kind="link" className={classes} onClick={onBanOrUnban}>
<AccessibleButton kind="link" className={classes} onClick={onBanOrUnban} disabled={isUpdating}>
{label}
</AccessibleButton>
);
Expand All @@ -863,33 +879,31 @@ interface IBaseRoomProps extends IBaseProps {
children?: ReactNode;
}

const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels, startUpdating, stopUpdating }) => {
// We do not show a Mute button for ourselves so it doesn't need to handle warning self demotion
const MuteToggleButton: React.FC<IBaseRoomProps> = ({
member,
room,
powerLevels,
isUpdating,
startUpdating,
stopUpdating,
}) => {
const cli = useContext(MatrixClientContext);

// Don't show the mute/unmute option if the user is not in the room
if (member.membership !== "join") return null;

const muted = isMuted(member, powerLevels);
const onMuteToggle = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();

const roomId = member.roomId;
const target = member.userId;

// if muting self, warn as it may be irreversible
if (target === cli.getUserId()) {
try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
} catch (e) {
logger.error("Failed to warn about self demotion: ", e);
return;
}
}

const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent) return;

const powerLevels = powerLevelEvent.getContent();
const levelToSend =
(powerLevels.events ? powerLevels.events["m.room.message"] : null) || powerLevels.events_default;
const powerLevels = powerLevelEvent?.getContent();
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
let level;
if (muted) {
// unmute
Expand All @@ -900,27 +914,29 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
}
level = parseInt(level);

if (!isNaN(level)) {
startUpdating();
cli.setPowerLevel(roomId, target, level, powerLevelEvent)
.then(
() => {
// 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("Mute toggle success");
},
function (err) {
logger.error("Mute error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("Error"),
description: _t("Failed to mute user"),
});
},
)
.finally(() => {
stopUpdating();
});
if (isNaN(level)) {
stopUpdating();
return;
}

cli.setPowerLevel(roomId, target, level, powerLevelEvent)
.then(
() => {
// 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("Mute toggle success");
},
function (err) {
logger.error("Mute error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("Error"),
description: _t("Failed to mute user"),
});
},
)
.finally(() => {
stopUpdating();
});
};

const classes = classNames("mx_UserInfo_field", {
Expand All @@ -929,7 +945,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,

const muteLabel = muted ? _t("Unmute") : _t("Mute");
return (
<AccessibleButton kind="link" className={classes} onClick={onMuteToggle}>
<AccessibleButton kind="link" className={classes} onClick={onMuteToggle} disabled={isUpdating}>
{muteLabel}
</AccessibleButton>
);
Expand All @@ -939,6 +955,7 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
room,
children,
member,
isUpdating,
startUpdating,
stopUpdating,
powerLevels,
Expand Down Expand Up @@ -966,17 +983,34 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({

if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = (
<RoomKickButton room={room} member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
<RoomKickButton
room={room}
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
<RedactMessagesButton
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = (
<BanToggleButton room={room} member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
<BanToggleButton
room={room}
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
Expand All @@ -985,6 +1019,7 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
member={member}
room={room}
powerLevels={powerLevels}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
Expand Down Expand Up @@ -1393,6 +1428,7 @@ const BasicUserInfo: React.FC<{
powerLevels={powerLevels}
member={member as RoomMember}
room={room}
isUpdating={pendingUpdateCount > 0}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
>
Expand Down
55 changes: 52 additions & 3 deletions test/components/views/right_panel/UserInfo-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,13 @@ describe("<RoomKickButton />", () => {

let defaultProps: Parameters<typeof RoomKickButton>[0];
beforeEach(() => {
defaultProps = { room: mockRoom, member: defaultMember, startUpdating: jest.fn(), stopUpdating: jest.fn() };
defaultProps = {
room: mockRoom,
member: defaultMember,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
isUpdating: false,
};
});

const renderComponent = (props = {}) => {
Expand Down Expand Up @@ -1002,7 +1008,13 @@ describe("<BanToggleButton />", () => {
const memberWithBanMembership = { ...defaultMember, membership: "ban" };
let defaultProps: Parameters<typeof BanToggleButton>[0];
beforeEach(() => {
defaultProps = { room: mockRoom, member: defaultMember, startUpdating: jest.fn(), stopUpdating: jest.fn() };
defaultProps = {
room: mockRoom,
member: defaultMember,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
isUpdating: false,
};
});

const renderComponent = (props = {}) => {
Expand Down Expand Up @@ -1130,6 +1142,7 @@ describe("<RoomAdminToolsContainer />", () => {
defaultProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
powerLevels: {},
Expand Down Expand Up @@ -1192,7 +1205,43 @@ describe("<RoomAdminToolsContainer />", () => {
powerLevels: { events: { "m.room.power_levels": 1 } },
});

expect(screen.getByText(/mute/i)).toBeInTheDocument();
const button = screen.getByText(/mute/i);
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(defaultProps.startUpdating).toHaveBeenCalled();
});

it("should disable buttons when isUpdating=true", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);

const defaultMemberWithPowerLevelAndJoinMembership = { ...defaultMember, powerLevel: 0, membership: "join" };

renderComponent({
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
isUpdating: true,
});

const button = screen.getByText(/mute/i);
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute("disabled");
expect(button).toHaveAttribute("aria-disabled", "true");
});

it("should not show mute button for one's own member", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getSafeUserId());
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);

renderComponent({
member: mockMeMember,
powerLevels: { events: { "m.room.power_levels": 100 } },
});

const button = screen.queryByText(/mute/i);
expect(button).not.toBeInTheDocument();
});
});

Expand Down