Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/a11y/focus-lock-ctx-menu

 Conflicts:
	src/components/views/spaces/SpaceCreateMenu.tsx
This commit is contained in:
Michael Telatynski 2021-10-06 16:38:45 +01:00
commit a6c780674a
952 changed files with 46775 additions and 36622 deletions

View file

@ -57,18 +57,27 @@ export const SpaceAvatar = ({
src={avatar}
alt=""
/>
<AccessibleButton onClick={() => {
avatarUploadRef.current.value = "";
setAvatarDataUrl(undefined);
setAvatar(undefined);
}} kind="link" className="mx_SpaceBasicSettings_avatar_remove">
<AccessibleButton
onClick={() => {
avatarUploadRef.current.value = "";
setAvatarDataUrl(undefined);
setAvatar(undefined);
}}
kind="link"
className="mx_SpaceBasicSettings_avatar_remove"
aria-label={_t("Delete avatar")}
>
{ _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">
<AccessibleButton
onClick={() => avatarUploadRef.current?.click()}
kind="link"
aria-label={_t("Upload avatar")}
>
{ _t("Upload") }
</AccessibleButton>
</React.Fragment>;
@ -77,16 +86,21 @@ export const SpaceAvatar = ({
return <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/*" />
<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>;
};

View file

@ -14,27 +14,63 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useRef, useState } from "react";
import React, { ComponentProps, RefObject, SyntheticEvent, KeyboardEvent, useContext, useRef, useState } from "react";
import classNames from "classnames";
import { EventType, RoomType, RoomCreateTypeField } from "matrix-js-sdk/src/@types/event";
import { Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import createRoom from "../../../createRoom";
import createRoom, { IOpts as ICreateOpts } from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { SpaceAvatar } from "./SpaceBasicSettings";
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
import RoomAliasField from "../elements/RoomAliasField";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog";
import SettingsStore from "../../../settings/SettingsStore";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import RoomAliasField from "../elements/RoomAliasField";
import { Key } from "../../../Keyboard";
export const createSpace = async (
name: string,
isPublic: boolean,
alias?: string,
topic?: string,
avatar?: string | File,
createOpts: Partial<ICreateRoomOpts> = {},
otherOpts: Partial<Omit<ICreateOpts, "createOpts">> = {},
) => {
return createRoom({
createOpts: {
name,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
invite: isPublic ? 0 : 50,
},
room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
topic,
...createOpts,
},
avatar,
roomType: RoomType.Space,
historyVisibility: isPublic ? HistoryVisibility.WorldReadable : HistoryVisibility.Invited,
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
...otherOpts,
});
};
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
@ -60,9 +96,121 @@ const spaceNameValidator = withValidation({
],
});
const nameToAlias = (name: string, domain: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
const nameToLocalpart = (name: string): string => {
return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
};
// XXX: Temporary for the Spaces release only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a new feature.") }</span>
<AccessibleButton
kind="link"
onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Spaces Feedback", "", GenericFeatureFeedbackDialog, {
title: _t("Spaces feedback"),
subheading: _t("Thank you for trying Spaces. " +
"Your feedback will help inform the next versions."),
rageshakeLabel: "spaces-feedback",
rageshakeData: Object.fromEntries([
"Spaces.allRoomsInHome",
].map(k => [k, SettingsStore.getValue(k)])),
});
}}
>
{ _t("Give feedback.") }
</AccessibleButton>
</div>;
};
type BProps = Omit<ComponentProps<typeof SpaceBasicSettings>, "nameDisabled" | "topicDisabled" | "avatarDisabled">;
interface ISpaceCreateFormProps extends BProps {
busy: boolean;
alias: string;
nameFieldRef: RefObject<Field>;
aliasFieldRef: RefObject<RoomAliasField>;
showAliasField?: boolean;
onSubmit(e: SyntheticEvent): void;
setAlias(alias: string): void;
}
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
busy,
onSubmit,
avatarUrl,
setAvatar,
name,
setName,
nameFieldRef,
alias,
aliasFieldRef,
setAlias,
showAliasField,
topic,
setTopic,
children,
}) => {
const cli = useContext(MatrixClientContext);
const domain = cli.getDomain();
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === Key.ENTER) {
onSubmit(ev);
}
};
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
<SpaceAvatar avatarUrl={avatarUrl} setAvatar={setAvatar} avatarDisabled={busy} />
<Field
name="spaceName"
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => {
const newName = ev.target.value;
if (!alias || alias === `#${nameToLocalpart(name)}:${domain}`) {
setAlias(`#${nameToLocalpart(newName)}:${domain}`);
aliasFieldRef.current?.validate({ allowEmpty: true });
}
setName(newName);
}}
onKeyDown={onKeyDown}
ref={nameFieldRef}
onValidate={spaceNameValidator}
disabled={busy}
autoComplete="off"
/>
{ showAliasField
? <RoomAliasField
ref={aliasFieldRef}
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToLocalpart(name) : _t("e.g. my-space")}
label={_t("Address")}
disabled={busy}
onKeyDown={onKeyDown}
/>
: null
}
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
disabled={busy}
/>
{ children }
</form>;
};
const SpaceCreateMenu = ({ onFinished }) => {
@ -83,61 +231,32 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
// validate the space alias field but do not require it
const aliasLocalpart = alias.substring(1, alias.length - cli.getDomain().length - 1);
if (visibility === Visibility.Public && aliasLocalpart &&
(await spaceAliasField.current.validate({ allowEmpty: true })) === false
) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
const initialState: ICreateRoomStateEvent[] = [
{
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 },
});
}
try {
await createRoom({
createOpts: {
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
creation_content: {
[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,
...Visibility.Public ? { invite: 0 } : {},
},
room_alias_name: visibility === Visibility.Public && alias
? alias.substr(1, alias.indexOf(":") - 1)
: undefined,
topic,
},
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
});
await createSpace(
name,
visibility === Visibility.Public,
aliasLocalpart ? alias : undefined,
topic,
avatar,
);
onFinished();
} catch (e) {
@ -147,10 +266,23 @@ const SpaceCreateMenu = ({ onFinished }) => {
let body;
if (visibility === null) {
const onCreateSpaceFromCommunityClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
onFinished();
};
body = <React.Fragment>
<h2>{ _t("Create a space") }</h2>
<p>{ _t("Spaces are a new way to group rooms and people. " +
"To join an existing space you'll need an invite.") }</p>
<p>
{ _t("Spaces are a new way to group rooms and people.") }
&nbsp;
{ _t("What kind of Space do you want to create?") }
&nbsp;
{ _t("You can change this later.") }
</p>
<SpaceCreateMenuType
title={_t("Public")}
@ -165,12 +297,19 @@ const SpaceCreateMenu = ({ onFinished }) => {
onClick={() => setVisibility(Visibility.Private)}
/>
<p>{ _t("You can change this later") }</p>
<p>
{ _t("You can also make Spaces from <a>communities</a>.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
{ sub }
</AccessibleButton>,
}) }
<br />
{ _t("To join a space you'll need an invite.") }
</p>
<SpaceFeedbackPrompt onClick={onFinished} />
</React.Fragment>;
} else {
const domain = cli.getDomain();
body = <React.Fragment>
<AccessibleTooltipButton
className="mx_SpaceCreateMenu_back"
@ -191,48 +330,20 @@ const SpaceCreateMenu = ({ onFinished }) => {
}
</p>
<form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}>
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
<Field
name="spaceName"
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => {
const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) {
setAlias(nameToAlias(newName, domain));
}
setName(newName);
}}
ref={spaceNameField}
onValidate={spaceNameValidator}
disabled={busy}
/>
{ visibility === Visibility.Public
? <RoomAliasField
ref={spaceAliasField}
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
label={_t("Address")}
/>
: null
}
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
disabled={busy}
/>
</form>
<SpaceCreateForm
busy={busy}
onSubmit={onSpaceCreateClick}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={visibility === Visibility.Public}
aliasFieldRef={spaceAliasField}
/>
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
{ busy ? _t("Creating...") : _t("Create") }
@ -249,13 +360,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
wrapperClassName="mx_SpaceCreateMenu_wrapper"
managed={false}
>
<BetaPill onClick={() => {
onFinished();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}} />
{ body }
</ContextMenu>;
};

View file

@ -14,112 +14,56 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
import React, {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import classNames from "classnames";
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 { SpaceButton, SpaceItem } from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import SpaceStore, {
HOME_SPACE,
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES,
} from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import NotificationBadge from "../rooms/NotificationBadge";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
interface IButtonProps {
space?: Room;
className?: string;
selected?: boolean;
tooltip?: string;
notificationState?: NotificationState;
isNarrow?: boolean;
onClick(): void;
}
const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
tooltip,
notificationState,
isNarrow,
children,
}) => {
const classes = classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_narrow: isNarrow,
});
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={32} height={32} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge forceCount={false} notification={notificationState} />
</div>;
}
let button;
if (isNarrow) {
button = (
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ notifBadge }
{ children }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
<span className="mx_SpaceButton_name">{ tooltip }</span>
{ notifBadge }
{ children }
</div>
</RovingAccessibleButton>
);
}
return <li className={classNames({
"mx_SpaceItem": true,
"collapsed": isNarrow,
})}>
{ button }
</li>;
};
import { SettingLevel } from "../../../settings/SettingLevel";
import UIStore from "../../../stores/UIStore";
const useSpaces = (): [Room[], Room[], Room | null] => {
const [invites, setInvites] = useState<Room[]>(SpaceStore.instance.invitedSpaces);
useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites);
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces);
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace);
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace);
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
return SpaceStore.instance.invitedSpaces;
});
const spaces = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => {
return SpaceStore.instance.spacePanelSpaces;
});
const activeSpace = useEventEmitterState<Room>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpace;
});
return [invites, spaces, activeSpace];
};
@ -129,60 +73,71 @@ interface IInnerSpacePanelProps {
setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
}
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : [];
const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps<typeof SpaceContextMenu>) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms")
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ _t("Home") }
</div>
<IconizedContextMenuOptionList first>
<IconizedContextMenuCheckbox
iconClassName="mx_SpacePanel_noIcon"
label={_t("Show all rooms")}
active={allRoomsInHome}
onClick={() => {
SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
return <div className="mx_SpaceTreeLevel">
interface IHomeButtonProps {
selected: boolean;
isPanelCollapsed: boolean;
}
const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <li
className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}
role="treeitem"
>
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
selected={selected}
label={allRoomsInHome ? _t("All rooms") : _t("Home")}
notificationState={allRoomsInHome
? RoomNotificationStateStore.instance.globalState
: SpaceStore.instance.getNotificationState(HOME_SPACE)}
isNarrow={isPanelCollapsed}
ContextMenuComponent={HomeButtonContextMenu}
contextMenuTooltip={_t("Options")}
/>
{ invites.map(s => (
<SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)) }
{ spaces.map((s, i) => (
<Draggable key={s.roomId} draggableId={s.roomId} index={i}>
{(provided, snapshot) => (
<SpaceItem
{...provided.draggableProps}
{...provided.dragHandleProps}
key={s.roomId}
innerRef={provided.innerRef}
className={snapshot.isDragging
? "mx_SpaceItem_dragging"
: undefined}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)}
</Draggable>
)) }
{ children }
</div>;
});
</li>;
};
const SpacePanel = () => {
const CreateSpaceButton = ({
isPanelCollapsed,
setPanelCollapsed,
}: Pick<IInnerSpacePanelProps, "isPanelCollapsed" | "setPanelCollapsed">) => {
// 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 [isPanelCollapsed, setPanelCollapsed] = useState(true);
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
@ -195,7 +150,87 @@ const SpacePanel = () => {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onNewClick = menuDisplayed ? closeMenu : () => {
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpaces", "1");
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
let betaDot: JSX.Element;
if (!localStorage.getItem("mx_seenSpaces") && !SpaceStore.instance.spacePanelSpaces.length) {
betaDot = <div className="mx_BetaDot" />;
}
return <li
className={classNames("mx_SpaceItem mx_SpaceItem_new", {
"collapsed": isPanelCollapsed,
})}
role="treeitem"
>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
label={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
{ betaDot }
{ contextMenu }
</li>;
};
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : [];
return <div className="mx_SpaceTreeLevel">
<HomeButton selected={!activeSpace} isPanelCollapsed={isPanelCollapsed} />
{ invites.map(s => (
<SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)) }
{ spaces.map((s, i) => (
<Draggable key={s.roomId} draggableId={s.roomId} index={i}>
{ (provided, snapshot) => (
<SpaceItem
{...provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
key={s.roomId}
innerRef={provided.innerRef}
className={snapshot.isDragging ? "mx_SpaceItem_dragging" : undefined}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
) }
</Draggable>
)) }
{ children }
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
</div>;
});
const SpacePanel = () => {
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const ref = useRef<HTMLUListElement>();
useLayoutEffect(() => {
UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
}, []);
const onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.defaultPrevented) return;
let handled = true;
switch (ev.key) {
@ -256,24 +291,22 @@ const SpacePanel = () => {
}
};
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return (
<DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
}}>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({ onKeyDownHandler }) => (
{ ({ onKeyDownHandler }) => (
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Spaces")}
ref={ref}
>
<Droppable droppableId="top-level-spaces">
{(provided, snapshot) => (
{ (provided, snapshot) => (
<AutoHideScrollbar
{...provided.droppableProps}
wrappedRef={provided.innerRef}
@ -288,26 +321,16 @@ const SpacePanel = () => {
>
{ provided.placeholder }
</InnerSpacePanel>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
)}
) }
</Droppable>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", { expanded: !isPanelCollapsed })}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/>
{ contextMenu }
</ul>
)}
) }
</RovingTabIndexProvider>
</DragDropContext>
);

View file

@ -16,11 +16,11 @@ limitations under the License.
import React, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { sleep } from "matrix-js-sdk/src/utils";
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 { showRoomInviteDialog } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -39,7 +39,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
onClick={async () => {
const permalinkCreator = new RoomPermalinkCreator(space);
permalinkCreator.load();
const success = await copyPlaintext(permalinkCreator.forRoom());
const success = await copyPlaintext(permalinkCreator.forShareableRoom());
const text = success ? _t("Copied!") : _t("Failed to copy");
setCopiedText(text);
await sleep(5000);
@ -54,8 +54,8 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
{ space.canInvite(MatrixClientPeg.get()?.getUserId()) ? <AccessibleButton
className="mx_SpacePublicShare_inviteButton"
onClick={() => {
showRoomInviteDialog(space.roomId);
if (onFinished) onFinished();
showRoomInviteDialog(space.roomId);
}}
>
<h3>{ _t("Invite people") }</h3>

View file

@ -21,12 +21,11 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import SpaceBasicSettings from "./SpaceBasicSettings";
import { avatarUrlForRoom } from "../../../Avatar";
import { IDialogProps } from "../dialogs/IDialogProps";
import { getTopic } from "../elements/RoomTopic";
import { defaultDispatcher } from "../../../dispatcher/dispatcher";
import { leaveSpace } from "../../../utils/space";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -96,8 +95,6 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<div className="mx_SettingsTab_section">
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
@ -123,15 +120,12 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
</AccessibleButton>
</div>
<span className="mx_SettingsTab_subheading">{_t("Leave Space")}</span>
<span className="mx_SettingsTab_subheading">{ _t("Leave Space") }</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<AccessibleButton
kind="danger"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
leaveSpace(space);
}}
>
{ _t("Leave Space") }

View file

@ -18,56 +18,29 @@ import React, { useState } from "react";
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 { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import AliasSettings from "../room_settings/AliasSettings";
import { useStateToggle } from "../../../hooks/useStateToggle";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { GuestAccess, HistoryVisibility, JoinRule } from "../settings/tabs/room/SecurityRoomSettingsTab";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
import JoinRuleSettings from "../settings/JoinRuleSettings";
import { useRoomState } from "../../../hooks/useRoomState";
interface IProps {
matrixClient: MatrixClient;
space: Room;
closeSettingsFn(): void;
}
enum SpaceVisibility {
Unlisted = "unlisted",
Private = "private",
}
const useLocalEcho = <T extends any>(
currentFactory: () => T,
setterFn: (value: T) => Promise<void>,
errorFn: (error: Error) => void,
): [value: T, handler: (value: T) => void] => {
const [value, setValue] = useState(currentFactory);
const handler = async (value: T) => {
setValue(value);
try {
await setterFn(value);
} catch (e) {
setValue(currentFactory());
errorFn(e);
}
};
return [value, handler];
};
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space, closeSettingsFn }: IProps) => {
const [error, setError] = useState("");
const userId = cli.getUserId();
const [visibility, setVisibility] = useLocalEcho<SpaceVisibility>(
() => space.getJoinRule() === JoinRule.Invite ? SpaceVisibility.Private : SpaceVisibility.Unlisted,
visibility => cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, {
join_rule: visibility === SpaceVisibility.Unlisted ? JoinRule.Public : JoinRule.Invite,
}, ""),
() => setError(_t("Failed to update the visibility of this space")),
);
const joinRule = useRoomState(space, state => state.getJoinRule());
const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho<boolean>(
() => space.currentState.getStateEvents(EventType.RoomGuestAccess, "")
?.getContent()?.guest_access === GuestAccess.CanJoin,
@ -87,43 +60,44 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
const [showAdvancedSection, toggleAdvancedSection] = useStateToggle();
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId);
const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId);
const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli);
const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
let advancedSection;
if (showAdvancedSection) {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Hide advanced") }
</AccessibleButton>
if (joinRule === JoinRule.Public) {
if (showAdvancedSection) {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Hide advanced") }
</AccessibleButton>
<LabelledToggleSwitch
value={guestAccessEnabled}
onChange={setGuestAccessEnabled}
disabled={!canSetGuestAccess}
label={_t("Enable guest access")}
/>
<p>
{ _t("Guests can join a space without having an account.") }
<br />
{ _t("This may be useful for public spaces.") }
</p>
</>;
} else {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Show advanced") }
</AccessibleButton>
</>;
<LabelledToggleSwitch
value={guestAccessEnabled}
onChange={setGuestAccessEnabled}
disabled={!canSetGuestAccess}
label={_t("Enable guest access")}
/>
<p>
{ _t("Guests can join a space without having an account.") }
<br />
{ _t("This may be useful for public spaces.") }
</p>
</>;
} else {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Show advanced") }
</AccessibleButton>
</>;
}
}
let addressesSection;
if (visibility !== SpaceVisibility.Private) {
if (space.getJoinRule() === JoinRule.Public) {
addressesSection = <>
<span className="mx_SettingsTab_subheading">{_t("Address")}</span>
<span className="mx_SettingsTab_subheading">{ _t("Address") }</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<AliasSettings
roomId={space.roomId}
@ -147,22 +121,10 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
</div>
<div>
<StyledRadioGroup
name="spaceVisibility"
value={visibility}
onChange={setVisibility}
disabled={!canSetJoinRule}
definitions={[
{
value: SpaceVisibility.Unlisted,
label: _t("Public"),
description: _t("anyone with the link can view and join"),
}, {
value: SpaceVisibility.Private,
label: _t("Invite only"),
description: _t("only invited people can view and join"),
},
]}
<JoinRuleSettings
room={space}
onError={() => setError(_t("Failed to update the visibility of this space"))}
closeSettingsFn={closeSettingsFn}
/>
</div>

View file

@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, InputHTMLAttributes, LegacyRef } from "react";
import React, {
createRef,
MouseEvent,
InputHTMLAttributes,
LegacyRef,
ComponentProps,
ComponentType,
} from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
@ -22,32 +29,118 @@ import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf } from "../../structures/ContextMenu";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import { toRightOf, useContextMenu } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { EventType } from "matrix-js-sdk/src/@types/event";
import AccessibleButton from "../elements/AccessibleButton";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
interface IButtonProps extends Omit<ComponentProps<typeof AccessibleTooltipButton>, "title"> {
space?: Room;
className?: string;
selected?: boolean;
label: string;
contextMenuTooltip?: string;
notificationState?: NotificationState;
isNarrow?: boolean;
avatarSize?: number;
ContextMenuComponent?: ComponentType<ComponentProps<typeof SpaceContextMenu>>;
onClick(ev: MouseEvent): void;
}
export const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
label,
contextMenuTooltip,
notificationState,
avatarSize,
isNarrow,
children,
ContextMenuComponent,
...props
}) => {
const [menuDisplayed, ref, openMenu, closeMenu] = useContextMenu<HTMLElement>();
const [onFocus, isActive, handle] = useRovingTabIndex(ref);
const tabIndex = isActive ? 0 : -1;
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={avatarSize} height={avatarSize} room={space} />;
}
let notifBadge;
if (notificationState) {
let ariaLabel = _t("Jump to first unread room.");
if (space?.getMyMembership() === "invite") {
ariaLabel = _t("Jump to first invite.");
}
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
aria-label={ariaLabel}
tabIndex={tabIndex}
showUnsentTooltip={true}
/>
</div>;
}
let contextMenu: JSX.Element;
if (menuDisplayed && ContextMenuComponent) {
contextMenu = <ContextMenuComponent
{...toRightOf(handle.current?.getBoundingClientRect(), 0)}
space={space}
onFinished={closeMenu}
/>;
}
return (
<AccessibleTooltipButton
{...props}
className={classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_hasMenuOpen: menuDisplayed,
mx_SpaceButton_narrow: isNarrow,
})}
title={label}
onClick={onClick}
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
inputRef={handle}
tabIndex={tabIndex}
onFocus={onFocus}
>
{ children }
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ !isNarrow && <span className="mx_SpaceButton_name">{ label }</span> }
{ notifBadge }
{ ContextMenuComponent && <ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={openMenu}
title={contextMenuTooltip}
isExpanded={menuDisplayed}
/> }
{ contextMenu }
</div>
</AccessibleTooltipButton>
);
};
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
space?: Room;
@ -57,11 +150,11 @@ interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
onExpand?: Function;
parents?: Set<string>;
innerRef?: LegacyRef<HTMLLIElement>;
dragHandleProps?: DraggableProvidedDragHandleProps;
}
interface IItemState {
collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[];
}
@ -81,7 +174,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = {
collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces,
};
@ -124,19 +216,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
evt.stopPropagation();
};
private onContextMenu = (ev: React.MouseEvent) => {
if (this.props.space.getMyMembership() !== "join") return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
right: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
const action = getKeyBindingsManager().getRoomListAction(ev);
@ -180,196 +259,13 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
SpaceStore.instance.setActiveSpace(this.props.space);
};
private onMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onMenuClose = () => {
this.setState({ contextMenuPosition: null });
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(this.context, this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "leave_room",
room_id: this.props.space.roomId,
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(this.context, this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(this.context, this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: this.props.space },
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private renderContextMenu(): React.ReactElement {
if (this.props.space.getMyMembership() !== "join") return null;
let contextMenu = null;
if (this.state.contextMenuPosition) {
const userId = this.context.getUserId();
let inviteOption;
if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) {
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={this.onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(this.context, this.props.space)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={this.onSettingsClick}
/>
);
} else {
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={this.onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={this.onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={this.onAddExistingRoomClick}
/>
</IconizedContextMenuOptionList>;
}
contextMenu = <IconizedContextMenu
{...toRightOf(this.state.contextMenuPosition, 0)}
onFinished={this.onMenuClose}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ this.props.space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={this.onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={this.onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={this.onMenuOpenClick}
title={_t("Space options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{ contextMenu }
</React.Fragment>
);
}
render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, dragHandleProps,
...otherProps } = this.props;
const collapsed = this.isCollapsed;
const isActive = activeSpaces.includes(space);
const itemClasses = classNames(this.props.className, {
"mx_SpaceItem": true,
"mx_SpaceItem_narrow": isPanelCollapsed,
@ -378,18 +274,15 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
});
const isInvite = space.getMyMembership() === "invite";
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isPanelCollapsed,
mx_SpaceButton_invite: isInvite,
});
const notificationState = isInvite
? StaticNotificationState.forSymbol("!", NotificationColor.Red)
: SpaceStore.instance.getNotificationState(space.roomId);
const hasChildren = this.state.childSpaces?.length;
let childItems;
if (this.state.childSpaces?.length && !collapsed) {
if (hasChildren && !collapsed) {
childItems = <SpaceTreeLevel
spaces={this.state.childSpaces}
activeSpaces={activeSpaces}
@ -398,16 +291,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/>;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge forceCount={false} notification={notificationState} />
</div>;
}
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = this.state.childSpaces?.length ?
const toggleCollapseButton = hasChildren ?
<AccessibleButton
className="mx_SpaceButton_toggleCollapse"
onClick={this.toggleCollapse}
@ -415,27 +299,33 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
aria-label={collapsed ? _t("Expand") : _t("Collapse")}
/> : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tabIndex, ...restDragHandleProps } = dragHandleProps || {};
return (
<li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton
className={classes}
title={space.name}
<li
{...otherProps}
className={itemClasses}
ref={innerRef}
aria-expanded={hasChildren ? !collapsed : undefined}
role="treeitem"
>
<SpaceButton
{...restDragHandleProps}
space={space}
className={isInvite ? "mx_SpaceButton_invite" : undefined}
selected={activeSpaces.includes(space)}
label={space.name}
contextMenuTooltip={_t("Space options")}
notificationState={notificationState}
isNarrow={isPanelCollapsed}
avatarSize={isNested ? 24 : 32}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
role="treeitem"
aria-expanded={!collapsed}
inputRef={this.buttonRef}
onKeyDown={this.onKeyDown}
ContextMenuComponent={this.props.space.getMyMembership() === "join" ? SpaceContextMenu : undefined}
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
</SpaceButton>
{ childItems }
</li>
@ -456,8 +346,8 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
isNested,
parents,
}) => {
return <ul className="mx_SpaceTreeLevel">
{spaces.map(s => {
return <ul className="mx_SpaceTreeLevel" role="group">
{ spaces.map(s => {
return (<SpaceItem
key={s.roomId}
activeSpaces={activeSpaces}
@ -465,7 +355,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
isNested={isNested}
parents={parents}
/>);
})}
}) }
</ul>;
};