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] 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",