Merge pull request #5704 from matrix-org/t3chguy/spaces4.2

Initial Space Room View and Creation UX
This commit is contained in:
Michael Telatynski 2021-03-02 17:17:39 +00:00 committed by GitHub
commit 49fc053b94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1712 additions and 57 deletions

View file

@ -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<IInviteDialogProps
constructor(props) {
super(props);
if (props.kind === KIND_INVITE && !props.roomId) {
throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog");
if ((props.kind === KIND_INVITE || props.kind === KIND_SPACE_INVITE) && !props.roomId) {
throw new Error("When using KIND_INVITE or KIND_SPACE_INVITE a roomId is required for an InviteDialog");
} else if (props.kind === KIND_CALL_TRANSFER && !props.call) {
throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog");
}
@ -1026,7 +1027,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
sectionSubname = _t("May include members not in %(communityName)s", {communityName});
}
if (this.props.kind === KIND_INVITE) {
if (this.props.kind === KIND_INVITE || this.props.kind === KIND_SPACE_INVITE) {
sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions");
}
@ -1247,38 +1248,35 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
buttonText = _t("Go");
goButtonFn = this._startDm;
} else if (this.props.kind === KIND_INVITE) {
title = _t("Invite to this room");
} else if (this.props.kind === KIND_INVITE || this.props.kind === KIND_SPACE_INVITE) {
title = this.props.kind === KIND_INVITE ? _t("Invite to this room") : _t("Invite to this space");
if (identityServersEnabled) {
helpText = _t(
"Invite someone using their name, email address, username (like <userId/>) or " +
"<a>share this room</a>.",
{},
{
userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">
{sub}
</a>,
},
);
} else {
helpText = _t(
"Invite someone using their name, username (like <userId/>) or <a>share this room</a>.",
{},
{
userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">
{sub}
</a>,
},
);
let helpTextUntranslated;
if (this.props.kind === KIND_INVITE) {
if (identityServersEnabled) {
helpTextUntranslated = _td("Invite someone using their name, email address, username " +
"(like <userId/>) or <a>share this room</a>.");
} else {
helpTextUntranslated = _td("Invite someone using their name, username " +
"(like <userId/>) or <a>share this room</a>.");
}
} else { // KIND_SPACE_INVITE
if (identityServersEnabled) {
helpTextUntranslated = _td("Invite someone using their name, email address, username " +
"(like <userId/>) or <a>share this space</a>.");
} else {
helpTextUntranslated = _td("Invite someone using their name, username " +
"(like <userId/>) or <a>share this space</a>.");
}
}
helpText = _t(helpTextUntranslated, {}, {
userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
});
buttonText = _t("Invite");
goButtonFn = this._inviteUsers;
} else if (this.props.kind === KIND_CALL_TRANSFER) {

View file

@ -0,0 +1,120 @@
/*
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, {useRef, useState} from "react";
import {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
interface IProps {
avatarUrl?: string;
avatarDisabled?: boolean;
name?: string,
nameDisabled?: boolean;
topic?: string;
topicDisabled?: boolean;
setAvatar(avatar: File): void;
setName(name: string): void;
setTopic(topic: string): void;
}
const SpaceBasicSettings = ({
avatarUrl,
avatarDisabled = false,
setAvatar,
name = "",
nameDisabled = false,
setName,
topic = "",
topicDisabled = false,
setTopic,
}: IProps) => {
const avatarUploadRef = useRef<HTMLInputElement>();
const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache
let avatarSection;
if (avatarDisabled) {
if (avatar) {
avatarSection = <img className="mx_SpaceBasicSettings_avatar" src={avatar} alt="" />;
} else {
avatarSection = <div className="mx_SpaceBasicSettings_avatar" />;
}
} else {
if (avatar) {
avatarSection = <React.Fragment>
<AccessibleButton
className="mx_SpaceBasicSettings_avatar"
onClick={() => avatarUploadRef.current?.click()}
element="img"
src={avatar}
alt=""
/>
<AccessibleButton onClick={() => {
avatarUploadRef.current.value = "";
setAvatarDataUrl(undefined);
setAvatar(undefined);
}} kind="link" className="mx_SpaceBasicSettings_avatar_remove">
{ _t("Delete") }
</AccessibleButton>
</React.Fragment>;
} else {
avatarSection = <React.Fragment>
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
<AccessibleButton onClick={() => avatarUploadRef.current?.click()} kind="link">
{ _t("Upload") }
</AccessibleButton>
</React.Fragment>;
}
}
return <div className="mx_SpaceBasicSettings">
<div className="mx_SpaceBasicSettings_avatarContainer">
{ avatarSection }
<input type="file" ref={avatarUploadRef} onChange={(e) => {
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/*" />
</div>
<Field
name="spaceName"
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => setName(ev.target.value)}
disabled={nameDisabled}
/>
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
disabled={topicDisabled}
/>
</div>;
};
export default SpaceBasicSettings;

View file

@ -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 (
<AccessibleButton className={classNames("mx_SpaceCreateMenuType", className)} onClick={onClick}>
<h3>{ title }</h3>
<span>{ description }</span>
</AccessibleButton>
);
};
enum Visibility {
Public,
Private,
}
const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null);
const [name, setName] = useState("");
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const [busy, setBusy] = useState<boolean>(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 = <React.Fragment>
<h2>{ _t("Create a space") }</h2>
<p>{ _t("Organise rooms into spaces, for just you or anyone") }</p>
<SpaceCreateMenuType
title={_t("Public")}
description={_t("Open space for anyone, best for communities")}
className="mx_SpaceCreateMenuType_public"
onClick={() => setVisibility(Visibility.Public)}
/>
<SpaceCreateMenuType
title={_t("Private")}
description={_t("Invite only space, best for yourself or teams")}
className="mx_SpaceCreateMenuType_private"
onClick={() => setVisibility(Visibility.Private)}
/>
{/*<p>{ _t("Looking to join an existing space?") }</p>*/}
</React.Fragment>;
} else {
body = <React.Fragment>
<AccessibleTooltipButton
className="mx_SpaceCreateMenu_back"
onClick={() => setVisibility(null)}
title={_t("Go back")}
/>
<h2>
{
visibility === Visibility.Public
? _t("Personalise your public space")
: _t("Personalise your private space")
}
</h2>
<p>
{
_t("Give it a photo, name and description to help you identify it.")
} {
_t("You can change these at any point.")
}
</p>
<SpaceBasicSettings setAvatar={setAvatar} name={name} setName={setName} topic={topic} setTopic={setTopic} />
<FormButton
label={busy ? _t("Creating...") : _t("Create")}
onClick={onSpaceCreateClick}
disabled={!name && !busy}
/>
</React.Fragment>;
}
return <ContextMenu
left={72}
top={62}
chevronOffset={0}
chevronFace={ChevronFace.None}
onFinished={onFinished}
wrapperClassName="mx_SpaceCreateMenu_wrapper"
managed={false}
>
<FocusLock returnFocus={true}>
{ body }
</FocusLock>
</ContextMenu>;
}
export default SpaceCreateMenu;

View file

@ -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<void>();
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 = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
@ -203,12 +217,19 @@ const SpacePanel = () => {
onExpand={() => setPanelCollapsed(false)}
/>) }
</div>
<SpaceButton
className={newClasses}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={menuDisplayed ? closeMenu : openMenu}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", {expanded: !isPanelCollapsed})}
onClick={evt => setPanelCollapsed(!isPanelCollapsed)}
title={expandCollapseButtonTitle}
/>
{ contextMenu }
</ul>
)}
</RovingTabIndexProvider>

View file

@ -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 <div className="mx_SpacePublicShare">
<AccessibleButton
className="mx_SpacePublicShare_shareButton"
onClick={async () => {
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") }
<span>{ copiedText }</span>
</AccessibleButton>
<AccessibleButton
className="mx_SpacePublicShare_inviteButton"
onClick={() => {
showSpaceInviteDialog(space.roomId);
onFinished();
}}
>
{ _t("Invite by email or username") }
</AccessibleButton>
</div>;
};
export default SpacePublicShare;