Prevent user from accidentally double clicking user info admin actions (#11254)
* Prevent user from accidentally double clicking user info admin actions * Iterate * Improve coverage * Improve coverage * Simplify * Simplifypull/28788/head^2
parent
cdffd1ca1f
commit
2760bfc836
|
@ -605,6 +605,7 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsC
|
||||||
|
|
||||||
interface IBaseProps {
|
interface IBaseProps {
|
||||||
member: RoomMember;
|
member: RoomMember;
|
||||||
|
isUpdating: boolean;
|
||||||
startUpdating(): void;
|
startUpdating(): void;
|
||||||
stopUpdating(): void;
|
stopUpdating(): void;
|
||||||
}
|
}
|
||||||
|
@ -612,6 +613,7 @@ interface IBaseProps {
|
||||||
export const RoomKickButton = ({
|
export const RoomKickButton = ({
|
||||||
room,
|
room,
|
||||||
member,
|
member,
|
||||||
|
isUpdating,
|
||||||
startUpdating,
|
startUpdating,
|
||||||
stopUpdating,
|
stopUpdating,
|
||||||
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
|
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
|
||||||
|
@ -621,6 +623,9 @@ export const RoomKickButton = ({
|
||||||
if (member.membership !== "invite" && member.membership !== "join") return <></>;
|
if (member.membership !== "invite" && member.membership !== "join") return <></>;
|
||||||
|
|
||||||
const onKick = async (): Promise<void> => {
|
const onKick = async (): Promise<void> => {
|
||||||
|
if (isUpdating) return; // only allow one operation at a time
|
||||||
|
startUpdating();
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
member,
|
member,
|
||||||
action: room.isSpaceRoom()
|
action: room.isSpaceRoom()
|
||||||
|
@ -669,9 +674,10 @@ export const RoomKickButton = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const [proceed, reason, rooms = []] = await finished;
|
const [proceed, reason, rooms = []] = await finished;
|
||||||
if (!proceed) return;
|
if (!proceed) {
|
||||||
|
stopUpdating();
|
||||||
startUpdating();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
|
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
|
||||||
.then(
|
.then(
|
||||||
|
@ -702,7 +708,12 @@ export const RoomKickButton = ({
|
||||||
: _t("Remove from room");
|
: _t("Remove from room");
|
||||||
|
|
||||||
return (
|
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}
|
{kickLabel}
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
|
@ -736,6 +747,7 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
|
||||||
export const BanToggleButton = ({
|
export const BanToggleButton = ({
|
||||||
room,
|
room,
|
||||||
member,
|
member,
|
||||||
|
isUpdating,
|
||||||
startUpdating,
|
startUpdating,
|
||||||
stopUpdating,
|
stopUpdating,
|
||||||
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
|
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
|
||||||
|
@ -743,6 +755,9 @@ export const BanToggleButton = ({
|
||||||
|
|
||||||
const isBanned = member.membership === "ban";
|
const isBanned = member.membership === "ban";
|
||||||
const onBanOrUnban = async (): Promise<void> => {
|
const onBanOrUnban = async (): Promise<void> => {
|
||||||
|
if (isUpdating) return; // only allow one operation at a time
|
||||||
|
startUpdating();
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
member,
|
member,
|
||||||
action: room.isSpaceRoom()
|
action: room.isSpaceRoom()
|
||||||
|
@ -809,9 +824,10 @@ export const BanToggleButton = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const [proceed, reason, rooms = []] = await finished;
|
const [proceed, reason, rooms = []] = await finished;
|
||||||
if (!proceed) return;
|
if (!proceed) {
|
||||||
|
stopUpdating();
|
||||||
startUpdating();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fn = (roomId: string): Promise<unknown> => {
|
const fn = (roomId: string): Promise<unknown> => {
|
||||||
if (isBanned) {
|
if (isBanned) {
|
||||||
|
@ -851,7 +867,7 @@ export const BanToggleButton = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibleButton kind="link" className={classes} onClick={onBanOrUnban}>
|
<AccessibleButton kind="link" className={classes} onClick={onBanOrUnban} disabled={isUpdating}>
|
||||||
{label}
|
{label}
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
|
@ -863,7 +879,15 @@ interface IBaseRoomProps extends IBaseProps {
|
||||||
children?: ReactNode;
|
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);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
// Don't show the mute/unmute option if the user is not in the room
|
// Don't show the mute/unmute option if the user is not in the room
|
||||||
|
@ -871,25 +895,15 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
|
||||||
|
|
||||||
const muted = isMuted(member, powerLevels);
|
const muted = isMuted(member, powerLevels);
|
||||||
const onMuteToggle = async (): Promise<void> => {
|
const onMuteToggle = async (): Promise<void> => {
|
||||||
|
if (isUpdating) return; // only allow one operation at a time
|
||||||
|
startUpdating();
|
||||||
|
|
||||||
const roomId = member.roomId;
|
const roomId = member.roomId;
|
||||||
const target = member.userId;
|
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", "");
|
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||||
if (!powerLevelEvent) return;
|
const powerLevels = powerLevelEvent?.getContent();
|
||||||
|
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
|
||||||
const powerLevels = powerLevelEvent.getContent();
|
|
||||||
const levelToSend =
|
|
||||||
(powerLevels.events ? powerLevels.events["m.room.message"] : null) || powerLevels.events_default;
|
|
||||||
let level;
|
let level;
|
||||||
if (muted) {
|
if (muted) {
|
||||||
// unmute
|
// unmute
|
||||||
|
@ -900,27 +914,29 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
|
||||||
}
|
}
|
||||||
level = parseInt(level);
|
level = parseInt(level);
|
||||||
|
|
||||||
if (!isNaN(level)) {
|
if (isNaN(level)) {
|
||||||
startUpdating();
|
stopUpdating();
|
||||||
cli.setPowerLevel(roomId, target, level, powerLevelEvent)
|
return;
|
||||||
.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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", {
|
const classes = classNames("mx_UserInfo_field", {
|
||||||
|
@ -929,7 +945,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
|
||||||
|
|
||||||
const muteLabel = muted ? _t("Unmute") : _t("Mute");
|
const muteLabel = muted ? _t("Unmute") : _t("Mute");
|
||||||
return (
|
return (
|
||||||
<AccessibleButton kind="link" className={classes} onClick={onMuteToggle}>
|
<AccessibleButton kind="link" className={classes} onClick={onMuteToggle} disabled={isUpdating}>
|
||||||
{muteLabel}
|
{muteLabel}
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
|
@ -939,6 +955,7 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||||
room,
|
room,
|
||||||
children,
|
children,
|
||||||
member,
|
member,
|
||||||
|
isUpdating,
|
||||||
startUpdating,
|
startUpdating,
|
||||||
stopUpdating,
|
stopUpdating,
|
||||||
powerLevels,
|
powerLevels,
|
||||||
|
@ -966,17 +983,34 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||||
|
|
||||||
if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
|
if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
|
||||||
kickButton = (
|
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()) {
|
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
|
||||||
redactButton = (
|
redactButton = (
|
||||||
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
|
<RedactMessagesButton
|
||||||
|
member={member}
|
||||||
|
isUpdating={isUpdating}
|
||||||
|
startUpdating={startUpdating}
|
||||||
|
stopUpdating={stopUpdating}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
|
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
|
||||||
banButton = (
|
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()) {
|
if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
|
||||||
|
@ -985,6 +1019,7 @@ export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||||
member={member}
|
member={member}
|
||||||
room={room}
|
room={room}
|
||||||
powerLevels={powerLevels}
|
powerLevels={powerLevels}
|
||||||
|
isUpdating={isUpdating}
|
||||||
startUpdating={startUpdating}
|
startUpdating={startUpdating}
|
||||||
stopUpdating={stopUpdating}
|
stopUpdating={stopUpdating}
|
||||||
/>
|
/>
|
||||||
|
@ -1393,6 +1428,7 @@ const BasicUserInfo: React.FC<{
|
||||||
powerLevels={powerLevels}
|
powerLevels={powerLevels}
|
||||||
member={member as RoomMember}
|
member={member as RoomMember}
|
||||||
room={room}
|
room={room}
|
||||||
|
isUpdating={pendingUpdateCount > 0}
|
||||||
startUpdating={startUpdating}
|
startUpdating={startUpdating}
|
||||||
stopUpdating={stopUpdating}
|
stopUpdating={stopUpdating}
|
||||||
>
|
>
|
||||||
|
|
|
@ -907,7 +907,13 @@ describe("<RoomKickButton />", () => {
|
||||||
|
|
||||||
let defaultProps: Parameters<typeof RoomKickButton>[0];
|
let defaultProps: Parameters<typeof RoomKickButton>[0];
|
||||||
beforeEach(() => {
|
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 = {}) => {
|
const renderComponent = (props = {}) => {
|
||||||
|
@ -1008,7 +1014,13 @@ describe("<BanToggleButton />", () => {
|
||||||
const memberWithBanMembership = { ...defaultMember, membership: "ban" };
|
const memberWithBanMembership = { ...defaultMember, membership: "ban" };
|
||||||
let defaultProps: Parameters<typeof BanToggleButton>[0];
|
let defaultProps: Parameters<typeof BanToggleButton>[0];
|
||||||
beforeEach(() => {
|
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 = {}) => {
|
const renderComponent = (props = {}) => {
|
||||||
|
@ -1136,6 +1148,7 @@ describe("<RoomAdminToolsContainer />", () => {
|
||||||
defaultProps = {
|
defaultProps = {
|
||||||
room: mockRoom,
|
room: mockRoom,
|
||||||
member: defaultMember,
|
member: defaultMember,
|
||||||
|
isUpdating: false,
|
||||||
startUpdating: jest.fn(),
|
startUpdating: jest.fn(),
|
||||||
stopUpdating: jest.fn(),
|
stopUpdating: jest.fn(),
|
||||||
powerLevels: {},
|
powerLevels: {},
|
||||||
|
@ -1198,7 +1211,43 @@ describe("<RoomAdminToolsContainer />", () => {
|
||||||
powerLevels: { events: { "m.room.power_levels": 1 } },
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue