Skip to content

Mvvm split user info, create userinfoadmintools container component #29808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/

import { logger } from "@sentry/browser";
import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";

import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import { bulkSpaceBehaviour } from "../../../../../utils/space";
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";

export interface BanButtonState {
/**
* The function to call when the button is clicked
*/
onBanOrUnbanClick: () => Promise<void>;
/**
* The label of the ban button can be ban or unban
*/
banLabel: string;
}
/**
* The view model for the room ban button used in the UserInfoAdminToolsContainer
* @param {RoomAdminToolsProps} props - the object containing the necceray props for banButton the view model
* @param {Room} props.room - the room to ban/unban the user in
* @param {RoomMember} props.member - the member to ban/unban
* @param {boolean} props.isUpdating - whether the operation is currently in progress
* @param {function} props.startUpdating - callback function to start the operation
* @param {function} props.stopUpdating - callback function to stop the operation
* @returns {BanButtonState} the room ban/unban button state
*/
export const useBanButtonViewModel = (props: RoomAdminToolsProps): BanButtonState => {
const { isUpdating, startUpdating, stopUpdating, room, member } = props;

const cli = useMatrixClientContext();

const isBanned = member.membership === KnownMembership.Ban;

let banLabel = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
if (isBanned) {
banLabel = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
}

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

const commonProps = {
member,
action: room.isSpaceRoom()
? isBanned
? _t("user_info|unban_button_space")
: _t("user_info|ban_button_space")
: isBanned
? _t("user_info|unban_button_room")
: _t("user_info|ban_button_room"),
title: isBanned
? _t("user_info|unban_room_confirm_title", { roomName: room.name })
: _t("user_info|ban_room_confirm_title", { roomName: room.name }),
askReason: !isBanned,
danger: !isBanned,
};

let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;

if (room.isSpaceRoom()) {
({ finished } = Modal.createDialog(
ConfirmSpaceUserActionDialog,
{
...commonProps,
space: room,
spaceChildFilter: isBanned
? (child: Room) => {
// Return true if the target member is banned and we have sufficient PL to unban
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership === KnownMembership.Ban &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
);
}
: (child: Room) => {
// Return true if the target member isn't banned and we have sufficient PL to ban
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership !== KnownMembership.Ban &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
);
},
allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
},
"mx_ConfirmSpaceUserActionDialog_wrapper",
));
} else {
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
}

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

const fn = (roomId: string): Promise<unknown> => {
if (isBanned) {
return cli.unban(roomId, member.userId);
} else {
return cli.ban(roomId, member.userId, reason || undefined);
}
};

bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.info("Ban success");
},
function (err) {
logger.error("Ban error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("user_info|error_ban_user"),
});
},
)
.finally(() => {
stopUpdating();
});
};

return {
onBanOrUnbanClick,
banLabel,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/

import { logger } from "@sentry/browser";
import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";

import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import { bulkSpaceBehaviour } from "../../../../../utils/space";
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";

interface RoomKickButtonState {
/**
* The function to call when the button is clicked
*/
onKickClick: () => Promise<void>;
/**
* Whether the user can be kicked based on membership value. If the user already join or was invited, it can be kicked
*/
canUserBeKicked: boolean;
/**
* The label of the kick button can be kick or disinvite
*/
kickLabel: string;
}

/**
* The view model for the room kick button used in the UserInfoAdminToolsContainer
* @param {RoomAdminToolsProps} props - the object containing the necceray props for kickButton the view model
* @param {Room} props.room - the room to kick/disinvite the user from
* @param {RoomMember} props.member - the member to kick/disinvite
* @param {boolean} props.isUpdating - whether the operation is currently in progress
* @param {function} props.startUpdating - callback function to start the operation
* @param {function} props.stopUpdating - callback function to stop the operation
* @returns {KickButtonState} the room kick/disinvite button state
*/
export function useRoomKickButtonViewModel(props: RoomAdminToolsProps): RoomKickButtonState {
const { isUpdating, startUpdating, stopUpdating, room, member } = props;

const cli = useMatrixClientContext();

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

const commonProps = {
member,
action: room.isSpaceRoom()
? member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_space")
: _t("user_info|kick_button_space")
: member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room")
: _t("user_info|kick_button_room"),
title:
member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room_name", { roomName: room.name })
: _t("user_info|kick_button_room_name", { roomName: room.name }),
askReason: member.membership === KnownMembership.Join,
danger: true,
};

let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;

if (room.isSpaceRoom()) {
({ finished } = Modal.createDialog(
ConfirmSpaceUserActionDialog,
{
...commonProps,
space: room,
spaceChildFilter: (child: Room) => {
// Return true if the target member is not banned and we have sufficient PL to ban them
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership === member.membership &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
);
},
allLabel: _t("user_info|kick_button_space_everything"),
specificLabel: _t("user_info|kick_space_specific"),
warningMessage: _t("user_info|kick_space_warning"),
},
"mx_ConfirmSpaceUserActionDialog_wrapper",
));
} else {
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
}

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

bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.info("Kick success");
},
function (err) {
logger.error("Kick error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_kicking_user"),
description: err?.message ?? "Operation failed",
});
},
)
.finally(() => {
stopUpdating();
});
};

const canUserBeKicked = member.membership === KnownMembership.Invite || member.membership === KnownMembership.Join;

const kickLabel = room.isSpaceRoom()
? member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_space")
: _t("user_info|kick_button_space")
: member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room")
: _t("user_info|kick_button_room");

return {
onKickClick,
canUserBeKicked,
kickLabel,
};
}
Loading
Loading