diff --git a/res/css/_components.scss b/res/css/_components.scss index 60428fd1938..0e197e0e509 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -74,6 +74,7 @@ @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; +@import "./views/dialogs/_ConfirmSpaceUserActionDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @@ -270,6 +271,7 @@ @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/spaces/_SpaceBasicSettings.scss"; +@import "./views/spaces/_SpaceChildrenPicker.scss"; @import "./views/spaces/_SpaceCreateMenu.scss"; @import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; diff --git a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.scss b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.scss new file mode 100644 index 00000000000..3b21ea74dec --- /dev/null +++ b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.scss @@ -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; + } + } +} diff --git a/res/css/views/dialogs/_ConfirmUserActionDialog.scss b/res/css/views/dialogs/_ConfirmUserActionDialog.scss index 5ac0f07b146..cdcb2b45875 100644 --- a/res/css/views/dialogs/_ConfirmUserActionDialog.scss +++ b/res/css/views/dialogs/_ConfirmUserActionDialog.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ConfirmUserActionDialog .mx_Dialog_content { +.mx_ConfirmUserActionDialog .mx_Dialog_content .mx_ConfirmUserActionDialog_user { min-height: 48px; margin-bottom: 24px; } @@ -22,10 +22,10 @@ limitations under the License. .mx_ConfirmUserActionDialog_avatar { float: left; margin-right: 20px; - margin-top: -2px; } .mx_ConfirmUserActionDialog_name { + padding-top: 2px; font-size: $font-18px; } @@ -37,16 +37,4 @@ limitations under the License. font-size: $font-14px; color: $primary-content; background-color: $background; - - border-radius: 3px; - border: solid 1px $input-border-color; - line-height: $font-36px; - padding-left: 16px; - padding-right: 16px; - padding-top: 1px; - padding-bottom: 1px; - - margin-bottom: 24px; - - width: 90%; } diff --git a/res/css/views/dialogs/_LeaveSpaceDialog.scss b/res/css/views/dialogs/_LeaveSpaceDialog.scss index 0d85a87faff..c0f24871c64 100644 --- a/res/css/views/dialogs/_LeaveSpaceDialog.scss +++ b/res/css/views/dialogs/_LeaveSpaceDialog.scss @@ -27,33 +27,13 @@ limitations under the License. display: flex; flex-direction: column; flex-wrap: nowrap; - max-height: 520px; + height: 520px; .mx_Dialog_content { flex-grow: 1; margin: 0; 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 { position: relative; border-radius: 8px; diff --git a/res/css/views/spaces/_SpaceChildrenPicker.scss b/res/css/views/spaces/_SpaceChildrenPicker.scss new file mode 100644 index 00000000000..e0fa6ef26da --- /dev/null +++ b/res/css/views/spaces/_SpaceChildrenPicker.scss @@ -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; + } +} diff --git a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx new file mode 100644 index 00000000000..1c5dd3fafa3 --- /dev/null +++ b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx @@ -0,0 +1,85 @@ +/* +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"; + +type BaseProps = ComponentProps; +interface IProps extends Omit { + space: Room; + allLabel: string; + specificLabel: string; + noneLabel?: string; + warningMessage?: string; + onFinished(success: boolean, reason?: string, rooms?: Room[]): void; + spaceChildFilter?(child: Room): boolean; +} + +const ConfirmSpaceUserActionDialog: React.FC = ({ + space, + spaceChildFilter, + allLabel, + specificLabel, + noneLabel, + warningMessage, + 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([]); + const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]); + + let warning: JSX.Element; + if (warningMessage) { + warning =
+ { warningMessage } +
; + } + + return ( + { + onFinished(success, reason, roomsToLeave); + }} + className="mx_ConfirmSpaceUserActionDialog" + > + { warning } + + + ); +}; + +export default ConfirmSpaceUserActionDialog; diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.tsx b/src/components/views/dialogs/ConfirmUserActionDialog.tsx index 7099556ac69..2f4e5116756 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.tsx +++ b/src/components/views/dialogs/ConfirmUserActionDialog.tsx @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ChangeEvent, ReactNode } from 'react'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import classNames from "classnames"; + import { _t } from '../../../languageHandler'; import { GroupMemberType } from '../../../groups'; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -25,12 +27,13 @@ import MemberAvatar from '../avatars/MemberAvatar'; import BaseAvatar from '../avatars/BaseAvatar'; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; +import Field from '../elements/Field'; interface IProps { // matrix-js-sdk (room) member object. Supply either this or 'groupMember' - member: RoomMember; + member?: RoomMember; // group member object. Supply either this or 'member' - groupMember: GroupMemberType; + groupMember?: GroupMemberType; // needed if a group member is specified matrixClient?: MatrixClient; action: string; // eg. 'Ban' @@ -41,9 +44,15 @@ interface IProps { // be the string entered. askReason?: boolean; danger?: boolean; + children?: ReactNode; + className?: string; onFinished: (success: boolean, reason?: string) => void; } +interface IState { + reason: string; +} + /* * A dialog for confirming an operation on another user. * Takes a user ID and a verb, displays the target user prominently @@ -53,37 +62,50 @@ interface IProps { * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ @replaceableComponent("views.dialogs.ConfirmUserActionDialog") -export default class ConfirmUserActionDialog extends React.Component { - private reasonField: React.RefObject = React.createRef(); - +export default class ConfirmUserActionDialog extends React.Component { static defaultProps = { danger: false, askReason: false, }; - public onOk = (): void => { - this.props.onFinished(true, this.reasonField.current?.value); + constructor(props: IProps) { + super(props); + + this.state = { + reason: "", + }; + } + + private onOk = (): void => { + this.props.onFinished(true, this.state.reason); }; - public onCancel = (): void => { + private onCancel = (): void => { this.props.onFinished(false); }; + private onReasonChange = (ev: ChangeEvent) => { + this.setState({ + reason: ev.target.value, + }); + }; + public render() { const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; if (this.props.askReason) { reasonBox = ( -
-
- -
-
+
+ + ); } @@ -105,19 +127,23 @@ export default class ConfirmUserActionDialog extends React.Component { return (
-
- { avatar } +
+
+ { avatar } +
+
{ name }
+
{ userId }
-
{ name }
-
{ userId }
+ + { reasonBox } + { this.props.children }
- { reasonBox } { - const [query, setQuery] = useState(""); - const lcQuery = query.toLowerCase().trim(); - - const filteredRooms = useMemo(() => { - if (!lcQuery) { - return rooms; - } - - const matcher = new QueryMatcher(rooms, { - keys: ["name"], - funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], - shouldMatchWordsOnly: false, - }); - - return matcher.match(lcQuery); - }, [rooms, lcQuery]); - - return
- - - { filteredRooms.map(room => { - return { - onChange(checked, room); - }} - />; - }) } - { filteredRooms.length < 1 ? - { _t("No results") } - : undefined } - -
; -}; - -const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => { - const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]); - const [state, setState] = useState(RoomsToLeave.None); - - useEffect(() => { - if (state === RoomsToLeave.All) { - setRoomsToLeave(spaceChildren); - } else { - setRoomsToLeave([]); - } - }, [setRoomsToLeave, state, spaceChildren]); - - return
- - - { state === RoomsToLeave.Specific && ( - { - if (selected) { - setRoomsToLeave([room, ...roomsToLeave]); - } else { - setRoomsToLeave(roomsToLeave.filter(r => r !== room)); - } - }} - /> - ) } -
; -}; +import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker"; interface IProps { space: Room; @@ -144,6 +43,7 @@ const isOnlyAdmin = (room: Room): boolean => { const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]); const [roomsToLeave, setRoomsToLeave] = useState([]); + const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]); let rejoinWarning; if (space.getJoinRule() !== JoinRule.Public) { @@ -180,12 +80,17 @@ const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { { spaceChildren.length > 0 && _t("Would you like to leave the rooms in this space?") }

- { spaceChildren.length > 0 && } + { spaceChildren.length > 0 && ( + + ) } { onlyAdminWarning &&
{ onlyAdminWarning } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 7de38b587fa..dcd2f68e37d 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -70,6 +70,8 @@ import { mediaFromMxc } from "../../../customisations/Media"; import UIStore from "../../../stores/UIStore"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import SpaceStore from "../../../stores/SpaceStore"; +import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialog"; +import { bulkSpaceBehaviour } from "../../../utils/space"; import { logger } from "matrix-js-sdk/src/logger"; @@ -532,7 +534,7 @@ interface IBaseProps { stopUpdating(): void; } -const RoomKickButton: React.FC = ({ member, startUpdating, stopUpdating }) => { +const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit) => { const cli = useContext(MatrixClientContext); // check if user can be kicked/disinvited @@ -542,21 +544,38 @@ const RoomKickButton: React.FC = ({ member, startUpdating, stopUpdat const { finished } = Modal.createTrackedDialog( 'Confirm User Action Dialog', 'onKick', - ConfirmUserActionDialog, + room.isSpaceRoom() ? ConfirmSpaceUserActionDialog : ConfirmUserActionDialog, { member, 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 from %(roomName)s", { roomName: room.name }) + : _t("Kick from %(roomName)s", { roomName: room.name }), askReason: member.membership === "join", 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"), + warningMessage: _t("They'll still be able to access whatever you're not an admin of."), }, + room.isSpaceRoom() ? "mx_ConfirmSpaceUserActionDialog_wrapper" : undefined, ); - const [proceed, reason] = await finished; + const [proceed, reason, rooms = []] = await finished; if (!proceed) return; 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 // get out of sync if we force setState here! logger.log("Kick success"); @@ -656,34 +675,69 @@ const RedactMessagesButton: React.FC = ({ member }) => { ; }; -const BanToggleButton: React.FC = ({ member, startUpdating, stopUpdating }) => { +const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit) => { const cli = useContext(MatrixClientContext); + const isBanned = member.membership === "ban"; const onBanOrUnban = async () => { const { finished } = Modal.createTrackedDialog( 'Confirm User Action Dialog', 'onBanOrUnban', - ConfirmUserActionDialog, + room.isSpaceRoom() ? ConfirmSpaceUserActionDialog : ConfirmUserActionDialog, { member, - action: member.membership === 'ban' ? _t("Unban") : _t("Ban"), - title: member.membership === 'ban' ? _t("Unban this user?") : _t("Ban this user?"), - askReason: member.membership !== 'ban', - danger: member.membership !== 'ban', + action: isBanned ? _t("Unban") : _t("Ban"), + title: isBanned + ? _t("Unban from %(roomName)s", { roomName: room.name }) + : _t("Ban from %(roomName)s", { roomName: room.name }), + askReason: !isBanned, + 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"), + warningMessage: isBanned + ? _t("They won't be able to access whatever you're not an admin of.") + : _t("They'll still be able to access whatever you're not an admin of."), }, + room.isSpaceRoom() ? "mx_ConfirmSpaceUserActionDialog_wrapper" : undefined, ); - const [proceed, reason] = await finished; + const [proceed, reason, rooms = []] = await finished; if (!proceed) return; startUpdating(); - let promise; - if (member.membership === 'ban') { - promise = cli.unban(member.roomId, member.userId); - } else { - promise = cli.ban(member.roomId, member.userId, reason || undefined); - } - promise.then(() => { + + const fn = (roomId: string) => { + 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"); @@ -699,12 +753,12 @@ const BanToggleButton: React.FC = ({ member, startUpdating, stopUpda }; let label = _t("Ban"); - if (member.membership === 'ban') { + if (isBanned) { label = _t("Unban"); } const classes = classNames("mx_UserInfo_field", { - mx_UserInfo_destructive: member.membership !== 'ban', + mx_UserInfo_destructive: !isBanned, }); return @@ -818,7 +872,12 @@ const RoomAdminToolsContainer: React.FC = ({ const canAffectUser = member.powerLevel < me.powerLevel || isMe; if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) { - kickButton = ; + kickButton = ; } if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) { redactButton = ( @@ -826,7 +885,12 @@ const RoomAdminToolsContainer: React.FC = ({ ); } if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) { - banButton = ; + banButton = ; } if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) { muteButton = ( diff --git a/src/components/views/spaces/SpaceChildrenPicker.tsx b/src/components/views/spaces/SpaceChildrenPicker.tsx new file mode 100644 index 00000000000..7b9e5534ca7 --- /dev/null +++ b/src/components/views/spaces/SpaceChildrenPicker.tsx @@ -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; + 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(rooms, { + keys: ["name"], + funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], + shouldMatchWordsOnly: false, + }); + + return matcher.match(lcQuery); + }, [rooms, lcQuery]); + + return
+ + + { filteredRooms.map(room => { + return { + onChange(checked, room); + }} + />; + }) } + { filteredRooms.length < 1 ? + { _t("No results") } + : undefined } + +
; +}; + +interface IProps { + space: Room; + spaceChildren: Room[]; + selected: Set; + noneLabel?: string; + allLabel: string; + specificLabel: string; + onChange(rooms: Room[]): void; +} + +const SpaceChildrenPicker = ({ + space, + spaceChildren, + selected, + onChange, + noneLabel, + allLabel, + specificLabel, +}: IProps) => { + const [state, setState] = useState(noneLabel ? Target.None : Target.All); + + useEffect(() => { + if (state === Target.All) { + onChange(spaceChildren); + } else { + onChange([]); + } + }, [onChange, state, spaceChildren]); + + return <> +
+ d.label)} + /> +
+ + { state === Target.Specific && ( + { + if (isSelected) { + onChange([room, ...selected]); + } else { + onChange([...selected].filter(r => r !== room)); + } + }} + /> + ) } + ; +}; + +export default SpaceChildrenPicker; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bdc86a9e083..80956f50d7b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1027,6 +1027,8 @@ "Upload": "Upload", "Name": "Name", "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", "Spaces are a new feature.": "Spaces are a new feature.", "Spaces feedback": "Spaces feedback", @@ -1858,8 +1860,11 @@ "Demote": "Demote", "Disinvite": "Disinvite", "Kick": "Kick", - "Disinvite this user?": "Disinvite this user?", - "Kick this user?": "Kick this user?", + "Disinvite from %(roomName)s": "Disinvite from %(roomName)s", + "Kick from %(roomName)s": "Kick from %(roomName)s", + "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", + "They'll still be able to access whatever you're not an admin of.": "They'll still be able to access whatever you're not an admin of.", "Failed to kick": "Failed to kick", "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.", @@ -1871,8 +1876,13 @@ "Remove %(count)s messages|one": "Remove 1 message", "Remove recent messages": "Remove recent messages", "Ban": "Ban", - "Unban this user?": "Unban this user?", - "Ban this user?": "Ban this user?", + "Unban from %(roomName)s": "Unban from %(roomName)s", + "Ban from %(roomName)s": "Ban from %(roomName)s", + "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", + "They won't be able to access whatever you're not an admin of.": "They won't be able to access whatever you're not an admin of.", "Failed to ban user": "Failed to ban user", "Failed to mute user": "Failed to mute user", "Unmute": "Unmute", @@ -2063,7 +2073,6 @@ "Application window": "Application window", "Share content": "Share content", "Join": "Join", - "No results": "No results", "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", "collapse": "collapse", "expand": "expand", @@ -2464,16 +2473,15 @@ "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!", "Updating %(brand)s": "Updating %(brand)s", - "Don't leave any rooms": "Don't leave any rooms", - "Leave all rooms": "Leave all rooms", - "Leave some rooms": "Leave some rooms", - "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'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.", "Leave %(spaceName)s": "Leave %(spaceName)s", "You are about to leave .": "You are about to leave .", "Would you like to leave the rooms in this space?": "Would you like to leave the rooms in this space?", + "Don't leave any rooms": "Don't leave any rooms", + "Leave all rooms": "Leave all rooms", + "Leave some rooms": "Leave some rooms", "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.", "Start using Key Backup": "Start using Key Backup", diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 5bbae369e74..aba9883ae74 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -155,20 +155,28 @@ export const showCreateNewSubspace = (space: Room): void => { ); }; +export const bulkSpaceBehaviour = async ( + space: Room, + children: Room[], + fn: (room: Room) => Promise, +): Promise => { + 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) => { Modal.createTrackedDialog("Leave Space", "", LeaveSpaceDialog, { space, onFinished: async (leave: boolean, rooms: Room[]) => { if (!leave) return; - const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner"); - try { - for (const room of rooms) { - await leaveRoomBehaviour(room.roomId); - } - await leaveRoomBehaviour(space.roomId); - } finally { - modal.close(); - } + await bulkSpaceBehaviour(space, rooms, room => leaveRoomBehaviour(room.roomId)); dis.dispatch({ action: "after_leave_room",