Mvvm split user info, create userinfoadmintools container component (#29808)
* feat: mvvm split user info, create userinfoadmintools container component * test: mvvm userinfoadmintools and view * feat: user info admin components more split and comments * test: mvvm user admin info mute view models more coverage * chore: rename user-info folder to user_info
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room, type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
|
||||
/**
|
||||
* Interface used by admin tools container subcomponents props
|
||||
*/
|
||||
export interface RoomAdminToolsProps {
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
isUpdating: boolean;
|
||||
startUpdating: () => void;
|
||||
stopUpdating: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface used by admin tools container props
|
||||
*/
|
||||
export interface RoomAdminToolsContainerProps {
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
}
|
||||
|
||||
interface UserInfoAdminToolsContainerState {
|
||||
shouldShowKickButton: boolean;
|
||||
shouldShowBanButton: boolean;
|
||||
shouldShowMuteButton: boolean;
|
||||
shouldShowRedactButton: boolean;
|
||||
isCurrentUserInTheRoom: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the user info admin tools container
|
||||
* @param {RoomAdminToolsContainerProps} props - the object containing the necceray props for the view model
|
||||
* @param {Room} props.room - the room that display the admin tools
|
||||
* @param {RoomMember} props.member - the selected member
|
||||
* @param {IPowerLevelsContent} props.powerLevels - current room power levels
|
||||
* @returns {UserInfoAdminToolsContainerState} the user info admin tools container state
|
||||
*/
|
||||
export const useUserInfoAdminToolsContainerViewModel = (
|
||||
props: RoomAdminToolsContainerProps,
|
||||
): UserInfoAdminToolsContainerState => {
|
||||
const cli = useMatrixClientContext();
|
||||
const { room, member, powerLevels } = props;
|
||||
|
||||
const editPowerLevel =
|
||||
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default;
|
||||
|
||||
// if these do not exist in the event then they should default to 50 as per the spec
|
||||
const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
|
||||
|
||||
const me = room.getMember(cli.getUserId() || "");
|
||||
const isCurrentUserInTheRoom = me !== null;
|
||||
|
||||
if (!isCurrentUserInTheRoom) {
|
||||
return {
|
||||
shouldShowKickButton: false,
|
||||
shouldShowBanButton: false,
|
||||
shouldShowMuteButton: false,
|
||||
shouldShowRedactButton: false,
|
||||
isCurrentUserInTheRoom: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isMe = me.userId === member.userId;
|
||||
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
|
||||
|
||||
return {
|
||||
shouldShowKickButton: !isMe && canAffectUser && me.powerLevel >= kickPowerLevel,
|
||||
shouldShowRedactButton: me.powerLevel >= redactPowerLevel && !room.isSpaceRoom(),
|
||||
shouldShowBanButton: !isMe && canAffectUser && me.powerLevel >= banPowerLevel,
|
||||
shouldShowMuteButton: !isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom(),
|
||||
isCurrentUserInTheRoom,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "@sentry/browser";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import { bulkSpaceBehaviour } from "../../../../../utils/space";
|
||||
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
|
||||
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
|
||||
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
|
||||
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
|
||||
|
||||
export interface BanButtonState {
|
||||
/**
|
||||
* The function to call when the button is clicked
|
||||
*/
|
||||
onBanOrUnbanClick: () => Promise<void>;
|
||||
/**
|
||||
* The label of the ban button can be ban or unban
|
||||
*/
|
||||
banLabel: string;
|
||||
}
|
||||
/**
|
||||
* The view model for the room ban button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomAdminToolsProps} props - the object containing the necceray props for banButton the view model
|
||||
* @param {Room} props.room - the room to ban/unban the user in
|
||||
* @param {RoomMember} props.member - the member to ban/unban
|
||||
* @param {boolean} props.isUpdating - whether the operation is currently in progress
|
||||
* @param {function} props.startUpdating - callback function to start the operation
|
||||
* @param {function} props.stopUpdating - callback function to stop the operation
|
||||
* @returns {BanButtonState} the room ban/unban button state
|
||||
*/
|
||||
export const useBanButtonViewModel = (props: RoomAdminToolsProps): BanButtonState => {
|
||||
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
|
||||
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const isBanned = member.membership === KnownMembership.Ban;
|
||||
|
||||
let banLabel = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
|
||||
if (isBanned) {
|
||||
banLabel = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
|
||||
}
|
||||
|
||||
const onBanOrUnbanClick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const commonProps = {
|
||||
member,
|
||||
action: room.isSpaceRoom()
|
||||
? isBanned
|
||||
? _t("user_info|unban_button_space")
|
||||
: _t("user_info|ban_button_space")
|
||||
: isBanned
|
||||
? _t("user_info|unban_button_room")
|
||||
: _t("user_info|ban_button_room"),
|
||||
title: isBanned
|
||||
? _t("user_info|unban_room_confirm_title", { roomName: room.name })
|
||||
: _t("user_info|ban_room_confirm_title", { roomName: room.name }),
|
||||
askReason: !isBanned,
|
||||
danger: !isBanned,
|
||||
};
|
||||
|
||||
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
({ finished } = Modal.createDialog(
|
||||
ConfirmSpaceUserActionDialog,
|
||||
{
|
||||
...commonProps,
|
||||
space: room,
|
||||
spaceChildFilter: isBanned
|
||||
? (child: Room) => {
|
||||
// Return true if the target member is banned and we have sufficient PL to unban
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership === KnownMembership.Ban &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
|
||||
);
|
||||
}
|
||||
: (child: Room) => {
|
||||
// Return true if the target member isn't banned and we have sufficient PL to ban
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership !== KnownMembership.Ban &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
|
||||
);
|
||||
},
|
||||
allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
|
||||
specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
|
||||
warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
|
||||
},
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
));
|
||||
} else {
|
||||
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
|
||||
}
|
||||
|
||||
const [proceed, reason, rooms = []] = await finished;
|
||||
if (!proceed) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
const fn = (roomId: string): Promise<unknown> => {
|
||||
if (isBanned) {
|
||||
return cli.unban(roomId, member.userId);
|
||||
} else {
|
||||
return cli.ban(roomId, member.userId, reason || undefined);
|
||||
}
|
||||
};
|
||||
|
||||
bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
|
||||
.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.info("Ban success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Ban error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("user_info|error_ban_user"),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onBanOrUnbanClick,
|
||||
banLabel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "@sentry/browser";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import { bulkSpaceBehaviour } from "../../../../../utils/space";
|
||||
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
|
||||
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
|
||||
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
|
||||
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
|
||||
|
||||
interface RoomKickButtonState {
|
||||
/**
|
||||
* The function to call when the button is clicked
|
||||
*/
|
||||
onKickClick: () => Promise<void>;
|
||||
/**
|
||||
* Whether the user can be kicked based on membership value. If the user already join or was invited, it can be kicked
|
||||
*/
|
||||
canUserBeKicked: boolean;
|
||||
/**
|
||||
* The label of the kick button can be kick or disinvite
|
||||
*/
|
||||
kickLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the room kick button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomAdminToolsProps} props - the object containing the necceray props for kickButton the view model
|
||||
* @param {Room} props.room - the room to kick/disinvite the user from
|
||||
* @param {RoomMember} props.member - the member to kick/disinvite
|
||||
* @param {boolean} props.isUpdating - whether the operation is currently in progress
|
||||
* @param {function} props.startUpdating - callback function to start the operation
|
||||
* @param {function} props.stopUpdating - callback function to stop the operation
|
||||
* @returns {KickButtonState} the room kick/disinvite button state
|
||||
*/
|
||||
export function useRoomKickButtonViewModel(props: RoomAdminToolsProps): RoomKickButtonState {
|
||||
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
|
||||
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const onKickClick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const commonProps = {
|
||||
member,
|
||||
action: room.isSpaceRoom()
|
||||
? member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_space")
|
||||
: _t("user_info|kick_button_space")
|
||||
: member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room")
|
||||
: _t("user_info|kick_button_room"),
|
||||
title:
|
||||
member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room_name", { roomName: room.name })
|
||||
: _t("user_info|kick_button_room_name", { roomName: room.name }),
|
||||
askReason: member.membership === KnownMembership.Join,
|
||||
danger: true,
|
||||
};
|
||||
|
||||
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
({ finished } = Modal.createDialog(
|
||||
ConfirmSpaceUserActionDialog,
|
||||
{
|
||||
...commonProps,
|
||||
space: room,
|
||||
spaceChildFilter: (child: Room) => {
|
||||
// Return true if the target member is not banned and we have sufficient PL to ban them
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership === member.membership &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
|
||||
);
|
||||
},
|
||||
allLabel: _t("user_info|kick_button_space_everything"),
|
||||
specificLabel: _t("user_info|kick_space_specific"),
|
||||
warningMessage: _t("user_info|kick_space_warning"),
|
||||
},
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
));
|
||||
} else {
|
||||
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
|
||||
}
|
||||
|
||||
const [proceed, reason, rooms = []] = await finished;
|
||||
if (!proceed) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
|
||||
.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.info("Kick success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Kick error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("user_info|error_kicking_user"),
|
||||
description: err?.message ?? "Operation failed",
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
const canUserBeKicked = member.membership === KnownMembership.Invite || member.membership === KnownMembership.Join;
|
||||
|
||||
const kickLabel = room.isSpaceRoom()
|
||||
? member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_space")
|
||||
: _t("user_info|kick_button_space")
|
||||
: member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room")
|
||||
: _t("user_info|kick_button_room");
|
||||
|
||||
return {
|
||||
onKickClick,
|
||||
canUserBeKicked,
|
||||
kickLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "@sentry/browser";
|
||||
import { type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
|
||||
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
|
||||
|
||||
interface MuteButtonState {
|
||||
/**
|
||||
* Whether the member is in the roomn based on the membership value
|
||||
*/
|
||||
isMemberInTheRoom: boolean;
|
||||
/**
|
||||
* The label of the mute button can be mute or unmute
|
||||
*/
|
||||
muteLabel: string;
|
||||
/**
|
||||
* The function to call when the mute button is clicked
|
||||
*/
|
||||
onMuteButtonClick: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the room mute button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomAdminToolsProps} props - the object containing the necceray props for muteButton the view model
|
||||
* @param {Room} props.room - the room to mute/unmute the user in
|
||||
* @param {RoomMember} props.member - the member to mute/unmute
|
||||
* @param {boolean} props.isUpdating - whether the operation is currently in progress
|
||||
* @param {function} props.startUpdating - callback function to start the operation
|
||||
* @param {function} props.stopUpdating - callback function to stop the operation
|
||||
* @returns {MuteButtonState} the room mute/unmute button state
|
||||
*/
|
||||
export const useMuteButtonViewModel = (props: RoomAdminToolsProps): MuteButtonState => {
|
||||
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
|
||||
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => {
|
||||
if (!powerLevelContent || !member) return false;
|
||||
|
||||
const levelToSend =
|
||||
(powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
|
||||
powerLevelContent.events_default;
|
||||
|
||||
// levelToSend could be undefined as .events_default is optional. Coercing in this case using
|
||||
// Number() would always return false, so this preserves behaviour
|
||||
// FIXME: per the spec, if `events_default` is unset, it defaults to zero. If
|
||||
// the member has a negative powerlevel, this will give an incorrect result.
|
||||
if (levelToSend === undefined) return false;
|
||||
|
||||
return member.powerLevel < levelToSend;
|
||||
};
|
||||
|
||||
const muted = isMuted(member, room.currentState.getStateEvents("m.room.power_levels", "")?.getContent() || {});
|
||||
const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
|
||||
|
||||
const isMemberInTheRoom = member.membership == KnownMembership.Join;
|
||||
|
||||
const onMuteButtonClick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const roomId = member.roomId;
|
||||
const target = member.userId;
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevels = powerLevelEvent?.getContent();
|
||||
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
|
||||
|
||||
let level;
|
||||
if (muted) {
|
||||
// unmute
|
||||
level = levelToSend;
|
||||
} else {
|
||||
// mute
|
||||
level = levelToSend - 1;
|
||||
}
|
||||
level = parseInt(level);
|
||||
|
||||
console.log("level", level);
|
||||
if (isNaN(level)) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
cli.setPowerLevel(roomId, target, level)
|
||||
.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.info("Mute toggle success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Mute error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("user_info|error_mute_user"),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isMemberInTheRoom,
|
||||
onMuteButtonClick,
|
||||
muteLabel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import Modal from "../../../../../Modal";
|
||||
import BulkRedactDialog from "../../../../views/dialogs/BulkRedactDialog";
|
||||
|
||||
export interface RedactMessagesButtonState {
|
||||
onRedactAllMessagesClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the redact messages button used in the UserInfoAdminToolsContainer
|
||||
* @param {RoomMember} member - the selected member to redact messages for
|
||||
* @returns {RedactMessagesButtonState} the redact messages button state
|
||||
*/
|
||||
export const useRedactMessagesButtonViewModel = (member: RoomMember): RedactMessagesButtonState => {
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
const onRedactAllMessagesClick = (): void => {
|
||||
const room = cli.getRoom(member.roomId);
|
||||
if (!room) return;
|
||||
|
||||
Modal.createDialog(BulkRedactDialog, {
|
||||
matrixClient: cli,
|
||||
room,
|
||||
member,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onRedactAllMessagesClick,
|
||||
};
|
||||
};
|
||||
@@ -34,10 +34,6 @@ import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/ment
|
||||
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
||||
import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block";
|
||||
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import ChatProblemIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat-problem";
|
||||
import VisibilityOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-off";
|
||||
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import Modal from "../../../Modal";
|
||||
@@ -61,15 +57,11 @@ import Spinner from "../elements/Spinner";
|
||||
import PowerSelector from "../elements/PowerSelector";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import PresenceLabel from "../rooms/PresenceLabel";
|
||||
import BulkRedactDialog from "../dialogs/BulkRedactDialog";
|
||||
import { ShareDialog } from "../dialogs/ShareDialog";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialog";
|
||||
import { bulkSpaceBehaviour } from "../../../utils/space";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
@@ -83,6 +75,7 @@ import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import CopyableText from "../elements/CopyableText";
|
||||
import { useUserTimezone } from "../../../hooks/useUserTimezone";
|
||||
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
|
||||
|
||||
export interface IDevice extends Device {
|
||||
ambiguous?: boolean;
|
||||
@@ -314,7 +307,7 @@ const Container: React.FC<{
|
||||
return <div className={classes}>{children}</div>;
|
||||
};
|
||||
|
||||
interface IPowerLevelsContent {
|
||||
export interface IPowerLevelsContent {
|
||||
events?: Record<string, number>;
|
||||
// eslint-disable-next-line camelcase
|
||||
users_default?: number;
|
||||
@@ -368,362 +361,6 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsC
|
||||
return powerLevels;
|
||||
};
|
||||
|
||||
interface IBaseProps {
|
||||
member: RoomMember;
|
||||
isUpdating: boolean;
|
||||
startUpdating(): void;
|
||||
stopUpdating(): void;
|
||||
}
|
||||
|
||||
export const RoomKickButton = ({
|
||||
room,
|
||||
member,
|
||||
isUpdating,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
// check if user can be kicked/disinvited
|
||||
if (member.membership !== KnownMembership.Invite && member.membership !== KnownMembership.Join) return <></>;
|
||||
|
||||
const onKick = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const commonProps = {
|
||||
member,
|
||||
action: room.isSpaceRoom()
|
||||
? member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_space")
|
||||
: _t("user_info|kick_button_space")
|
||||
: member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room")
|
||||
: _t("user_info|kick_button_room"),
|
||||
title:
|
||||
member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room_name", { roomName: room.name })
|
||||
: _t("user_info|kick_button_room_name", { roomName: room.name }),
|
||||
askReason: member.membership === KnownMembership.Join,
|
||||
danger: true,
|
||||
};
|
||||
|
||||
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
({ finished } = Modal.createDialog(
|
||||
ConfirmSpaceUserActionDialog,
|
||||
{
|
||||
...commonProps,
|
||||
space: room,
|
||||
spaceChildFilter: (child: Room) => {
|
||||
// Return true if the target member is not banned and we have sufficient PL to ban them
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership === member.membership &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
|
||||
);
|
||||
},
|
||||
allLabel: _t("user_info|kick_button_space_everything"),
|
||||
specificLabel: _t("user_info|kick_space_specific"),
|
||||
warningMessage: _t("user_info|kick_space_warning"),
|
||||
},
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
));
|
||||
} else {
|
||||
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
|
||||
}
|
||||
|
||||
const [proceed, reason, rooms = []] = await finished;
|
||||
if (!proceed) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
|
||||
.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("Kick success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Kick error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("user_info|error_kicking_user"),
|
||||
description: err?.message ?? "Operation failed",
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
const kickLabel = room.isSpaceRoom()
|
||||
? member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_space")
|
||||
: _t("user_info|kick_button_space")
|
||||
: member.membership === KnownMembership.Invite
|
||||
? _t("user_info|disinvite_button_room")
|
||||
: _t("user_info|kick_button_room");
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
onKick();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
label={kickLabel}
|
||||
kind="critical"
|
||||
Icon={LeaveIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onRedactAllMessages = (): void => {
|
||||
const room = cli.getRoom(member.roomId);
|
||||
if (!room) return;
|
||||
|
||||
Modal.createDialog(BulkRedactDialog, {
|
||||
matrixClient: cli,
|
||||
room,
|
||||
member,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
onRedactAllMessages();
|
||||
}}
|
||||
label={_t("user_info|redact_button")}
|
||||
kind="critical"
|
||||
Icon={CloseIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BanToggleButton = ({
|
||||
room,
|
||||
member,
|
||||
isUpdating,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const isBanned = member.membership === KnownMembership.Ban;
|
||||
const onBanOrUnban = async (): Promise<void> => {
|
||||
if (isUpdating) return; // only allow one operation at a time
|
||||
startUpdating();
|
||||
|
||||
const commonProps = {
|
||||
member,
|
||||
action: room.isSpaceRoom()
|
||||
? isBanned
|
||||
? _t("user_info|unban_button_space")
|
||||
: _t("user_info|ban_button_space")
|
||||
: isBanned
|
||||
? _t("user_info|unban_button_room")
|
||||
: _t("user_info|ban_button_room"),
|
||||
title: isBanned
|
||||
? _t("user_info|unban_room_confirm_title", { roomName: room.name })
|
||||
: _t("user_info|ban_room_confirm_title", { roomName: room.name }),
|
||||
askReason: !isBanned,
|
||||
danger: !isBanned,
|
||||
};
|
||||
|
||||
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
({ finished } = Modal.createDialog(
|
||||
ConfirmSpaceUserActionDialog,
|
||||
{
|
||||
...commonProps,
|
||||
space: room,
|
||||
spaceChildFilter: isBanned
|
||||
? (child: Room) => {
|
||||
// Return true if the target member is banned and we have sufficient PL to unban
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership === KnownMembership.Ban &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
|
||||
);
|
||||
}
|
||||
: (child: Room) => {
|
||||
// Return true if the target member isn't banned and we have sufficient PL to ban
|
||||
const myMember = child.getMember(cli.credentials.userId || "");
|
||||
const theirMember = child.getMember(member.userId);
|
||||
return (
|
||||
!!myMember &&
|
||||
!!theirMember &&
|
||||
theirMember.membership !== KnownMembership.Ban &&
|
||||
myMember.powerLevel > theirMember.powerLevel &&
|
||||
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
|
||||
);
|
||||
},
|
||||
allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
|
||||
specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
|
||||
warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
|
||||
},
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
));
|
||||
} else {
|
||||
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
|
||||
}
|
||||
|
||||
const [proceed, reason, rooms = []] = await finished;
|
||||
if (!proceed) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
const fn = (roomId: string): Promise<unknown> => {
|
||||
if (isBanned) {
|
||||
return cli.unban(roomId, member.userId);
|
||||
} else {
|
||||
return cli.ban(roomId, member.userId, reason || undefined);
|
||||
}
|
||||
};
|
||||
|
||||
bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
|
||||
.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("Ban success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Ban error: " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("user_info|error_ban_user"),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
let label = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
|
||||
if (isBanned) {
|
||||
label = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
onBanOrUnban();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
label={label}
|
||||
kind="critical"
|
||||
Icon={ChatProblemIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IBaseRoomProps extends IBaseProps {
|
||||
room: Room;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// 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 !== KnownMembership.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;
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevels = powerLevelEvent?.getContent();
|
||||
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
|
||||
let level;
|
||||
if (muted) {
|
||||
// unmute
|
||||
level = levelToSend;
|
||||
} else {
|
||||
// mute
|
||||
level = levelToSend - 1;
|
||||
}
|
||||
level = parseInt(level);
|
||||
|
||||
if (isNaN(level)) {
|
||||
stopUpdating();
|
||||
return;
|
||||
}
|
||||
|
||||
cli.setPowerLevel(roomId, target, level)
|
||||
.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("common|error"),
|
||||
description: _t("user_info|error_mute_user"),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
stopUpdating();
|
||||
});
|
||||
};
|
||||
|
||||
const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
onMuteToggle();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
label={muteLabel}
|
||||
kind="critical"
|
||||
Icon={VisibilityOffIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const IgnoreToggleButton: React.FC<{
|
||||
member: User | RoomMember;
|
||||
}> = ({ member }) => {
|
||||
@@ -786,96 +423,6 @@ const IgnoreToggleButton: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||
room,
|
||||
children,
|
||||
member,
|
||||
isUpdating,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
powerLevels,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
let kickButton;
|
||||
let banButton;
|
||||
let muteButton;
|
||||
let redactButton;
|
||||
|
||||
const editPowerLevel =
|
||||
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default;
|
||||
|
||||
// if these do not exist in the event then they should default to 50 as per the spec
|
||||
const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
|
||||
|
||||
const me = room.getMember(cli.getUserId() || "");
|
||||
if (!me) {
|
||||
// we aren't in the room, so return no admin tooling
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const isMe = me.userId === member.userId;
|
||||
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
|
||||
|
||||
if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
|
||||
kickButton = (
|
||||
<RoomKickButton
|
||||
room={room}
|
||||
member={member}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
|
||||
redactButton = (
|
||||
<RedactMessagesButton
|
||||
member={member}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
|
||||
banButton = (
|
||||
<BanToggleButton
|
||||
room={room}
|
||||
member={member}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
|
||||
muteButton = (
|
||||
<MuteToggleButton
|
||||
member={member}
|
||||
room={room}
|
||||
powerLevels={powerLevels}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kickButton || banButton || muteButton || redactButton || children) {
|
||||
return (
|
||||
<Container>
|
||||
{muteButton}
|
||||
{redactButton}
|
||||
{kickButton}
|
||||
{banButton}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return <div />;
|
||||
};
|
||||
|
||||
const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
|
||||
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
|
||||
};
|
||||
@@ -1283,7 +830,7 @@ const BasicUserInfo: React.FC<{
|
||||
}
|
||||
|
||||
adminToolsContainer = (
|
||||
<RoomAdminToolsContainer
|
||||
<UserInfoAdminToolsContainer
|
||||
powerLevels={powerLevels}
|
||||
member={member as RoomMember}
|
||||
room={room}
|
||||
@@ -1292,7 +839,7 @@ const BasicUserInfo: React.FC<{
|
||||
stopUpdating={stopUpdating}
|
||||
>
|
||||
{synapseDeactivateButton}
|
||||
</RoomAdminToolsContainer>
|
||||
</UserInfoAdminToolsContainer>
|
||||
);
|
||||
} else if (synapseDeactivateButton) {
|
||||
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
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, { type JSX, type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { MenuItem } from "@vector-im/compound-web";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import ChatProblemIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat-problem";
|
||||
import VisibilityOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-off";
|
||||
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { type IPowerLevelsContent } from "../UserInfo";
|
||||
import { useUserInfoAdminToolsContainerViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
|
||||
import { useMuteButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
|
||||
import { useBanButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
|
||||
import { useRoomKickButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
|
||||
import { useRedactMessagesButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
|
||||
|
||||
const Container: React.FC<{
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
const classes = classNames("mx_UserInfo_container", className);
|
||||
return <div className={classes}>{children}</div>;
|
||||
};
|
||||
|
||||
interface IBaseProps {
|
||||
member: RoomMember;
|
||||
isUpdating: boolean;
|
||||
startUpdating(): void;
|
||||
stopUpdating(): void;
|
||||
}
|
||||
|
||||
export const RoomKickButton = ({
|
||||
room,
|
||||
member,
|
||||
isUpdating,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
|
||||
const vm = useRoomKickButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating });
|
||||
// check if user can be kicked/disinvited
|
||||
if (!vm.canUserBeKicked) return <></>;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
vm.onKickClick();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
label={vm.kickLabel}
|
||||
kind="critical"
|
||||
Icon={LeaveIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
|
||||
const vm = useRedactMessagesButtonViewModel(member);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
vm.onRedactAllMessagesClick();
|
||||
}}
|
||||
label={_t("user_info|redact_button")}
|
||||
kind="critical"
|
||||
Icon={CloseIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BanToggleButton = ({
|
||||
room,
|
||||
member,
|
||||
isUpdating,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
|
||||
const vm = useBanButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating });
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
vm.onBanOrUnbanClick();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
label={vm.banLabel}
|
||||
kind="critical"
|
||||
Icon={ChatProblemIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IBaseRoomProps extends IBaseProps {
|
||||
room: Room;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// 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 vm = useMuteButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating });
|
||||
// Don't show the mute/unmute option if the user is not in the room
|
||||
if (!vm.isMemberInTheRoom) return null;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
role="button"
|
||||
onSelect={async (ev) => {
|
||||
ev.preventDefault();
|
||||
vm.onMuteButtonClick();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
label={vm.muteLabel}
|
||||
kind="critical"
|
||||
Icon={VisibilityOffIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserInfoAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||
room,
|
||||
children,
|
||||
member,
|
||||
isUpdating,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
powerLevels,
|
||||
}) => {
|
||||
let kickButton;
|
||||
let banButton;
|
||||
let muteButton;
|
||||
let redactButton;
|
||||
|
||||
const vm = useUserInfoAdminToolsContainerViewModel({ room, member, powerLevels });
|
||||
|
||||
if (!vm.isCurrentUserInTheRoom) {
|
||||
// we aren't in the room, so return no admin tooling
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (vm.shouldShowKickButton) {
|
||||
kickButton = (
|
||||
<RoomKickButton
|
||||
room={room}
|
||||
member={member}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (vm.shouldShowRedactButton) {
|
||||
redactButton = (
|
||||
<RedactMessagesButton
|
||||
member={member}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (vm.shouldShowBanButton) {
|
||||
banButton = (
|
||||
<BanToggleButton
|
||||
room={room}
|
||||
member={member}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (vm.shouldShowMuteButton) {
|
||||
muteButton = (
|
||||
<MuteToggleButton
|
||||
member={member}
|
||||
room={room}
|
||||
powerLevels={powerLevels}
|
||||
isUpdating={isUpdating}
|
||||
startUpdating={startUpdating}
|
||||
stopUpdating={stopUpdating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kickButton || banButton || muteButton || redactButton || children) {
|
||||
return (
|
||||
<Container>
|
||||
{muteButton}
|
||||
{redactButton}
|
||||
{kickButton}
|
||||
{banButton}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return <div />;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
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 { renderHook } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { type Room, type MatrixClient, RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
|
||||
import {
|
||||
type RoomAdminToolsContainerProps,
|
||||
useUserInfoAdminToolsContainerViewModel,
|
||||
} from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
|
||||
import { withClientContextRenderOptions } from "../../../../../../test-utils";
|
||||
|
||||
describe("UserInfoAdminToolsContainerViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
let mockRoom: Mocked<Room>;
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let mockPowerLevels: IPowerLevelsContent;
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
let defaultContainerProps: RoomAdminToolsContainerProps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue(undefined),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
mockPowerLevels = {
|
||||
users: {
|
||||
"@currentuser:example.com": 100,
|
||||
},
|
||||
events: {},
|
||||
state_default: 50,
|
||||
ban: 50,
|
||||
kick: 50,
|
||||
redact: 50,
|
||||
};
|
||||
|
||||
defaultContainerProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
powerLevels: mockPowerLevels,
|
||||
};
|
||||
|
||||
mockClient = mocked({
|
||||
getUser: jest.fn(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
isUserIgnored: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
setIgnoredUsers: jest.fn(),
|
||||
getUserId: jest.fn().mockReturnValue(defaultUserId),
|
||||
getSafeUserId: jest.fn(),
|
||||
getDomain: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
removeListener: jest.fn(),
|
||||
currentState: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
getRoom: jest.fn(),
|
||||
credentials: {},
|
||||
setPowerLevel: jest.fn(),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
const renderAdminToolsContainerHook = (props = defaultContainerProps) => {
|
||||
return renderHook(
|
||||
() => useUserInfoAdminToolsContainerViewModel(props),
|
||||
withClientContextRenderOptions(mockClient),
|
||||
);
|
||||
};
|
||||
|
||||
describe("useUserInfoAdminToolsContainerViewModel", () => {
|
||||
it("should return false when user is not in the room", () => {
|
||||
mockRoom.getMember.mockReturnValue(null);
|
||||
const { result } = renderAdminToolsContainerHook();
|
||||
expect(result.current).toEqual({
|
||||
isCurrentUserInTheRoom: false,
|
||||
shouldShowKickButton: false,
|
||||
shouldShowBanButton: false,
|
||||
shouldShowMuteButton: false,
|
||||
shouldShowRedactButton: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show kick, ban and mute buttons if user is me", () => {
|
||||
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
|
||||
mockMeMember.powerLevel = 51; // defaults to 50
|
||||
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
|
||||
|
||||
const props = {
|
||||
...defaultContainerProps,
|
||||
room: mockRoom,
|
||||
member: mockMeMember,
|
||||
powerLevels: mockPowerLevels,
|
||||
};
|
||||
const { result } = renderAdminToolsContainerHook(props);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isCurrentUserInTheRoom: true,
|
||||
shouldShowKickButton: false,
|
||||
shouldShowBanButton: false,
|
||||
shouldShowMuteButton: false,
|
||||
shouldShowRedactButton: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns mute toggle button if conditions met", () => {
|
||||
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
|
||||
mockMeMember.powerLevel = 51; // defaults to 50
|
||||
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
|
||||
|
||||
const defaultMemberWithPowerLevelAndJoinMembership = {
|
||||
...defaultMember,
|
||||
powerLevel: 0,
|
||||
membership: KnownMembership.Join,
|
||||
} as RoomMember;
|
||||
|
||||
const { result } = renderAdminToolsContainerHook({
|
||||
...defaultContainerProps,
|
||||
member: defaultMemberWithPowerLevelAndJoinMembership,
|
||||
powerLevels: { events: { "m.room.power_levels": 1 } },
|
||||
});
|
||||
|
||||
expect(result.current.shouldShowMuteButton).toBe(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);
|
||||
mockClient.getUserId.mockReturnValueOnce(mockMeMember.userId);
|
||||
|
||||
const { result } = renderAdminToolsContainerHook({
|
||||
...defaultContainerProps,
|
||||
member: mockMeMember,
|
||||
powerLevels: { events: { "m.room.power_levels": 100 } },
|
||||
});
|
||||
|
||||
expect(result.current.shouldShowMuteButton).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
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 { cleanup, renderHook } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
|
||||
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
|
||||
import { useBanButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
|
||||
import Modal from "../../../../../../../src/Modal";
|
||||
import { withClientContextRenderOptions } from "../../../../../../test-utils";
|
||||
|
||||
describe("useBanButtonViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
let mockRoom: Mocked<Room>;
|
||||
let mockSpace: Mocked<Room>;
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
const memberWithBanMembership = { ...defaultMember, membership: KnownMembership.Ban } as RoomMember;
|
||||
|
||||
let defaultAdminToolsProps: RoomAdminToolsProps;
|
||||
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue(undefined),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
mockSpace = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue("m.space"),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(true),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
defaultAdminToolsProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
isUpdating: false,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
};
|
||||
|
||||
mockClient = mocked({
|
||||
getUser: jest.fn(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
isUserIgnored: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
setIgnoredUsers: jest.fn(),
|
||||
getUserId: jest.fn().mockReturnValue(defaultUserId),
|
||||
getSafeUserId: jest.fn(),
|
||||
getDomain: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
removeListener: jest.fn(),
|
||||
currentState: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
getRoom: jest.fn(),
|
||||
credentials: {},
|
||||
setPowerLevel: jest.fn(),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
mockRoom.getMember.mockReturnValue(defaultMember);
|
||||
});
|
||||
|
||||
const renderBanButtonHook = (props = defaultAdminToolsProps) => {
|
||||
return renderHook(() => useBanButtonViewModel(props), withClientContextRenderOptions(mockClient));
|
||||
};
|
||||
|
||||
it("renders the correct labels for banned and unbanned members", () => {
|
||||
// test for room
|
||||
const propsWithBanMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
member: memberWithBanMembership,
|
||||
};
|
||||
|
||||
// defaultMember is not banned
|
||||
const { result } = renderBanButtonHook();
|
||||
expect(result.current.banLabel).toBe("Ban from room");
|
||||
cleanup();
|
||||
|
||||
const { result: result2 } = renderBanButtonHook(propsWithBanMembership);
|
||||
expect(result2.current.banLabel).toBe("Unban from room");
|
||||
cleanup();
|
||||
|
||||
// test for space
|
||||
const { result: result3 } = renderBanButtonHook({ ...defaultAdminToolsProps, room: mockSpace });
|
||||
expect(result3.current.banLabel).toBe("Ban from space");
|
||||
cleanup();
|
||||
|
||||
const { result: result4 } = renderBanButtonHook({
|
||||
...propsWithBanMembership,
|
||||
room: mockSpace,
|
||||
});
|
||||
expect(result4.current.banLabel).toBe("Unban from space");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => {
|
||||
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
|
||||
|
||||
const propsWithSpace = {
|
||||
...defaultAdminToolsProps,
|
||||
room: mockSpace,
|
||||
};
|
||||
const { result } = renderBanButtonHook(propsWithSpace);
|
||||
await result.current.onBanOrUnbanClick();
|
||||
|
||||
// check the last call arguments and the presence of the spaceChildFilter callback
|
||||
expect(createDialogSpy).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
);
|
||||
|
||||
// test the spaceChildFilter callback
|
||||
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
|
||||
|
||||
// make dummy values for myMember and theirMember, then we will test
|
||||
// null vs their member followed by
|
||||
// truthy my member vs their member
|
||||
const mockMyMember = { powerLevel: 1 };
|
||||
const mockTheirMember = { membership: "is not ban", powerLevel: 0 };
|
||||
|
||||
const mockRoom = {
|
||||
getMember: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockTheirMember)
|
||||
.mockReturnValueOnce(mockMyMember)
|
||||
.mockReturnValueOnce(mockTheirMember),
|
||||
currentState: {
|
||||
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
expect(callback(mockRoom)).toBe(false);
|
||||
expect(callback(mockRoom)).toBe(true);
|
||||
});
|
||||
|
||||
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => {
|
||||
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
|
||||
|
||||
const propsWithBanMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
member: memberWithBanMembership,
|
||||
room: mockSpace,
|
||||
};
|
||||
const { result } = renderBanButtonHook(propsWithBanMembership);
|
||||
await result.current.onBanOrUnbanClick();
|
||||
|
||||
// check the last call arguments and the presence of the spaceChildFilter callback
|
||||
expect(createDialogSpy).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
);
|
||||
|
||||
// test the spaceChildFilter callback
|
||||
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
|
||||
|
||||
// make dummy values for myMember and theirMember, then we will test
|
||||
// null vs their member followed by
|
||||
// my member vs their member
|
||||
const mockMyMember = { powerLevel: 1 };
|
||||
const mockTheirMember = { membership: KnownMembership.Ban, powerLevel: 0 };
|
||||
|
||||
const mockRoom = {
|
||||
getMember: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockTheirMember)
|
||||
.mockReturnValueOnce(mockMyMember)
|
||||
.mockReturnValueOnce(mockTheirMember),
|
||||
currentState: {
|
||||
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
expect(callback(mockRoom)).toBe(false);
|
||||
expect(callback(mockRoom)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
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 { cleanup, renderHook } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
|
||||
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
|
||||
import { useRoomKickButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
|
||||
import Modal from "../../../../../../../src/Modal";
|
||||
import { withClientContextRenderOptions } from "../../../../../../test-utils";
|
||||
|
||||
describe("useRoomKickButtonViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
let mockRoom: Mocked<Room>;
|
||||
let mockSpace: Mocked<Room>;
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
const memberWithInviteMembership = { ...defaultMember, membership: KnownMembership.Invite } as RoomMember;
|
||||
const memberWithJoinMembership = { ...defaultMember, membership: KnownMembership.Join } as RoomMember;
|
||||
|
||||
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
let defaultAdminToolsProps: RoomAdminToolsProps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue(undefined),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
mockSpace = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue("m.space"),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(true),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
defaultAdminToolsProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
isUpdating: false,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
};
|
||||
|
||||
mockClient = mocked({
|
||||
getUser: jest.fn(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
isUserIgnored: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
setIgnoredUsers: jest.fn(),
|
||||
getUserId: jest.fn().mockReturnValue(defaultUserId),
|
||||
getSafeUserId: jest.fn(),
|
||||
getDomain: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
removeListener: jest.fn(),
|
||||
currentState: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
getRoom: jest.fn(),
|
||||
credentials: {},
|
||||
setPowerLevel: jest.fn(),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
// mock useContext to return mockClient
|
||||
// jest.spyOn(React, "useContext").mockReturnValue(mockClient);
|
||||
|
||||
mockRoom.getMember.mockReturnValue(defaultMember);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
createDialogSpy.mockReset();
|
||||
});
|
||||
|
||||
const renderKickButtonHook = (props = defaultAdminToolsProps) => {
|
||||
return renderHook(() => useRoomKickButtonViewModel(props), withClientContextRenderOptions(mockClient));
|
||||
};
|
||||
|
||||
it("renders nothing if member.membership is undefined", () => {
|
||||
// .membership is undefined in our member by default
|
||||
const { result } = renderKickButtonHook();
|
||||
expect(result.current.canUserBeKicked).toBe(false);
|
||||
});
|
||||
|
||||
it("renders something if member.membership is 'invite' or 'join'", () => {
|
||||
let props = {
|
||||
...defaultAdminToolsProps,
|
||||
member: memberWithInviteMembership,
|
||||
};
|
||||
const { result } = renderKickButtonHook(props);
|
||||
expect(result.current.canUserBeKicked).toBe(true);
|
||||
|
||||
cleanup();
|
||||
|
||||
props = {
|
||||
...defaultAdminToolsProps,
|
||||
member: memberWithJoinMembership,
|
||||
};
|
||||
const { result: result2 } = renderKickButtonHook(props);
|
||||
expect(result2.current.canUserBeKicked).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the correct label", () => {
|
||||
// test for room
|
||||
const propsWithJoinMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
member: memberWithJoinMembership,
|
||||
};
|
||||
|
||||
const { result } = renderKickButtonHook(propsWithJoinMembership);
|
||||
expect(result.current.kickLabel).toBe("Remove from room");
|
||||
cleanup();
|
||||
|
||||
const propsWithInviteMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
member: memberWithInviteMembership,
|
||||
};
|
||||
|
||||
const { result: result2 } = renderKickButtonHook(propsWithInviteMembership);
|
||||
expect(result2.current.kickLabel).toBe("Disinvite from room");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the correct label for space", () => {
|
||||
const propsWithInviteMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
room: mockSpace,
|
||||
member: memberWithInviteMembership,
|
||||
};
|
||||
|
||||
const propsWithJoinMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
room: mockSpace,
|
||||
member: memberWithJoinMembership,
|
||||
};
|
||||
|
||||
const { result: result3 } = renderKickButtonHook(propsWithJoinMembership);
|
||||
expect(result3.current.kickLabel).toBe("Remove from space");
|
||||
cleanup();
|
||||
|
||||
const { result: result4 } = renderKickButtonHook(propsWithInviteMembership);
|
||||
expect(result4.current.kickLabel).toBe("Disinvite from space");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("clicking the kick button calls Modal.createDialog with the correct arguments when room is a space", async () => {
|
||||
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
|
||||
const propsWithInviteMembership = {
|
||||
...defaultAdminToolsProps,
|
||||
room: mockSpace,
|
||||
member: memberWithInviteMembership,
|
||||
};
|
||||
const { result } = renderKickButtonHook(propsWithInviteMembership);
|
||||
|
||||
await result.current.onKickClick();
|
||||
|
||||
// check the last call arguments and the presence of the spaceChildFilter callback
|
||||
expect(createDialogSpy).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
);
|
||||
|
||||
// test the spaceChildFilter callback
|
||||
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
|
||||
|
||||
// make dummy values for myMember and theirMember, then we will test
|
||||
// null vs their member followed by
|
||||
// my member vs their member
|
||||
const mockMyMember = { powerLevel: 1 };
|
||||
const mockTheirMember = { membership: KnownMembership.Invite, powerLevel: 0 };
|
||||
|
||||
const mockRoom = {
|
||||
getMember: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockTheirMember)
|
||||
.mockReturnValueOnce(mockMyMember)
|
||||
.mockReturnValueOnce(mockTheirMember),
|
||||
currentState: {
|
||||
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
expect(callback(mockRoom)).toBe(false);
|
||||
expect(callback(mockRoom)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
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 { renderHook } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import {
|
||||
type Room,
|
||||
type MatrixClient,
|
||||
RoomMember,
|
||||
type MatrixEvent,
|
||||
type ISendEventResponse,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
|
||||
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
|
||||
import { useMuteButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
|
||||
import { isMuted } from "../../../../../../../src/components/views/right_panel/UserInfo";
|
||||
import { withClientContextRenderOptions } from "../../../../../../test-utils";
|
||||
|
||||
describe("useMuteButtonViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
let mockRoom: Mocked<Room>;
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
let defaultAdminToolsProps: RoomAdminToolsProps;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue(undefined),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
defaultAdminToolsProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
isUpdating: false,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
};
|
||||
|
||||
mockClient = mocked({
|
||||
getUser: jest.fn(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
isUserIgnored: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
setIgnoredUsers: jest.fn(),
|
||||
getUserId: jest.fn().mockReturnValue(defaultUserId),
|
||||
getSafeUserId: jest.fn(),
|
||||
getDomain: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
removeListener: jest.fn(),
|
||||
currentState: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
getRoom: jest.fn(),
|
||||
credentials: {},
|
||||
setPowerLevel: jest.fn(),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
|
||||
mockClient.setPowerLevel.mockImplementation(() => Promise.resolve({} as ISendEventResponse));
|
||||
|
||||
mockRoom.currentState.getStateEvents.mockReturnValueOnce({
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
events: {
|
||||
"m.room.message": 0,
|
||||
},
|
||||
events_default: 0,
|
||||
}),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
jest.spyOn(mockClient, "setPowerLevel").mockImplementation(() => Promise.resolve({} as ISendEventResponse));
|
||||
jest.spyOn(mockRoom.currentState, "getStateEvents").mockReturnValue({
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
events: {
|
||||
"m.room.message": 0,
|
||||
},
|
||||
events_default: 0,
|
||||
}),
|
||||
} as unknown as MatrixEvent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderMuteButtonHook = (props = defaultAdminToolsProps) => {
|
||||
return renderHook(() => useMuteButtonViewModel(props), withClientContextRenderOptions(mockClient));
|
||||
};
|
||||
|
||||
it("should early return when isUpdating=true", async () => {
|
||||
const defaultMemberWithPowerLevelAndJoinMembership = {
|
||||
...defaultMember,
|
||||
powerLevel: 0,
|
||||
membership: KnownMembership.Join,
|
||||
} as RoomMember;
|
||||
|
||||
const { result } = renderMuteButtonHook({
|
||||
...defaultAdminToolsProps,
|
||||
member: defaultMemberWithPowerLevelAndJoinMembership,
|
||||
isUpdating: true,
|
||||
});
|
||||
|
||||
const resultClick = await result.current.onMuteButtonClick();
|
||||
|
||||
expect(resultClick).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should stop updating when level is NaN", async () => {
|
||||
const { result } = renderMuteButtonHook({
|
||||
...defaultAdminToolsProps,
|
||||
member: defaultMember,
|
||||
isUpdating: false,
|
||||
});
|
||||
|
||||
jest.spyOn(mockRoom.currentState, "getStateEvents").mockReturnValueOnce({
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
events: {
|
||||
"m.room.message": NaN,
|
||||
},
|
||||
events_default: NaN,
|
||||
}),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
await result.current.onMuteButtonClick();
|
||||
|
||||
expect(defaultAdminToolsProps.stopUpdating).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set powerlevel to default when user is muted", async () => {
|
||||
const defaultMutedMember = {
|
||||
...defaultMember,
|
||||
powerLevel: -1,
|
||||
membership: KnownMembership.Join,
|
||||
} as RoomMember;
|
||||
|
||||
const { result } = renderMuteButtonHook({
|
||||
...defaultAdminToolsProps,
|
||||
member: defaultMutedMember,
|
||||
isUpdating: false,
|
||||
});
|
||||
|
||||
await result.current.onMuteButtonClick();
|
||||
|
||||
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, 0);
|
||||
});
|
||||
|
||||
it("should set powerlevel - 1 when user is unmuted", async () => {
|
||||
const defaultUnmutedMember = {
|
||||
...defaultMember,
|
||||
powerLevel: 0,
|
||||
membership: KnownMembership.Join,
|
||||
} as RoomMember;
|
||||
|
||||
const { result } = renderMuteButtonHook({
|
||||
...defaultAdminToolsProps,
|
||||
member: defaultUnmutedMember,
|
||||
isUpdating: false,
|
||||
});
|
||||
|
||||
await result.current.onMuteButtonClick();
|
||||
|
||||
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, -1);
|
||||
});
|
||||
|
||||
it("returns false if either argument is falsy", () => {
|
||||
// @ts-ignore to let us purposely pass incorrect args
|
||||
expect(isMuted(defaultMember, null)).toBe(false);
|
||||
// @ts-ignore to let us purposely pass incorrect args
|
||||
expect(isMuted(null, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events and .events_default are undefined, returns false", () => {
|
||||
const powerLevelContents = {};
|
||||
expect(isMuted(defaultMember, powerLevelContents)).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events is undefined, uses .events_default", () => {
|
||||
const higherPowerLevelContents = { events_default: 10 };
|
||||
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true);
|
||||
|
||||
const lowerPowerLevelContents = { events_default: -10 };
|
||||
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => {
|
||||
const higherPowerLevelContents = { events: {}, events_default: 10 };
|
||||
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true);
|
||||
|
||||
const lowerPowerLevelContents = { events: {}, events_default: -10 };
|
||||
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => {
|
||||
const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 };
|
||||
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(false);
|
||||
|
||||
const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 };
|
||||
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
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 { renderHook } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
|
||||
import { useRedactMessagesButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
|
||||
import Modal from "../../../../../../../src/Modal";
|
||||
import BulkRedactDialog from "../../../../../../../src/components/views/dialogs/BulkRedactDialog";
|
||||
import { withClientContextRenderOptions } from "../../../../../../test-utils";
|
||||
|
||||
describe("useRedactMessagesButtonViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
let mockRoom: Mocked<Room>;
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue(undefined),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
mockClient = mocked({
|
||||
getUser: jest.fn(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
isUserIgnored: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
setIgnoredUsers: jest.fn(),
|
||||
getUserId: jest.fn().mockReturnValue(defaultUserId),
|
||||
getSafeUserId: jest.fn(),
|
||||
getDomain: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
removeListener: jest.fn(),
|
||||
currentState: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
getRoom: jest.fn(),
|
||||
credentials: {},
|
||||
setPowerLevel: jest.fn(),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
const renderRedactButtonHook = (props = defaultMember) => {
|
||||
return renderHook(() => useRedactMessagesButtonViewModel(props), withClientContextRenderOptions(mockClient));
|
||||
};
|
||||
|
||||
it("should show BulkRedactDialog upon clicking the Remove messages button", async () => {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
mockClient.getRoom.mockReturnValue(mockRoom);
|
||||
mockClient.getUserId.mockReturnValue("@arbitraryId:server");
|
||||
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!);
|
||||
mockMeMember.powerLevel = 51; // defaults to 50
|
||||
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember;
|
||||
mockRoom.getMember.mockImplementation((userId) =>
|
||||
userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel,
|
||||
);
|
||||
|
||||
const { result } = renderRedactButtonHook();
|
||||
await result.current.onRedactAllMessagesClick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
BulkRedactDialog,
|
||||
expect.objectContaining({ member: defaultMemberWithPowerLevel }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, cleanup, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
|
||||
import { fireEvent, render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
EventType,
|
||||
Device,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
UserVerificationStatus,
|
||||
@@ -30,13 +29,9 @@ import {
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import UserInfo, {
|
||||
BanToggleButton,
|
||||
disambiguateDevices,
|
||||
getPowerLevels,
|
||||
isMuted,
|
||||
PowerLevelEditor,
|
||||
RoomAdminToolsContainer,
|
||||
RoomKickButton,
|
||||
UserInfoHeader,
|
||||
UserOptionsSection,
|
||||
} from "../../../../../src/components/views/right_panel/UserInfo";
|
||||
@@ -53,7 +48,6 @@ import { shouldShowComponent } from "../../../../../src/customisations/helpers/U
|
||||
import { UIComponent } from "../../../../../src/settings/UIFeature";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog";
|
||||
import BulkRedactDialog from "../../../../../src/components/views/dialogs/BulkRedactDialog";
|
||||
|
||||
jest.mock("../../../../../src/utils/direct-messages", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/direct-messages"),
|
||||
@@ -92,7 +86,6 @@ const defaultUserId = "@user:example.com";
|
||||
const defaultUser = new User(defaultUserId);
|
||||
|
||||
let mockRoom: Mocked<Room>;
|
||||
let mockSpace: Mocked<Room>;
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let mockCrypto: Mocked<CryptoApi>;
|
||||
const origDate = global.Date.prototype.toLocaleString;
|
||||
@@ -115,23 +108,6 @@ beforeEach(() => {
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
mockSpace = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue("m.space"),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(true),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
mockCrypto = mocked({
|
||||
getDeviceVerificationStatus: jest.fn(),
|
||||
getUserDeviceInfo: jest.fn(),
|
||||
@@ -800,384 +776,6 @@ describe("<PowerLevelEditor />", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<RoomKickButton />", () => {
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
const memberWithInviteMembership = { ...defaultMember, membership: KnownMembership.Invite };
|
||||
const memberWithJoinMembership = { ...defaultMember, membership: KnownMembership.Join };
|
||||
|
||||
let defaultProps: Parameters<typeof RoomKickButton>[0];
|
||||
beforeEach(() => {
|
||||
defaultProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
isUpdating: false,
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
const Wrapper = (wrapperProps = {}) => {
|
||||
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
|
||||
};
|
||||
|
||||
return render(<RoomKickButton {...defaultProps} {...props} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
};
|
||||
|
||||
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
afterEach(() => {
|
||||
createDialogSpy.mockReset();
|
||||
});
|
||||
|
||||
it("renders nothing if member.membership is undefined", () => {
|
||||
// .membership is undefined in our member by default
|
||||
const { container } = renderComponent();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders something if member.membership is 'invite' or 'join'", () => {
|
||||
let result = renderComponent({ member: memberWithInviteMembership });
|
||||
expect(result.container).not.toBeEmptyDOMElement();
|
||||
|
||||
cleanup();
|
||||
|
||||
result = renderComponent({ member: memberWithJoinMembership });
|
||||
expect(result.container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders the correct label", () => {
|
||||
// test for room
|
||||
renderComponent({ member: memberWithJoinMembership });
|
||||
expect(screen.getByText(/remove from room/i)).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
renderComponent({ member: memberWithInviteMembership });
|
||||
expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
// test for space
|
||||
mockRoom.isSpaceRoom.mockReturnValue(true);
|
||||
renderComponent({ member: memberWithJoinMembership });
|
||||
expect(screen.getByText(/remove from space/i)).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
renderComponent({ member: memberWithInviteMembership });
|
||||
expect(screen.getByText(/disinvite from space/i)).toBeInTheDocument();
|
||||
cleanup();
|
||||
mockRoom.isSpaceRoom.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("clicking the kick button calls Modal.createDialog with the correct arguments", async () => {
|
||||
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
|
||||
|
||||
renderComponent({ room: mockSpace, member: memberWithInviteMembership });
|
||||
await userEvent.click(screen.getByText(/disinvite from/i));
|
||||
|
||||
// check the last call arguments and the presence of the spaceChildFilter callback
|
||||
expect(createDialogSpy).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
);
|
||||
|
||||
// test the spaceChildFilter callback
|
||||
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
|
||||
|
||||
// make dummy values for myMember and theirMember, then we will test
|
||||
// null vs their member followed by
|
||||
// my member vs their member
|
||||
const mockMyMember = { powerLevel: 1 };
|
||||
const mockTheirMember = { membership: KnownMembership.Invite, powerLevel: 0 };
|
||||
|
||||
const mockRoom = {
|
||||
getMember: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockTheirMember)
|
||||
.mockReturnValueOnce(mockMyMember)
|
||||
.mockReturnValueOnce(mockTheirMember),
|
||||
currentState: {
|
||||
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
expect(callback(mockRoom)).toBe(false);
|
||||
expect(callback(mockRoom)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BanToggleButton />", () => {
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
const memberWithBanMembership = { ...defaultMember, membership: KnownMembership.Ban };
|
||||
let defaultProps: Parameters<typeof BanToggleButton>[0];
|
||||
beforeEach(() => {
|
||||
defaultProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
isUpdating: false,
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
const Wrapper = (wrapperProps = {}) => {
|
||||
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
|
||||
};
|
||||
|
||||
return render(<BanToggleButton {...defaultProps} {...props} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
};
|
||||
|
||||
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
afterEach(() => {
|
||||
createDialogSpy.mockReset();
|
||||
});
|
||||
|
||||
it("renders the correct labels for banned and unbanned members", () => {
|
||||
// test for room
|
||||
// defaultMember is not banned
|
||||
renderComponent();
|
||||
expect(screen.getByText("Ban from room")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
renderComponent({ member: memberWithBanMembership });
|
||||
expect(screen.getByText("Unban from room")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
// test for space
|
||||
mockRoom.isSpaceRoom.mockReturnValue(true);
|
||||
renderComponent();
|
||||
expect(screen.getByText("Ban from space")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
renderComponent({ member: memberWithBanMembership });
|
||||
expect(screen.getByText("Unban from space")).toBeInTheDocument();
|
||||
cleanup();
|
||||
mockRoom.isSpaceRoom.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => {
|
||||
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
|
||||
|
||||
renderComponent({ room: mockSpace });
|
||||
await userEvent.click(screen.getByText(/ban from/i));
|
||||
|
||||
// check the last call arguments and the presence of the spaceChildFilter callback
|
||||
expect(createDialogSpy).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
);
|
||||
|
||||
// test the spaceChildFilter callback
|
||||
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
|
||||
|
||||
// make dummy values for myMember and theirMember, then we will test
|
||||
// null vs their member followed by
|
||||
// truthy my member vs their member
|
||||
const mockMyMember = { powerLevel: 1 };
|
||||
const mockTheirMember = { membership: "is not ban", powerLevel: 0 };
|
||||
|
||||
const mockRoom = {
|
||||
getMember: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockTheirMember)
|
||||
.mockReturnValueOnce(mockMyMember)
|
||||
.mockReturnValueOnce(mockTheirMember),
|
||||
currentState: {
|
||||
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
expect(callback(mockRoom)).toBe(false);
|
||||
expect(callback(mockRoom)).toBe(true);
|
||||
});
|
||||
|
||||
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => {
|
||||
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
|
||||
|
||||
renderComponent({ room: mockSpace, member: memberWithBanMembership });
|
||||
await userEvent.click(screen.getByText(/ban from/i));
|
||||
|
||||
// check the last call arguments and the presence of the spaceChildFilter callback
|
||||
expect(createDialogSpy).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
|
||||
"mx_ConfirmSpaceUserActionDialog_wrapper",
|
||||
);
|
||||
|
||||
// test the spaceChildFilter callback
|
||||
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
|
||||
|
||||
// make dummy values for myMember and theirMember, then we will test
|
||||
// null vs their member followed by
|
||||
// my member vs their member
|
||||
const mockMyMember = { powerLevel: 1 };
|
||||
const mockTheirMember = { membership: KnownMembership.Ban, powerLevel: 0 };
|
||||
|
||||
const mockRoom = {
|
||||
getMember: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockTheirMember)
|
||||
.mockReturnValueOnce(mockMyMember)
|
||||
.mockReturnValueOnce(mockTheirMember),
|
||||
currentState: {
|
||||
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
expect(callback(mockRoom)).toBe(false);
|
||||
expect(callback(mockRoom)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<RoomAdminToolsContainer />", () => {
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
defaultMember.membership = KnownMembership.Invite;
|
||||
|
||||
let defaultProps: Parameters<typeof RoomAdminToolsContainer>[0];
|
||||
beforeEach(() => {
|
||||
defaultProps = {
|
||||
room: mockRoom,
|
||||
member: defaultMember,
|
||||
isUpdating: false,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
powerLevels: {},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
const Wrapper = (wrapperProps = {}) => {
|
||||
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
|
||||
};
|
||||
|
||||
return render(<RoomAdminToolsContainer {...defaultProps} {...props} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
};
|
||||
|
||||
it("returns a single empty div if room.getMember is falsy", () => {
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchInlineSnapshot(`
|
||||
<DocumentFragment>
|
||||
<div />
|
||||
</DocumentFragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it("can return a single empty div in case where room.getMember is not falsy", () => {
|
||||
mockRoom.getMember.mockReturnValueOnce(defaultMember);
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchInlineSnapshot(`
|
||||
<DocumentFragment>
|
||||
<div />
|
||||
</DocumentFragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns kick, redact messages, ban buttons if conditions met", () => {
|
||||
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
|
||||
mockMeMember.powerLevel = 51; // defaults to 50
|
||||
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
|
||||
|
||||
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 };
|
||||
|
||||
renderComponent({ member: defaultMemberWithPowerLevel });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Disinvite from room" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ban from room" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove messages" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show BulkRedactDialog upon clicking the Remove messages button", async () => {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
mockClient.getRoom.mockReturnValue(mockRoom);
|
||||
mockClient.getUserId.mockReturnValue("@arbitraryId:server");
|
||||
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!);
|
||||
mockMeMember.powerLevel = 51; // defaults to 50
|
||||
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember;
|
||||
mockRoom.getMember.mockImplementation((userId) =>
|
||||
userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel,
|
||||
);
|
||||
|
||||
renderComponent({ member: defaultMemberWithPowerLevel });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove messages" }));
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
BulkRedactDialog,
|
||||
expect.objectContaining({ member: defaultMemberWithPowerLevel }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns mute toggle button if conditions met", () => {
|
||||
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
|
||||
mockMeMember.powerLevel = 51; // defaults to 50
|
||||
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
|
||||
|
||||
const defaultMemberWithPowerLevelAndJoinMembership = {
|
||||
...defaultMember,
|
||||
powerLevel: 0,
|
||||
membership: KnownMembership.Join,
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
member: defaultMemberWithPowerLevelAndJoinMembership,
|
||||
powerLevels: { events: { "m.room.power_levels": 1 } },
|
||||
});
|
||||
|
||||
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: KnownMembership.Join,
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
member: defaultMemberWithPowerLevelAndJoinMembership,
|
||||
powerLevels: { events: { "m.room.power_levels": 1 } },
|
||||
isUpdating: true,
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Mute" });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disambiguateDevices", () => {
|
||||
it("does not add ambiguous key to unique names", () => {
|
||||
const initialDevices = [
|
||||
@@ -1217,47 +815,6 @@ describe("disambiguateDevices", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMuted", () => {
|
||||
// this member has a power level of 0
|
||||
const isMutedMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
it("returns false if either argument is falsy", () => {
|
||||
// @ts-ignore to let us purposely pass incorrect args
|
||||
expect(isMuted(isMutedMember, null)).toBe(false);
|
||||
// @ts-ignore to let us purposely pass incorrect args
|
||||
expect(isMuted(null, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events and .events_default are undefined, returns false", () => {
|
||||
const powerLevelContents = {};
|
||||
expect(isMuted(isMutedMember, powerLevelContents)).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events is undefined, uses .events_default", () => {
|
||||
const higherPowerLevelContents = { events_default: 10 };
|
||||
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true);
|
||||
|
||||
const lowerPowerLevelContents = { events_default: -10 };
|
||||
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => {
|
||||
const higherPowerLevelContents = { events: {}, events_default: 10 };
|
||||
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true);
|
||||
|
||||
const lowerPowerLevelContents = { events: {}, events_default: -10 };
|
||||
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false);
|
||||
});
|
||||
|
||||
it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => {
|
||||
const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 };
|
||||
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(false);
|
||||
|
||||
const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 };
|
||||
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPowerLevels", () => {
|
||||
it("returns an empty object when room.currentState.getStateEvents return null", () => {
|
||||
mockRoom.currentState.getStateEvents.mockReturnValueOnce(null);
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
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, fireEvent } from "jest-matrix-react";
|
||||
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { UserInfoAdminToolsContainer } from "../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer";
|
||||
import { useUserInfoAdminToolsContainerViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
|
||||
import { useRoomKickButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
|
||||
import { useBanButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
|
||||
import { useMuteButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
|
||||
import { useRedactMessagesButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
|
||||
import { stubClient } from "../../../../test-utils";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
|
||||
jest.mock("../../../../../src/utils/DMRoomMap", () => {
|
||||
const mock = {
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
getDMRoomsForUserId: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
shared: jest.fn().mockReturnValue(mock),
|
||||
sharedInstance: mock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
"../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel",
|
||||
() => ({
|
||||
useUserInfoAdminToolsContainerViewModel: jest.fn().mockReturnValue({
|
||||
isCurrentUserInTheRoom: true,
|
||||
shouldShowKickButton: true,
|
||||
shouldShowBanButton: true,
|
||||
shouldShowMuteButton: true,
|
||||
shouldShowRedactButton: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel", () => ({
|
||||
useRoomKickButtonViewModel: jest.fn().mockReturnValue({
|
||||
canUserBeKicked: true,
|
||||
kickLabel: "Kick",
|
||||
onKickClick: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({
|
||||
useBanButtonViewModel: jest.fn().mockReturnValue({
|
||||
banLabel: "Ban",
|
||||
onBanOrUnbanClick: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel", () => ({
|
||||
useMuteButtonViewModel: jest.fn().mockReturnValue({
|
||||
isMemberInTheRoom: true,
|
||||
muteLabel: "Mute",
|
||||
onMuteButtonClick: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel", () => ({
|
||||
useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({
|
||||
onRedactAllMessagesClick: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const defaultRoomId = "!fkfk";
|
||||
|
||||
describe("UserInfoAdminToolsContainer", () => {
|
||||
// Setup it data
|
||||
const mockRoom = mocked({
|
||||
roomId: defaultRoomId,
|
||||
getType: jest.fn().mockReturnValue(undefined),
|
||||
isSpaceRoom: jest.fn().mockReturnValue(false),
|
||||
getMember: jest.fn().mockReturnValue(undefined),
|
||||
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
|
||||
name: "test room",
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
currentState: {
|
||||
getStateEvents: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
getEventReadUpTo: jest.fn(),
|
||||
} as unknown as Room);
|
||||
|
||||
const mockMember = {
|
||||
userId: "@user:example.com",
|
||||
membership: "join",
|
||||
powerLevel: 0,
|
||||
} as unknown as RoomMember;
|
||||
|
||||
const mockPowerLevels = {
|
||||
users: {
|
||||
"@currentuser:example.com": 100,
|
||||
},
|
||||
events: {},
|
||||
state_default: 50,
|
||||
ban: 50,
|
||||
kick: 50,
|
||||
redact: 50,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
room: mockRoom,
|
||||
member: mockMember,
|
||||
powerLevels: mockPowerLevels,
|
||||
isUpdating: false,
|
||||
startUpdating: jest.fn(),
|
||||
stopUpdating: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMatrixClient = stubClient();
|
||||
|
||||
const renderComponent = (props = defaultProps) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockMatrixClient}>
|
||||
<UserInfoAdminToolsContainer {...props} />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocked(useUserInfoAdminToolsContainerViewModel).mockReturnValue({
|
||||
isCurrentUserInTheRoom: true,
|
||||
shouldShowKickButton: true,
|
||||
shouldShowBanButton: true,
|
||||
shouldShowMuteButton: true,
|
||||
shouldShowRedactButton: true,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders all admin tools when user has permissions", () => {
|
||||
renderComponent();
|
||||
|
||||
// Check that all buttons are rendered
|
||||
expect(screen.getByText("Mute")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kick")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ban")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remove messages")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no admin tools when current user is not in the room", () => {
|
||||
mocked(useUserInfoAdminToolsContainerViewModel).mockReturnValue({
|
||||
isCurrentUserInTheRoom: false,
|
||||
shouldShowKickButton: false,
|
||||
shouldShowBanButton: false,
|
||||
shouldShowMuteButton: false,
|
||||
shouldShowRedactButton: false,
|
||||
});
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
// Should render an empty div
|
||||
expect(container.firstChild).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders children when provided", () => {
|
||||
render(
|
||||
<UserInfoAdminToolsContainer {...defaultProps}>
|
||||
<div data-testid="child-element">Custom Child</div>
|
||||
</UserInfoAdminToolsContainer>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-element")).toBeInTheDocument();
|
||||
expect(screen.getByText("Custom Child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Kick behavior", () => {
|
||||
it("clicking kick button calls the appropriate handler", () => {
|
||||
const mockedOnKickClick = jest.fn();
|
||||
mocked(useRoomKickButtonViewModel).mockReturnValue({
|
||||
canUserBeKicked: true,
|
||||
kickLabel: "Kick",
|
||||
onKickClick: mockedOnKickClick,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const kickButton = screen.getByText("Kick");
|
||||
fireEvent.click(kickButton);
|
||||
|
||||
expect(mockedOnKickClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not display kick buttun if user can't be kicked", () => {
|
||||
mocked(useRoomKickButtonViewModel).mockReturnValue({
|
||||
canUserBeKicked: false,
|
||||
kickLabel: "Kick",
|
||||
onKickClick: jest.fn(),
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByText("Kick")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the correct label when user can be disinvited", () => {
|
||||
mocked(useRoomKickButtonViewModel).mockReturnValue({
|
||||
canUserBeKicked: true,
|
||||
kickLabel: "Disinvite",
|
||||
onKickClick: jest.fn(),
|
||||
});
|
||||
|
||||
renderComponent({
|
||||
...defaultProps,
|
||||
member: mockMember,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Disinvite")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ban behavior", () => {
|
||||
it("clicking ban button calls the appropriate handler", () => {
|
||||
const mockedOnBanOrUnbanClick = jest.fn();
|
||||
mocked(useBanButtonViewModel).mockReturnValue({
|
||||
banLabel: "Ban",
|
||||
onBanOrUnbanClick: mockedOnBanOrUnbanClick,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const banButton = screen.getByText("Ban");
|
||||
fireEvent.click(banButton);
|
||||
|
||||
expect(mockedOnBanOrUnbanClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the correct label", () => {
|
||||
const mockedOnBanOrUnbanClick = jest.fn();
|
||||
mocked(useBanButtonViewModel).mockReturnValue({
|
||||
banLabel: "Unban",
|
||||
onBanOrUnbanClick: mockedOnBanOrUnbanClick,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
// The label should be "Unban"
|
||||
expect(screen.getByText("Unban")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mute behavior", () => {
|
||||
it("clicking mute button calls the appropriate handler", () => {
|
||||
const mockedOnMuteButtonClick = jest.fn();
|
||||
mocked(useMuteButtonViewModel).mockReturnValue({
|
||||
isMemberInTheRoom: true,
|
||||
muteLabel: "Mute",
|
||||
onMuteButtonClick: mockedOnMuteButtonClick,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const muteButton = screen.getByText("Mute");
|
||||
fireEvent.click(muteButton);
|
||||
|
||||
expect(mockedOnMuteButtonClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not display mute button if user is not in the room", () => {
|
||||
mocked(useMuteButtonViewModel).mockReturnValue({
|
||||
isMemberInTheRoom: false,
|
||||
muteLabel: "Mute",
|
||||
onMuteButtonClick: jest.fn(),
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByText("Mute")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the correct label", () => {
|
||||
mocked(useMuteButtonViewModel).mockReturnValue({
|
||||
isMemberInTheRoom: true,
|
||||
muteLabel: "Mute",
|
||||
onMuteButtonClick: jest.fn(),
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByText("Mute")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Redact behavior", () => {
|
||||
it("clicking redact button calls the appropriate handler", () => {
|
||||
const mockedOnRedactAllMessagesClick = jest.fn();
|
||||
mocked(useRedactMessagesButtonViewModel).mockReturnValue({
|
||||
onRedactAllMessagesClick: mockedOnRedactAllMessagesClick,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const redactButton = screen.getByText("Remove messages");
|
||||
fireEvent.click(redactButton);
|
||||
|
||||
expect(mockedOnRedactAllMessagesClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user