From a6e5112be08eabf3e7c7b0d138e81178ad209edb Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 4 Aug 2021 10:37:35 +0100
Subject: [PATCH 1/4] Offer a way to create a space based on existing community
---
res/css/_components.scss | 1 +
res/css/structures/_GroupView.scss | 55 +++
res/css/structures/_SpaceRoomView.scss | 14 +-
.../context_menus/_TagTileContextMenu.scss | 4 +
.../_CreateSpaceFromCommunityDialog.scss | 176 +++++++++
.../user/_PreferencesUserSettingsTab.scss | 20 ++
src/components/structures/GroupView.js | 26 ++
src/components/structures/SpaceRoomView.tsx | 44 ++-
.../views/context_menus/TagTileContextMenu.js | 18 +
.../CreateSpaceFromCommunityDialog.tsx | 338 ++++++++++++++++++
.../views/dialogs/CreateSubspaceDialog.tsx | 29 +-
.../views/dialogs/UserSettingsDialog.tsx | 2 +-
src/components/views/right_panel/UserInfo.tsx | 2 +-
.../tabs/user/PreferencesUserSettingsTab.tsx | 114 +++++-
.../views/spaces/SpaceCreateMenu.tsx | 68 ++--
src/i18n/strings/en_EN.json | 31 +-
src/stores/GroupFilterOrderStore.js | 8 +-
src/stores/GroupStore.js | 4 +-
src/utils/space.tsx | 9 +
19 files changed, 895 insertions(+), 68 deletions(-)
create mode 100644 res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
create mode 100644 src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 76551b51f8..dd1b0d220b 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -75,6 +75,7 @@
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
@import "./views/dialogs/_CreateGroupDialog.scss";
@import "./views/dialogs/_CreateRoomDialog.scss";
+@import "./views/dialogs/_CreateSpaceFromCommunityDialog.scss";
@import "./views/dialogs/_CreateSubspaceDialog.scss";
@import "./views/dialogs/_DeactivateAccountDialog.scss";
@import "./views/dialogs/_DevtoolsDialog.scss";
diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss
index 60f9ebdd08..21137d8a12 100644
--- a/res/css/structures/_GroupView.scss
+++ b/res/css/structures/_GroupView.scss
@@ -368,6 +368,61 @@ limitations under the License.
padding: 40px 20px;
}
+.mx_GroupView_spaceUpgradePrompt {
+ padding: 16px 50px;
+ background-color: $header-panel-bg-color;
+ border-radius: 8px;
+ max-width: 632px;
+ font-size: $font-15px;
+ line-height: $font-24px;
+ margin-top: 24px;
+ position: relative;
+
+ > h2 {
+ font-size: inherit;
+ font-weight: $font-semi-bold;
+ }
+
+ > p, h2 {
+ margin: 0;
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ height: $font-24px;
+ width: 20px;
+ left: 18px;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: contain;
+ mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
+ background-color: $secondary-fg-color;
+ }
+
+ .mx_AccessibleButton {
+ width: 16px;
+ height: 16px;
+ border-radius: 8px;
+ background-color: $input-darker-bg-color;
+ position: absolute;
+ top: 16px;
+ right: 16px;
+
+ &::before {
+ content: "";
+ position: absolute;
+ width: inherit;
+ height: inherit;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: 8px;
+ mask-image: url('$(res)/img/image-view/close.svg');
+ background-color: $secondary-fg-color;
+ }
+ }
+}
+
.mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar > :not(.mx_MemberInfo_avatar) {
padding-left: 16px;
padding-right: 16px;
diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss
index 58a4b426c2..945de01eba 100644
--- a/res/css/structures/_SpaceRoomView.scss
+++ b/res/css/structures/_SpaceRoomView.scss
@@ -180,6 +180,18 @@ $SpaceRoomViewInnerWidth: 428px;
}
}
+ .mx_SpaceRoomView_preview_migratedCommunity {
+ margin-bottom: 16px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid $input-border-color;
+ width: max-content;
+
+ .mx_BaseAvatar {
+ margin-right: 4px;
+ }
+ }
+
.mx_SpaceRoomView_preview_inviter {
display: flex;
align-items: center;
@@ -342,7 +354,7 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceFeedbackPrompt {
padding: 7px; // 8px - 1px border
- border: 1px solid $menu-border-color;
+ border: 1px solid rgba($primary-fg-color, .1);
border-radius: 8px;
width: max-content;
margin: 0 0 -40px auto; // collapse its own height to not push other components down
diff --git a/res/css/views/context_menus/_TagTileContextMenu.scss b/res/css/views/context_menus/_TagTileContextMenu.scss
index d707f4ce7c..14f5ec817e 100644
--- a/res/css/views/context_menus/_TagTileContextMenu.scss
+++ b/res/css/views/context_menus/_TagTileContextMenu.scss
@@ -51,6 +51,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/hide.svg');
}
+.mx_TagTileContextMenu_createSpace::before {
+ mask-image: url('$(res)/img/element-icons/message/fwd.svg');
+}
+
.mx_TagTileContextMenu_separator {
margin-top: 0;
margin-bottom: 0;
diff --git a/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
new file mode 100644
index 0000000000..059d38c77c
--- /dev/null
+++ b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
@@ -0,0 +1,176 @@
+/*
+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_CreateSpaceFromCommunityDialog_wrapper {
+ .mx_Dialog {
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.mx_CreateSpaceFromCommunityDialog {
+ width: 480px;
+ color: $primary-fg-color;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ min-height: 0;
+
+ .mx_CreateSpaceFromCommunityDialog_content {
+ > p {
+ font-size: $font-15px;
+ line-height: $font-24px;
+ color: $secondary-fg-color;
+
+ &.mx_CreateSpaceFromCommunityDialog_flairNotice {
+ font-size: $font-12px;
+ line-height: $font-15px;
+ }
+ }
+
+ .mx_SpaceBasicSettings {
+ > p {
+ font-size: $font-12px;
+ line-height: $font-15px;
+ margin: 8px 0 16px;
+ }
+ }
+
+ .mx_JoinRuleDropdown .mx_Dropdown_menu {
+ width: auto !important; // override fixed width
+ }
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_footer {
+ display: flex;
+ margin-top: 20px;
+
+ > span {
+ flex-grow: 1;
+ font-size: $font-12px;
+ line-height: $font-15px;
+ color: $secondary-fg-color;
+
+ .mx_ProgressBar {
+ height: 8px;
+ width: 100%;
+
+ @mixin ProgressBarBorderRadius 8px;
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_progressText {
+ margin-top: 8px;
+ font-size: $font-15px;
+ line-height: $font-24px;
+ color: $primary-fg-color;
+ }
+
+ > * {
+ vertical-align: middle;
+ }
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_error {
+ padding-left: 12px;
+
+ > img {
+ align-self: center;
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_errorHeading {
+ font-weight: $font-semi-bold;
+ font-size: $font-15px;
+ line-height: $font-18px;
+ color: $notice-primary-color;
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_errorCaption {
+ margin-top: 4px;
+ font-size: $font-12px;
+ line-height: $font-15px;
+ color: $primary-fg-color;
+ }
+ }
+
+ .mx_AccessibleButton {
+ display: inline-block;
+ align-self: center;
+ }
+
+ .mx_AccessibleButton_kind_primary {
+ padding: 8px 36px;
+ margin-left: 24px;
+ }
+
+ .mx_AccessibleButton_kind_primary_outline {
+ margin-left: auto;
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_retryButton {
+ margin-left: 12px;
+ padding-left: 24px;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ background-color: $primary-fg-color;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: contain;
+ mask-image: url('$(res)/img/element-icons/retry.svg');
+ width: 18px;
+ height: 18px;
+ left: 0;
+ }
+ }
+
+ .mx_AccessibleButton_kind_link {
+ padding: 0;
+ }
+ }
+}
+
+.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog {
+ .mx_InfoDialog {
+ max-width: 500px;
+ }
+
+ .mx_AccessibleButton_kind_link {
+ padding: 0;
+ }
+
+ .mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark {
+ position: relative;
+ border-radius: 50%;
+ border: 3px solid $accent-color;
+ width: 68px;
+ height: 68px;
+ margin: 12px auto 32px;
+
+ &::before {
+ width: inherit;
+ height: inherit;
+ content: '';
+ position: absolute;
+ background-color: $accent-color;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
+ mask-size: 48px;
+ }
+ }
+}
diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
index be0af9123b..efd2548d3d 100644
--- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
@@ -22,4 +22,24 @@ limitations under the License.
.mx_SettingsTab_section {
margin-bottom: 30px;
}
+
+ .mx_PreferencesUserSettingsTab_CommunityMigrator {
+ margin-right: 200px;
+
+ > div {
+ font-weight: $font-semi-bold;
+ font-size: $font-15px;
+ line-height: $font-18px;
+ color: $primary-fg-color;
+
+ .mx_BaseAvatar {
+ margin-right: 12px;
+ vertical-align: middle;
+ }
+
+ .mx_AccessibleButton {
+ float: right;
+ }
+ }
+ }
}
diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js
index 99fa94e62b..26bb1b8ae7 100644
--- a/src/components/structures/GroupView.js
+++ b/src/components/structures/GroupView.js
@@ -399,6 +399,8 @@ class FeaturedUser extends React.Component {
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
+const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
+
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
@@ -422,6 +424,7 @@ export default class GroupView extends React.Component {
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
+ showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
};
componentDidMount() {
@@ -807,6 +810,11 @@ export default class GroupView extends React.Component {
showGroupAddRoomDialog(this.props.groupId);
};
+ _dismissUpgradeNotice = () => {
+ localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
+ this.setState({ showUpgradeNotice: false });
+ }
+
_getGroupSection() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
@@ -843,10 +851,28 @@ export default class GroupView extends React.Component {
},
) }
:
;
+
+ let communitiesUpgradeNotice;
+ if (this.state.showUpgradeNotice) {
+ communitiesUpgradeNotice =
+
{ _t("Communities can now be made into Spaces") }
+
+ { _t("Spaces are a new way to make a community, with new features coming.") }
+
+ { _t("Ask the admins of this community to make it into a Space " +
+ "and keep a look out for the invite.") }
+
+ { _t("Communities won't receive further updates.") }
+
+
+
;
+ }
+
return
{ header }
{ hostingSignup }
{ changeDelayWarning }
+ { communitiesUpgradeNotice }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index 4064b2f48e..4eb9b855f7 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
+import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
+import { useAsyncMemo } from "../../hooks/useAsyncMemo";
+import Spinner from "../views/elements/Spinner";
+import GroupAvatar from "../views/avatars/GroupAvatar";
interface IProps {
space: Room;
@@ -158,7 +162,33 @@ const onBetaClick = () => {
});
};
-const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
+// XXX: temporary community migration component
+const GroupTile = ({ groupId }: { groupId: string }) => {
+ const cli = useContext(MatrixClientContext);
+ const groupSummary = useAsyncMemo(() => cli.getGroupSummary(groupId), [cli, groupId]);
+
+ if (!groupSummary) return ;
+
+ return <>
+
+ { groupSummary.profile.name }
+ >;
+};
+
+interface ISpacePreviewProps {
+ space: Room;
+ onJoinButtonClicked(): void;
+ onRejectButtonClicked(): void;
+}
+
+const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
@@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
;
}
+ let migratedCommunitySection: JSX.Element;
+ const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
+ if (createContent[CreateEventField]) {
+ migratedCommunitySection =
+ { _t("Created from ", {}, {
+ Community: () => ,
+ }) }
+
;
+ }
+
return
-
+ { migratedCommunitySection }
{ inviterSection }
diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js
index c40ff4207b..0c3c48a07f 100644
--- a/src/components/views/context_menus/TagTileContextMenu.js
+++ b/src/components/views/context_menus/TagTileContextMenu.js
@@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
+import { createSpaceFromCommunity } from "../../../utils/space";
+import GroupStore from "../../../stores/GroupStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
@@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
this.props.onFinished();
};
+ _onCreateSpaceClick = () => {
+ createSpaceFromCommunity(this.context, this.props.tag);
+ this.props.onFinished();
+ };
+
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
@@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
);
}
+ let createSpaceOption;
+ if (GroupStore.isUserPrivileged(this.props.tag)) {
+ createSpaceOption = <>
+
+
+ >;
+ }
+
return
;
}
}
diff --git a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
new file mode 100644
index 0000000000..bef50b008f
--- /dev/null
+++ b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
@@ -0,0 +1,338 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { useEffect, useRef, useState } from "react";
+import { JoinRule } from "matrix-js-sdk/src/@types/partials";
+import { EventType } from "matrix-js-sdk/src/@types/event";
+import { MatrixClient } from "matrix-js-sdk/src/matrix";
+
+import { _t } from '../../../languageHandler';
+import BaseDialog from "./BaseDialog";
+import AccessibleButton from "../elements/AccessibleButton";
+import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
+import JoinRuleDropdown from "../elements/JoinRuleDropdown";
+import Field from "../elements/Field";
+import RoomAliasField from "../elements/RoomAliasField";
+import { GroupMember } from "../right_panel/UserInfo";
+import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore";
+import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks";
+import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
+import Spinner from "../elements/Spinner";
+import { mediaFromMxc } from "../../../customisations/Media";
+import SpaceStore from "../../../stores/SpaceStore";
+import Modal from "../../../Modal";
+import InfoDialog from "./InfoDialog";
+import dis from "../../../dispatcher/dispatcher";
+import { Action } from "../../../dispatcher/actions";
+import { UserTab } from "./UserSettingsDialog";
+import TagOrderActions from "../../../actions/TagOrderActions";
+
+interface IProps {
+ matrixClient: MatrixClient;
+ groupId: string;
+ onFinished(spaceId?: string): void;
+}
+
+export const CreateEventField = "io.element.migrated_from_community";
+
+interface IGroupRoom {
+ displayname: string;
+ name?: string;
+ roomId: string;
+ canonicalAlias?: string;
+ avatarUrl?: string;
+ topic?: string;
+ numJoinedMembers?: number;
+ worldReadable?: boolean;
+ guestCanJoin?: boolean;
+ isPublic?: boolean;
+}
+
+/* eslint-disable camelcase */
+export interface IGroupSummary {
+ profile: {
+ avatar_url?: string;
+ is_openly_joinable?: boolean;
+ is_public?: boolean;
+ long_description: string;
+ name: string;
+ short_description: string;
+ };
+ rooms_section: {
+ rooms: unknown[];
+ categories: Record;
+ total_room_count_estimate: number;
+ };
+ user: {
+ is_privileged: boolean;
+ is_public: boolean;
+ is_publicised: boolean;
+ membership: string;
+ };
+ users_section: {
+ users: unknown[];
+ roles: Record;
+ total_user_count_estimate: number;
+ };
+}
+/* eslint-enable camelcase */
+
+const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, groupId, onFinished }) => {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const [avatar, setAvatar] = useState(null); // undefined means to remove avatar
+ const [name, setName] = useState("");
+ const spaceNameField = useRef();
+ const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain());
+ const spaceAliasField = useRef();
+ const [topic, setTopic] = useState("");
+ const [joinRule, setJoinRule] = useState(JoinRule.Public);
+
+ const groupSummary = useAsyncMemo(() => cli.getGroupSummary(groupId), [groupId]);
+ useEffect(() => {
+ if (groupSummary) {
+ setName(groupSummary.profile.name || "");
+ setTopic(groupSummary.profile.short_description || "");
+ setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite);
+ setLoading(false);
+ }
+ }, [groupSummary]);
+
+ if (loading) {
+ return ;
+ }
+
+ const onCreateSpaceClick = async (e) => {
+ e.preventDefault();
+ if (busy) return;
+
+ setError(null);
+ setBusy(true);
+
+ // require & validate the space name field
+ if (!await spaceNameField.current.validate({ allowEmpty: false })) {
+ setBusy(false);
+ spaceNameField.current.focus();
+ spaceNameField.current.validate({ allowEmpty: false, focused: true });
+ return;
+ }
+ // validate the space name alias field but do not require it
+ if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
+ setBusy(false);
+ spaceAliasField.current.focus();
+ spaceAliasField.current.validate({ allowEmpty: true, focused: true });
+ return;
+ }
+
+ try {
+ const [rooms, members, invitedMembers] = await Promise.all([
+ cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise,
+ cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise,
+ cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise,
+ ]);
+
+ const viaMap = new Map();
+ for (const { roomId, canonicalAlias } of rooms) {
+ const room = cli.getRoom(roomId);
+ if (room) {
+ viaMap.set(roomId, calculateRoomVia(room));
+ } else if (canonicalAlias) {
+ try {
+ const { servers } = await cli.getRoomIdForAlias(canonicalAlias);
+ viaMap.set(roomId, servers);
+ } catch (e) {
+ console.warn("Failed to resolve alias during community migration", e);
+ }
+ }
+
+ if (!viaMap.get(roomId)?.length) {
+ // XXX: lets guess the via, this might end up being incorrect.
+ const str = canonicalAlias || roomId;
+ viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]);
+ }
+ }
+
+ const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
+ const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
+ creation_content: {
+ [CreateEventField]: groupId,
+ },
+ initial_state: rooms.map(({ roomId }) => ({
+ type: EventType.SpaceChild,
+ state_key: roomId,
+ content: {
+ via: viaMap.get(roomId) || [],
+ },
+ })),
+ invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
+ }, {
+ andView: false,
+ });
+
+ // eagerly remove it from the community panel
+ dis.dispatch(TagOrderActions.removeTag(cli, groupId));
+
+ // don't bother awaiting this, as we don't hugely care if it fails
+ cli.setGroupProfile(groupId, {
+ ...groupSummary.profile,
+ long_description: `` +
+ _t("This community has been upgraded into a Space") + `
`
+ + groupSummary.profile.long_description,
+ } as IGroupSummary["profile"]).catch(e => {
+ console.warn("Failed to update community profile during migration", e);
+ });
+
+ onFinished(roomId);
+
+ const onSpaceClick = () => {
+ dis.dispatch({
+ action: "view_room",
+ room_id: roomId,
+ });
+ };
+
+ const onPreferencesClick = () => {
+ dis.dispatch({
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Preferences,
+ });
+ };
+
+ let spacesDisabledCopy;
+ if (!SpaceStore.spacesEnabled) {
+ spacesDisabledCopy = _t("To view Spaces, hide communities in Preferences", {}, {
+ a: sub => { sub },
+ });
+ }
+
+ Modal.createDialog(InfoDialog, {
+ title: _t("Space created"),
+ description: <>
+
+
+ { _t(" has been made and everyone who was a part of the community has " +
+ "been invited to it.", {}, {
+ SpaceName: () =>
+ { name }
+ ,
+ }) }
+
+ { spacesDisabledCopy }
+
+
+ { _t("To create a Space from another community, just pick the community in Preferences.") }
+
+ >,
+ button: _t("Preferences"),
+ onFinished: (openPreferences: boolean) => {
+ if (openPreferences) {
+ onPreferencesClick();
+ }
+ },
+ }, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog");
+ } catch (e) {
+ console.error(e);
+ setError(e);
+ }
+
+ setBusy(false);
+ };
+
+ let footer;
+ if (error) {
+ footer = <>
+
+
+
+ { _t("Failed to migrate community") }
+ { _t("Try again") }
+
+
+
+ { _t("Retry") }
+
+ >;
+ } else {
+ footer = <>
+ onFinished()}>
+ { _t("Cancel") }
+
+
+ { busy ? _t("Creating...") : _t("Create Space") }
+
+ >;
+ }
+
+ return
+
+
+ { _t("Spaces are the new version of communities - with new features coming.") }
+
+ { _t("All rooms will automatically be automatically added, a link to the Space will be " +
+ "added to your old community description and all community members will be invited.") }
+
+
+ { _t("Flair won't be available in Spaces for the foreseeable future.") }
+
+
+
+ { _t("This description will be shown to people when they view your space") }
+
+ { joinRule === JoinRule.Public
+ ? _t("Open space for anyone, best for communities")
+ : _t("Invite only, best for yourself or teams")
+ }
+
+
+
+
+ { footer }
+
+ ;
+};
+
+export default CreateSpaceFromCommunityDialog;
+
diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx
index 0d71eb2de3..03927c7d62 100644
--- a/src/components/views/dialogs/CreateSubspaceDialog.tsx
+++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx
@@ -16,8 +16,7 @@ limitations under the License.
import React, { useRef, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
-import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
-import { RoomType } from "matrix-js-sdk/src/@types/event";
+import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
@@ -27,8 +26,7 @@ import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
-import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
-import createRoom from "../../../createRoom";
+import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
@@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick
}
try {
- await createRoom({
- createOpts: {
- preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
- name,
- power_level_content_override: {
- // Only allow Admins to write to the timeline to prevent hidden sync spam
- events_default: 100,
- ...joinRule === JoinRule.Public ? { invite: 0 } : {},
- },
- room_alias_name: joinRule === JoinRule.Public && alias
- ? alias.substr(1, alias.indexOf(":") - 1)
- : undefined,
- topic,
- },
- avatar,
- roomType: RoomType.Space,
- parentSpace,
- spinner: false,
- encryption: false,
- andView: true,
- inlineErrors: true,
- });
+ await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
onFinished(true);
} catch (e) {
diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx
index 7608d7cb55..9613b27d17 100644
--- a/src/components/views/dialogs/UserSettingsDialog.tsx
+++ b/src/components/views/dialogs/UserSettingsDialog.tsx
@@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component
UserTab.Preferences,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
- ,
+ ,
));
if (SettingsStore.getValue(UIFeature.Voip)) {
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx
index c837e814c8..088c59d724 100644
--- a/src/components/views/right_panel/UserInfo.tsx
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -851,7 +851,7 @@ const RoomAdminToolsContainer: React.FC = ({
return ;
};
-interface GroupMember {
+export interface GroupMember {
userId: string;
displayname?: string; // XXX: GroupMember objects are inconsistent :((
avatarUrl?: string;
diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
index d3da8a7784..1dea0a7770 100644
--- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
@@ -15,7 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React from 'react';
+import React, { useContext, useEffect, useState } from 'react';
+import { EventType } from 'matrix-js-sdk/src/@types/event';
+
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import SettingsStore from "../../../../../settings/SettingsStore";
@@ -27,6 +29,18 @@ import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
+import GroupAvatar from "../../../avatars/GroupAvatar";
+import dis from "../../../../../dispatcher/dispatcher";
+import GroupActions from "../../../../../actions/GroupActions";
+import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
+import { useDispatcher } from "../../../../../hooks/useDispatcher";
+import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
+import { createSpaceFromCommunity } from "../../../../../utils/space";
+import Spinner from "../../../elements/Spinner";
+
+interface IProps {
+ closeSettingsFn(success: boolean): void;
+}
interface IState {
autoLaunch: boolean;
@@ -42,8 +56,86 @@ interface IState {
readMarkerOutOfViewThresholdMs: string;
}
+type Community = IGroupSummary & {
+ groupId: string;
+ spaceId?: string;
+};
+
+const CommunityMigrator = ({ onFinished }) => {
+ const cli = useContext(MatrixClientContext);
+ const [communities, setCommunities] = useState(null);
+ useEffect(() => {
+ dis.dispatch(GroupActions.fetchJoinedGroups(cli));
+ }, [cli]);
+ useDispatcher(dis, async payload => {
+ if (payload.action === "GroupActions.fetchJoinedGroups.success") {
+ const communities: Community[] = [];
+
+ const migratedSpaceMap = new Map(cli.getRooms().map(room => {
+ const createContent = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
+ if (createContent?.[CreateEventField]) {
+ return [createContent[CreateEventField], room.roomId] as [string, string];
+ }
+ }).filter(Boolean));
+
+ for (const groupId of payload.result.groups) {
+ const summary = await cli.getGroupSummary(groupId) as IGroupSummary;
+ if (summary.user.is_privileged) {
+ communities.push({
+ ...summary,
+ groupId,
+ spaceId: migratedSpaceMap.get(groupId),
+ });
+ }
+ }
+
+ setCommunities(communities);
+ }
+ });
+
+ if (!communities) {
+ return ;
+ }
+
+ return
+ { communities.map(community => (
+
+
+ { community.profile.name }
+
{
+ if (community.spaceId) {
+ dis.dispatch({
+ action: "view_room",
+ room_id: community.spaceId,
+ });
+ onFinished();
+ } else {
+ createSpaceFromCommunity(cli, community.groupId).then(([spaceId]) => {
+ if (spaceId) {
+ community.spaceId = spaceId;
+ setCommunities([...communities]); // force component re-render
+ }
+ });
+ }
+ }}
+ >
+ { community.spaceId ? _t("Open Space") : _t("Create Space") }
+
+
+ )) }
+
;
+};
+
@replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab")
-export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
+export default class PreferencesUserSettingsTab extends React.Component {
static ROOM_LIST_SETTINGS = [
'breadcrumbs',
];
@@ -52,6 +144,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
"Spaces.allRoomsInHome",
];
+ static COMMUNITIES_SETTINGS = [
+ // TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088
+ ];
+
static KEYBINDINGS_SETTINGS = [
'ctrlFForSearch',
];
@@ -241,6 +337,20 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
}
+
+
{ _t("Communities") }
+
{ _t("Communities have been archived to make way for Spaces but you can convert your " +
+ "communities into Spaces below.") }
+
{ _t("Convert your Communities to Spaces") }
+
{ _t("Converting will ensure your conversations get the latest features.") }
+
+ { _t("Show my Communities") }
+ { _t("If a community isn't shown you may not have permission to convert it.") }
+
+
+ { this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) }
+
+
{ _t("Keyboard shortcuts") }
diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx
index 406028dbc7..ef70deb672 100644
--- a/src/components/views/spaces/SpaceCreateMenu.tsx
+++ b/src/components/views/spaces/SpaceCreateMenu.tsx
@@ -18,23 +18,57 @@ import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, u
import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
+import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
+import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
-import createRoom from "../../../createRoom";
+import createRoom, { IOpts as ICreateOpts } from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
-import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import RoomAliasField from "../elements/RoomAliasField";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog";
import SettingsStore from "../../../settings/SettingsStore";
+export const createSpace = async (
+ name: string,
+ isPublic: boolean,
+ alias?: string,
+ topic?: string,
+ avatar?: string | File,
+ createOpts: Partial = {},
+ otherOpts: Partial> = {},
+) => {
+ return createRoom({
+ createOpts: {
+ name,
+ preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
+ power_level_content_override: {
+ // Only allow Admins to write to the timeline to prevent hidden sync spam
+ events_default: 100,
+ ...isPublic ? { invite: 0 } : {},
+ },
+ room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
+ topic,
+ ...createOpts,
+ },
+ avatar,
+ roomType: RoomType.Space,
+ historyVisibility: isPublic ? HistoryVisibility.WorldReadable : HistoryVisibility.Invited,
+ spinner: false,
+ encryption: false,
+ andView: true,
+ inlineErrors: true,
+ ...otherOpts,
+ });
+};
+
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
@@ -92,7 +126,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
;
};
-type BProps = Pick, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
+type BProps = Omit, "nameDisabled" | "topicDisabled" | "avatarDisabled">;
interface ISpaceCreateFormProps extends BProps {
busy: boolean;
alias: string;
@@ -106,6 +140,7 @@ interface ISpaceCreateFormProps extends BProps {
export const SpaceCreateForm: React.FC = ({
busy,
onSubmit,
+ avatarUrl,
setAvatar,
name,
setName,
@@ -122,7 +157,7 @@ export const SpaceCreateForm: React.FC = ({
const domain = cli.getDomain();
return