diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel.tsx new file mode 100644 index 00000000000..54ed32ceb50 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel.tsx @@ -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, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel.tsx new file mode 100644 index 00000000000..525b10e0936 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel.tsx @@ -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; + /** + * 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 => { + 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 => { + 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, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel.tsx new file mode 100644 index 00000000000..8ae179ac07d --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel.tsx @@ -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; + /** + * 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 => { + 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, + }; +} diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel.tsx new file mode 100644 index 00000000000..1608628198b --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel.tsx @@ -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; +} + +/** + * 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 => { + 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, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel.tsx new file mode 100644 index 00000000000..73b8ea70f07 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel.tsx @@ -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, + }; +}; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 7bba4f09505..542e6421dee 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -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
{children}
; }; -interface IPowerLevelsContent { +export interface IPowerLevelsContent { events?: Record; // 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): 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 => { - 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 ( - { - ev.preventDefault(); - onKick(); - }} - disabled={isUpdating} - label={kickLabel} - kind="critical" - Icon={LeaveIcon} - /> - ); -}; - -const RedactMessagesButton: React.FC = ({ 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 ( - { - ev.preventDefault(); - onRedactAllMessages(); - }} - label={_t("user_info|redact_button")} - kind="critical" - Icon={CloseIcon} - /> - ); -}; - -export const BanToggleButton = ({ - room, - member, - isUpdating, - startUpdating, - stopUpdating, -}: Omit): JSX.Element => { - const cli = useContext(MatrixClientContext); - - const isBanned = member.membership === KnownMembership.Ban; - const onBanOrUnban = async (): Promise => { - 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 => { - 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 ( - { - 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 = ({ - 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 => { - 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 ( - { - 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 = ({ - 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
; - } - - const isMe = me.userId === member.userId; - const canAffectUser = member.powerLevel < me.powerLevel || isMe; - - if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) { - kickButton = ( - - ); - } - if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) { - redactButton = ( - - ); - } - if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) { - banButton = ( - - ); - } - if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) { - muteButton = ( - - ); - } - - if (kickButton || banButton || muteButton || redactButton || children) { - return ( - - {muteButton} - {redactButton} - {kickButton} - {banButton} - {children} - - ); - } - - return
; -}; - 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 = ( - {synapseDeactivateButton} - + ); } else if (synapseDeactivateButton) { adminToolsContainer = {synapseDeactivateButton}; diff --git a/src/components/views/right_panel/user_info/UserInfoAdminToolsContainer.tsx b/src/components/views/right_panel/user_info/UserInfoAdminToolsContainer.tsx new file mode 100644 index 00000000000..1f3eeb706dd --- /dev/null +++ b/src/components/views/right_panel/user_info/UserInfoAdminToolsContainer.tsx @@ -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
{children}
; +}; + +interface IBaseProps { + member: RoomMember; + isUpdating: boolean; + startUpdating(): void; + stopUpdating(): void; +} + +export const RoomKickButton = ({ + room, + member, + isUpdating, + startUpdating, + stopUpdating, +}: Omit): JSX.Element | null => { + const vm = useRoomKickButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating }); + // check if user can be kicked/disinvited + if (!vm.canUserBeKicked) return <>; + + return ( + { + ev.preventDefault(); + vm.onKickClick(); + }} + disabled={isUpdating} + label={vm.kickLabel} + kind="critical" + Icon={LeaveIcon} + /> + ); +}; + +const RedactMessagesButton: React.FC = ({ member }) => { + const vm = useRedactMessagesButtonViewModel(member); + + return ( + { + ev.preventDefault(); + vm.onRedactAllMessagesClick(); + }} + label={_t("user_info|redact_button")} + kind="critical" + Icon={CloseIcon} + /> + ); +}; + +export const BanToggleButton = ({ + room, + member, + isUpdating, + startUpdating, + stopUpdating, +}: Omit): JSX.Element => { + const vm = useBanButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating }); + + return ( + { + 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 = ({ + 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 ( + { + ev.preventDefault(); + vm.onMuteButtonClick(); + }} + disabled={isUpdating} + label={vm.muteLabel} + kind="critical" + Icon={VisibilityOffIcon} + /> + ); +}; + +export const UserInfoAdminToolsContainer: React.FC = ({ + 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
; + } + + if (vm.shouldShowKickButton) { + kickButton = ( + + ); + } + if (vm.shouldShowRedactButton) { + redactButton = ( + + ); + } + if (vm.shouldShowBanButton) { + banButton = ( + + ); + } + if (vm.shouldShowMuteButton) { + muteButton = ( + + ); + } + + if (kickButton || banButton || muteButton || redactButton || children) { + return ( + + {muteButton} + {redactButton} + {kickButton} + {banButton} + {children} + + ); + } + + return
; +}; diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel-test.tsx new file mode 100644 index 00000000000..d07bc1ad7aa --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel-test.tsx @@ -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; + let mockClient: Mocked; + 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); + }); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel-test.tsx new file mode 100644 index 00000000000..a4825d15507 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel-test.tsx @@ -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; + let mockSpace: Mocked; + let mockClient: Mocked; + + 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); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel-test.tsx new file mode 100644 index 00000000000..e4e0d578be5 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel-test.tsx @@ -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; + let mockSpace: Mocked; + let mockClient: Mocked; + + 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); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx new file mode 100644 index 00000000000..5ebd537855c --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx @@ -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; + let mockClient: Mocked; + + 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); + }); +}); diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel-test.tsx new file mode 100644 index 00000000000..cb3187a82c4 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel-test.tsx @@ -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; + let mockClient: Mocked; + + 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 }), + ); + }); +}); diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index 1a58b1e2e0b..ce0edca0ac0 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -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; -let mockSpace: Mocked; let mockClient: Mocked; let mockCrypto: Mocked; 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("", () => { }); }); -describe("", () => { - const defaultMember = new RoomMember(defaultRoomId, defaultUserId); - const memberWithInviteMembership = { ...defaultMember, membership: KnownMembership.Invite }; - const memberWithJoinMembership = { ...defaultMember, membership: KnownMembership.Join }; - - let defaultProps: Parameters[0]; - beforeEach(() => { - defaultProps = { - room: mockRoom, - member: defaultMember, - startUpdating: jest.fn(), - stopUpdating: jest.fn(), - isUpdating: false, - }; - }); - - const renderComponent = (props = {}) => { - const Wrapper = (wrapperProps = {}) => { - return ; - }; - - return render(, { - 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("", () => { - const defaultMember = new RoomMember(defaultRoomId, defaultUserId); - const memberWithBanMembership = { ...defaultMember, membership: KnownMembership.Ban }; - let defaultProps: Parameters[0]; - beforeEach(() => { - defaultProps = { - room: mockRoom, - member: defaultMember, - startUpdating: jest.fn(), - stopUpdating: jest.fn(), - isUpdating: false, - }; - }); - - const renderComponent = (props = {}) => { - const Wrapper = (wrapperProps = {}) => { - return ; - }; - - return render(, { - 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("", () => { - const defaultMember = new RoomMember(defaultRoomId, defaultUserId); - defaultMember.membership = KnownMembership.Invite; - - let defaultProps: Parameters[0]; - beforeEach(() => { - defaultProps = { - room: mockRoom, - member: defaultMember, - isUpdating: false, - startUpdating: jest.fn(), - stopUpdating: jest.fn(), - powerLevels: {}, - }; - }); - - const renderComponent = (props = {}) => { - const Wrapper = (wrapperProps = {}) => { - return ; - }; - - return render(, { - wrapper: Wrapper, - }); - }; - - it("returns a single empty div if room.getMember is falsy", () => { - const { asFragment } = renderComponent(); - expect(asFragment()).toMatchInlineSnapshot(` - -
- - `); - }); - - 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(` - -
- - `); - }); - - 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); diff --git a/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx b/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx new file mode 100644 index 00000000000..30a4f78842b --- /dev/null +++ b/test/unit-tests/components/views/right_panel/UserInfoAdminToolsContainer-test.tsx @@ -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( + + + , + ); + }; + + 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( + +
Custom Child
+
, + ); + + 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(); + }); + }); +});