diff --git a/res/css/_components.scss b/res/css/_components.scss index 014c295025..76551b51f8 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -88,6 +88,7 @@ @import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_JoinRuleDropdown.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; +@import "./views/dialogs/_LeaveSpaceDialog.scss"; @import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss"; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index b299198349..42e17c8d98 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -50,35 +50,6 @@ limitations under the License. line-height: $font-15px; } - .mx_AddExistingToSpace_entry { - display: flex; - margin-top: 12px; - - .mx_DecoratedRoomAvatar, // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling - .mx_BaseAvatar.mx_RoomAvatar_isSpaceRoom { - margin-right: 12px; - } - - img.mx_RoomAvatar_isSpaceRoom, - .mx_RoomAvatar_isSpaceRoom img { - border-radius: 8px; - } - - .mx_AddExistingToSpace_entry_name { - font-size: $font-15px; - line-height: 30px; - flex-grow: 1; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin-right: 12px; - } - - .mx_Checkbox { - align-items: center; - } - } - .mx_AccessibleButton_kind_link { font-size: $font-12px; line-height: $font-15px; @@ -255,3 +226,32 @@ limitations under the License. line-height: $font-24px; } } + +.mx_AddExistingToSpace_entry { + display: flex; + margin-top: 12px; + + .mx_DecoratedRoomAvatar, // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_BaseAvatar.mx_RoomAvatar_isSpaceRoom { + margin-right: 12px; + } + + img.mx_RoomAvatar_isSpaceRoom, + .mx_RoomAvatar_isSpaceRoom img { + border-radius: 8px; + } + + .mx_AddExistingToSpace_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + + .mx_Checkbox { + align-items: center; + } +} diff --git a/res/css/views/dialogs/_LeaveSpaceDialog.scss b/res/css/views/dialogs/_LeaveSpaceDialog.scss new file mode 100644 index 0000000000..c982f50e52 --- /dev/null +++ b/res/css/views/dialogs/_LeaveSpaceDialog.scss @@ -0,0 +1,96 @@ +/* +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_LeaveSpaceDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + padding: 24px 32px; + } +} + +.mx_LeaveSpaceDialog { + width: 440px; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + max-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; + margin: 12px 0 0; + padding: 12px 8px 12px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + > p { + color: $primary-fg-color; + } + } + + .mx_Dialog_buttons { + margin-top: 20px; + + .mx_Dialog_primary { + background-color: $notice-primary-color !important; // override default colour + border-color: $notice-primary-color; + } + } +} diff --git a/src/components/views/dialogs/LeaveSpaceDialog.tsx b/src/components/views/dialogs/LeaveSpaceDialog.tsx new file mode 100644 index 0000000000..6e1e798e9d --- /dev/null +++ b/src/components/views/dialogs/LeaveSpaceDialog.tsx @@ -0,0 +1,197 @@ +/* +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 { JoinRule } from "matrix-js-sdk/src/@types/partials"; + +import { _t } from '../../../languageHandler'; +import DialogButtons from "../elements/DialogButtons"; +import BaseDialog from "../dialogs/BaseDialog"; +import SpaceStore from "../../../stores/SpaceStore"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +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(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.All); + + 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)); + } + }} + /> + ) } +
; +}; + +interface IProps { + space: Room; + onFinished(leave: boolean, rooms?: Room[]): void; +} + +const isOnlyAdmin = (room: Room): boolean => { + return !room.getJoinedMembers().some(member => { + return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100; + }); +}; + +const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { + const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]); + const [roomsToLeave, setRoomsToLeave] = useState([]); + + let rejoinWarning; + if (space.getJoinRule() !== JoinRule.Public) { + rejoinWarning = _t("You won't be able to rejoin unless you are re-invited."); + } + + let onlyAdminWarning; + if (isOnlyAdmin(space)) { + onlyAdminWarning = _t("You're the only admin of this space. " + + "Leaving it will mean no one has control over it."); + } else { + const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length; + if (numChildrenOnlyAdminIn > 0) { + onlyAdminWarning = _t("You're the only admin of some of the rooms or spaces you wish to leave. " + + "Leaving them will leave them without any admins."); + } + } + + return onFinished(false)} + fixedWidth={false} + > +
+

+ { _t("Are you sure you want to leave ?", {}, { + spaceName: () => { space.name }, + }) } +   + { rejoinWarning } +

+ + { spaceChildren.length > 0 && } + + { onlyAdminWarning &&
+ { onlyAdminWarning } +
} +
+ onFinished(true, roomsToLeave)} + hasCancel={true} + onCancel={onFinished} + /> +
; +}; + +export default LeaveSpaceDialog; diff --git a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx index 8ee848a28c..595bdb2448 100644 --- a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx +++ b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx @@ -25,7 +25,7 @@ import SpaceBasicSettings from "./SpaceBasicSettings"; import { avatarUrlForRoom } from "../../../Avatar"; import { IDialogProps } from "../dialogs/IDialogProps"; import { getTopic } from "../elements/RoomTopic"; -import { defaultDispatcher } from "../../../dispatcher/dispatcher"; +import { leaveSpace } from "../../../utils/space"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -125,10 +125,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp { - defaultDispatcher.dispatch({ - action: "leave_room", - room_id: space.roomId, - }); + leaveSpace(space); }} > { _t("Leave Space") } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 7b42367e31..827fc6bde1 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -31,6 +31,7 @@ import { _t } from "../../../languageHandler"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { toRightOf } from "../../structures/ContextMenu"; import { + leaveSpace, shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, @@ -213,10 +214,7 @@ export class SpaceItem extends React.PureComponent { ev.preventDefault(); ev.stopPropagation(); - defaultDispatcher.dispatch({ - action: "leave_room", - room_id: this.props.space.roomId, - }); + leaveSpace(this.props.space); this.setState({ contextMenuPosition: null }); // also close the menu }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 901204d743..11d1f2140a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2376,6 +2376,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", + "Leave all rooms and spaces": "Leave all rooms and spaces", + "Don't leave any": "Don't leave any", + "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'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", + "Are you sure you want to leave ?": "Are you sure you want to leave ?", "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", "I don't want my encrypted messages": "I don't want my encrypted messages", diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index d064b01257..dfa8bef8be 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -329,7 +329,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }, roomId); } - private getChildren(spaceId: string): Room[] { + public getChildren(spaceId: string): Room[] { const room = this.matrixClient?.getRoom(spaceId); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); return sortBy(childEvents, ev => { diff --git a/src/utils/space.tsx b/src/utils/space.tsx index e705b4eee4..fecb581e65 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -33,6 +33,10 @@ import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSu import defaultDispatcher from "../dispatcher/dispatcher"; import RoomViewStore from "../stores/RoomViewStore"; import { Action } from "../dispatcher/actions"; +import { leaveRoomBehaviour } from "./membership"; +import Spinner from "../components/views/elements/Spinner"; +import dis from "../dispatcher/dispatcher"; +import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -148,3 +152,24 @@ export const showCreateNewSubspace = (space: Room): void => { "mx_CreateSubspaceDialog_wrapper", ); }; + +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 { + await Promise.all(rooms.map(r => leaveRoomBehaviour(r.roomId))); + await leaveRoomBehaviour(space.roomId); + } finally { + modal.close(); + } + + dis.dispatch({ + action: "after_leave_room", + room_id: space.roomId, + }); + }, + }, "mx_LeaveSpaceDialog_wrapper"); +};