{auxPanel}
+ {fileDropTarget}
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx
new file mode 100644
index 0000000000..06df6a528e
--- /dev/null
+++ b/src/components/structures/SpaceRoomDirectory.tsx
@@ -0,0 +1,576 @@
+/*
+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, {useMemo, useRef, useState} from "react";
+import Room from "matrix-js-sdk/src/models/room";
+import MatrixEvent from "matrix-js-sdk/src/models/event";
+import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
+
+import {MatrixClientPeg} from "../../MatrixClientPeg";
+import dis from "../../dispatcher/dispatcher";
+import {_t} from "../../languageHandler";
+import AccessibleButton from "../views/elements/AccessibleButton";
+import BaseDialog from "../views/dialogs/BaseDialog";
+import FormButton from "../views/elements/FormButton";
+import SearchBox from "./SearchBox";
+import RoomAvatar from "../views/avatars/RoomAvatar";
+import RoomName from "../views/elements/RoomName";
+import {useAsyncMemo} from "../../hooks/useAsyncMemo";
+import {shouldShowSpaceSettings} from "../../utils/space";
+import {EnhancedMap} from "../../utils/maps";
+import StyledCheckbox from "../views/elements/StyledCheckbox";
+import AutoHideScrollbar from "./AutoHideScrollbar";
+import BaseAvatar from "../views/avatars/BaseAvatar";
+
+interface IProps {
+ space: Room;
+ initialText?: string;
+ onFinished(): void;
+}
+
+/* eslint-disable camelcase */
+export interface ISpaceSummaryRoom {
+ canonical_alias?: string;
+ aliases: string[];
+ avatar_url?: string;
+ guest_can_join: boolean;
+ name?: string;
+ num_joined_members: number
+ room_id: string;
+ topic?: string;
+ world_readable: boolean;
+ num_refs: number;
+ room_type: string;
+}
+
+export interface ISpaceSummaryEvent {
+ room_id: string;
+ event_id: string;
+ origin_server_ts: number;
+ type: string;
+ state_key: string;
+ content: {
+ order?: string;
+ auto_join?: boolean;
+ via?: string;
+ };
+}
+/* eslint-enable camelcase */
+
+interface ISubspaceProps {
+ space: ISpaceSummaryRoom;
+ event?: MatrixEvent;
+ editing?: boolean;
+ onPreviewClick?(): void;
+ queueAction?(action: IAction): void;
+ onJoinClick?(): void;
+}
+
+const SubSpace: React.FC
= ({
+ space,
+ editing,
+ event,
+ queueAction,
+ onJoinClick,
+ onPreviewClick,
+ children,
+}) => {
+ const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space");
+
+ const evContent = event?.getContent();
+ const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
+ const [removed, _setRemoved] = useState(!evContent?.via);
+
+ const cli = MatrixClientPeg.get();
+ const cliRoom = cli.getRoom(space.room_id);
+ const myMembership = cliRoom?.getMyMembership();
+
+ // TODO DRY code
+ let actions;
+ if (editing && queueAction) {
+ if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
+ const setAutoJoin = () => {
+ _setAutoJoin(v => {
+ queueAction({
+ event,
+ removed,
+ autoJoin: !v,
+ });
+ return !v;
+ });
+ };
+
+ const setRemoved = () => {
+ _setRemoved(v => {
+ queueAction({
+ event,
+ removed: !v,
+ autoJoin,
+ });
+ return !v;
+ });
+ };
+
+ if (removed) {
+ actions =
+
+ ;
+ } else {
+ actions =
+
+
+ ;
+ }
+ } else {
+ actions =
+ { _t("No permissions")}
+ ;
+ }
+ // TODO confirm remove from space click behaviour here
+ } else {
+ if (myMembership === "join") {
+ actions =
+ { _t("You're in this space")}
+ ;
+ } else if (onJoinClick) {
+ actions =
+
+ { _t("Preview") }
+
+
+
+ }
+ }
+
+ let url: string;
+ if (space.avatar_url) {
+ url = MatrixClientPeg.get().mxcUrlToHttp(
+ space.avatar_url,
+ Math.floor(24 * window.devicePixelRatio),
+ Math.floor(24 * window.devicePixelRatio),
+ "crop",
+ );
+ }
+
+ return
+
+
+ { name }
+
+
+ { actions }
+
+
+
+ { children }
+
+
+};
+
+interface IAction {
+ event: MatrixEvent;
+ removed: boolean;
+ autoJoin: boolean;
+}
+
+interface IRoomTileProps {
+ room: ISpaceSummaryRoom;
+ event?: MatrixEvent;
+ editing?: boolean;
+ onPreviewClick(): void;
+ queueAction?(action: IAction): void;
+ onJoinClick?(): void;
+}
+
+const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => {
+ const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room");
+
+ const evContent = event?.getContent();
+ const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
+ const [removed, _setRemoved] = useState(!evContent?.via);
+
+ const cli = MatrixClientPeg.get();
+ const cliRoom = cli.getRoom(room.room_id);
+ const myMembership = cliRoom?.getMyMembership();
+
+ let actions;
+ if (editing && queueAction) {
+ if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
+ const setAutoJoin = () => {
+ _setAutoJoin(v => {
+ queueAction({
+ event,
+ removed,
+ autoJoin: !v,
+ });
+ return !v;
+ });
+ };
+
+ const setRemoved = () => {
+ _setRemoved(v => {
+ queueAction({
+ event,
+ removed: !v,
+ autoJoin,
+ });
+ return !v;
+ });
+ };
+
+ if (removed) {
+ actions =
+
+ ;
+ } else {
+ actions =
+
+
+ ;
+ }
+ } else {
+ actions =
+ { _t("No permissions")}
+ ;
+ }
+ // TODO confirm remove from space click behaviour here
+ } else {
+ if (myMembership === "join") {
+ actions =
+ { _t("You're in this room")}
+ ;
+ } else if (onJoinClick) {
+ actions =
+
+ { _t("Preview") }
+
+
+
+ }
+ }
+
+ let url: string;
+ if (room.avatar_url) {
+ url = cli.mxcUrlToHttp(
+ room.avatar_url,
+ Math.floor(32 * window.devicePixelRatio),
+ Math.floor(32 * window.devicePixelRatio),
+ "crop",
+ );
+ }
+
+ const content =
+
+
+
+
+ { name }
+
+
+ { room.topic }
+
+
+
+ { room.num_joined_members }
+
+
+
+ { actions }
+
+ ;
+
+ if (editing) {
+ return
+ { content }
+
+ }
+
+ return
+ { content }
+ ;
+};
+
+export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
+ // Don't let the user view a room they won't be able to either peek or join:
+ // fail earlier so they don't have to click back to the directory.
+ if (MatrixClientPeg.get().isGuest()) {
+ if (!room.world_readable && !room.guest_can_join) {
+ dis.dispatch({ action: "require_registration" });
+ return;
+ }
+ }
+
+ const roomAlias = getDisplayAliasForRoom(room) || undefined;
+ dis.dispatch({
+ action: "view_room",
+ auto_join: autoJoin,
+ should_peek: true,
+ _type: "room_directory", // instrumentation
+ room_alias: roomAlias,
+ room_id: room.room_id,
+ via_servers: viaServers,
+ oob_data: {
+ avatarUrl: room.avatar_url,
+ // XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
+ name: room.name || roomAlias || _t("Unnamed room"),
+ },
+ });
+};
+
+interface IHierarchyLevelProps {
+ spaceId: string;
+ rooms: Map;
+ editing?: boolean;
+ relations: EnhancedMap;
+ parents: Set;
+ queueAction?(action: IAction): void;
+ onPreviewClick(roomId: string): void;
+ onRemoveFromSpaceClick?(roomId: string): void;
+ onJoinClick?(roomId: string): void;
+}
+
+export const HierarchyLevel = ({
+ spaceId,
+ rooms,
+ editing,
+ relations,
+ parents,
+ onPreviewClick,
+ onJoinClick,
+ queueAction,
+}: IHierarchyLevelProps) => {
+ const cli = MatrixClientPeg.get();
+ const space = cli.getRoom(spaceId);
+ // TODO respect order
+ const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => {
+ if (!rooms.has(roomId)) return result; // TODO wat
+ result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
+ return result;
+ }, [[], []]) || [[], []];
+
+ // Don't render this subspace if it has no rooms we can show
+ // TODO this is broken - as a space may have subspaces we still need to show
+ // if (!childRooms.length) return null;
+
+ const userId = cli.getUserId();
+
+ const newParents = new Set(parents).add(spaceId);
+ return
+ {
+ childRooms.map(roomId => (
+ {
+ onPreviewClick(roomId);
+ }}
+ onJoinClick={onJoinClick ? () => {
+ onJoinClick(roomId);
+ } : undefined}
+ />
+ ))
+ }
+
+ {
+ subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
+ {
+ onPreviewClick(roomId);
+ }}
+ onJoinClick={() => {
+ onJoinClick(roomId);
+ }}
+ >
+
+
+ ))
+ }
+
+};
+
+const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => {
+ // TODO pagination
+ const cli = MatrixClientPeg.get();
+ const [query, setQuery] = useState(initialText);
+ const [isEditing, setIsEditing] = useState(false);
+
+ const onCreateRoomClick = () => {
+ dis.dispatch({
+ action: 'view_create_room',
+ public: true,
+ });
+ onFinished();
+ };
+
+ // stored within a ref as we don't need to re-render when it changes
+ const pendingActions = useRef(new Map());
+
+ let adminButton;
+ if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test
+ const onManageButtonClicked = () => {
+ setIsEditing(true);
+ };
+
+ const onSaveButtonClicked = () => {
+ // TODO setBusy
+ pendingActions.current.forEach(({event, autoJoin, removed}) => {
+ const content = {
+ ...event.getContent(),
+ auto_join: autoJoin,
+ };
+
+ if (removed) {
+ delete content["via"];
+ }
+
+ cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey());
+ });
+ setIsEditing(false);
+ };
+
+ if (isEditing) {
+ adminButton =
+
+ { _t("All users join by default") }
+ ;
+ } else {
+ adminButton = ;
+ }
+ }
+
+ const [rooms, relations, viaMap] = useAsyncMemo(async () => {
+ try {
+ const data = await cli.getSpaceSummary(space.roomId);
+
+ const parentChildRelations = new EnhancedMap();
+ const viaMap = new EnhancedMap>();
+ data.events.map((ev: ISpaceSummaryEvent) => {
+ if (ev.type === EventType.SpaceChild) {
+ parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
+ }
+ if (Array.isArray(ev.content["via"])) {
+ const set = viaMap.getOrCreate(ev.state_key, new Set());
+ ev.content["via"].forEach(via => set.add(via));
+ }
+ });
+
+ return [data.rooms, parentChildRelations, viaMap];
+ } catch (e) {
+ console.error(e); // TODO
+ }
+
+ return [];
+ }, [space], []);
+
+ const roomsMap = useMemo(() => {
+ if (!rooms) return null;
+ const lcQuery = query.toLowerCase();
+
+ const filteredRooms = rooms.filter(r => {
+ return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms
+ || r.name?.toLowerCase().includes(lcQuery)
+ || r.topic?.toLowerCase().includes(lcQuery);
+ });
+
+ return new Map(filteredRooms.map(r => [r.room_id, r]));
+ // const root = rooms.get(space.roomId);
+ }, [rooms, query]);
+
+ const title =
+
+
+
{ _t("Explore rooms") }
+
+
+ ;
+ const explanation =
+ _t("If you can't find the room you're looking for, ask for an invite or Create a new room.", null,
+ {a: sub => {
+ return {sub};
+ }},
+ );
+
+ let content;
+ if (roomsMap) {
+ content =
+ {
+ pendingActions.current.set(action.event.room_id, action);
+ }}
+ onPreviewClick={roomId => {
+ showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false);
+ onFinished();
+ }}
+ onJoinClick={(roomId) => {
+ showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true);
+ onFinished();
+ }}
+ />
+ ;
+ }
+
+ // TODO loading state/error state
+ return (
+
+
+ { explanation }
+
+
+
+
+ { adminButton }
+
+ { content }
+
+
+ );
+};
+
+export default SpaceRoomDirectory;
+
+// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
+// but works with the objects we get from the public room list
+function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
+ return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
+}
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index 6c64df31eb..5c91efc1c0 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -15,7 +15,7 @@ limitations under the License.
*/
import React, {RefObject, useContext, useRef, useState} from "react";
-import {EventType} from "matrix-js-sdk/src/@types/event";
+import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../contexts/MatrixClientContext";
@@ -24,8 +24,9 @@ import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
+import InlineSpinner from "../views/elements/InlineSpinner";
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,8 +47,13 @@ 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 {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory";
+import {useAsyncMemo} from "../../hooks/useAsyncMemo";
+import {EnhancedMap} from "../../utils/maps";
+import AutoHideScrollbar from "./AutoHideScrollbar";
import MemberAvatar from "../views/avatars/MemberAvatar";
+import {useStateToggle} from "../../hooks/useStateToggle";
interface IProps {
space: Room;
@@ -108,6 +114,94 @@ 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);
+
+ const [_, forceUpdate] = useStateToggle(false); // TODO
+
+ let addRoomButtons;
+ if (canAddRooms) {
+ addRoomButtons =
+ {
+ const [added] = await showAddExistingRooms(cli, space);
+ if (added) {
+ forceUpdate();
+ }
+ }}>
+ { _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") }
+ ;
+ }
+
+ const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => {
+ try {
+ const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join");
+
+ const parentChildRelations = new EnhancedMap
();
+ data.events.map((ev: ISpaceSummaryEvent) => {
+ if (ev.type === EventType.SpaceChild) {
+ parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
+ }
+ });
+
+ const roomsMap = new Map(data.rooms.map(r => [r.room_id, r]));
+ const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length;
+ return [false, roomsMap, parentChildRelations, numRooms];
+ } catch (e) {
+ console.error(e); // TODO
+ }
+
+ return [false];
+ }, [space, _], [true]);
+
+ let previewRooms;
+ if (roomsMap) {
+ previewRooms =
+
+
{ myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}
+ { numRooms }
+
+ {
+ showRoom(roomsMap.get(roomId), [], false); // TODO
+ }}
+ />
+ ;
+ } else if (loading) {
+ previewRooms = ;
+ } else {
+ previewRooms = {_t("Your server does not support showing space hierarchies.")}
;
+ }
+
return
@@ -167,6 +261,13 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
{ joinButtons }
+
+ { inviteButton }
+ { addRoomButtons }
+ { settingsButton }
+
+
+ { previewRooms }
;
};
@@ -361,7 +462,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/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js
index 97ae968ff3..6dc9fc01b0 100644
--- a/src/components/views/dialogs/InfoDialog.js
+++ b/src/components/views/dialogs/InfoDialog.js
@@ -27,7 +27,7 @@ export default class InfoDialog extends React.Component {
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.node,
- button: PropTypes.string,
+ button: PropTypes.oneOfType(PropTypes.string, PropTypes.bool),
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
@@ -60,11 +60,11 @@ export default class InfoDialog extends React.Component {
{ this.props.description }
-
-
+ }
);
}
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 {
}
render() {
- const TintableSvg = sdk.getComponent("elements.TintableSvg");
-
- let fileDropTarget = null;
- if (this.props.draggingFile) {
- fileDropTarget = (
-
-
-
-
- { _t("Drop file here to upload") }
-
-
- );
- }
-
const callView = (
{
{ stateViews }
{ appsDrawer }
- { fileDropTarget }
{ callView }
{ this.props.children }
diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js
index 495a0f0d2c..d4d618c821 100644
--- a/src/components/views/rooms/MemberList.js
+++ b/src/components/views/rooms/MemberList.js
@@ -27,6 +27,8 @@ import * as sdk from "../../../index";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
+import RoomAvatar from "../avatars/RoomAvatar";
+import RoomName from "../elements/RoomName";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@@ -456,6 +458,8 @@ export default class MemberList extends React.Component {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
+ } else if (room.isSpaceRoom()) {
+ inviteButtonText = _t("Invite to this space");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
@@ -483,12 +487,26 @@ export default class MemberList extends React.Component {
onSearch={ this.onSearchQueryChanged } />
);
+ 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}
>
void;
@@ -152,6 +155,50 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
defaultHidden: false,
addRoomLabel: _td("Add room"),
addRoomContextMenu: (onFinished: () => void) => {
+ if (SpaceStore.instance.activeSpace) {
+ const canAddRooms = SpaceStore.instance.activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
+ MatrixClientPeg.get().getUserId());
+
+ return
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onFinished();
+ showCreateNewRoom(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
+ }}
+ disabled={!canAddRooms}
+ tooltip={canAddRooms ? undefined
+ : _t("You do not have permissions to create new rooms in this space")}
+ />
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onFinished();
+ showAddExistingRooms(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
+ }}
+ disabled={!canAddRooms}
+ tooltip={canAddRooms ? undefined
+ : _t("You do not have permissions to add rooms to this space")}
+ />
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onFinished();
+ defaultDispatcher.fire(Action.ViewRoomDirectory);
+ }}
+ />
+ ;
+ }
+
return
+
+
+
;
+ }
+
// We shamelessly rip off the MemberInfo styles here.
return (
+ { scopeHeader }
{
{
- showSpaceInviteDialog(space.roomId);
+ showRoomInviteDialog(space.roomId);
onFinished();
}}
>
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
index f94798433f..04d6c02208 100644
--- a/src/components/views/spaces/SpaceTreeLevel.tsx
+++ b/src/components/views/spaces/SpaceTreeLevel.tsx
@@ -23,7 +23,27 @@ 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, showSpaceSettings} 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 {Action} from "../../../dispatcher/actions";
+import RoomViewStore from "../../../stores/RoomViewStore";
+import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
+import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
+import {showRoomInviteDialog} 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;
@@ -78,6 +98,200 @@ export class SpaceItem extends React.PureComponent {
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 {
+ showRoomInviteDialog(this.props.space.roomId);
+ }
+ this.setState({contextMenuPosition: null}); // also close the menu
+ };
+
+ private onSettingsClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ showSpaceSettings(this.context, 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;
@@ -133,6 +347,7 @@ export class SpaceItem extends React.PureComponent {
{ notifBadge }
+ { this.renderContextMenu() }
);
@@ -149,6 +364,7 @@ export class SpaceItem extends React.PureComponent {
{ space.name }
{ notifBadge }
+ { this.renderContextMenu() }
);
diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts
index b00dc148e4..30ff74b071 100644
--- a/src/contexts/RoomContext.ts
+++ b/src/contexts/RoomContext.ts
@@ -43,6 +43,7 @@ const RoomContext = createContext
({
canReply: false,
layout: Layout.Group,
matrixClientIsReady: false,
+ dragCounter: 0,
});
RoomContext.displayName = "RoomContext";
export default RoomContext;
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 8abe12c528..267721b533 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -58,6 +58,8 @@
"You cannot place VoIP calls in this browser.": "You cannot place VoIP calls in this browser.",
"Too Many Calls": "Too Many Calls",
"You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.",
+ "Already in call": "Already in call",
+ "You're already in a call with this person.": "You're already in a call with this person.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!",
@@ -809,6 +811,7 @@
"Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting",
"Expand code blocks by default": "Expand code blocks by default",
"Show line numbers in code blocks": "Show line numbers in code blocks",
+ "Jump to the bottom of the timeline when you send a message": "Jump to the bottom of the timeline when you send a message",
"Show avatars in user and room mentions": "Show avatars in user and room mentions",
"Enable big emoji in chat": "Enable big emoji in chat",
"Send typing notifications": "Send typing notifications",
@@ -1003,6 +1006,16 @@
"Failed to copy": "Failed to copy",
"Share invite link": "Share invite link",
"Invite by email or username": "Invite by email or username",
+ "Invite members": "Invite members",
+ "Share your public space": "Share your public space",
+ "Invite people": "Invite people",
+ "Settings": "Settings",
+ "Leave space": "Leave space",
+ "New room": "New room",
+ "Space Home": "Space Home",
+ "Members": "Members",
+ "Explore rooms": "Explore rooms",
+ "Space options": "Space options",
"Remove": "Remove",
"This bridge was provisioned by .": "This bridge was provisioned by .",
"This bridge is managed by .": "This bridge is managed by .",
@@ -1406,8 +1419,6 @@
"Remove %(phone)s?": "Remove %(phone)s?",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.",
"Phone Number": "Phone Number",
- "Drop File Here": "Drop File Here",
- "Drop file here to upload": "Drop file here to upload",
"This user has not verified all of their sessions.": "This user has not verified all of their sessions.",
"You have not verified this user.": "You have not verified this user.",
"You have verified this user. This user has verified all of their sessions.": "You have verified this user. This user has verified all of their sessions.",
@@ -1437,6 +1448,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)",
@@ -1509,6 +1521,10 @@
"Rooms": "Rooms",
"Add room": "Add room",
"Create new room": "Create new room",
+ "You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space",
+ "Add existing room": "Add existing room",
+ "You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space",
+ "Explore space rooms": "Explore space rooms",
"Explore community rooms": "Explore community rooms",
"Explore public rooms": "Explore public rooms",
"Low priority": "Low priority",
@@ -1579,7 +1595,6 @@
"Favourited": "Favourited",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
- "Settings": "Settings",
"Leave Room": "Leave Room",
"Room options": "Room options",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
@@ -1668,7 +1683,6 @@
"The homeserver the user you’re verifying is connected to": "The homeserver the user you’re verifying is connected to",
"Yours, or the other users’ internet connection": "Yours, or the other users’ internet connection",
"Yours, or the other users’ session": "Yours, or the other users’ session",
- "Members": "Members",
"Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Unpin": "Unpin",
@@ -1699,6 +1713,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",
@@ -1977,6 +1992,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",
@@ -2168,7 +2192,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.",
@@ -2282,6 +2305,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.",
@@ -2489,11 +2520,12 @@
"Explore Public Rooms": "Explore Public Rooms",
"Create a Group Chat": "Create a Group Chat",
"Upgrade to %(hostSignupBrand)s": "Upgrade to %(hostSignupBrand)s",
- "Explore rooms": "Explore rooms",
"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.",
+ "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",
@@ -2555,9 +2587,23 @@
"No more results": "No more results",
"Room": "Room",
"Failed to reject invite": "Failed to reject invite",
+ "Drop file here to upload": "Drop file here to upload",
"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.",
+ "Unnamed Space": "Unnamed Space",
+ "Undo": "Undo",
+ "Remove from Space": "Remove from Space",
+ "No permissions": "No permissions",
+ "You're in this space": "You're in this space",
+ "You're in this room": "You're in this room",
+ "Save changes": "Save changes",
+ "All users join by default": "All users join by default",
+ "Manage rooms": "Manage rooms",
+ "Find a room...": "Find a room...",
"Accept Invite": "Accept Invite",
+ "Add existing rooms & spaces": "Add existing rooms & spaces",
+ "Default Rooms": "Default Rooms",
+ "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
"%(count)s members|other": "%(count)s members",
"%(count)s members|one": "%(count)s member",
" invited you to ": " invited you to ",
@@ -2571,7 +2617,6 @@
"Failed to create initial space rooms": "Failed to create initial space rooms",
"Skip for now": "Skip for now",
"Creating rooms...": "Creating rooms...",
- "Share your public space": "Share your public space",
"At the moment only you can see it.": "At the moment only you can see it.",
"Finish": "Finish",
"Who are you working with?": "Who are you working with?",
diff --git a/src/resizer/distributors/collapse.ts b/src/resizer/distributors/collapse.ts
index ddf3bd687e..f8db0be52c 100644
--- a/src/resizer/distributors/collapse.ts
+++ b/src/resizer/distributors/collapse.ts
@@ -22,6 +22,7 @@ import Sizer from "../sizer";
export interface ICollapseConfig extends IConfig {
toggleSize: number;
onCollapsed?(collapsed: boolean, id: string, element: HTMLElement): void;
+ isItemCollapsed(element: HTMLElement): boolean;
}
class CollapseItem extends ResizeItem {
@@ -31,6 +32,11 @@ class CollapseItem extends ResizeItem {
callback(collapsed, this.id, this.domNode);
}
}
+
+ get isCollapsed() {
+ const isItemCollapsed = this.resizer.config.isItemCollapsed;
+ return isItemCollapsed(this.domNode);
+ }
}
export default class CollapseDistributor extends FixedDistributor {
@@ -39,11 +45,12 @@ export default class CollapseDistributor extends FixedDistributor {
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);
+ }
+};