Allow options to cascade kicks/bans throughout spaces

This commit is contained in:
Michael Telatynski 2021-09-17 15:34:49 +01:00
parent cf8100ad17
commit 1858c63c4a
12 changed files with 481 additions and 177 deletions

View file

@ -1,8 +1,10 @@
// autogenerated by rethemendex.sh // autogenerated by rethemendex.sh
@import "./_animations.scss";
@import "./_common.scss"; @import "./_common.scss";
@import "./_font-sizes.scss"; @import "./_font-sizes.scss";
@import "./_font-weights.scss"; @import "./_font-weights.scss";
@import "./structures/_AutoHideScrollbar.scss"; @import "./structures/_AutoHideScrollbar.scss";
@import "./structures/_BackdropPanel.scss";
@import "./structures/_CompatibilityPage.scss"; @import "./structures/_CompatibilityPage.scss";
@import "./structures/_ContextualMenu.scss"; @import "./structures/_ContextualMenu.scss";
@import "./structures/_CreateRoom.scss"; @import "./structures/_CreateRoom.scss";
@ -17,7 +19,6 @@
@import "./structures/_LeftPanelWidget.scss"; @import "./structures/_LeftPanelWidget.scss";
@import "./structures/_MainSplit.scss"; @import "./structures/_MainSplit.scss";
@import "./structures/_MatrixChat.scss"; @import "./structures/_MatrixChat.scss";
@import "./structures/_BackdropPanel.scss";
@import "./structures/_MyGroups.scss"; @import "./structures/_MyGroups.scss";
@import "./structures/_NonUrgentToastContainer.scss"; @import "./structures/_NonUrgentToastContainer.scss";
@import "./structures/_NotificationPanel.scss"; @import "./structures/_NotificationPanel.scss";
@ -72,6 +73,7 @@
@import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; @import "./views/dialogs/_CommunityPrototypeInviteDialog.scss";
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.scss";
@import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss";
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
@import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss";
@ -266,6 +268,7 @@
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss";
@import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss";
@import "./views/spaces/_SpaceBasicSettings.scss"; @import "./views/spaces/_SpaceBasicSettings.scss";
@import "./views/spaces/_SpaceChildrenPicker.scss";
@import "./views/spaces/_SpaceCreateMenu.scss"; @import "./views/spaces/_SpaceCreateMenu.scss";
@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/spaces/_SpacePublicShare.scss";
@import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/terms/_InlineTermsAgreement.scss";

View file

@ -0,0 +1,66 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ConfirmSpaceUserActionDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
padding: 24px 32px;
}
}
.mx_ConfirmSpaceUserActionDialog {
width: 440px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
height: 520px;
.mx_Dialog_content {
margin: 12px 0;
flex-grow: 1;
overflow-y: auto;
}
.mx_ConfirmUserActionDialog_reasonField {
margin-bottom: 12px;
}
.mx_ConfirmSpaceUserActionDialog_warning {
position: relative;
border-radius: 8px;
padding: 12px 8px 12px 42px;
background-color: $header-panel-bg-color;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-content;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-content;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
}
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_ConfirmUserActionDialog .mx_Dialog_content { .mx_ConfirmUserActionDialog .mx_Dialog_content .mx_ConfirmUserActionDialog_user {
min-height: 48px; min-height: 48px;
margin-bottom: 24px; margin-bottom: 24px;
} }

View file

@ -27,33 +27,13 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
max-height: 520px; height: 520px;
.mx_Dialog_content { .mx_Dialog_content {
flex-grow: 1; flex-grow: 1;
margin: 0; margin: 0;
overflow-y: auto; overflow-y: auto;
.mx_RadioButton + .mx_RadioButton {
margin-top: 16px;
}
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
border-radius: 8px;
}
.mx_LeaveSpaceDialog_noResults {
display: block;
margin-top: 24px;
}
.mx_LeaveSpaceDialog_section {
margin: 16px 0;
}
.mx_LeaveSpaceDialog_section_warning { .mx_LeaveSpaceDialog_section_warning {
position: relative; position: relative;
border-radius: 8px; border-radius: 8px;

View file

@ -0,0 +1,35 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_SpaceChildrenPicker {
margin: 16px 0;
.mx_RadioButton + .mx_RadioButton {
margin-top: 16px;
}
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
border-radius: 8px;
}
.mx_SpaceChildrenPicker_noResults {
display: block;
margin-top: 24px;
}
}

View file

@ -0,0 +1,86 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps, useMemo, useState } from 'react';
import ConfirmUserActionDialog from "./ConfirmUserActionDialog";
import SpaceStore from "../../../stores/SpaceStore";
import { Room } from "matrix-js-sdk/src/models/room";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
import { _t } from '../../../languageHandler';
type BaseProps = ComponentProps<typeof ConfirmUserActionDialog>;
interface IProps extends Omit<BaseProps, "groupMember" | "matrixClient" | "children" | "onFinished"> {
space: Room;
allLabel: string;
specificLabel: string;
noneLabel?: string;
onFinished(success: boolean, reason?: string, rooms?: Room[]): void;
spaceChildFilter?(child: Room): boolean;
}
const ConfirmSpaceUserActionDialog: React.FC<IProps> = ({
space,
spaceChildFilter,
allLabel,
specificLabel,
noneLabel,
onFinished,
...props
}) => {
const spaceChildren = useMemo(() => {
const children = SpaceStore.instance.getChildren(space.roomId);
if (spaceChildFilter) {
return children.filter(spaceChildFilter);
}
return children;
}, [space.roomId, spaceChildFilter]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
let warning: JSX.Element;
if (!spaceChildren.length) {
warning = <div className="mx_ConfirmSpaceUserActionDialog_warning">
{ _t("Youre not an admin of anything theyre a member of in <SpaceName/>, " +
"so banning wont remove them from any rooms or spaces in <SpaceName/>.", {}, {
SpaceName: () => <b>{ space.name }</b>,
}) }
</div>;
}
return (
<ConfirmUserActionDialog
{...props}
onFinished={(success: boolean, reason?: string) => {
onFinished(success, reason, roomsToLeave);
}}
className="mx_ConfirmSpaceUserActionDialog"
>
{ warning }
<SpaceChildrenPicker
space={space}
spaceChildren={spaceChildren}
selected={selectedRooms}
allLabel={allLabel}
specificLabel={specificLabel}
noneLabel={noneLabel}
onChange={setRoomsToLeave}
/>
</ConfirmUserActionDialog>
);
};
export default ConfirmSpaceUserActionDialog;

View file

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ReactNode } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import classNames from "classnames";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups'; import { GroupMemberType } from '../../../groups';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -28,9 +30,9 @@ import DialogButtons from "../elements/DialogButtons";
interface IProps { interface IProps {
// matrix-js-sdk (room) member object. Supply either this or 'groupMember' // matrix-js-sdk (room) member object. Supply either this or 'groupMember'
member: RoomMember; member?: RoomMember;
// group member object. Supply either this or 'member' // group member object. Supply either this or 'member'
groupMember: GroupMemberType; groupMember?: GroupMemberType;
// needed if a group member is specified // needed if a group member is specified
matrixClient?: MatrixClient; matrixClient?: MatrixClient;
action: string; // eg. 'Ban' action: string; // eg. 'Ban'
@ -41,6 +43,8 @@ interface IProps {
// be the string entered. // be the string entered.
askReason?: boolean; askReason?: boolean;
danger?: boolean; danger?: boolean;
children?: ReactNode;
className?: string;
onFinished: (success: boolean, reason?: string) => void; onFinished: (success: boolean, reason?: string) => void;
} }
@ -105,19 +109,23 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
return ( return (
<BaseDialog <BaseDialog
className="mx_ConfirmUserActionDialog" className={classNames("mx_ConfirmUserActionDialog", this.props.className)}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >
<div id="mx_Dialog_content" className="mx_Dialog_content"> <div id="mx_Dialog_content" className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_avatar"> <div className="mx_ConfirmUserActionDialog_user">
{ avatar } <div className="mx_ConfirmUserActionDialog_avatar">
{ avatar }
</div>
<div className="mx_ConfirmUserActionDialog_name">{ name }</div>
<div className="mx_ConfirmUserActionDialog_userId">{ userId }</div>
</div> </div>
<div className="mx_ConfirmUserActionDialog_name">{ name }</div>
<div className="mx_ConfirmUserActionDialog_userId">{ userId }</div> { reasonBox }
{ this.props.children }
</div> </div>
{ reasonBox }
<DialogButtons primaryButton={this.props.action} <DialogButtons primaryButton={this.props.action}
onPrimaryButtonClick={this.onOk} onPrimaryButtonClick={this.onOk}
primaryButtonClass={confirmButtonClass} primaryButtonClass={confirmButtonClass}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useEffect, useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { JoinRule } from "matrix-js-sdk/src/@types/partials";
@ -22,108 +22,7 @@ import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog"; import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
import { Entry } from "./AddExistingToSpaceDialog";
import SearchBox from "../../structures/SearchBox";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import StyledRadioGroup from "../elements/StyledRadioGroup";
enum RoomsToLeave {
All = "All",
Specific = "Specific",
None = "None",
}
const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const filteredRooms = useMemo(() => {
if (!lcQuery) {
return rooms;
}
const matcher = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return matcher.match(lcQuery);
}, [rooms, lcQuery]);
return <div className="mx_LeaveSpaceDialog_section">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
{ filteredRooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
{ filteredRooms.length < 1 ? <span className="mx_LeaveSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => {
if (state === RoomsToLeave.All) {
setRoomsToLeave(spaceChildren);
} else {
setRoomsToLeave([]);
}
}, [setRoomsToLeave, state, spaceChildren]);
return <div className="mx_LeaveSpaceDialog_section">
<StyledRadioGroup
name="roomsToLeave"
value={state}
onChange={setState}
definitions={[
{
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),
},
]}
/>
{ state === RoomsToLeave.Specific && (
<SpaceChildPicker
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
rooms={spaceChildren}
selected={selected}
onChange={(selected: boolean, room: Room) => {
if (selected) {
setRoomsToLeave([room, ...roomsToLeave]);
} else {
setRoomsToLeave(roomsToLeave.filter(r => r !== room));
}
}}
/>
) }
</div>;
};
interface IProps { interface IProps {
space: Room; space: Room;
@ -139,6 +38,7 @@ const isOnlyAdmin = (room: Room): boolean => {
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => { const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]); const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]); const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
let rejoinWarning; let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) { if (space.getJoinRule() !== JoinRule.Public) {
@ -173,12 +73,17 @@ const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
{ rejoinWarning } { rejoinWarning }
</p> </p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker { spaceChildren.length > 0 && (
space={space} <SpaceChildrenPicker
spaceChildren={spaceChildren} space={space}
roomsToLeave={roomsToLeave} spaceChildren={spaceChildren}
setRoomsToLeave={setRoomsToLeave} selected={selectedRooms}
/> } onChange={setRoomsToLeave}
noneLabel={_t("Don't leave any")}
allLabel={_t("Leave all rooms and spaces")}
specificLabel={_t("Leave specific rooms and spaces")}
/>
) }
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning"> { onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
{ onlyAdminWarning } { onlyAdminWarning }

View file

@ -70,6 +70,8 @@ import { mediaFromMxc } from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import SpaceStore from "../../../stores/SpaceStore"; import SpaceStore from "../../../stores/SpaceStore";
import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialog";
import { bulkSpaceBehaviour } from "../../../utils/space";
export interface IDevice { export interface IDevice {
deviceId: string; deviceId: string;
@ -530,7 +532,7 @@ interface IBaseProps {
stopUpdating(): void; stopUpdating(): void;
} }
const RoomKickButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpdating }) => { const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
// check if user can be kicked/disinvited // check if user can be kicked/disinvited
@ -540,21 +542,35 @@ const RoomKickButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpdat
const { finished } = Modal.createTrackedDialog( const { finished } = Modal.createTrackedDialog(
'Confirm User Action Dialog', 'Confirm User Action Dialog',
'onKick', 'onKick',
ConfirmUserActionDialog, room.isSpaceRoom() ? ConfirmSpaceUserActionDialog : ConfirmUserActionDialog,
{ {
member, member,
action: member.membership === "invite" ? _t("Disinvite") : _t("Kick"), action: member.membership === "invite" ? _t("Disinvite") : _t("Kick"),
title: member.membership === "invite" ? _t("Disinvite this user?") : _t("Kick this user?"), title: member.membership === "invite" ? _t("Disinvite this user?") : _t("Kick this user?"),
askReason: member.membership === "join", askReason: member.membership === "join",
danger: true, danger: true,
// space-specific props
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("Kick them from everything I'm able to"),
specificLabel: _t("Kick them from specific things I'm able to"),
}, },
room.isSpaceRoom() ? "mx_ConfirmSpaceUserActionDialog_wrapper" : undefined,
); );
const [proceed, reason] = await finished; const [proceed, reason, rooms = []] = await finished;
if (!proceed) return; if (!proceed) return;
startUpdating(); startUpdating();
cli.kick(member.roomId, member.userId, reason || undefined).then(() => {
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 // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Kick success"); console.log("Kick success");
@ -654,34 +670,64 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
</AccessibleButton>; </AccessibleButton>;
}; };
const BanToggleButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpdating }) => { const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const isBanned = member.membership === "ban";
const onBanOrUnban = async () => { const onBanOrUnban = async () => {
const { finished } = Modal.createTrackedDialog( const { finished } = Modal.createTrackedDialog(
'Confirm User Action Dialog', 'Confirm User Action Dialog',
'onBanOrUnban', 'onBanOrUnban',
ConfirmUserActionDialog, room.isSpaceRoom() ? ConfirmSpaceUserActionDialog : ConfirmUserActionDialog,
{ {
member, member,
action: member.membership === 'ban' ? _t("Unban") : _t("Ban"), action: isBanned ? _t("Unban") : _t("Ban"),
title: member.membership === 'ban' ? _t("Unban this user?") : _t("Ban this user?"), title: isBanned ? _t("Unban this user?") : _t("Ban this user?"),
askReason: member.membership !== 'ban', askReason: !isBanned,
danger: member.membership !== 'ban', danger: !isBanned,
// space-specific props
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 === "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 !== "ban" &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel);
},
allLabel: isBanned
? _t("Unban them from everything I'm able to")
: _t("Ban them from everything I'm able to"),
specificLabel: isBanned
? _t("Unban them from specific things I'm able to")
: _t("Ban them from specific things I'm able to"),
}, },
room.isSpaceRoom() ? "mx_ConfirmSpaceUserActionDialog_wrapper" : undefined,
); );
const [proceed, reason] = await finished; const [proceed, reason, rooms = []] = await finished;
if (!proceed) return; if (!proceed) return;
startUpdating(); startUpdating();
let promise;
if (member.membership === 'ban') { const fn = (roomId: string) => {
promise = cli.unban(member.roomId, member.userId); if (isBanned) {
} else { return cli.unban(roomId, member.userId);
promise = cli.ban(member.roomId, member.userId, reason || undefined); } else {
} return cli.ban(roomId, member.userId, reason || undefined);
promise.then(() => { }
};
bulkSpaceBehaviour(room, rooms, room => fn(room.roomId)).then(() => {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Ban success"); console.log("Ban success");
@ -697,12 +743,12 @@ const BanToggleButton: React.FC<IBaseProps> = ({ member, startUpdating, stopUpda
}; };
let label = _t("Ban"); let label = _t("Ban");
if (member.membership === 'ban') { if (isBanned) {
label = _t("Unban"); label = _t("Unban");
} }
const classes = classNames("mx_UserInfo_field", { const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: member.membership !== 'ban', mx_UserInfo_destructive: !isBanned,
}); });
return <AccessibleButton className={classes} onClick={onBanOrUnban}> return <AccessibleButton className={classes} onClick={onBanOrUnban}>
@ -816,7 +862,12 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
const canAffectUser = member.powerLevel < me.powerLevel || isMe; const canAffectUser = member.powerLevel < me.powerLevel || isMe;
if (canAffectUser && me.powerLevel >= kickPowerLevel) { if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; kickButton = <RoomKickButton
room={room}
member={member}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>;
} }
if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) { if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
redactButton = ( redactButton = (
@ -824,7 +875,12 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
); );
} }
if (canAffectUser && me.powerLevel >= banPowerLevel) { if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; banButton = <BanToggleButton
room={room}
member={member}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>;
} }
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) { if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = ( muteButton = (

View file

@ -0,0 +1,150 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import SearchBox from "../../structures/SearchBox";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { Entry } from "../dialogs/AddExistingToSpaceDialog";
enum Target {
All = "All",
Specific = "Specific",
None = "None",
}
interface ISpecificChildrenPickerProps {
filterPlaceholder: string;
rooms: Room[];
selected: Set<Room>;
onChange(selected: boolean, room: Room): void;
}
const SpecificChildrenPicker = ({ filterPlaceholder, rooms, selected, onChange }: ISpecificChildrenPickerProps) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const filteredRooms = useMemo(() => {
if (!lcQuery) {
return rooms;
}
const matcher = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return matcher.match(lcQuery);
}, [rooms, lcQuery]);
return <div className="mx_SpaceChildrenPicker">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoFocus={true}
/>
<AutoHideScrollbar>
{ filteredRooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
{ filteredRooms.length < 1 ? <span className="mx_SpaceChildrenPicker_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
interface IProps {
space: Room;
spaceChildren: Room[];
selected: Set<Room>;
noneLabel?: string;
allLabel: string;
specificLabel: string;
onChange(rooms: Room[]): void;
}
const SpaceChildrenPicker = ({
space,
spaceChildren,
selected,
onChange,
noneLabel,
allLabel,
specificLabel,
}: IProps) => {
const [state, setState] = useState<string>(noneLabel ? Target.None : Target.All);
useEffect(() => {
if (state === Target.All) {
onChange(spaceChildren);
} else {
onChange([]);
}
}, [onChange, state, spaceChildren]);
return <>
<div className="mx_SpaceChildrenPicker">
<StyledRadioGroup
name="roomsToLeave"
value={state}
onChange={setState}
definitions={[
{
value: Target.None,
label: noneLabel,
}, {
value: Target.All,
label: allLabel,
}, {
value: Target.Specific,
label: specificLabel,
},
].filter(d => d.label)}
/>
</div>
{ state === Target.Specific && (
<SpecificChildrenPicker
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
rooms={spaceChildren}
selected={selected}
onChange={(isSelected: boolean, room: Room) => {
if (isSelected) {
onChange([room, ...selected]);
} else {
onChange([...selected].filter(r => r !== room));
}
}}
/>
) }
</>;
};
export default SpaceChildrenPicker;

View file

@ -1018,6 +1018,8 @@
"Upload": "Upload", "Upload": "Upload",
"Name": "Name", "Name": "Name",
"Description": "Description", "Description": "Description",
"No results": "No results",
"Search %(spaceName)s": "Search %(spaceName)s",
"Please enter a name for the space": "Please enter a name for the space", "Please enter a name for the space": "Please enter a name for the space",
"Spaces are a new feature.": "Spaces are a new feature.", "Spaces are a new feature.": "Spaces are a new feature.",
"Spaces feedback": "Spaces feedback", "Spaces feedback": "Spaces feedback",
@ -1847,6 +1849,8 @@
"Kick": "Kick", "Kick": "Kick",
"Disinvite this user?": "Disinvite this user?", "Disinvite this user?": "Disinvite this user?",
"Kick this user?": "Kick this user?", "Kick this user?": "Kick this user?",
"Kick them from everything I'm able to": "Kick them from everything I'm able to",
"Kick them from specific things I'm able to": "Kick them from specific things I'm able to",
"Failed to kick": "Failed to kick", "Failed to kick": "Failed to kick",
"No recent messages by %(user)s found": "No recent messages by %(user)s found", "No recent messages by %(user)s found": "No recent messages by %(user)s found",
"Try scrolling up in the timeline to see if there are any earlier ones.": "Try scrolling up in the timeline to see if there are any earlier ones.", "Try scrolling up in the timeline to see if there are any earlier ones.": "Try scrolling up in the timeline to see if there are any earlier ones.",
@ -1860,6 +1864,10 @@
"Ban": "Ban", "Ban": "Ban",
"Unban this user?": "Unban this user?", "Unban this user?": "Unban this user?",
"Ban this user?": "Ban this user?", "Ban this user?": "Ban this user?",
"Unban them from everything I'm able to": "Unban them from everything I'm able to",
"Ban them from everything I'm able to": "Ban them from everything I'm able to",
"Unban them from specific things I'm able to": "Unban them from specific things I'm able to",
"Ban them from specific things I'm able to": "Ban them from specific things I'm able to",
"Failed to ban user": "Failed to ban user", "Failed to ban user": "Failed to ban user",
"Failed to mute user": "Failed to mute user", "Failed to mute user": "Failed to mute user",
"Unmute": "Unmute", "Unmute": "Unmute",
@ -2050,7 +2058,6 @@
"Application window": "Application window", "Application window": "Application window",
"Share content": "Share content", "Share content": "Share content",
"Join": "Join", "Join": "Join",
"No results": "No results",
"Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.": "Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.", "Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.": "Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.",
"collapse": "collapse", "collapse": "collapse",
"expand": "expand", "expand": "expand",
@ -2217,6 +2224,7 @@
"Confirm Removal": "Confirm Removal", "Confirm Removal": "Confirm Removal",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.",
"Reason (optional)": "Reason (optional)", "Reason (optional)": "Reason (optional)",
"Youre not an admin of anything theyre a member of in <SpaceName/>, so banning wont remove them from any rooms or spaces in <SpaceName/>.": "Youre not an admin of anything theyre a member of in <SpaceName/>, so banning wont remove them from any rooms or spaces in <SpaceName/>.",
"Clear all data in this session?": "Clear all data in this session?", "Clear all data in this session?": "Clear all data in this session?",
"Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.", "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.",
"Clear all data": "Clear all data", "Clear all data": "Clear all data",
@ -2430,15 +2438,14 @@
"Clear cache and resync": "Clear cache and resync", "Clear cache and resync": "Clear cache and resync",
"%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!", "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!",
"Updating %(brand)s": "Updating %(brand)s", "Updating %(brand)s": "Updating %(brand)s",
"Don't leave any": "Don't leave any",
"Leave all rooms and spaces": "Leave all rooms and spaces",
"Leave specific rooms and spaces": "Leave specific rooms and spaces",
"Search %(spaceName)s": "Search %(spaceName)s",
"You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.", "You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.",
"You're the only admin of this space. Leaving it will mean no one has control over it.": "You're the only admin of this space. Leaving it will mean no one has control over it.", "You're the only admin of this space. Leaving it will mean no one has control over it.": "You're the only admin of this space. Leaving it will mean no one has control over it.",
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.", "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.",
"Leave %(spaceName)s": "Leave %(spaceName)s", "Leave %(spaceName)s": "Leave %(spaceName)s",
"Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?", "Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?",
"Don't leave any": "Don't leave any",
"Leave all rooms and spaces": "Leave all rooms and spaces",
"Leave specific rooms and spaces": "Leave specific rooms and spaces",
"Leave space": "Leave space", "Leave space": "Leave space",
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.",
"Start using Key Backup": "Start using Key Backup", "Start using Key Backup": "Start using Key Backup",

View file

@ -155,20 +155,28 @@ export const showCreateNewSubspace = (space: Room): void => {
); );
}; };
export const bulkSpaceBehaviour = async (
space: Room,
children: Room[],
fn: (room: Room) => Promise<unknown>,
): Promise<void> => {
const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner");
try {
for (const room of children) {
await fn(room);
}
await fn(space);
} finally {
modal.close();
}
};
export const leaveSpace = (space: Room) => { export const leaveSpace = (space: Room) => {
Modal.createTrackedDialog("Leave Space", "", LeaveSpaceDialog, { Modal.createTrackedDialog("Leave Space", "", LeaveSpaceDialog, {
space, space,
onFinished: async (leave: boolean, rooms: Room[]) => { onFinished: async (leave: boolean, rooms: Room[]) => {
if (!leave) return; if (!leave) return;
const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner"); await bulkSpaceBehaviour(space, rooms, room => leaveRoomBehaviour(room.roomId));
try {
for (const room of rooms) {
await leaveRoomBehaviour(room.roomId);
}
await leaveRoomBehaviour(space.roomId);
} finally {
modal.close();
}
dis.dispatch({ dis.dispatch({
action: "after_leave_room", action: "after_leave_room",