diff --git a/res/css/_components.scss b/res/css/_components.scss index 5b4823b5c2..40e7fdddab 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -58,6 +58,7 @@ @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; +@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @@ -91,6 +92,7 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; +@import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 5bf0d953f3..5515fe4060 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -160,3 +160,20 @@ limitations under the License. mask-position: center; } } + +.mx_RightPanel_scopeHeader { + margin: 24px; + text-align: center; + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + + .mx_BaseAvatar { + margin-right: 8px; + vertical-align: middle; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } +} diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 559f405e59..ee60389c59 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -135,6 +135,90 @@ $SpaceRoomViewInnerWidth: 428px; padding: 8px 22px; } } + + .mx_SpaceRoomView_landing_adminButtons { + margin-top: 32px; + + .mx_AccessibleButton { + position: relative; + width: 160px; + height: 124px; + box-sizing: border-box; + padding: 72px 16px 0; + border-radius: 12px; + border: 1px solid $space-button-outline-color; + margin-right: 28px; + margin-bottom: 28px; + font-size: $font-14px; + display: inline-block; + vertical-align: bottom; + + &:last-child { + margin-right: 0; + } + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::before, &::after { + position: absolute; + content: ""; + left: 16px; + top: 16px; + height: 40px; + width: 40px; + border-radius: 20px; + } + + &::after { + mask-position: center; + mask-size: 30px; + mask-repeat: no-repeat; + background: #ffffff; // white icon fill + } + + &.mx_SpaceRoomView_landing_inviteButton { + &::before { + background-color: $accent-color; + } + + &::after { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + + &.mx_SpaceRoomView_landing_addButton { + &::before { + background-color: #ac3ba8; + } + + &::after { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + } + + &.mx_SpaceRoomView_landing_createButton { + &::before { + background-color: #368bd6; + } + + &::after { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + } + + &.mx_SpaceRoomView_landing_settingsButton { + &::before { + background-color: #5c56f5; + } + + &::after { + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + } + } + } } .mx_SpaceRoomView_privateScope { diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss new file mode 100644 index 0000000000..0c9d8e3840 --- /dev/null +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -0,0 +1,185 @@ +/* +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_AddExistingToSpaceDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_AddExistingToSpaceDialog { + width: 480px; + color: $primary-fg-color; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + + .mx_Dialog_title { + display: flex; + + .mx_BaseAvatar { + display: inline-flex; + margin: 5px 16px 5px 5px; + vertical-align: middle; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + margin: 0; + vertical-align: unset; + } + + > div { + > h1 { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + margin: 0; + } + + .mx_AddExistingToSpaceDialog_onlySpace { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + + .mx_Dropdown_input { + border: none; + + > .mx_Dropdown_option { + padding-left: 0; + flex: unset; + height: unset; + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + + .mx_BaseAvatar { + display: none; + } + } + + .mx_Dropdown_menu { + .mx_AddExistingToSpaceDialog_dropdownOptionActive { + color: $accent-color; + padding-right: 32px; + position: relative; + + &::before { + content: ''; + width: 20px; + height: 20px; + top: 8px; + right: 0; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + } + } + } + } + + .mx_SearchBox { + margin: 0; + } + + .mx_AddExistingToSpaceDialog_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_AddExistingToSpaceDialog_content { + .mx_AddExistingToSpaceDialog_noResults { + margin-top: 24px; + } + } + + .mx_AddExistingToSpaceDialog_section { + margin-top: 24px; + + > h3 { + margin: 0; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_AddExistingToSpaceDialog_entry { + display: flex; + margin-top: 12px; + + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_AddExistingToSpaceDialog_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + } + + .mx_FormButton { + min-width: 92px; + font-weight: normal; + box-sizing: border-box; + } + } + } + + .mx_AddExistingToSpaceDialog_section_spaces { + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_AddExistingToSpaceDialog_footer { + display: flex; + margin-top: 32px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + + > * { + vertical-align: middle; + } + } + + .mx_AccessibleButton { + display: inline-block; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } + + .mx_FormButton { + padding: 8px 22px; + } +} diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss new file mode 100644 index 0000000000..c1fa539e9b --- /dev/null +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -0,0 +1,55 @@ +/* +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_SpaceSettingsDialog { + width: 480px; + color: $primary-fg-color; + + .mx_SpaceSettings_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-left: 16px; + } + + .mx_AccessibleButton_kind_danger { + margin-top: 28px; + } + + .mx_SpaceSettingsDialog_buttons { + display: flex; + margin-top: 64px; + + .mx_AccessibleButton { + display: inline-block; + } + + .mx_AccessibleButton_kind_link { + margin-left: auto; + } + } + + .mx_FormButton { + padding: 8px 22px; + } +} diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index 182c280217..3f7f83d334 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -19,6 +19,7 @@ limitations under the License. flex-direction: column; flex: 1; overflow-y: auto; + margin-top: 8px; } .mx_MemberInfo_name { diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 1e3506e371..075e9ff585 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -44,6 +44,12 @@ limitations under the License. .mx_AutoHideScrollbar { flex: 1 1 0; } + + .mx_RightPanel_scopeHeader { + // vertically align with position on other right panel cards + // to prevent it bouncing as user navigates right panel + margin-top: -8px; + } } .mx_GroupMemberList_query, diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 728ae11e79..503411d2b3 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -50,10 +50,13 @@ export function showStartChatInviteDialog(initialText) { } export function showRoomInviteDialog(roomId) { + const isSpace = MatrixClientPeg.get()?.getRoom(roomId)?.isSpaceRoom(); // This dialog handles the room creation internally - we don't need to worry about it. - const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( - 'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId}, + "Invite Users", isSpace ? "Space" : "Room", InviteDialog, { + kind: isSpace ? KIND_SPACE_INVITE : KIND_INVITE, + roomId, + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } @@ -75,13 +78,6 @@ export function showCommunityInviteDialog(communityId) { } } -export const showSpaceInviteDialog = (roomId) => { - Modal.createTrackedDialog("Invite Users", "Space", InviteDialog, { - kind: KIND_SPACE_INVITE, - roomId, - }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); -}; - /** * Checks if the given MatrixEvent is a valid 3rd party user invite. * @param {MatrixEvent} event The event to check diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8e3d3e6b5f..83b3565738 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1072,6 +1072,7 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + const isSpace = roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); const warnings = []; @@ -1081,7 +1082,9 @@ export default class MatrixChat extends React.PureComponent { warnings.push(( {' '/* Whitespace, otherwise the sentences get smashed together */ } - { _t("This room is not public. You will not be able to rejoin without an invite.") } + { isSpace + ? _t("This space is not public. You will not be able to rejoin without an invite.") + : _t("This room is not public. You will not be able to rejoin without an invite.") } )); } @@ -1094,11 +1097,14 @@ export default class MatrixChat extends React.PureComponent { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - Modal.createTrackedDialog('Leave room', '', QuestionDialog, { - title: _t("Leave room"), + const isSpace = roomToLeave?.isSpaceRoom(); + Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { + title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( - { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + { isSpace + ? _t("Are you sure you want to leave the space '%(spaceName)s'?", {spaceName: roomToLeave.name}) + : _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } { warnings } ), @@ -1112,6 +1118,10 @@ export default class MatrixChat extends React.PureComponent { const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); d.finally(() => modal.close()); + dis.dispatch({ + action: "after_leave_room", + room_id: roomId, + }); } }, }); diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 6c64df31eb..f1a8a4d71b 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -25,7 +25,7 @@ import AccessibleButton from "../views/elements/AccessibleButton"; import RoomName from "../views/elements/RoomName"; import RoomTopic from "../views/elements/RoomTopic"; import FormButton from "../views/elements/FormButton"; -import {inviteMultipleToRoom, showSpaceInviteDialog} from "../../RoomInvite"; +import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite"; import {useRoomMembers} from "../../hooks/useRoomMembers"; import createRoom, {IOpts, Preset} from "../../createRoom"; import Field from "../views/elements/Field"; @@ -46,7 +46,7 @@ import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload"; import {useStateArray} from "../../hooks/useStateArray"; import SpacePublicShare from "../views/spaces/SpacePublicShare"; -import {shouldShowSpaceSettings} from "../../utils/space"; +import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space"; import MemberAvatar from "../views/avatars/MemberAvatar"; interface IProps { @@ -108,6 +108,47 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => ; } + let inviteButton; + if (myMembership === "join" && space.canInvite(userId)) { + inviteButton = ( + { + showRoomInviteDialog(space.roomId); + }}> + { _t("Invite people") } + + ); + } + + const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + let addRoomButtons; + if (canAddRooms) { + addRoomButtons = + { + const [added] = await showAddExistingRooms(cli, space); + if (added) { + // TODO update rooms shown once we show hierarchy here + } + }}> + { _t("Add existing rooms & spaces") } + + { + showCreateNewRoom(cli, space); + }}> + { _t("Create a new room") } + + ; + } + + let settingsButton; + if (shouldShowSpaceSettings(cli, space)) { + settingsButton = { + showSpaceSettings(cli, space); + }}> + { _t("Settings") } + ; + } + return
@@ -167,6 +208,11 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
{ joinButtons } +
+ { inviteButton } + { addRoomButtons } + { settingsButton } +
; }; @@ -361,7 +407,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
showSpaceInviteDialog(space.roomId)} + onClick={() => showRoomInviteDialog(space.roomId)} > { _t("Invite by username") } diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx new file mode 100644 index 0000000000..66efaefd9d --- /dev/null +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -0,0 +1,208 @@ +/* +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, {useState} from "react"; +import classNames from "classnames"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClient} from "matrix-js-sdk/src/client"; + +import {_t} from '../../../languageHandler'; +import {IDialogProps} from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import FormButton from "../elements/FormButton"; +import Dropdown from "../elements/Dropdown"; +import SearchBox from "../../structures/SearchBox"; +import SpaceStore from "../../../stores/SpaceStore"; +import RoomAvatar from "../avatars/RoomAvatar"; +import {getDisplayAliasForRoom} from "../../../Rooms"; +import AccessibleButton from "../elements/AccessibleButton"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import {allSettled} from "../../../utils/promise"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; +import StyledCheckbox from "../elements/StyledCheckbox"; + +interface IProps extends IDialogProps { + matrixClient: MatrixClient; + space: Room; + onCreateRoomClick(cli: MatrixClient, space: Room): void; +} + +const Entry = ({ room, checked, onChange }) => { + return
+ + { room.name } + onChange(e.target.checked)} checked={checked} /> +
; +}; + +const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase(); + + const [selectedSpace, setSelectedSpace] = useState(space); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + const existingSubspacesSet = new Set(existingSubspaces); + const spaces = SpaceStore.instance.getSpaces().filter(s => { + return !existingSubspacesSet.has(s) // not already in space + && space !== s // not the top-level space + && selectedSpace !== s // not the selected space + && s.name.toLowerCase().includes(lcQuery); // contains query + }); + + const existingRooms = SpaceStore.instance.getChildRooms(space.roomId); + const existingRoomsSet = new Set(existingRooms); + const rooms = cli.getVisibleRooms().filter(room => { + return !existingRoomsSet.has(room) // not already in space + && room.name.toLowerCase().includes(lcQuery) // contains query + && !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM + }); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + let spaceOptionSection; + if (existingSubspacesSet.size > 0) { + const options = [space, ...existingSubspaces].map((space) => { + const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { + mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, + }); + return
+ + { space.name || getDisplayAliasForRoom(space) || space.roomId } +
; + }); + + spaceOptionSection = ( + { + setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); + }} + value={selectedSpace.roomId} + label={_t("Space selection")} + > + { options } + + ); + } else { + spaceOptionSection =
+ { space.name || getDisplayAliasForRoom(space) || space.roomId } +
; + } + + const title = + +
+

{ _t("Add existing spaces/rooms") }

+ { spaceOptionSection } +
+
; + + return + { error &&
{ error }
} + + + + { spaces.length > 0 ? ( +
+

{ _t("Spaces") }

+ { spaces.map(space => { + return { + if (checked) { + selectedToAdd.add(space); + } else { + selectedToAdd.delete(space); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + />; + }) } +
+ ) : null } + + { rooms.length > 0 ? ( +
+

{ _t("Rooms") }

+ { rooms.map(room => { + return { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + />; + }) } +
+ ) : undefined } + + { spaces.length + rooms.length < 1 ? + { _t("No results") } + : undefined } +
+ +
+ +
{ _t("Don't want to add an existing room?") }
+ onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + +
+ + { + setBusy(true); + try { + await allSettled(Array.from(selectedToAdd).map((room) => + SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); + onFinished(true); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(_t("Failed to add rooms to space")); + } + setBusy(false); + }} + /> +
+
; +}; + +export default AddExistingToSpaceDialog; + diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index 2b6bb5e187..0771b0ec45 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -17,6 +17,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import {Room} from "matrix-js-sdk/src/models/room"; + import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import withValidation from '../elements/Validation'; @@ -30,6 +32,7 @@ export default class CreateRoomDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, defaultPublic: PropTypes.bool, + parentSpace: PropTypes.instanceOf(Room), }; constructor(props) { @@ -85,6 +88,10 @@ export default class CreateRoomDialog extends React.Component { opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } + if (this.props.parentSpace) { + opts.parentSpace = this.props.parentSpace; + } + return opts; } diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx new file mode 100644 index 0000000000..f6bf5b87e6 --- /dev/null +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -0,0 +1,162 @@ +/* +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, {useState} from 'react'; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import {EventType} from "matrix-js-sdk/src/@types/event"; + +import {_t} from '../../../languageHandler'; +import {IDialogProps} from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import DevtoolsDialog from "./DevtoolsDialog"; +import SpaceBasicSettings from '../spaces/SpaceBasicSettings'; +import {getTopic} from "../elements/RoomTopic"; +import {avatarUrlForRoom} from "../../../Avatar"; +import ToggleSwitch from "../elements/ToggleSwitch"; +import AccessibleButton from "../elements/AccessibleButton"; +import FormButton from "../elements/FormButton"; +import Modal from "../../../Modal"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import {allSettled} from "../../../utils/promise"; +import {useDispatcher} from "../../../hooks/useDispatcher"; + +interface IProps extends IDialogProps { + matrixClient: MatrixClient; + space: Room; +} + +const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFinished }) => { + useDispatcher(defaultDispatcher, ({action, ...params}) => { + if (action === "after_leave_room" && params.room_id === space.roomId) { + onFinished(false); + } + }); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const userId = cli.getUserId(); + + const [newAvatar, setNewAvatar] = useState(null); // undefined means to remove avatar + const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId); + const avatarChanged = newAvatar !== null; + + const [name, setName] = useState(space.name); + const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId); + const nameChanged = name !== space.name; + + const currentTopic = getTopic(space); + const [topic, setTopic] = useState(currentTopic); + const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId); + const topicChanged = topic !== currentTopic; + + const currentJoinRule = space.getJoinRule(); + const [joinRule, setJoinRule] = useState(currentJoinRule); + const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId); + const joinRuleChanged = joinRule !== currentJoinRule; + + const onSave = async () => { + setBusy(true); + const promises = []; + + if (avatarChanged) { + promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, { + url: await cli.uploadContent(newAvatar), + }, "")); + } + + if (nameChanged) { + promises.push(cli.setRoomName(space.roomId, name)); + } + + if (topicChanged) { + promises.push(cli.setRoomTopic(space.roomId, topic)); + } + + if (joinRuleChanged) { + promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, "")); + } + + const results = await allSettled(promises); + setBusy(false); + const failures = results.filter(r => r.status === "rejected"); + if (failures.length > 0) { + console.error("Failed to save space settings: ", failures); + setError(_t("Failed to save space settings.")); + } + }; + + return +
+
{ _t("Edit settings relating to your space.") }
+ + { error &&
{ error }
} + + + +
+ { _t("Make this space private") } + setJoinRule(checked ? "private" : "invite")} + disabled={!canSetJoinRule} + aria-label={_t("Make this space private")} + /> +
+ + { + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: space.roomId, + }); + }} + /> + +
+ Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}> + { _t("View dev tools") } + + + { _t("Cancel") } + + +
+
+
; +}; + +export default SpaceSettingsDialog; + diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index a4b5cd0fbb..eb47a56269 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -60,7 +60,9 @@ import QuestionDialog from "../dialogs/QuestionDialog"; import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog"; import InfoDialog from "../dialogs/InfoDialog"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import RoomAvatar from "../avatars/RoomAvatar"; +import RoomName from "../elements/RoomName"; interface IDevice { deviceId: string; @@ -302,7 +304,8 @@ const UserOptionsSection: React.FC<{ member: RoomMember; isIgnored: boolean; canInvite: boolean; -}> = ({member, isIgnored, canInvite}) => { + isSpace?: boolean; +}> = ({member, isIgnored, canInvite, isSpace}) => { const cli = useContext(MatrixClientContext); let ignoreButton = null; @@ -342,7 +345,7 @@ const UserOptionsSection: React.FC<{ ); - if (member.roomId) { + if (member.roomId && !isSpace) { const onReadReceiptButton = function() { const room = cli.getRoom(member.roomId); dis.dispatch({ @@ -434,14 +437,18 @@ const UserOptionsSection: React.FC<{ ); }; -const warnSelfDemote = async () => { +const warnSelfDemote = async (isSpace) => { const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { title: _t("Demote yourself?"), description:
- { _t("You will not be able to undo this change as you are demoting yourself, " + - "if you are the last privileged user in the room it will be impossible " + - "to regain privileges.") } + { isSpace + ? _t("You will not be able to undo this change as you are demoting yourself, " + + "if you are the last privileged user in the space it will be impossible " + + "to regain privileges.") + : _t("You will not be able to undo this change as you are demoting yourself, " + + "if you are the last privileged user in the room it will be impossible " + + "to regain privileges.") }
, button: _t("Demote"), }); @@ -717,7 +724,7 @@ const MuteToggleButton: React.FC = ({member, room, powerLevels, // if muting self, warn as it may be irreversible if (target === cli.getUserId()) { try { - if (!(await warnSelfDemote())) return; + if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); return; @@ -806,7 +813,7 @@ const RoomAdminToolsContainer: React.FC = ({ if (canAffectUser && me.powerLevel >= kickPowerLevel) { kickButton = ; } - if (me.powerLevel >= redactPowerLevel) { + if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) { redactButton = ( ); @@ -1085,7 +1092,7 @@ const PowerLevelEditor: React.FC<{ } else if (myUserId === target) { // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. try { - if (!(await warnSelfDemote())) return; + if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); } @@ -1315,12 +1322,10 @@ const BasicUserInfo: React.FC<{ if (!isRoomEncrypted) { if (!cryptoEnabled) { text = _t("This client does not support end-to-end encryption."); - } else if (room) { + } else if (room && !room.isSpaceRoom()) { text = _t("Messages in this room are not end-to-end encrypted."); - } else { - // TODO what to render for GroupMember } - } else { + } else if (!room.isSpaceRoom()) { text = _t("Messages in this room are end-to-end encrypted."); } @@ -1381,7 +1386,9 @@ const BasicUserInfo: React.FC<{ + member={member} + isSpace={room?.isSpaceRoom()} + /> { adminToolsContainer } @@ -1498,7 +1505,7 @@ interface IProps { user: Member; groupId?: string; room?: Room; - phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo; + phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo | RightPanelPhases.SpaceMemberInfo; onClose(): void; } @@ -1542,7 +1549,9 @@ const UserInfo: React.FC = ({ previousPhase = RightPanelPhases.RoomMemberInfo; refireParams = {member: member}; } else if (room) { - previousPhase = RightPanelPhases.RoomMemberList; + previousPhase = previousPhase = room.isSpaceRoom() + ? RightPanelPhases.SpaceMemberList + : RightPanelPhases.RoomMemberList; } const onEncryptionPanelClose = () => { @@ -1557,6 +1566,7 @@ const UserInfo: React.FC = ({ switch (phase) { case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.GroupMemberInfo: + case RightPanelPhases.SpaceMemberInfo: content = ( = ({ } } - const header = ; + let scopeHeader; + if (room?.isSpaceRoom()) { + scopeHeader =
+ + +
; + } + + const header = + { scopeHeader } + + ; return ); + let previousPhase = RightPanelPhases.RoomSummary; + // We have no previousPhase for when viewing a MemberList from a Space + let scopeHeader; + if (room?.isSpaceRoom()) { + previousPhase = undefined; + scopeHeader =
+ + +
; + } + return + { scopeHeader } + { inviteButton } + } footer={footer} onClose={this.props.onClose} - previousPhase={RightPanelPhases.RoomSummary} + previousPhase={previousPhase} >
+ + +
; + } + // We shamelessly rip off the MemberInfo styles here. return (
+ { scopeHeader }
{ { - showSpaceInviteDialog(space.roomId); + showRoomInviteDialog(space.roomId); onFinished(); }} > diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 063b74c22c..1dd6f73d45 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1438,6 +1438,7 @@ "and %(count)s others...|one": "and one other...", "Invite to this room": "Invite to this room", "Invite to this community": "Invite to this community", + "Invite to this space": "Invite to this space", "Invited": "Invited", "Filter room members": "Filter room members", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", @@ -1700,6 +1701,7 @@ "Share Link to User": "Share Link to User", "Direct message": "Direct message", "Demote yourself?": "Demote yourself?", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", "Demote": "Demote", "Disinvite": "Disinvite", @@ -1978,6 +1980,15 @@ "Add a new server...": "Add a new server...", "%(networkName)s rooms": "%(networkName)s rooms", "Matrix rooms": "Matrix rooms", + "Space selection": "Space selection", + "Add existing spaces/rooms": "Add existing spaces/rooms", + "Filter your rooms and spaces": "Filter your rooms and spaces", + "Spaces": "Spaces", + "Don't want to add an existing room?": "Don't want to add an existing room?", + "Create a new room": "Create a new room", + "Applying...": "Applying...", + "Apply": "Apply", + "Failed to add rooms to space": "Failed to add rooms to space", "Matrix ID": "Matrix ID", "Matrix Room ID": "Matrix Room ID", "email address": "email address", @@ -2169,7 +2180,6 @@ "Start a conversation with someone using their name or username (like ).": "Start a conversation with someone using their name or username (like ).", "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here": "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here", "Go": "Go", - "Invite to this space": "Invite to this space", "Invite someone using their name, email address, username (like ) or share this room.": "Invite someone using their name, email address, username (like ) or share this room.", "Invite someone using their name, username (like ) or share this room.": "Invite someone using their name, username (like ) or share this room.", "Invite someone using their name, email address, username (like ) or share this space.": "Invite someone using their name, email address, username (like ) or share this space.", @@ -2283,6 +2293,14 @@ "Link to selected message": "Link to selected message", "Copy": "Copy", "Command Help": "Command Help", + "Failed to save space settings.": "Failed to save space settings.", + "Space settings": "Space settings", + "Edit settings relating to your space.": "Edit settings relating to your space.", + "Make this space private": "Make this space private", + "Leave Space": "Leave Space", + "View dev tools": "View dev tools", + "Saving...": "Saving...", + "Save Changes": "Save Changes", "To help us prevent this in future, please send us logs.": "To help us prevent this in future, please send us logs.", "Missing session data": "Missing session data", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", @@ -2494,7 +2512,10 @@ "Failed to reject invitation": "Failed to reject invitation", "Cannot create rooms in this community": "Cannot create rooms in this community", "You do not have permission to create rooms in this community.": "You do not have permission to create rooms in this community.", + "This space is not public. You will not be able to rejoin without an invite.": "This space is not public. You will not be able to rejoin without an invite.", "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.", + "Leave space": "Leave space", + "Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?", "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?", "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", "Signed Out": "Signed Out", @@ -2559,6 +2580,8 @@ "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", "Accept Invite": "Accept Invite", + "Invite people": "Invite people", + "Add existing rooms & spaces": "Add existing rooms & spaces", "%(count)s members|other": "%(count)s members", "%(count)s members|one": "%(count)s member", " invited you to ": " invited you to ", diff --git a/src/utils/space.ts b/src/utils/space.ts index 98801cabd0..bc31829f45 100644 --- a/src/utils/space.ts +++ b/src/utils/space.ts @@ -19,6 +19,11 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; import {EventType} from "matrix-js-sdk/src/@types/event"; import {calculateRoomVia} from "../utils/permalinks/Permalinks"; +import Modal from "../Modal"; +import SpaceSettingsDialog from "../components/views/dialogs/SpaceSettingsDialog"; +import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToSpaceDialog"; +import CreateRoomDialog from "../components/views/dialogs/CreateRoomDialog"; +import createRoom, {IOpts} from "../createRoom"; export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => { const userId = cli.getUserId(); @@ -37,3 +42,40 @@ export const makeSpaceParentEvent = (room: Room, canonical = false) => ({ }, state_key: room.roomId, }); + +export const showSpaceSettings = (cli: MatrixClient, space: Room) => { + Modal.createTrackedDialog("Space Settings", "", SpaceSettingsDialog, { + matrixClient: cli, + space, + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); +}; + +export const showAddExistingRooms = async (cli: MatrixClient, space: Room) => { + return Modal.createTrackedDialog( + "Space Landing", + "Add Existing", + AddExistingToSpaceDialog, + { + matrixClient: cli, + onCreateRoomClick: showCreateNewRoom, + space, + }, + "mx_AddExistingToSpaceDialog_wrapper", + ).finished; +}; + +export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => { + const modal = Modal.createTrackedDialog<[boolean, IOpts]>( + "Space Landing", + "Create Room", + CreateRoomDialog, + { + defaultPublic: space.getJoinRule() === "public", + parentSpace: space, + }, + ); + const [shouldCreate, opts] = await modal.finished; + if (shouldCreate) { + await createRoom(opts); + } +};