avatarUploadRef.current?.click()} />
+
avatarUploadRef.current?.click()} kind="link">
+ { _t("Upload") }
+
+ ;
+ }
+ }
+
+ return
+
+ { avatarSection }
+ {
+ if (!e.target.files?.length) return;
+ const file = e.target.files[0];
+ setAvatar(file);
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ setAvatarDataUrl(ev.target.result as string);
+ };
+ reader.readAsDataURL(file);
+ }} accept="image/*" />
+
+
+
setName(ev.target.value)}
+ disabled={nameDisabled}
+ />
+
+ setTopic(ev.target.value)}
+ rows={3}
+ disabled={topicDisabled}
+ />
+ ;
+};
+
+export default SpaceBasicSettings;
diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx
new file mode 100644
index 00000000000..7023a7bd94a
--- /dev/null
+++ b/src/components/views/spaces/SpaceCreateMenu.tsx
@@ -0,0 +1,170 @@
+/*
+Copyright 2020 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, {useContext, useState} from "react";
+import classNames from "classnames";
+import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types/event";
+
+import {_t} from "../../../languageHandler";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+import {ChevronFace, ContextMenu} from "../../structures/ContextMenu";
+import FormButton from "../elements/FormButton";
+import createRoom, {IStateEvent, Preset, Visibility} from "../../../createRoom";
+import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import SpaceBasicSettings from "./SpaceBasicSettings";
+import AccessibleButton from "../elements/AccessibleButton";
+import FocusLock from "react-focus-lock";
+
+const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
+ return (
+
+ { title }
+ { description }
+
+ );
+};
+
+const SpaceCreateMenu = ({ onFinished }) => {
+ const cli = useContext(MatrixClientContext);
+ const [visibility, setVisibility] = useState
(null);
+ const [name, setName] = useState("");
+ const [avatar, setAvatar] = useState(null);
+ const [topic, setTopic] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const onSpaceCreateClick = async () => {
+ if (busy) return;
+ setBusy(true);
+ const initialState: IStateEvent[] = [
+ {
+ type: EventType.RoomHistoryVisibility,
+ content: {
+ "history_visibility": visibility === Visibility.Public ? "world_readable" : "invited",
+ },
+ },
+ ];
+ if (avatar) {
+ const url = await cli.uploadContent(avatar);
+
+ initialState.push({
+ type: EventType.RoomAvatar,
+ content: { url },
+ });
+ }
+ if (topic) {
+ initialState.push({
+ type: EventType.RoomTopic,
+ content: { topic },
+ });
+ }
+
+ try {
+ await createRoom({
+ createOpts: {
+ preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
+ name,
+ creation_content: {
+ // Based on MSC1840
+ [RoomCreateTypeField]: RoomType.Space,
+ },
+ initial_state: initialState,
+ power_level_content_override: {
+ // Only allow Admins to write to the timeline to prevent hidden sync spam
+ events_default: 100,
+ },
+ },
+ spinner: false,
+ encryption: false,
+ andView: true,
+ inlineErrors: true,
+ });
+
+ onFinished();
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ let body;
+ if (visibility === null) {
+ body =
+ { _t("Create a space") }
+ { _t("Organise rooms into spaces, for just you or anyone") }
+
+ setVisibility(Visibility.Public)}
+ />
+ setVisibility(Visibility.Private)}
+ />
+
+ {/*{ _t("Looking to join an existing space?") }
*/}
+ ;
+ } else {
+ body =
+ setVisibility(null)}
+ title={_t("Go back")}
+ />
+
+
+ {
+ visibility === Visibility.Public
+ ? _t("Personalise your public space")
+ : _t("Personalise your private space")
+ }
+
+
+ {
+ _t("Give it a photo, name and description to help you identify it.")
+ } {
+ _t("You can change these at any point.")
+ }
+
+
+
+
+
+ ;
+ }
+
+ return
+
+ { body }
+
+ ;
+}
+
+export default SpaceCreateMenu;
diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx
new file mode 100644
index 00000000000..dc071921d00
--- /dev/null
+++ b/src/components/views/spaces/SpacePanel.tsx
@@ -0,0 +1,233 @@
+/*
+Copyright 2020 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 {_t} from "../../../languageHandler";
+import RoomAvatar from "../avatars/RoomAvatar";
+import {useContextMenu} from "../../structures/ContextMenu";
+import SpaceCreateMenu from "./SpaceCreateMenu";
+import {SpaceItem} from "./SpaceTreeLevel";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+import {useEventEmitter} from "../../../hooks/useEventEmitter";
+import SpaceStore, {HOME_SPACE, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../../stores/SpaceStore";
+import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
+import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
+import NotificationBadge from "../rooms/NotificationBadge";
+import {
+ RovingAccessibleButton,
+ RovingAccessibleTooltipButton,
+ RovingTabIndexProvider,
+} from "../../../accessibility/RovingTabIndex";
+import {Key} from "../../../Keyboard";
+
+interface IButtonProps {
+ space?: Room;
+ className?: string;
+ selected?: boolean;
+ tooltip?: string;
+ notificationState?: SpaceNotificationState;
+ isNarrow?: boolean;
+ onClick(): void;
+}
+
+const SpaceButton: React.FC = ({
+ space,
+ className,
+ selected,
+ onClick,
+ tooltip,
+ notificationState,
+ isNarrow,
+ children,
+}) => {
+ const classes = classNames("mx_SpaceButton", className, {
+ mx_SpaceButton_active: selected,
+ });
+
+ let avatar = ;
+ if (space) {
+ avatar = ;
+ }
+
+ let notifBadge;
+ if (notificationState) {
+ notifBadge =
+
+
;
+ }
+
+ let button;
+ if (isNarrow) {
+ button = (
+
+ { avatar }
+ { notifBadge }
+ { children }
+
+ );
+ } else {
+ button = (
+
+ { avatar }
+ { tooltip }
+ { notifBadge }
+ { children }
+
+ );
+ }
+
+ return
+ { button }
+ ;
+}
+
+const useSpaces = (): [Room[], Room | null] => {
+ const [spaces, setSpaces] = useState(SpaceStore.instance.spacePanelSpaces);
+ useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
+ const [activeSpace, setActiveSpace] = useState(SpaceStore.instance.activeSpace);
+ useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace);
+ return [spaces, activeSpace];
+};
+
+const SpacePanel = () => {
+ // We don't need the handle as we position the menu in a constant location
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+ const [spaces, activeSpace] = useSpaces();
+ const [isPanelCollapsed, setPanelCollapsed] = useState(true);
+
+ const newClasses = classNames("mx_SpaceButton_new", {
+ mx_SpaceButton_newCancel: menuDisplayed,
+ });
+
+ let contextMenu = null;
+ if (menuDisplayed) {
+ contextMenu = ;
+ }
+
+ const onKeyDown = (ev: React.KeyboardEvent) => {
+ let handled = true;
+
+ switch (ev.key) {
+ case Key.ARROW_UP:
+ onMoveFocus(ev.target as Element, true);
+ break;
+ case Key.ARROW_DOWN:
+ onMoveFocus(ev.target as Element, false);
+ break;
+ default:
+ handled = false;
+ }
+
+ if (handled) {
+ // consume all other keys in context menu
+ ev.stopPropagation();
+ ev.preventDefault();
+ }
+ };
+
+ const onMoveFocus = (element: Element, up: boolean) => {
+ let descending = false; // are we currently descending or ascending through the DOM tree?
+ let classes: DOMTokenList;
+
+ do {
+ const child = up ? element.lastElementChild : element.firstElementChild;
+ const sibling = up ? element.previousElementSibling : element.nextElementSibling;
+
+ if (descending) {
+ if (child) {
+ element = child;
+ } else if (sibling) {
+ element = sibling;
+ } else {
+ descending = false;
+ element = element.parentElement;
+ }
+ } else {
+ if (sibling) {
+ element = sibling;
+ descending = true;
+ } else {
+ element = element.parentElement;
+ }
+ }
+
+ if (element) {
+ if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
+ element = up ? element.lastElementChild : element.firstElementChild;
+ descending = true;
+ }
+ classes = element.classList;
+ }
+ } while (element && !classes.contains("mx_SpaceButton"));
+
+ if (element) {
+ (element as HTMLElement).focus();
+ }
+ };
+
+ const activeSpaces = activeSpace ? [activeSpace] : [];
+ const expandCollapseButtonTitle = isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel");
+ // TODO drag and drop for re-arranging order
+ return
+ {({onKeyDownHandler}) => (
+
+ )}
+
+};
+
+export default SpacePanel;
diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx
new file mode 100644
index 00000000000..8dfbe84f969
--- /dev/null
+++ b/src/components/views/spaces/SpacePublicShare.tsx
@@ -0,0 +1,65 @@
+/*
+Copyright 2020 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 {_t} from "../../../languageHandler";
+import AccessibleButton from "../elements/AccessibleButton";
+import {copyPlaintext} from "../../../utils/strings";
+import {sleep} from "../../../utils/promise";
+import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
+import {showSpaceInviteDialog} from "../../../RoomInvite";
+
+interface IProps {
+ space: Room;
+ onFinished(): void;
+}
+
+const SpacePublicShare = ({ space, onFinished }: IProps) => {
+ const [copiedText, setCopiedText] = useState(_t("Click to copy"));
+
+ return
+
{
+ const permalinkCreator = new RoomPermalinkCreator(space);
+ permalinkCreator.load();
+ const success = await copyPlaintext(permalinkCreator.forRoom());
+ const text = success ? _t("Copied!") : _t("Failed to copy");
+ setCopiedText(text);
+ await sleep(10);
+ if (copiedText === text) { // if the text hasn't changed by another click then clear it after some time
+ setCopiedText(_t("Click to copy"));
+ }
+ }}
+ >
+ { _t("Share invite link") }
+ { copiedText }
+
+
{
+ showSpaceInviteDialog(space.roomId);
+ onFinished();
+ }}
+ >
+ { _t("Invite by email or username") }
+
+
;
+};
+
+export default SpacePublicShare;
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
new file mode 100644
index 00000000000..582ded2942a
--- /dev/null
+++ b/src/components/views/spaces/SpaceTreeLevel.tsx
@@ -0,0 +1,404 @@
+/*
+Copyright 2020 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 from "react";
+import classNames from "classnames";
+import {Room} from "matrix-js-sdk/src/models/room";
+
+import RoomAvatar from "../avatars/RoomAvatar";
+import SpaceStore from "../../../stores/SpaceStore";
+import NotificationBadge from "../rooms/NotificationBadge";
+import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
+import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
+import IconizedContextMenu, {
+ IconizedContextMenuOption,
+ IconizedContextMenuOptionList,
+} from "../context_menus/IconizedContextMenu";
+import {_t} from "../../../languageHandler";
+import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
+import {toRightOf} from "../../structures/ContextMenu";
+import {shouldShowSpaceSettings, showCreateNewRoom} from "../../../utils/space";
+import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import {ButtonEvent} from "../elements/AccessibleButton";
+import defaultDispatcher from "../../../dispatcher/dispatcher";
+import Modal from "../../../Modal";
+import SpacePublicShare from "./SpacePublicShare";
+import {OpenSpaceSettingsPayload} from "../../../dispatcher/payloads/OpenSpaceSettingsPayload";
+import {Action} from "../../../dispatcher/actions";
+import RoomViewStore from "../../../stores/RoomViewStore";
+import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
+import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
+import {showSpaceInviteDialog} from "../../../RoomInvite";
+import InfoDialog from "../dialogs/InfoDialog";
+import {EventType} from "matrix-js-sdk/src/@types/event";
+import SpaceRoomDirectory from "../../structures/SpaceRoomDirectory";
+
+interface IItemProps {
+ space?: Room;
+ activeSpaces: Room[];
+ isNested?: boolean;
+ isPanelCollapsed?: boolean;
+ onExpand?: Function;
+}
+
+interface IItemState {
+ collapsed: boolean;
+ contextMenuPosition: Pick;
+}
+
+export class SpaceItem extends React.PureComponent {
+ static contextType = MatrixClientContext;
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ collapsed: !props.isNested, // default to collapsed for root items
+ contextMenuPosition: null,
+ };
+ }
+
+ private toggleCollapse(evt) {
+ if (this.props.onExpand && this.state.collapsed) {
+ this.props.onExpand();
+ }
+ this.setState({collapsed: !this.state.collapsed});
+ // don't bubble up so encapsulating button for space
+ // doesn't get triggered
+ evt.stopPropagation();
+ }
+
+ private onContextMenu = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setState({
+ contextMenuPosition: {
+ right: ev.clientX,
+ top: ev.clientY,
+ height: 0,
+ },
+ });
+ }
+
+ private onClick = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ SpaceStore.instance.setActiveSpace(this.props.space);
+ };
+
+ private onMenuOpenClick = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const target = ev.target as HTMLButtonElement;
+ this.setState({contextMenuPosition: target.getBoundingClientRect()});
+ };
+
+ private onMenuClose = () => {
+ this.setState({contextMenuPosition: null});
+ };
+
+ private onHomeClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ defaultDispatcher.dispatch({
+ action: "view_room",
+ room_id: this.props.space.roomId,
+ });
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onInviteClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.props.space.getJoinRule() === "public") {
+ const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
+ title: _t("Invite members"),
+ description:
+ { _t("Share your public space") }
+ modal.close()} />
+ ,
+ fixedWidth: false,
+ button: false,
+ className: "mx_SpacePanel_sharePublicSpace",
+ hasCloseButton: true,
+ });
+ } else {
+ showSpaceInviteDialog(this.props.space.roomId);
+ }
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onSettingsClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ defaultDispatcher.dispatch({
+ action: Action.OpenSpaceSettings,
+ space: this.props.space,
+ });
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onLeaveClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ defaultDispatcher.dispatch({
+ action: "leave_room",
+ room_id: this.props.space.roomId,
+ });
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onNewRoomClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ showCreateNewRoom(this.context, this.props.space);
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onMembersClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (!RoomViewStore.getRoomId()) {
+ defaultDispatcher.dispatch({
+ action: "view_room",
+ room_id: this.props.space.roomId,
+ }, true);
+ }
+
+ defaultDispatcher.dispatch({
+ action: Action.SetRightPanelPhase,
+ phase: RightPanelPhases.SpaceMemberList,
+ refireParams: { space: this.props.space },
+ });
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onExploreRoomsClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ Modal.createTrackedDialog("Space room directory", "Space panel", SpaceRoomDirectory, {
+ space: this.props.space,
+ }, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private renderContextMenu(): React.ReactElement {
+ let contextMenu = null;
+ if (this.state.contextMenuPosition) {
+ const userId = this.context.getUserId();
+
+ let inviteOption;
+ if (this.props.space.canInvite(userId)) {
+ inviteOption = (
+
+ );
+ }
+
+ let settingsOption;
+ let leaveSection;
+ if (shouldShowSpaceSettings(this.context, this.props.space)) {
+ settingsOption = (
+
+ );
+ } else {
+ leaveSection =
+
+ ;
+ }
+
+ let newRoomOption;
+ if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
+ newRoomOption = (
+
+ );
+ }
+
+ contextMenu =
+
+ { this.props.space.name }
+
+
+ { inviteOption }
+
+
+ { settingsOption }
+
+ { newRoomOption }
+
+ { leaveSection }
+ ;
+ }
+
+ return (
+
+
+ { contextMenu }
+
+ );
+ }
+
+ render() {
+ const {space, activeSpaces, isNested} = this.props;
+
+ const forceCollapsed = this.props.isPanelCollapsed;
+ const isNarrow = this.props.isPanelCollapsed;
+ const collapsed = this.state.collapsed || forceCollapsed;
+
+ const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId);
+ const isActive = activeSpaces.includes(space);
+ const itemClasses = classNames({
+ "mx_SpaceItem": true,
+ "collapsed": collapsed,
+ "hasSubSpaces": childSpaces && childSpaces.length,
+ });
+ const classes = classNames("mx_SpaceButton", {
+ mx_SpaceButton_active: isActive,
+ mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
+ });
+ const notificationState = SpaceStore.instance.getNotificationState(space.roomId);
+ const childItems = childSpaces && !collapsed ? : null;
+ let notifBadge;
+ if (notificationState) {
+ notifBadge =
+
+
;
+ }
+
+ const avatarSize = isNested ? 24 : 32;
+
+ const toggleCollapseButton = childSpaces && childSpaces.length ?
+