Merge pull request #5704 from matrix-org/t3chguy/spaces4.2
Initial Space Room View and Creation UX
This commit is contained in:
commit
49fc053b94
32 changed files with 1712 additions and 57 deletions
|
@ -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) {
|
||||
|
|
120
src/components/views/spaces/SpaceBasicSettings.tsx
Normal file
120
src/components/views/spaces/SpaceBasicSettings.tsx
Normal 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;
|
175
src/components/views/spaces/SpaceCreateMenu.tsx
Normal file
175
src/components/views/spaces/SpaceCreateMenu.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
65
src/components/views/spaces/SpacePublicShare.tsx
Normal file
65
src/components/views/spaces/SpacePublicShare.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue