From 483d56320c8810ae88dbfd934be3c5d3eea8dee1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Mar 2021 17:27:09 +0000 Subject: [PATCH 1/4] Beginning of space creation UX from space panel --- res/css/_components.scss | 2 + res/css/structures/_SpacePanel.scss | 19 ++ res/css/views/spaces/_SpaceBasicSettings.scss | 86 +++++++++ res/css/views/spaces/_SpaceCreateMenu.scss | 138 ++++++++++++++ res/img/element-icons/lock.svg | 3 + res/img/element-icons/plus.svg | 3 + src/components/structures/ContextMenu.tsx | 3 +- .../views/spaces/SpaceBasicSettings.tsx | 120 ++++++++++++ .../views/spaces/SpaceCreateMenu.tsx | 175 ++++++++++++++++++ src/components/views/spaces/SpacePanel.tsx | 21 +++ src/createRoom.ts | 4 +- src/i18n/strings/en_EN.json | 22 ++- 12 files changed, 588 insertions(+), 8 deletions(-) create mode 100644 res/css/views/spaces/_SpaceBasicSettings.scss create mode 100644 res/css/views/spaces/_SpaceCreateMenu.scss create mode 100644 res/img/element-icons/lock.svg create mode 100644 res/img/element-icons/plus.svg create mode 100644 src/components/views/spaces/SpaceBasicSettings.tsx create mode 100644 src/components/views/spaces/SpaceCreateMenu.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 29b5262826..8d6597aefa 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -233,6 +233,8 @@ @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; +@import "./views/spaces/_SpaceBasicSettings.scss"; +@import "./views/spaces/_SpaceCreateMenu.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 563c5eba58..24d2243912 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -177,6 +177,25 @@ $activeBorderColor: $secondary-fg-color; padding: $activeBorderTransparentGap; } + &.mx_SpaceButton_new .mx_SpaceButton_icon { + background-color: $accent-color; + transition: all .1s ease-in-out; // TODO transition + + &::before { + background-color: #ffffff; + mask-image: url('$(res)/img/element-icons/plus.svg'); + transition: all .2s ease-in-out; // TODO transition + } + } + + &.mx_SpaceButton_newCancel .mx_SpaceButton_icon { + background-color: $icon-button-color; + + &::before { + transform: rotate(45deg); + } + } + .mx_BaseAvatar { /* moving the border-radius to this element from _image element so we can add a border to it without the initials being displaced */ diff --git a/res/css/views/spaces/_SpaceBasicSettings.scss b/res/css/views/spaces/_SpaceBasicSettings.scss new file mode 100644 index 0000000000..204ccab2b7 --- /dev/null +++ b/res/css/views/spaces/_SpaceBasicSettings.scss @@ -0,0 +1,86 @@ +/* +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_SpaceBasicSettings { + .mx_Field { + margin: 32px 0; + } + + .mx_SpaceBasicSettings_avatarContainer { + display: flex; + margin-top: 24px; + + .mx_SpaceBasicSettings_avatar { + position: relative; + height: 80px; + width: 80px; + background-color: $tertiary-fg-color; + border-radius: 16px; + } + + img.mx_SpaceBasicSettings_avatar { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 16px; + } + + // only show it when the button is a div and not an img (has avatar) + div.mx_SpaceBasicSettings_avatar { + cursor: pointer; + + &::before { + content: ""; + position: absolute; + height: 80px; + width: 80px; + top: 0; + left: 0; + background-color: #ffffff; // white icon fill + mask-repeat: no-repeat; + mask-position: center; + mask-size: 20px; + mask-image: url('$(res)/img/element-icons/camera.svg'); + } + } + + > input[type="file"] { + display: none; + } + + > .mx_AccessibleButton_kind_link { + display: inline-block; + padding: 0; + margin: auto 16px; + color: #368bd6; + } + + > .mx_SpaceBasicSettings_avatar_remove { + color: $notice-primary-color; + } + } + + .mx_FormButton { + padding: 8px 22px; + margin-left: auto; + display: block; + width: min-content; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } +} diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss new file mode 100644 index 0000000000..2a11ec9f23 --- /dev/null +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -0,0 +1,138 @@ +/* +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. +*/ + +// TODO: the space panel currently does not have a fixed width, +// just the headers at each level have a max-width of 150px +// so this will look slightly off for now. We should probably use css grid for the whole main layout... +$spacePanelWidth: 200px; + +.mx_SpaceCreateMenu_wrapper { + // background blur everything except SpacePanel + .mx_ContextualMenu_background { + background-color: $dialog-backdrop-color; + opacity: 0.6; + left: $spacePanelWidth; + } + + .mx_ContextualMenu { + padding: 24px; + width: 480px; + box-sizing: border-box; + background-color: $primary-bg-color; + + > div { + > h2 { + font-weight: $font-semi-bold; + font-size: $font-18px; + margin-top: 4px; + } + + > p { + font-size: $font-15px; + color: $secondary-fg-color; + margin: 0; + } + } + + .mx_SpaceCreateMenuType { + position: relative; + padding: 16px 32px 16px 72px; + width: 432px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid $input-darker-bg-color; + font-size: $font-15px; + margin: 20px 0; + + > h3 { + font-weight: $font-semi-bold; + margin: 0 0 4px; + } + + > span { + color: $secondary-fg-color; + } + + &::before { + position: absolute; + content: ''; + width: 32px; + height: 32px; + top: 24px; + left: 20px; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 32px; + background-color: $tertiary-fg-color; + } + + &:hover { + border-color: $accent-color; + + &::before { + background-color: $accent-color; + } + + > span { + color: $primary-fg-color; + } + } + } + + .mx_SpaceCreateMenuType_public::before { + mask-image: url('$(res)/img/globe.svg'); + mask-size: 26px; + } + .mx_SpaceCreateMenuType_private::before { + mask-image: url('$(res)/img/element-icons/lock.svg'); + } + + .mx_SpaceCreateMenu_back { + width: 28px; + height: 28px; + position: relative; + background-color: $theme-button-bg-color; + border-radius: 14px; + margin-bottom: 12px; + + &::before { + content: ""; + position: absolute; + height: 28px; + width: 28px; + top: 0; + left: 0; + background-color: $muted-fg-color; + transform: rotate(90deg); + mask-repeat: no-repeat; + mask-position: 2px 3px; + mask-size: 24px; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + } + + .mx_FormButton { + padding: 8px 22px; + margin-left: auto; + display: block; + width: min-content; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } +} diff --git a/res/img/element-icons/lock.svg b/res/img/element-icons/lock.svg new file mode 100644 index 0000000000..06fe52a391 --- /dev/null +++ b/res/img/element-icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/plus.svg b/res/img/element-icons/plus.svg new file mode 100644 index 0000000000..ea1972237d --- /dev/null +++ b/res/img/element-icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index b5e5966d91..726ff547ff 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -76,6 +76,7 @@ export interface IProps extends IPosition { hasBackground?: boolean; // whether this context menu should be focus managed. If false it must handle itself managed?: boolean; + wrapperClassName?: string; // Function to be called on menu close onFinished(); @@ -365,7 +366,7 @@ export class ContextMenu extends React.PureComponent { return (
{ + const avatarUploadRef = useRef(); + const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache + + let avatarSection; + if (avatarDisabled) { + if (avatar) { + avatarSection = ; + } else { + avatarSection =
; + } + } else { + if (avatar) { + avatarSection = + avatarUploadRef.current?.click()} + element="img" + src={avatar} + alt="" + /> + { + avatarUploadRef.current.value = ""; + setAvatarDataUrl(undefined); + setAvatar(undefined); + }} kind="link" className="mx_SpaceBasicSettings_avatar_remove"> + { _t("Delete") } + + ; + } else { + avatarSection = +
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 0000000000..9d0543a6c5 --- /dev/null +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -0,0 +1,175 @@ +/* +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, {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} 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 } +
+ ); +}; + +enum Visibility { + Public, + Private, +} + +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 index 760181e0e0..48e2c86b2c 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -20,6 +20,8 @@ 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"; @@ -112,9 +114,21 @@ const useSpaces = (): [Room[], Room | null] => { }; 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; @@ -203,12 +217,19 @@ const SpacePanel = () => { onExpand={() => setPanelCollapsed(false)} />) }
+ setPanelCollapsed(!isPanelCollapsed)} title={expandCollapseButtonTitle} /> + { contextMenu } )} diff --git a/src/createRoom.ts b/src/createRoom.ts index 9e3960cdb7..e773c51290 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -41,7 +41,7 @@ enum Visibility { Private = "private", } -enum Preset { +export enum Preset { PrivateChat = "private_chat", TrustedPrivateChat = "trusted_private_chat", PublicChat = "public_chat", @@ -54,7 +54,7 @@ interface Invite3PID { address: string; } -interface IStateEvent { +export interface IStateEvent { type: string; state_key?: string; // defaults to an empty string content: object; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 38460a5f6e..1b29e65b40 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -978,11 +978,27 @@ "From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", + "Delete": "Delete", + "Upload": "Upload", + "Name": "Name", + "Description": "Description", + "Create a space": "Create a space", + "Organise rooms into spaces, for just you or anyone": "Organise rooms into spaces, for just you or anyone", + "Public": "Public", + "Open space for anyone, best for communities": "Open space for anyone, best for communities", + "Private": "Private", + "Invite only space, best for yourself or teams": "Invite only space, best for yourself or teams", + "Go back": "Go back", + "Personalise your public space": "Personalise your public space", + "Personalise your private space": "Personalise your private space", + "Give it a photo, name and description to help you identify it.": "Give it a photo, name and description to help you identify it.", + "You can change these at any point.": "You can change these at any point.", + "Creating...": "Creating...", + "Create": "Create", "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", "Home": "Home", "Remove": "Remove", - "Upload": "Upload", "This bridge was provisioned by .": "This bridge was provisioned by .", "This bridge is managed by .": "This bridge is managed by .", "Workspace: ": "Workspace: ", @@ -1136,7 +1152,6 @@ "Disconnect anyway": "Disconnect anyway", "You are still sharing your personal data on the identity server .": "You are still sharing your personal data on the identity server .", "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.", - "Go back": "Go back", "Identity Server (%(server)s)": "Identity Server (%(server)s)", "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.", "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.", @@ -2011,7 +2026,6 @@ "You can change this later if needed.": "You can change this later if needed.", "What's the name of your community or team?": "What's the name of your community or team?", "Enter name": "Enter name", - "Create": "Create", "Add image (optional)": "Add image (optional)", "An image will help people identify your community.": "An image will help people identify your community.", "Community IDs cannot be empty.": "Community IDs cannot be empty.", @@ -2033,7 +2047,6 @@ "Create a public room": "Create a public room", "Create a private room": "Create a private room", "Create a room in %(communityName)s": "Create a room in %(communityName)s", - "Name": "Name", "Topic (optional)": "Topic (optional)", "Make this room public": "Make this room public", "Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.", @@ -2456,7 +2469,6 @@ "Your community hasn't got a Long Description, a HTML page to show to community members.
Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.
Click here to open settings and give it one!", "Long Description (HTML)": "Long Description (HTML)", "Upload avatar": "Upload avatar", - "Description": "Description", "Community %(groupId)s not found": "Community %(groupId)s not found", "This homeserver does not support communities": "This homeserver does not support communities", "Failed to load %(groupId)s": "Failed to load %(groupId)s", From c8fe3f76765108869e85a4b0140461210c0f5eff Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Mar 2021 17:54:53 +0000 Subject: [PATCH 2/4] Pass room creation opts for new rooms into RoomView --- src/components/structures/LoggedInView.tsx | 3 +++ src/components/structures/MatrixChat.tsx | 6 +++++- src/components/structures/RoomView.tsx | 3 +++ src/createRoom.ts | 6 +++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c01214f3f4..1694b4bcf5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -55,6 +55,7 @@ import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; import HostSignupContainer from '../views/host_signup/HostSignupContainer'; +import { IOpts } from "../../createRoom"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -91,6 +92,7 @@ interface IProps { currentGroupId?: string; currentGroupIsNew?: boolean; justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } interface IUsageLimit { @@ -619,6 +621,7 @@ class LoggedInView extends React.Component { viaServers={this.props.viaServers} key={this.props.currentRoomId || 'roomview'} resizeNotifier={this.props.resizeNotifier} + justCreatedOpts={this.props.roomJustCreatedOpts} />; break; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 5045e44182..8e3d3e6b5f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -48,7 +48,7 @@ import * as Lifecycle from '../../Lifecycle'; import '../../stores/LifecycleStore'; import PageTypes from '../../PageTypes'; -import createRoom from "../../createRoom"; +import createRoom, {IOpts} from "../../createRoom"; import {_t, _td, getCurrentLanguage} from '../../languageHandler'; import SettingsStore from "../../settings/SettingsStore"; import ThemeController from "../../settings/controllers/ThemeController"; @@ -144,6 +144,8 @@ interface IRoomInfo { oob_data?: object; via_servers?: string[]; threepid_invite?: IThreepidInvite; + + justCreatedOpts?: IOpts; } /* eslint-enable camelcase */ @@ -201,6 +203,7 @@ interface IState { viaServers?: string[]; pendingInitialSync?: boolean; justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } export default class MatrixChat extends React.PureComponent { @@ -922,6 +925,7 @@ export default class MatrixChat extends React.PureComponent { roomOobData: roomInfo.oob_data, viaServers: roomInfo.via_servers, ready: true, + roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { this.notifyNewScreen('room/' + presentedId, replaceLast); }); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 6c8560f42c..933514754c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -80,6 +80,8 @@ import { showToast as showNotificationsToast } from "../../toasts/DesktopNotific import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { objectHasDiff } from "../../utils/objects"; +import SpaceRoomView from "./SpaceRoomView"; +import { IOpts } from "../../createRoom"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -114,6 +116,7 @@ interface IProps { autoJoin?: boolean; resizeNotifier: ResizeNotifier; + justCreatedOpts?: IOpts; // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; diff --git a/src/createRoom.ts b/src/createRoom.ts index e773c51290..00a970eedc 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -75,7 +75,7 @@ interface ICreateOpts { power_level_content_override?: object; } -interface IOpts { +export interface IOpts { dmUserId?: string; createOpts?: ICreateOpts; spinner?: boolean; @@ -197,6 +197,9 @@ export default function createRoom(opts: IOpts): Promise { // room has been created, so we race here with the client knowing that // the room exists, causing things like // https://github.com/vector-im/vector-web/issues/1813 + // Even if we were to block on the echo, servers tend to split the room + // state over multiple syncs so we can't atomically know when we have the + // entire thing. if (opts.andView) { dis.dispatch({ action: 'view_room', @@ -206,6 +209,7 @@ export default function createRoom(opts: IOpts): Promise { // so we are expecting the room to come down the sync // stream, if it hasn't already. joining: true, + justCreatedOpts: opts, }); } CountlyAnalytics.instance.trackRoomCreate(startTime, roomId); From c10512fd569021089c34a8ea2b6083d0996f02b3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Mar 2021 18:10:17 +0000 Subject: [PATCH 3/4] Initial SpaceRoomView work --- res/css/_components.scss | 2 + res/css/structures/_SpaceRoomView.scss | 244 +++++++++ res/css/views/spaces/_SpacePublicShare.scss | 60 +++ res/img/element-icons/link.svg | 3 + res/themes/dark/css/_dark.scss | 1 + res/themes/legacy-dark/css/_legacy-dark.scss | 1 + .../legacy-light/css/_legacy-light.scss | 1 + res/themes/light/css/_light.scss | 1 + src/RoomInvite.js | 9 +- src/components/structures/RightPanel.js | 23 +- src/components/structures/RoomView.tsx | 20 +- src/components/structures/SpaceRoomView.tsx | 503 ++++++++++++++++++ src/components/views/dialogs/InviteDialog.tsx | 66 ++- .../views/spaces/SpacePublicShare.tsx | 65 +++ .../payloads/SetRightPanelPhasePayload.ts | 2 + src/hooks/useStateArray.ts | 29 + src/i18n/strings/en_EN.json | 41 +- src/stores/RightPanelStorePhases.ts | 12 + src/utils/space.ts | 28 + 19 files changed, 1066 insertions(+), 45 deletions(-) create mode 100644 res/css/structures/_SpaceRoomView.scss create mode 100644 res/css/views/spaces/_SpacePublicShare.scss create mode 100644 res/img/element-icons/link.svg create mode 100644 src/components/structures/SpaceRoomView.tsx create mode 100644 src/components/views/spaces/SpacePublicShare.tsx create mode 100644 src/hooks/useStateArray.ts create mode 100644 src/utils/space.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 8d6597aefa..ca66aa60ec 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -28,6 +28,7 @@ @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_SpacePanel.scss"; +@import "./structures/_SpaceRoomView.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @@ -235,6 +236,7 @@ @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/spaces/_SpaceBasicSettings.scss"; @import "./views/spaces/_SpaceCreateMenu.scss"; +@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss new file mode 100644 index 0000000000..559f405e59 --- /dev/null +++ b/res/css/structures/_SpaceRoomView.scss @@ -0,0 +1,244 @@ +/* +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. +*/ + +$SpaceRoomViewInnerWidth: 428px; + +.mx_SpaceRoomView { + .mx_MainSplit > div:first-child { + padding: 80px 60px; + flex-grow: 1; + + h1 { + margin: 0; + font-size: $font-24px; + font-weight: $font-semi-bold; + color: $primary-fg-color; + width: max-content; + } + + .mx_SpaceRoomView_description { + font-size: $font-15px; + color: $secondary-fg-color; + margin-top: 12px; + margin-bottom: 24px; + } + + .mx_SpaceRoomView_buttons { + display: block; + margin-top: 44px; + width: $SpaceRoomViewInnerWidth; + text-align: right; // button alignment right + + .mx_FormButton { + padding: 8px 22px; + margin-left: 16px; + } + } + + .mx_Field { + max-width: $SpaceRoomViewInnerWidth; + + & + .mx_Field { + margin-top: 28px; + } + } + + .mx_SpaceRoomView_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } + + .mx_SpaceRoomView_landing { + overflow-y: auto; + + > .mx_BaseAvatar_image, + > .mx_BaseAvatar > .mx_BaseAvatar_image { + border-radius: 12px; + } + + .mx_SpaceRoomView_landing_name { + margin: 24px 0 16px; + font-size: $font-15px; + color: $secondary-fg-color; + + > span { + display: inline-block; + } + + .mx_SpaceRoomView_landing_nameRow { + margin-top: 12px; + + > h1 { + display: inline-block; + } + } + + .mx_SpaceRoomView_landing_inviter { + .mx_BaseAvatar { + margin-right: 4px; + vertical-align: middle; + } + } + + .mx_SpaceRoomView_landing_memberCount { + position: relative; + margin-left: 24px; + padding: 0 0 0 28px; + line-height: $font-24px; + vertical-align: text-bottom; + + &::before { + position: absolute; + content: ''; + width: 24px; + height: 24px; + top: 0; + left: 0; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + } + + .mx_SpaceRoomView_landing_topic { + font-size: $font-15px; + } + + .mx_SpaceRoomView_landing_joinButtons { + margin-top: 24px; + + .mx_FormButton { + padding: 8px 22px; + } + } + } + + .mx_SpaceRoomView_privateScope { + .mx_RadioButton { + width: $SpaceRoomViewInnerWidth; + border-radius: 8px; + border: 1px solid $space-button-outline-color; + padding: 16px 16px 16px 72px; + margin-top: 36px; + cursor: pointer; + box-sizing: border-box; + position: relative; + + > div:first-of-type { + // hide radio dot + display: none; + } + + .mx_RadioButton_content { + margin: 0; + + > h3 { + margin: 0 0 4px; + font-size: $font-15px; + font-weight: $font-semi-bold; + line-height: $font-18px; + } + + > div { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + + &::before { + content: ""; + position: absolute; + height: 32px; + width: 32px; + top: 24px; + left: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .mx_RadioButton_checked { + border-color: $accent-color; + + .mx_RadioButton_content { + > div { + color: $primary-fg-color; + } + } + + &::before { + background-color: $accent-color; + } + } + + .mx_SpaceRoomView_privateScope_justMeButton::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + + .mx_SpaceRoomView_inviteTeammates { + .mx_SpaceRoomView_inviteTeammates_buttons { + color: $secondary-fg-color; + margin-top: 28px; + + .mx_AccessibleButton { + position: relative; + display: inline-block; + padding-left: 32px; + line-height: 24px; // to center icons + + &::before { + content: ""; + position: absolute; + height: 24px; + width: 24px; + top: 0; + left: 0; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + & + .mx_AccessibleButton { + margin-left: 32px; + } + } + + .mx_SpaceRoomView_inviteTeammates_inviteDialogButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + } +} diff --git a/res/css/views/spaces/_SpacePublicShare.scss b/res/css/views/spaces/_SpacePublicShare.scss new file mode 100644 index 0000000000..9ba0549ae3 --- /dev/null +++ b/res/css/views/spaces/_SpacePublicShare.scss @@ -0,0 +1,60 @@ +/* +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_SpacePublicShare { + .mx_AccessibleButton { + border: 1px solid $space-button-outline-color; + box-sizing: border-box; + border-radius: 8px; + padding: 12px 24px 12px 52px; + margin-top: 16px; + width: $SpaceRoomViewInnerWidth; + font-size: $font-15px; + line-height: $font-24px; + position: relative; + display: flex; + + > span { + color: #368bd6; + margin-left: auto; + } + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::before { + content: ""; + position: absolute; + width: 30px; + height: 30px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + background: $muted-fg-color; + left: 12px; + top: 9px; + } + + &.mx_SpacePublicShare_shareButton::before { + mask-image: url('$(res)/img/element-icons/link.svg'); + } + + &.mx_SpacePublicShare_inviteButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } +} diff --git a/res/img/element-icons/link.svg b/res/img/element-icons/link.svg new file mode 100644 index 0000000000..ab3d54b838 --- /dev/null +++ b/res/img/element-icons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index a878aa3cdd..0de5e69782 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -123,6 +123,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: rgba(141, 151, 165, 0.2); $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 3e3c299af9..8c5f20178b 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -120,6 +120,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: rgba(141, 151, 165, 0.2); $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index a740ba155c..3ba10a68ea 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -187,6 +187,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: #E3E8F0; $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 1c89d83c01..76bf2ddc21 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -181,6 +181,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: #E3E8F0; $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 06d3fb04e8..728ae11e79 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -22,7 +22,7 @@ import MultiInviter from './utils/MultiInviter'; import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; -import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import InviteDialog, {KIND_DM, KIND_INVITE, KIND_SPACE_INVITE} from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; @@ -75,6 +75,13 @@ export function showCommunityInviteDialog(communityId) { } } +export const showSpaceInviteDialog = (roomId) => { + Modal.createTrackedDialog("Invite Users", "Space", InviteDialog, { + kind: KIND_SPACE_INVITE, + roomId, + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); +}; + /** * Checks if the given MatrixEvent is a valid 3rd party user invite. * @param {MatrixEvent} event The event to check diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index d66049d3a5..3d9df2e927 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -24,7 +24,11 @@ import dis from '../../dispatcher/dispatcher'; import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; -import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases"; +import { + RightPanelPhases, + RIGHT_PANEL_PHASES_NO_ARGS, + RIGHT_PANEL_SPACE_PHASES, +} from "../../stores/RightPanelStorePhases"; import RightPanelStore from "../../stores/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import {Action} from "../../dispatcher/actions"; @@ -79,6 +83,8 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; + } else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { + return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // from its props and some from a store, except if the contents of the store changes @@ -99,9 +105,8 @@ export default class RightPanel extends React.Component { return rps.roomPanelPhase; } return RightPanelPhases.RoomMemberInfo; - } else { - return rps.roomPanelPhase; } + return rps.roomPanelPhase; } componentDidMount() { @@ -181,6 +186,7 @@ export default class RightPanel extends React.Component { verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, widgetId: payload.widgetId, + space: payload.space, }); } } @@ -232,6 +238,13 @@ export default class RightPanel extends React.Component { panel = ; } break; + case RightPanelPhases.SpaceMemberList: + panel = ; + break; case RightPanelPhases.GroupMemberList: if (this.props.groupId) { @@ -244,10 +257,11 @@ export default class RightPanel extends React.Component { break; case RightPanelPhases.RoomMemberInfo: + case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: panel = ; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 933514754c..1961779d0e 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1400,7 +1400,7 @@ export default class RoomView extends React.Component { }); }; - private onRejectButtonClicked = ev => { + private onRejectButtonClicked = () => { this.setState({ rejecting: true, }); @@ -1460,7 +1460,7 @@ export default class RoomView extends React.Component { } }; - private onRejectThreepidInviteButtonClicked = ev => { + private onRejectThreepidInviteButtonClicked = () => { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we // just ignore them. @@ -1723,7 +1723,7 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership == 'invite') { + if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself if (this.state.joining || this.state.rejecting) { return ( @@ -1852,7 +1852,7 @@ export default class RoomView extends React.Component { room={this.state.room} /> ); - if (!this.state.canPeek) { + if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { return (
{ previewBar } @@ -1874,6 +1874,18 @@ export default class RoomView extends React.Component { ); } + if (this.state.room?.isSpaceRoom()) { + return ; + } + const auxPanel = ( { + const members = useRoomMembers(room); + const count = members.length; + + if (children) return children(count); + return count; +}; + +const useMyRoomMembership = (room: Room) => { + const [membership, setMembership] = useState(room.getMyMembership()); + useEventEmitter(room, "Room.myMembership", () => { + setMembership(room.getMyMembership()); + }); + return membership; +}; + +const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { + const cli = useContext(MatrixClientContext); + const myMembership = useMyRoomMembership(space); + const joinRule = space.getJoinRule(); + const userId = cli.getUserId(); + + let joinButtons; + if (myMembership === "invite") { + joinButtons =
+ + + {_t("Decline")} + +
; + } else if (myMembership !== "join" && joinRule === "public") { + joinButtons =
+ +
; + } + + return
+ +
+ + {(name) => { + const tags = { name: () =>
+

{ name }

+ + {(count) => count > 0 ? ( + { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }} + > + { _t("%(count)s members", { count }) } + + ) : null} + +
}; + if (myMembership === "invite") { + const inviteSender = space.getMember(userId)?.events.member?.getSender(); + const inviter = inviteSender && space.getMember(inviteSender); + + if (inviteSender) { + return _t(" invited you to ", {}, { + name: tags.name, + inviter: () => inviter + ? + + { inviter.name } + + : + { inviteSender } + , + }) as JSX.Element; + } else { + return _t("You have been invited to ", {}, tags) as JSX.Element; + } + } else if (shouldShowSpaceSettings(cli, space)) { + if (space.getJoinRule() === "public") { + return _t("Your public space ", {}, tags) as JSX.Element; + } else { + return _t("Your private space ", {}, tags) as JSX.Element; + } + } + return _t("Welcome to ", {}, tags) as JSX.Element; + }} +
+
+
+ +
+ { joinButtons } +
; +}; + +const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const placeholders = [_t("General"), _t("Random"), _t("Support")]; + // TODO vary default prefills for "Just Me" spaces + const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "roomName" + i; + return setRoomName(i, ev.target.value)} + />; + }); + + const onNextClick = async () => { + setError(""); + setBusy(true); + try { + await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + return createRoom({ + createOpts: { + preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, + name, + }, + spinner: false, + encryption: false, + andView: false, + inlineErrors: true, + parentSpace: space, + }); + })); + onFinished(); + } catch (e) { + console.error("Failed to create initial space rooms", e); + setError(_t("Failed to create initial space rooms")); + } + setBusy(false); + }; + + let onClick = onFinished; + let buttonLabel = _t("Skip for now"); + if (roomNames.some(name => name.trim())) { + onClick = onNextClick; + buttonLabel = busy ? _t("Creating rooms...") : _t("Next") + } + + return
+

{ title }

+
{ description }
+ + { error &&
{ error }
} + { fields } + +
+ +
+
; +}; + +const SpaceSetupPublicShare = ({ space, onFinished }) => { + return
+

{ _t("Share your public space") }

+
{ _t("At the moment only you can see it.") }
+ + + +
+ +
+
; +}; + +const SpaceSetupPrivateScope = ({ onFinished }) => { + const [option, setOption] = useState(null); + + return
+

{ _t("Who are you working with?") }

+
{ _t("Ensure the right people have access to the space.") }
+ + +

{ _t("Just Me") }

+
{ _t("A private space just for you") }
+ , + }, { + value: "meAndMyTeammates", + className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton", + label: +

{ _t("Me and my teammates") }

+
{ _t("A private space for you and your teammates") }
+
, + }, + ]} + /> + +
+ onFinished(option !== "justMe")} /> +
+
; +}; + +const validateEmailRules = withValidation({ + rules: [{ + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }], +}); + +const SpaceSetupPrivateInvite = ({ space, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const fieldRefs: RefObject[] = [useRef(), useRef(), useRef()]; + const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "emailAddress" + i; + return setEmailAddress(i, ev.target.value)} + ref={fieldRefs[i]} + onValidate={validateEmailRules} + />; + }); + + const onNextClick = async () => { + setError(""); + for (let i = 0; i < fieldRefs.length; i++) { + const fieldRef = fieldRefs[i]; + const valid = await fieldRef.current.validate({ allowEmpty: true }); + + if (valid === false) { // true/null are allowed + fieldRef.current.focus(); + fieldRef.current.validate({ allowEmpty: true, focused: true }); + return; + } + } + + setBusy(true); + const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean); + try { + const result = await inviteMultipleToRoom(space.roomId, targetIds); + + const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error"); + if (failedUsers.length > 0) { + console.log("Failed to invite users to space: ", result); + setError(_t("Failed to invite the following users to your space: %(csvUsers)s", { + csvUsers: failedUsers.join(", "), + })); + } else { + onFinished(); + } + } catch (err) { + console.error("Failed to invite users to space: ", err); + setError(_t("We couldn't invite those users. Please check the users you want to invite and try again.")); + } + setBusy(false); + }; + + return
+

{ _t("Invite your teammates") }

+
{ _t("Ensure the right people have access to the space.") }
+ + { error &&
{ error }
} + { fields } + +
+ showSpaceInviteDialog(space.roomId)} + > + { _t("Invite by username") } + +
+ +
+ {_t("Skip for now")} + +
+
; +}; + +export default class SpaceRoomView extends React.PureComponent { + static contextType = MatrixClientContext; + + private readonly creator: string; + private readonly dispatcherRef: string; + private readonly rightPanelStoreToken: EventSubscription; + + constructor(props, context) { + super(props, context); + + let phase = Phase.Landing; + + this.creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); + const showSetup = this.props.justCreatedOpts && this.context.getUserId() === this.creator; + + if (showSetup) { + phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat + ? Phase.PublicCreateRooms : Phase.PrivateScope; + } + + this.state = { + phase, + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }; + + this.dispatcherRef = defaultDispatcher.register(this.onAction); + this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); + } + + componentWillUnmount() { + defaultDispatcher.unregister(this.dispatcherRef); + this.rightPanelStoreToken.remove(); + } + + private onRightPanelStoreUpdate = () => { + this.setState({ + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }); + }; + + private onAction = (payload: ActionPayload) => { + if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return; + + if (payload.action === Action.ViewUser && payload.member) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberInfo, + refireParams: { + space: this.props.space, + member: payload.member, + }, + }); + } else if (payload.action === "view_3pid_invite" && payload.event) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.Space3pidMemberInfo, + refireParams: { + space: this.props.space, + event: payload.event, + }, + }); + } else { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: this.props.space }, + }); + } + }; + + private renderBody() { + switch (this.state.phase) { + case Phase.Landing: + return ; + + case Phase.PublicCreateRooms: + return this.setState({ phase: Phase.PublicShare })} + />; + case Phase.PublicShare: + return this.setState({ phase: Phase.Landing })} + />; + + case Phase.PrivateScope: + return { + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); + }} + />; + case Phase.PrivateInvite: + return this.setState({ phase: Phase.PrivateCreateRooms })} + />; + case Phase.PrivateCreateRooms: + return this.setState({ phase: Phase.Landing })} + />; + } + } + + render() { + const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing + ? + : null; + + return
+ + + { this.renderBody() } + + +
; + } +} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 5b936e822c..9bc5b6476f 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, {createRef} from 'react'; -import {_t} from "../../../languageHandler"; +import {_t, _td} from "../../../languageHandler"; import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; @@ -48,6 +48,7 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; +export const KIND_SPACE_INVITE = "space_invite"; export const KIND_CALL_TRANSFER = "call_transfer"; const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first @@ -309,7 +310,7 @@ interface IInviteDialogProps { // not provided. kind: string, - // The room ID this dialog is for. Only required for KIND_INVITE. + // The room ID this dialog is for. Only required for KIND_INVITE and KIND_SPACE_INVITE. roomId: string, // The call to transfer. Only required for KIND_CALL_TRANSFER. @@ -348,8 +349,8 @@ export default class InviteDialog extends React.PureComponent) or " + - "share this room.", - {}, - { - userId: () => - {userId}, - a: (sub) => - - {sub} - , - }, - ); - } else { - helpText = _t( - "Invite someone using their name, username (like ) or share this room.", - {}, - { - userId: () => - {userId}, - a: (sub) => - - {sub} - , - }, - ); + let helpTextUntranslated; + if (this.props.kind === KIND_INVITE) { + if (identityServersEnabled) { + helpTextUntranslated = _td("Invite someone using their name, email address, username " + + "(like ) or share this room."); + } else { + helpTextUntranslated = _td("Invite someone using their name, username " + + "(like ) or share this room."); + } + } else { // KIND_SPACE_INVITE + if (identityServersEnabled) { + helpTextUntranslated = _td("Invite someone using their name, email address, username " + + "(like ) or share this space."); + } else { + helpTextUntranslated = _td("Invite someone using their name, username " + + "(like ) or share this space."); + } } + helpText = _t(helpTextUntranslated, {}, { + userId: () => + {userId}, + a: (sub) => + {sub}, + }); + buttonText = _t("Invite"); goButtonFn = this._inviteUsers; } else if (this.props.kind === KIND_CALL_TRANSFER) { diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx new file mode 100644 index 0000000000..064d1640a2 --- /dev/null +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -0,0 +1,65 @@ +/* +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 {_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/dispatcher/payloads/SetRightPanelPhasePayload.ts b/src/dispatcher/payloads/SetRightPanelPhasePayload.ts index 4126e8a669..430fad6145 100644 --- a/src/dispatcher/payloads/SetRightPanelPhasePayload.ts +++ b/src/dispatcher/payloads/SetRightPanelPhasePayload.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { ActionPayload } from "../payloads"; @@ -35,4 +36,5 @@ export interface SetRightPanelPhaseRefireParams { // XXX: The type for event should 'view_3pid_invite' action's payload event?: any; widgetId?: string; + space?: Room; } diff --git a/src/hooks/useStateArray.ts b/src/hooks/useStateArray.ts new file mode 100644 index 0000000000..e8ff6efff0 --- /dev/null +++ b/src/hooks/useStateArray.ts @@ -0,0 +1,29 @@ +/* +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 {useState} from "react"; + +// Hook to simplify managing state of arrays of a common type +export const useStateArray = (initialSize: number, initialState: T | T[]): [T[], (i: number, v: T) => void] => { + const [data, setData] = useState(() => { + return Array.isArray(initialState) ? initialState : new Array(initialSize).fill(initialState); + }); + return [data, (index: number, value: T) => setData(data => { + const copy = [...data]; + copy[index] = value; + return copy; + })] +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1b29e65b40..ae12b195a0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -998,6 +998,11 @@ "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", "Home": "Home", + "Click to copy": "Click to copy", + "Copied!": "Copied!", + "Failed to copy": "Failed to copy", + "Share invite link": "Share invite link", + "Invite by email or username": "Invite by email or username", "Remove": "Remove", "This bridge was provisioned by .": "This bridge was provisioned by .", "This bridge is managed by .": "This bridge is managed by .", @@ -1814,8 +1819,6 @@ "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", "Click here to see older messages.": "Click here to see older messages.", "This room is a continuation of another conversation.": "This room is a continuation of another conversation.", - "Copied!": "Copied!", - "Failed to copy": "Failed to copy", "Add an Integration": "Add an Integration", "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?", "Edited at %(date)s": "Edited at %(date)s", @@ -2164,8 +2167,11 @@ "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.", + "Invite someone using their name, username (like ) or share this space.": "Invite someone using their name, username (like ) or share this space.", "Transfer": "Transfer", "a new master key signature": "a new master key signature", "a new cross-signing key signature": "a new cross-signing key signature", @@ -2550,6 +2556,37 @@ "Failed to reject invite": "Failed to reject invite", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", + "Accept Invite": "Accept Invite", + "%(count)s members|other": "%(count)s members", + "%(count)s members|one": "%(count)s member", + " invited you to ": " invited you to ", + "You have been invited to ": "You have been invited to ", + "Your public space ": "Your public space ", + "Your private space ": "Your private space ", + "Welcome to ": "Welcome to ", + "Random": "Random", + "Support": "Support", + "Room name": "Room name", + "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?", + "Ensure the right people have access to the space.": "Ensure the right people have access to the space.", + "Just Me": "Just Me", + "A private space just for you": "A private space just for you", + "Me and my teammates": "Me and my teammates", + "A private space for you and your teammates": "A private space for you and your teammates", + "Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s", + "Invite your teammates": "Invite your teammates", + "Invite by username": "Invite by username", + "Inviting...": "Inviting...", + "What discussions do you want to have?": "What discussions do you want to have?", + "We'll create rooms for each topic.": "We'll create rooms for each topic.", + "What projects are you working on?": "What projects are you working on?", + "We'll create rooms for each of them. You can add existing rooms after setup.": "We'll create rooms for each of them. You can add existing rooms after setup.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position", diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts index 11b51dfc2d..aea78c7460 100644 --- a/src/stores/RightPanelStorePhases.ts +++ b/src/stores/RightPanelStorePhases.ts @@ -31,6 +31,11 @@ export enum RightPanelPhases { GroupRoomList = 'GroupRoomList', GroupRoomInfo = 'GroupRoomInfo', GroupMemberInfo = 'GroupMemberInfo', + + // Space stuff + SpaceMemberList = "SpaceMemberList", + SpaceMemberInfo = "SpaceMemberInfo", + Space3pidMemberInfo = "Space3pidMemberInfo", } // These are the phases that are safe to persist (the ones that don't require additional @@ -43,3 +48,10 @@ export const RIGHT_PANEL_PHASES_NO_ARGS = [ RightPanelPhases.GroupMemberList, RightPanelPhases.GroupRoomList, ]; + +// Subset of phases visible in the Space View +export const RIGHT_PANEL_SPACE_PHASES = [ + RightPanelPhases.SpaceMemberList, + RightPanelPhases.Space3pidMemberInfo, + RightPanelPhases.SpaceMemberInfo, +]; diff --git a/src/utils/space.ts b/src/utils/space.ts new file mode 100644 index 0000000000..85faedf5d6 --- /dev/null +++ b/src/utils/space.ts @@ -0,0 +1,28 @@ +/* +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 {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"; + +export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => { + const userId = cli.getUserId(); + return space.getMyMembership() === "join" + && (space.currentState.maySendStateEvent(EventType.RoomAvatar, userId) + || space.currentState.maySendStateEvent(EventType.RoomName, userId) + || space.currentState.maySendStateEvent(EventType.RoomTopic, userId) + || space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId)); +}; From 1a7a0e619d72eb3c0ec3f3626ebd802195a27a07 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Mar 2021 19:05:50 +0000 Subject: [PATCH 4/4] extend createRoom for creating rooms in a space --- src/createRoom.ts | 17 +++++++++++++++++ src/utils/permalinks/Permalinks.js | 16 ++++++++++++++-- src/utils/space.ts | 11 +++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/createRoom.ts b/src/createRoom.ts index 00a970eedc..a5343076ac 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -17,6 +17,7 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; @@ -31,6 +32,8 @@ import GroupStore from "./stores/GroupStore"; import CountlyAnalytics from "./CountlyAnalytics"; import { isJoinedOrNearlyJoined } from "./utils/membership"; import { VIRTUAL_ROOM_EVENT_TYPE } from "./CallHandler"; +import SpaceStore from "./stores/SpaceStore"; +import { makeSpaceParentEvent } from "./utils/space"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -84,6 +87,7 @@ export interface IOpts { inlineErrors?: boolean; andView?: boolean; associatedWithCommunity?: string; + parentSpace?: Room; } /** @@ -175,6 +179,16 @@ export default function createRoom(opts: IOpts): Promise { }); } + if (opts.parentSpace) { + opts.createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true)); + opts.createOpts.initial_state.push({ + type: EventType.RoomHistoryVisibility, + content: { + "history_visibility": opts.createOpts.preset === Preset.PublicChat ? "world_readable" : "invited", + }, + }); + } + let modal; if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); @@ -189,6 +203,9 @@ export default function createRoom(opts: IOpts): Promise { return Promise.resolve(); } }).then(() => { + if (opts.parentSpace) { + return SpaceStore.instance.addRoomToSpace(opts.parentSpace, roomId, [client.getDomain()], true); + } if (opts.associatedWithCommunity) { return GroupStore.addRoomToGroup(opts.associatedWithCommunity, roomId, false); } diff --git a/src/utils/permalinks/Permalinks.js b/src/utils/permalinks/Permalinks.js index 086abc669d..bcf4d87136 100644 --- a/src/utils/permalinks/Permalinks.js +++ b/src/utils/permalinks/Permalinks.js @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from "../../MatrixClientPeg"; import isIp from "is-ip"; -import * as utils from 'matrix-js-sdk/src/utils'; +import * as utils from "matrix-js-sdk/src/utils"; +import {Room} from "matrix-js-sdk/src/models/room"; + +import {MatrixClientPeg} from "../../MatrixClientPeg"; import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor"; import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; import ElementPermalinkConstructor from "./ElementPermalinkConstructor"; @@ -121,6 +123,10 @@ export class RoomPermalinkCreator { this._started = false; } + get serverCandidates() { + return this._serverCandidates; + } + isStarted() { return this._started; } @@ -451,3 +457,9 @@ function isHostnameIpAddress(hostname) { return isIp(hostname); } + +export const calculateRoomVia = (room: Room) => { + const permalinkCreator = new RoomPermalinkCreator(room); + permalinkCreator.load(); + return permalinkCreator.serverCandidates; +}; diff --git a/src/utils/space.ts b/src/utils/space.ts index 85faedf5d6..98801cabd0 100644 --- a/src/utils/space.ts +++ b/src/utils/space.ts @@ -18,6 +18,8 @@ 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 {calculateRoomVia} from "../utils/permalinks/Permalinks"; + export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => { const userId = cli.getUserId(); return space.getMyMembership() === "join" @@ -26,3 +28,12 @@ export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => { || space.currentState.maySendStateEvent(EventType.RoomTopic, userId) || space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId)); }; + +export const makeSpaceParentEvent = (room: Room, canonical = false) => ({ + type: EventType.SpaceParent, + content: { + "via": calculateRoomVia(room), + "canonical": canonical, + }, + state_key: room.roomId, +});