Merge pull request #6507 from matrix-org/t3chguy/fix/18089
Allow pagination of the space hierarchy and use new APIs
This commit is contained in:
commit
15c731d574
10 changed files with 765 additions and 796 deletions
|
@ -28,8 +28,8 @@
|
||||||
@import "./structures/_RoomView.scss";
|
@import "./structures/_RoomView.scss";
|
||||||
@import "./structures/_ScrollPanel.scss";
|
@import "./structures/_ScrollPanel.scss";
|
||||||
@import "./structures/_SearchBox.scss";
|
@import "./structures/_SearchBox.scss";
|
||||||
|
@import "./structures/_SpaceHierarchy.scss";
|
||||||
@import "./structures/_SpacePanel.scss";
|
@import "./structures/_SpacePanel.scss";
|
||||||
@import "./structures/_SpaceRoomDirectory.scss";
|
|
||||||
@import "./structures/_SpaceRoomView.scss";
|
@import "./structures/_SpaceRoomView.scss";
|
||||||
@import "./structures/_TabbedView.scss";
|
@import "./structures/_TabbedView.scss";
|
||||||
@import "./structures/_ToastContainer.scss";
|
@import "./structures/_ToastContainer.scss";
|
||||||
|
|
|
@ -14,21 +14,6 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_dialogWrapper > .mx_Dialog {
|
|
||||||
max-width: 960px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory {
|
|
||||||
height: 100%;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: $primary-fg-color;
|
|
||||||
word-break: break-word;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory,
|
|
||||||
.mx_SpaceRoomView_landing {
|
.mx_SpaceRoomView_landing {
|
||||||
.mx_Dialog_title {
|
.mx_Dialog_title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -68,7 +53,7 @@ limitations under the License.
|
||||||
margin: 24px 0 16px;
|
margin: 24px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_noResults {
|
.mx_SpaceHierarchy_noResults {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
|
@ -78,13 +63,19 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_listHeader {
|
.mx_SpaceHierarchy_listHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: $font-15px;
|
font-size: $font-15px;
|
||||||
line-height: $font-24px;
|
line-height: $font-24px;
|
||||||
color: $primary-fg-color;
|
color: $primary-fg-color;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
> h4 {
|
||||||
|
font-weight: $font-semi-bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_AccessibleButton {
|
.mx_AccessibleButton {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
|
@ -105,7 +96,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_error {
|
.mx_SpaceHierarchy_error {
|
||||||
position: relative;
|
position: relative;
|
||||||
font-weight: $font-semi-bold;
|
font-weight: $font-semi-bold;
|
||||||
color: $notice-primary-color;
|
color: $notice-primary-color;
|
||||||
|
@ -124,13 +115,14 @@ limitations under the License.
|
||||||
background-image: url("$(res)/img/element-icons/warning-badge.svg");
|
background-image: url("$(res)/img/element-icons/warning-badge.svg");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_list {
|
.mx_SpaceHierarchy_list {
|
||||||
margin-top: 16px;
|
list-style: none;
|
||||||
padding-bottom: 40px;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_roomCount {
|
.mx_SpaceHierarchy_roomCount {
|
||||||
> h3 {
|
> h3 {
|
||||||
display: inline;
|
display: inline;
|
||||||
font-weight: $font-semi-bold;
|
font-weight: $font-semi-bold;
|
||||||
|
@ -147,13 +139,13 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_subspace {
|
.mx_SpaceHierarchy_subspace {
|
||||||
.mx_BaseAvatar_image {
|
.mx_BaseAvatar_image {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_subspace_toggle {
|
.mx_SpaceHierarchy_subspace_toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -1px;
|
left: -1px;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
@ -177,17 +169,17 @@ limitations under the License.
|
||||||
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
|
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_SpaceRoomDirectory_subspace_toggle_shown::before {
|
&.mx_SpaceHierarchy_subspace_toggle_shown::before {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_subspace_children {
|
.mx_SpaceHierarchy_subspace_children {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_roomTile {
|
.mx_SpaceHierarchy_roomTile {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -204,7 +196,7 @@ limitations under the License.
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_roomTile_name {
|
.mx_SpaceHierarchy_roomTile_name {
|
||||||
font-weight: $font-semi-bold;
|
font-weight: $font-semi-bold;
|
||||||
font-size: $font-15px;
|
font-size: $font-15px;
|
||||||
line-height: $font-18px;
|
line-height: $font-18px;
|
||||||
|
@ -232,7 +224,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_roomTile_info {
|
.mx_SpaceHierarchy_roomTile_info {
|
||||||
font-size: $font-14px;
|
font-size: $font-14px;
|
||||||
line-height: $font-18px;
|
line-height: $font-18px;
|
||||||
color: $secondary-fg-color;
|
color: $secondary-fg-color;
|
||||||
|
@ -244,7 +236,7 @@ limitations under the License.
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_actions {
|
.mx_SpaceHierarchy_actions {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
|
@ -278,12 +270,12 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
li.mx_SpaceRoomDirectory_roomTileWrapper {
|
li.mx_SpaceHierarchy_roomTileWrapper {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_roomTile,
|
.mx_SpaceHierarchy_roomTile,
|
||||||
.mx_SpaceRoomDirectory_subspace_children {
|
.mx_SpaceHierarchy_subspace_children {
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -295,8 +287,8 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_actions {
|
.mx_SpaceHierarchy_actions {
|
||||||
.mx_SpaceRoomDirectory_actionsText {
|
.mx_SpaceHierarchy_actionsText {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: $font-12px;
|
font-size: $font-12px;
|
||||||
line-height: $font-15px;
|
line-height: $font-15px;
|
||||||
|
@ -311,7 +303,7 @@ limitations under the License.
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_createRoom {
|
.mx_SpaceHierarchy_createRoom {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 16px auto 0;
|
margin: 16px auto 0;
|
||||||
width: max-content;
|
width: max-content;
|
|
@ -363,11 +363,6 @@ $SpaceRoomViewInnerWidth: 428px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
margin: 0 0 -40px auto; // collapse its own height to not push other components down
|
margin: 0 0 -40px auto; // collapse its own height to not push other components down
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_list {
|
|
||||||
// we don't want this container to get forced into the flexbox layout
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomView_privateScope {
|
.mx_SpaceRoomView_privateScope {
|
||||||
|
|
|
@ -833,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||||
// but works with the objects we get from the public room list
|
// but works with the objects we get from the public room list
|
||||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||||
}
|
}
|
||||||
|
|
717
src/components/structures/SpaceHierarchy.tsx
Normal file
717
src/components/structures/SpaceHierarchy.tsx
Normal file
|
@ -0,0 +1,717 @@
|
||||||
|
/*
|
||||||
|
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, {
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
KeyboardEvent,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
useContext,
|
||||||
|
SetStateAction,
|
||||||
|
Dispatch,
|
||||||
|
} from "react";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||||
|
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { sortBy } from "lodash";
|
||||||
|
|
||||||
|
import dis from "../../dispatcher/dispatcher";
|
||||||
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||||
|
import Spinner from "../views/elements/Spinner";
|
||||||
|
import SearchBox from "./SearchBox";
|
||||||
|
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||||
|
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||||
|
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||||
|
import { mediaFromMxc } from "../../customisations/Media";
|
||||||
|
import InfoTooltip from "../views/elements/InfoTooltip";
|
||||||
|
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
||||||
|
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||||
|
import { getChildOrder } from "../../stores/SpaceStore";
|
||||||
|
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||||
|
import { linkifyElement } from "../../HtmlUtils";
|
||||||
|
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import { Key } from "../../Keyboard";
|
||||||
|
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||||
|
import { getDisplayAliasForRoom } from "./RoomDirectory";
|
||||||
|
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
space: Room;
|
||||||
|
initialText?: string;
|
||||||
|
additionalButtons?: ReactNode;
|
||||||
|
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITileProps {
|
||||||
|
room: IHierarchyRoom;
|
||||||
|
suggested?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
numChildRooms?: number;
|
||||||
|
hasPermissions?: boolean;
|
||||||
|
onViewRoomClick(autoJoin: boolean): void;
|
||||||
|
onToggleClick?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tile: React.FC<ITileProps> = ({
|
||||||
|
room,
|
||||||
|
suggested,
|
||||||
|
selected,
|
||||||
|
hasPermissions,
|
||||||
|
onToggleClick,
|
||||||
|
onViewRoomClick,
|
||||||
|
numChildRooms,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||||
|
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||||
|
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||||
|
|
||||||
|
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||||
|
|
||||||
|
const onPreviewClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
onViewRoomClick(false);
|
||||||
|
};
|
||||||
|
const onJoinClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
onViewRoomClick(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
let button;
|
||||||
|
if (joinedRoom) {
|
||||||
|
button = <AccessibleButton
|
||||||
|
onClick={onPreviewClick}
|
||||||
|
kind="primary_outline"
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
|
{ _t("View") }
|
||||||
|
</AccessibleButton>;
|
||||||
|
} else if (onJoinClick) {
|
||||||
|
button = <AccessibleButton
|
||||||
|
onClick={onJoinClick}
|
||||||
|
kind="primary"
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
|
{ _t("Join") }
|
||||||
|
</AccessibleButton>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkbox;
|
||||||
|
if (onToggleClick) {
|
||||||
|
if (hasPermissions) {
|
||||||
|
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||||
|
} else {
|
||||||
|
checkbox = <TextWithTooltip
|
||||||
|
tooltip={_t("You don't have permission")}
|
||||||
|
onClick={ev => { ev.stopPropagation(); }}
|
||||||
|
>
|
||||||
|
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||||
|
</TextWithTooltip>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let avatar;
|
||||||
|
if (joinedRoom) {
|
||||||
|
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
|
||||||
|
} else {
|
||||||
|
avatar = <BaseAvatar
|
||||||
|
name={name}
|
||||||
|
idName={room.room_id}
|
||||||
|
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = _t("%(count)s members", { count: room.num_joined_members });
|
||||||
|
if (numChildRooms !== undefined) {
|
||||||
|
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
||||||
|
}
|
||||||
|
|
||||||
|
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
||||||
|
if (topic) {
|
||||||
|
description += " · " + topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suggestedSection;
|
||||||
|
if (suggested) {
|
||||||
|
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
||||||
|
{ _t("Suggested") }
|
||||||
|
</InfoTooltip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = <React.Fragment>
|
||||||
|
{ avatar }
|
||||||
|
<div className="mx_SpaceHierarchy_roomTile_name">
|
||||||
|
{ name }
|
||||||
|
{ suggestedSection }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mx_SpaceHierarchy_roomTile_info"
|
||||||
|
ref={e => e && linkifyElement(e)}
|
||||||
|
onClick={ev => {
|
||||||
|
// prevent clicks on links from bubbling up to the room tile
|
||||||
|
if ((ev.target as HTMLElement).tagName === "A") {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ description }
|
||||||
|
</div>
|
||||||
|
<div className="mx_SpaceHierarchy_actions">
|
||||||
|
{ button }
|
||||||
|
{ checkbox }
|
||||||
|
</div>
|
||||||
|
</React.Fragment>;
|
||||||
|
|
||||||
|
let childToggle: JSX.Element;
|
||||||
|
let childSection: JSX.Element;
|
||||||
|
let onKeyDown: KeyboardEventHandler;
|
||||||
|
if (children) {
|
||||||
|
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||||
|
childToggle = <div
|
||||||
|
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
|
||||||
|
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
|
||||||
|
})}
|
||||||
|
onClick={ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggleShowChildren();
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
if (showChildren) {
|
||||||
|
const onChildrenKeyDown = (e) => {
|
||||||
|
if (e.key === Key.ARROW_LEFT) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
ref.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
childSection = <div
|
||||||
|
className="mx_SpaceHierarchy_subspace_children"
|
||||||
|
onKeyDown={onChildrenKeyDown}
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown = (e) => {
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
if (showChildren) {
|
||||||
|
handled = true;
|
||||||
|
toggleShowChildren();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_RIGHT:
|
||||||
|
handled = true;
|
||||||
|
if (showChildren) {
|
||||||
|
const childSection = ref.current?.nextElementSibling;
|
||||||
|
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
||||||
|
} else {
|
||||||
|
toggleShowChildren();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return <li
|
||||||
|
className="mx_SpaceHierarchy_roomTileWrapper"
|
||||||
|
role="treeitem"
|
||||||
|
aria-expanded={children ? showChildren : undefined}
|
||||||
|
>
|
||||||
|
<AccessibleButton
|
||||||
|
className={classNames("mx_SpaceHierarchy_roomTile", {
|
||||||
|
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
|
||||||
|
})}
|
||||||
|
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
inputRef={ref}
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
|
{ content }
|
||||||
|
{ childToggle }
|
||||||
|
</AccessibleButton>
|
||||||
|
{ childSection }
|
||||||
|
</li>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
|
||||||
|
const room = hierarchy.roomMap.get(roomId);
|
||||||
|
|
||||||
|
// Don't let the user view a room they won't be able to either peek or join:
|
||||||
|
// fail earlier so they don't have to click back to the directory.
|
||||||
|
if (cli.isGuest()) {
|
||||||
|
if (!room.world_readable && !room.guest_can_join) {
|
||||||
|
dis.dispatch({ action: "require_registration" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
||||||
|
dis.dispatch({
|
||||||
|
action: "view_room",
|
||||||
|
auto_join: autoJoin,
|
||||||
|
should_peek: true,
|
||||||
|
_type: "room_directory", // instrumentation
|
||||||
|
room_alias: roomAlias,
|
||||||
|
room_id: room.room_id,
|
||||||
|
via_servers: Array.from(hierarchy.viaMap.get(roomId) || []),
|
||||||
|
oob_data: {
|
||||||
|
avatarUrl: room.avatar_url,
|
||||||
|
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||||
|
name: room.name || roomAlias || _t("Unnamed room"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IHierarchyLevelProps {
|
||||||
|
root: IHierarchyRoom;
|
||||||
|
roomSet: Set<IHierarchyRoom>;
|
||||||
|
hierarchy: RoomHierarchy;
|
||||||
|
parents: Set<string>;
|
||||||
|
selectedMap?: Map<string, Set<string>>;
|
||||||
|
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
||||||
|
onToggleClick?(parentId: string, childId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HierarchyLevel = ({
|
||||||
|
root,
|
||||||
|
roomSet,
|
||||||
|
hierarchy,
|
||||||
|
parents,
|
||||||
|
selectedMap,
|
||||||
|
onViewRoomClick,
|
||||||
|
onToggleClick,
|
||||||
|
}: IHierarchyLevelProps) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const space = cli.getRoom(root.room_id);
|
||||||
|
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||||
|
|
||||||
|
const sortedChildren = sortBy(root.children_state, ev => {
|
||||||
|
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
|
||||||
|
const room = hierarchy.roomMap.get(ev.state_key);
|
||||||
|
if (room && roomSet.has(room)) {
|
||||||
|
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
|
||||||
|
|
||||||
|
const newParents = new Set(parents).add(root.room_id);
|
||||||
|
return <React.Fragment>
|
||||||
|
{
|
||||||
|
childRooms.map(room => (
|
||||||
|
<Tile
|
||||||
|
key={room.room_id}
|
||||||
|
room={room}
|
||||||
|
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
|
||||||
|
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
|
||||||
|
onViewRoomClick={(autoJoin) => {
|
||||||
|
onViewRoomClick(room.room_id, autoJoin);
|
||||||
|
}}
|
||||||
|
hasPermissions={hasPermissions}
|
||||||
|
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
|
||||||
|
<Tile
|
||||||
|
key={space.room_id}
|
||||||
|
room={space}
|
||||||
|
numChildRooms={space.children_state.filter(ev => {
|
||||||
|
const room = hierarchy.roomMap.get(ev.state_key);
|
||||||
|
return room && roomSet.has(room) && !room.room_type;
|
||||||
|
}).length}
|
||||||
|
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
|
||||||
|
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
|
||||||
|
onViewRoomClick={(autoJoin) => {
|
||||||
|
onViewRoomClick(space.room_id, autoJoin);
|
||||||
|
}}
|
||||||
|
hasPermissions={hasPermissions}
|
||||||
|
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
|
||||||
|
>
|
||||||
|
<HierarchyLevel
|
||||||
|
root={space}
|
||||||
|
roomSet={roomSet}
|
||||||
|
hierarchy={hierarchy}
|
||||||
|
parents={newParents}
|
||||||
|
selectedMap={selectedMap}
|
||||||
|
onViewRoomClick={onViewRoomClick}
|
||||||
|
onToggleClick={onToggleClick}
|
||||||
|
/>
|
||||||
|
</Tile>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</React.Fragment>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export const useSpaceSummary = (space: Room): {
|
||||||
|
loading: boolean;
|
||||||
|
rooms: IHierarchyRoom[];
|
||||||
|
hierarchy: RoomHierarchy;
|
||||||
|
loadMore(pageSize?: number): Promise <void>;
|
||||||
|
} => {
|
||||||
|
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
|
||||||
|
|
||||||
|
const resetHierarchy = useCallback(() => {
|
||||||
|
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
|
||||||
|
setHierarchy(hierarchy);
|
||||||
|
|
||||||
|
let discard = false;
|
||||||
|
hierarchy.load().then(() => {
|
||||||
|
if (discard) return;
|
||||||
|
setRooms(hierarchy.rooms);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
discard = true;
|
||||||
|
};
|
||||||
|
}, [space]);
|
||||||
|
useEffect(resetHierarchy, [resetHierarchy]);
|
||||||
|
|
||||||
|
useDispatcher(defaultDispatcher, (payload => {
|
||||||
|
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||||
|
setLoading(true);
|
||||||
|
setRooms([]); // TODO
|
||||||
|
resetHierarchy();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const loadMore = useCallback(async (pageSize?: number) => {
|
||||||
|
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
await hierarchy.load(pageSize);
|
||||||
|
setRooms(hierarchy.rooms);
|
||||||
|
setLoading(false);
|
||||||
|
}, [hierarchy]);
|
||||||
|
|
||||||
|
return { loading, rooms, hierarchy, loadMore };
|
||||||
|
};
|
||||||
|
|
||||||
|
const useIntersectionObserver = (callback: () => void) => {
|
||||||
|
const handleObserver = (entries: IntersectionObserverEntry[]) => {
|
||||||
|
const target = entries[0];
|
||||||
|
if (target.isIntersecting) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const observerRef = useRef<IntersectionObserver>();
|
||||||
|
return (element: HTMLDivElement) => {
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect();
|
||||||
|
} else if (element) {
|
||||||
|
observerRef.current = new IntersectionObserver(handleObserver, {
|
||||||
|
root: element.parentElement,
|
||||||
|
rootMargin: "0px 0px 600px 0px",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observerRef.current && element) {
|
||||||
|
observerRef.current.observe(element);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IManageButtonsProps {
|
||||||
|
hierarchy: RoomHierarchy;
|
||||||
|
selected: Map<string, Set<string>>;
|
||||||
|
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
|
||||||
|
setError: Dispatch<SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageButtonsProps) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||||
|
return [
|
||||||
|
...selected.get(parentId).values(),
|
||||||
|
].map(childId => [parentId, childId]) as [string, string][];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||||
|
return hierarchy.isSuggested(parentId, childId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabled = !selectedRelations.length || removing || saving;
|
||||||
|
|
||||||
|
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||||
|
let props = {};
|
||||||
|
if (!selectedRelations.length) {
|
||||||
|
Button = AccessibleTooltipButton;
|
||||||
|
props = {
|
||||||
|
tooltip: _t("Select a room below first"),
|
||||||
|
yOffset: -40,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
onClick={async () => {
|
||||||
|
setRemoving(true);
|
||||||
|
try {
|
||||||
|
for (const [parentId, childId] of selectedRelations) {
|
||||||
|
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||||
|
|
||||||
|
hierarchy.removeRelation(parentId, childId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(_t("Failed to remove some rooms. Try again later"));
|
||||||
|
}
|
||||||
|
setRemoving(false);
|
||||||
|
setSelected(new Map());
|
||||||
|
}}
|
||||||
|
kind="danger_outline"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{ removing ? _t("Removing...") : _t("Remove") }
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
onClick={async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
for (const [parentId, childId] of selectedRelations) {
|
||||||
|
const suggested = !selectionAllSuggested;
|
||||||
|
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
|
||||||
|
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
...existingContent,
|
||||||
|
suggested: !selectionAllSuggested,
|
||||||
|
};
|
||||||
|
|
||||||
|
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||||
|
|
||||||
|
// mutate the local state to save us having to refetch the world
|
||||||
|
existingContent.suggested = content.suggested;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError("Failed to update some suggestions. Try again later");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
setSelected(new Map());
|
||||||
|
}}
|
||||||
|
kind="primary_outline"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{ saving
|
||||||
|
? _t("Saving...")
|
||||||
|
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SpaceHierarchy = ({
|
||||||
|
space,
|
||||||
|
initialText = "",
|
||||||
|
showRoom,
|
||||||
|
additionalButtons,
|
||||||
|
}: IProps) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const [query, setQuery] = useState(initialText);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||||
|
|
||||||
|
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
|
||||||
|
|
||||||
|
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
|
||||||
|
if (!rooms.length) return new Set();
|
||||||
|
const lcQuery = query.toLowerCase().trim();
|
||||||
|
if (!lcQuery) return new Set(rooms);
|
||||||
|
|
||||||
|
const directMatches = rooms.filter(r => {
|
||||||
|
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const queue = [...directMatches.map(r => r.room_id)];
|
||||||
|
while (queue.length) {
|
||||||
|
const roomId = queue.pop();
|
||||||
|
visited.add(roomId);
|
||||||
|
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
|
||||||
|
if (!visited.has(parentId)) {
|
||||||
|
queue.push(parentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(rooms.filter(r => visited.has(r.room_id)));
|
||||||
|
}, [rooms, hierarchy, query]);
|
||||||
|
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const loaderRef = useIntersectionObserver(loadMore);
|
||||||
|
|
||||||
|
if (!loading && hierarchy.noSupport) {
|
||||||
|
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
||||||
|
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
||||||
|
state.refs[0]?.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleClick = (parentId: string, childId: string): void => {
|
||||||
|
setError("");
|
||||||
|
if (!selected.has(parentId)) {
|
||||||
|
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentSet = selected.get(parentId);
|
||||||
|
if (!parentSet.has(childId)) {
|
||||||
|
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parentSet.delete(childId);
|
||||||
|
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||||
|
{ ({ onKeyDownHandler }) => {
|
||||||
|
let content: JSX.Element;
|
||||||
|
let loader: JSX.Element;
|
||||||
|
|
||||||
|
if (loading && !rooms.length) {
|
||||||
|
content = <Spinner />;
|
||||||
|
} else {
|
||||||
|
const hasPermissions = space?.getMyMembership() === "join" &&
|
||||||
|
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||||
|
|
||||||
|
let results: JSX.Element;
|
||||||
|
if (filteredRoomSet.size) {
|
||||||
|
results = <>
|
||||||
|
<HierarchyLevel
|
||||||
|
root={hierarchy.roomMap.get(space.roomId)}
|
||||||
|
roomSet={filteredRoomSet}
|
||||||
|
hierarchy={hierarchy}
|
||||||
|
parents={new Set()}
|
||||||
|
selectedMap={selected}
|
||||||
|
onToggleClick={hasPermissions ? onToggleClick : undefined}
|
||||||
|
onViewRoomClick={(roomId, autoJoin) => {
|
||||||
|
showRoom(cli, hierarchy, roomId, autoJoin);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
if (hierarchy.canLoadMore) {
|
||||||
|
loader = <div ref={loaderRef}>
|
||||||
|
<Spinner />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results = <div className="mx_SpaceHierarchy_noResults">
|
||||||
|
<h3>{ _t("No results found") }</h3>
|
||||||
|
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
content = <>
|
||||||
|
<div className="mx_SpaceHierarchy_listHeader">
|
||||||
|
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
|
||||||
|
<span>
|
||||||
|
{ additionalButtons }
|
||||||
|
{ hasPermissions && (
|
||||||
|
<ManageButtons
|
||||||
|
hierarchy={hierarchy}
|
||||||
|
selected={selected}
|
||||||
|
setSelected={setSelected}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ error && <div className="mx_SpaceHierarchy_error">
|
||||||
|
{ error }
|
||||||
|
</div> }
|
||||||
|
<ul
|
||||||
|
className="mx_SpaceHierarchy_list"
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
|
role="tree"
|
||||||
|
aria-label={_t("Space")}
|
||||||
|
>
|
||||||
|
{ results }
|
||||||
|
</ul>
|
||||||
|
{ loader }
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<SearchBox
|
||||||
|
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
|
||||||
|
placeholder={_t("Search names and descriptions")}
|
||||||
|
onSearch={setQuery}
|
||||||
|
autoFocus={true}
|
||||||
|
initialValue={initialText}
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ content }
|
||||||
|
</>;
|
||||||
|
} }
|
||||||
|
</RovingTabIndexProvider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpaceHierarchy;
|
|
@ -1,732 +0,0 @@
|
||||||
/*
|
|
||||||
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, { ReactNode, KeyboardEvent, useMemo, useState, KeyboardEventHandler } from "react";
|
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
|
||||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
|
||||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { sortBy } from "lodash";
|
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
|
||||||
import dis from "../../dispatcher/dispatcher";
|
|
||||||
import { _t } from "../../languageHandler";
|
|
||||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
|
||||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
|
||||||
import Spinner from "../views/elements/Spinner";
|
|
||||||
import SearchBox from "./SearchBox";
|
|
||||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
|
||||||
import RoomName from "../views/elements/RoomName";
|
|
||||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
|
||||||
import { EnhancedMap } from "../../utils/maps";
|
|
||||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
|
||||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
|
||||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
|
||||||
import { mediaFromMxc } from "../../customisations/Media";
|
|
||||||
import InfoTooltip from "../views/elements/InfoTooltip";
|
|
||||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
|
||||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
|
||||||
import { getChildOrder } from "../../stores/SpaceStore";
|
|
||||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
|
||||||
import { linkifyElement } from "../../HtmlUtils";
|
|
||||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
|
||||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
|
||||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
|
||||||
import { Action } from "../../dispatcher/actions";
|
|
||||||
import { Key } from "../../Keyboard";
|
|
||||||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
|
||||||
|
|
||||||
interface IHierarchyProps {
|
|
||||||
space: Room;
|
|
||||||
initialText?: string;
|
|
||||||
additionalButtons?: ReactNode;
|
|
||||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ITileProps {
|
|
||||||
room: ISpaceSummaryRoom;
|
|
||||||
suggested?: boolean;
|
|
||||||
selected?: boolean;
|
|
||||||
numChildRooms?: number;
|
|
||||||
hasPermissions?: boolean;
|
|
||||||
onViewRoomClick(autoJoin: boolean): void;
|
|
||||||
onToggleClick?(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tile: React.FC<ITileProps> = ({
|
|
||||||
room,
|
|
||||||
suggested,
|
|
||||||
selected,
|
|
||||||
hasPermissions,
|
|
||||||
onToggleClick,
|
|
||||||
onViewRoomClick,
|
|
||||||
numChildRooms,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const cli = MatrixClientPeg.get();
|
|
||||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
|
||||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
|
||||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
|
||||||
|
|
||||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
|
||||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
|
||||||
|
|
||||||
const onPreviewClick = (ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
onViewRoomClick(false);
|
|
||||||
};
|
|
||||||
const onJoinClick = (ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
onViewRoomClick(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
let button;
|
|
||||||
if (joinedRoom) {
|
|
||||||
button = <AccessibleButton
|
|
||||||
onClick={onPreviewClick}
|
|
||||||
kind="primary_outline"
|
|
||||||
onFocus={onFocus}
|
|
||||||
tabIndex={isActive ? 0 : -1}
|
|
||||||
>
|
|
||||||
{ _t("View") }
|
|
||||||
</AccessibleButton>;
|
|
||||||
} else if (onJoinClick) {
|
|
||||||
button = <AccessibleButton
|
|
||||||
onClick={onJoinClick}
|
|
||||||
kind="primary"
|
|
||||||
onFocus={onFocus}
|
|
||||||
tabIndex={isActive ? 0 : -1}
|
|
||||||
>
|
|
||||||
{ _t("Join") }
|
|
||||||
</AccessibleButton>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let checkbox;
|
|
||||||
if (onToggleClick) {
|
|
||||||
if (hasPermissions) {
|
|
||||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
|
||||||
} else {
|
|
||||||
checkbox = <TextWithTooltip
|
|
||||||
tooltip={_t("You don't have permission")}
|
|
||||||
onClick={ev => { ev.stopPropagation(); }}
|
|
||||||
>
|
|
||||||
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
|
||||||
</TextWithTooltip>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let avatar;
|
|
||||||
if (joinedRoom) {
|
|
||||||
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
|
|
||||||
} else {
|
|
||||||
avatar = <BaseAvatar
|
|
||||||
name={name}
|
|
||||||
idName={room.room_id}
|
|
||||||
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let description = _t("%(count)s members", { count: room.num_joined_members });
|
|
||||||
if (numChildRooms !== undefined) {
|
|
||||||
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
|
||||||
}
|
|
||||||
|
|
||||||
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
|
||||||
if (topic) {
|
|
||||||
description += " · " + topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
let suggestedSection;
|
|
||||||
if (suggested) {
|
|
||||||
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
|
||||||
{ _t("Suggested") }
|
|
||||||
</InfoTooltip>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = <React.Fragment>
|
|
||||||
{ avatar }
|
|
||||||
<div className="mx_SpaceRoomDirectory_roomTile_name">
|
|
||||||
{ name }
|
|
||||||
{ suggestedSection }
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mx_SpaceRoomDirectory_roomTile_info"
|
|
||||||
ref={e => e && linkifyElement(e)}
|
|
||||||
onClick={ev => {
|
|
||||||
// prevent clicks on links from bubbling up to the room tile
|
|
||||||
if ((ev.target as HTMLElement).tagName === "A") {
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ description }
|
|
||||||
</div>
|
|
||||||
<div className="mx_SpaceRoomDirectory_actions">
|
|
||||||
{ button }
|
|
||||||
{ checkbox }
|
|
||||||
</div>
|
|
||||||
</React.Fragment>;
|
|
||||||
|
|
||||||
let childToggle: JSX.Element;
|
|
||||||
let childSection: JSX.Element;
|
|
||||||
let onKeyDown: KeyboardEventHandler;
|
|
||||||
if (children) {
|
|
||||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
|
||||||
childToggle = <div
|
|
||||||
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
|
|
||||||
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
|
|
||||||
})}
|
|
||||||
onClick={ev => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
toggleShowChildren();
|
|
||||||
}}
|
|
||||||
/>;
|
|
||||||
|
|
||||||
if (showChildren) {
|
|
||||||
const onChildrenKeyDown = (e) => {
|
|
||||||
if (e.key === Key.ARROW_LEFT) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
ref.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
childSection = <div
|
|
||||||
className="mx_SpaceRoomDirectory_subspace_children"
|
|
||||||
onKeyDown={onChildrenKeyDown}
|
|
||||||
role="group"
|
|
||||||
>
|
|
||||||
{ children }
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeyDown = (e) => {
|
|
||||||
let handled = false;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case Key.ARROW_LEFT:
|
|
||||||
if (showChildren) {
|
|
||||||
handled = true;
|
|
||||||
toggleShowChildren();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Key.ARROW_RIGHT:
|
|
||||||
handled = true;
|
|
||||||
if (showChildren) {
|
|
||||||
const childSection = ref.current?.nextElementSibling;
|
|
||||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
|
|
||||||
} else {
|
|
||||||
toggleShowChildren();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handled) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return <li
|
|
||||||
className="mx_SpaceRoomDirectory_roomTileWrapper"
|
|
||||||
role="treeitem"
|
|
||||||
aria-expanded={children ? showChildren : undefined}
|
|
||||||
>
|
|
||||||
<AccessibleButton
|
|
||||||
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
|
||||||
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
|
||||||
})}
|
|
||||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
inputRef={ref}
|
|
||||||
onFocus={onFocus}
|
|
||||||
tabIndex={isActive ? 0 : -1}
|
|
||||||
>
|
|
||||||
{ content }
|
|
||||||
{ childToggle }
|
|
||||||
</AccessibleButton>
|
|
||||||
{ childSection }
|
|
||||||
</li>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
|
||||||
// Don't let the user view a room they won't be able to either peek or join:
|
|
||||||
// fail earlier so they don't have to click back to the directory.
|
|
||||||
if (MatrixClientPeg.get().isGuest()) {
|
|
||||||
if (!room.world_readable && !room.guest_can_join) {
|
|
||||||
dis.dispatch({ action: "require_registration" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
|
||||||
dis.dispatch({
|
|
||||||
action: "view_room",
|
|
||||||
auto_join: autoJoin,
|
|
||||||
should_peek: true,
|
|
||||||
_type: "room_directory", // instrumentation
|
|
||||||
room_alias: roomAlias,
|
|
||||||
room_id: room.room_id,
|
|
||||||
via_servers: viaServers,
|
|
||||||
oob_data: {
|
|
||||||
avatarUrl: room.avatar_url,
|
|
||||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
|
||||||
name: room.name || roomAlias || _t("Unnamed room"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IHierarchyLevelProps {
|
|
||||||
spaceId: string;
|
|
||||||
rooms: Map<string, ISpaceSummaryRoom>;
|
|
||||||
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
|
|
||||||
parents: Set<string>;
|
|
||||||
selectedMap?: Map<string, Set<string>>;
|
|
||||||
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
|
||||||
onToggleClick?(parentId: string, childId: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HierarchyLevel = ({
|
|
||||||
spaceId,
|
|
||||||
rooms,
|
|
||||||
relations,
|
|
||||||
parents,
|
|
||||||
selectedMap,
|
|
||||||
onViewRoomClick,
|
|
||||||
onToggleClick,
|
|
||||||
}: IHierarchyLevelProps) => {
|
|
||||||
const cli = MatrixClientPeg.get();
|
|
||||||
const space = cli.getRoom(spaceId);
|
|
||||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
|
||||||
|
|
||||||
const children = Array.from(relations.get(spaceId)?.values() || []);
|
|
||||||
const sortedChildren = sortBy(children, ev => {
|
|
||||||
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
|
|
||||||
return getChildOrder(ev.content.order, null, ev.state_key);
|
|
||||||
});
|
|
||||||
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
|
|
||||||
const roomId = ev.state_key;
|
|
||||||
if (!rooms.has(roomId)) return result;
|
|
||||||
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
|
|
||||||
return result;
|
|
||||||
}, [[], []]) || [[], []];
|
|
||||||
|
|
||||||
const newParents = new Set(parents).add(spaceId);
|
|
||||||
return <React.Fragment>
|
|
||||||
{
|
|
||||||
childRooms.map(roomId => (
|
|
||||||
<Tile
|
|
||||||
key={roomId}
|
|
||||||
room={rooms.get(roomId)}
|
|
||||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
|
||||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
|
||||||
onViewRoomClick={(autoJoin) => {
|
|
||||||
onViewRoomClick(roomId, autoJoin);
|
|
||||||
}}
|
|
||||||
hasPermissions={hasPermissions}
|
|
||||||
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
|
|
||||||
<Tile
|
|
||||||
key={roomId}
|
|
||||||
room={rooms.get(roomId)}
|
|
||||||
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
|
|
||||||
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
|
|
||||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
|
||||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
|
||||||
onViewRoomClick={(autoJoin) => {
|
|
||||||
onViewRoomClick(roomId, autoJoin);
|
|
||||||
}}
|
|
||||||
hasPermissions={hasPermissions}
|
|
||||||
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
|
||||||
>
|
|
||||||
<HierarchyLevel
|
|
||||||
spaceId={roomId}
|
|
||||||
rooms={rooms}
|
|
||||||
relations={relations}
|
|
||||||
parents={newParents}
|
|
||||||
selectedMap={selectedMap}
|
|
||||||
onViewRoomClick={onViewRoomClick}
|
|
||||||
onToggleClick={onToggleClick}
|
|
||||||
/>
|
|
||||||
</Tile>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</React.Fragment>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSpaceSummary = (space: Room): [
|
|
||||||
null,
|
|
||||||
ISpaceSummaryRoom[],
|
|
||||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
|
||||||
Map<string, Set<string>>?,
|
|
||||||
Map<string, Set<string>>?,
|
|
||||||
] | [Error] => {
|
|
||||||
// crude temporary refresh token approach until we have pagination and rework the data flow here
|
|
||||||
const [refreshToken, setRefreshToken] = useState(0);
|
|
||||||
useDispatcher(defaultDispatcher, (payload => {
|
|
||||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
|
||||||
setRefreshToken(t => t + 1);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// TODO pagination
|
|
||||||
return useAsyncMemo(async () => {
|
|
||||||
try {
|
|
||||||
const data = await space.client.getSpaceSummary(space.roomId);
|
|
||||||
|
|
||||||
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
|
||||||
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
|
||||||
const viaMap = new EnhancedMap<string, Set<string>>();
|
|
||||||
data.events.map((ev: ISpaceSummaryEvent) => {
|
|
||||||
if (ev.type === EventType.SpaceChild) {
|
|
||||||
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
|
|
||||||
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
|
|
||||||
}
|
|
||||||
if (Array.isArray(ev.content.via)) {
|
|
||||||
const set = viaMap.getOrCreate(ev.state_key, new Set());
|
|
||||||
ev.content.via.forEach(via => set.add(via));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e); // TODO
|
|
||||||
return [e];
|
|
||||||
}
|
|
||||||
}, [space, refreshToken], [undefined]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|
||||||
space,
|
|
||||||
initialText = "",
|
|
||||||
showRoom,
|
|
||||||
additionalButtons,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const cli = MatrixClientPeg.get();
|
|
||||||
const userId = cli.getUserId();
|
|
||||||
const [query, setQuery] = useState(initialText);
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
|
||||||
|
|
||||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
|
|
||||||
|
|
||||||
const roomsMap = useMemo(() => {
|
|
||||||
if (!rooms) return null;
|
|
||||||
const lcQuery = query.toLowerCase().trim();
|
|
||||||
|
|
||||||
const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
|
|
||||||
if (!lcQuery) return roomsMap;
|
|
||||||
|
|
||||||
const directMatches = rooms.filter(r => {
|
|
||||||
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const queue = [...directMatches.map(r => r.room_id)];
|
|
||||||
while (queue.length) {
|
|
||||||
const roomId = queue.pop();
|
|
||||||
visited.add(roomId);
|
|
||||||
childParentMap.get(roomId)?.forEach(parentId => {
|
|
||||||
if (!visited.has(parentId)) {
|
|
||||||
queue.push(parentId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any mappings for rooms which were not visited in the walk
|
|
||||||
Array.from(roomsMap.keys()).forEach(roomId => {
|
|
||||||
if (!visited.has(roomId)) {
|
|
||||||
roomsMap.delete(roomId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return roomsMap;
|
|
||||||
}, [rooms, childParentMap, query]);
|
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [removing, setRemoving] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
if (summaryError) {
|
|
||||||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
|
|
||||||
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
|
|
||||||
state.refs[0]?.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO loading state/error state
|
|
||||||
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
|
||||||
{ ({ onKeyDownHandler }) => {
|
|
||||||
let content;
|
|
||||||
if (roomsMap) {
|
|
||||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
|
||||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
|
||||||
|
|
||||||
let countsStr;
|
|
||||||
if (numSpaces > 1) {
|
|
||||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
|
||||||
} else if (numSpaces > 0) {
|
|
||||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
|
||||||
} else {
|
|
||||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
|
||||||
}
|
|
||||||
|
|
||||||
let manageButtons;
|
|
||||||
if (space.getMyMembership() === "join" &&
|
|
||||||
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
|
|
||||||
) {
|
|
||||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
|
||||||
return [
|
|
||||||
...selected.get(parentId).values(),
|
|
||||||
].map(childId => [parentId, childId]) as [string, string][];
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
|
||||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
|
||||||
});
|
|
||||||
|
|
||||||
const disabled = !selectedRelations.length || removing || saving;
|
|
||||||
|
|
||||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
|
||||||
let props = {};
|
|
||||||
if (!selectedRelations.length) {
|
|
||||||
Button = AccessibleTooltipButton;
|
|
||||||
props = {
|
|
||||||
tooltip: _t("Select a room below first"),
|
|
||||||
yOffset: -40,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
manageButtons = <>
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
onClick={async () => {
|
|
||||||
setRemoving(true);
|
|
||||||
try {
|
|
||||||
for (const [parentId, childId] of selectedRelations) {
|
|
||||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
|
||||||
parentChildMap.get(parentId).delete(childId);
|
|
||||||
if (parentChildMap.get(parentId).size > 0) {
|
|
||||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
|
||||||
} else {
|
|
||||||
parentChildMap.delete(parentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(_t("Failed to remove some rooms. Try again later"));
|
|
||||||
}
|
|
||||||
setRemoving(false);
|
|
||||||
}}
|
|
||||||
kind="danger_outline"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{ removing ? _t("Removing...") : _t("Remove") }
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
onClick={async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
for (const [parentId, childId] of selectedRelations) {
|
|
||||||
const suggested = !selectionAllSuggested;
|
|
||||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
|
||||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
|
||||||
|
|
||||||
const content = {
|
|
||||||
...existingContent,
|
|
||||||
suggested: !selectionAllSuggested,
|
|
||||||
};
|
|
||||||
|
|
||||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
|
||||||
|
|
||||||
parentChildMap.get(parentId).get(childId).content = content;
|
|
||||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError("Failed to update some suggestions. Try again later");
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
setSelected(new Map());
|
|
||||||
}}
|
|
||||||
kind="primary_outline"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{ saving
|
|
||||||
? _t("Saving...")
|
|
||||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let results;
|
|
||||||
if (roomsMap.size) {
|
|
||||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
|
||||||
|
|
||||||
results = <>
|
|
||||||
<HierarchyLevel
|
|
||||||
spaceId={space.roomId}
|
|
||||||
rooms={roomsMap}
|
|
||||||
relations={parentChildMap}
|
|
||||||
parents={new Set()}
|
|
||||||
selectedMap={selected}
|
|
||||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
|
||||||
setError("");
|
|
||||||
if (!selected.has(parentId)) {
|
|
||||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentSet = selected.get(parentId);
|
|
||||||
if (!parentSet.has(childId)) {
|
|
||||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
parentSet.delete(childId);
|
|
||||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
|
||||||
} : undefined}
|
|
||||||
onViewRoomClick={(roomId, autoJoin) => {
|
|
||||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{ children && <hr /> }
|
|
||||||
</>;
|
|
||||||
} else {
|
|
||||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
|
||||||
<h3>{ _t("No results found") }</h3>
|
|
||||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
content = <>
|
|
||||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
|
||||||
{ countsStr }
|
|
||||||
<span>
|
|
||||||
{ additionalButtons }
|
|
||||||
{ manageButtons }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
|
||||||
{ error }
|
|
||||||
</div> }
|
|
||||||
<AutoHideScrollbar
|
|
||||||
className="mx_SpaceRoomDirectory_list"
|
|
||||||
onKeyDown={onKeyDownHandler}
|
|
||||||
role="tree"
|
|
||||||
aria-label={_t("Space")}
|
|
||||||
>
|
|
||||||
{ results }
|
|
||||||
{ children }
|
|
||||||
</AutoHideScrollbar>
|
|
||||||
</>;
|
|
||||||
} else {
|
|
||||||
content = <Spinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<SearchBox
|
|
||||||
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
|
|
||||||
placeholder={_t("Search names and descriptions")}
|
|
||||||
onSearch={setQuery}
|
|
||||||
autoFocus={true}
|
|
||||||
initialValue={initialText}
|
|
||||||
onKeyDown={onKeyDownHandler}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ content }
|
|
||||||
</>;
|
|
||||||
} }
|
|
||||||
</RovingTabIndexProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
space: Room;
|
|
||||||
initialText?: string;
|
|
||||||
onFinished(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
|
|
||||||
const onCreateRoomClick = () => {
|
|
||||||
dis.dispatch({
|
|
||||||
action: 'view_create_room',
|
|
||||||
public: true,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = <React.Fragment>
|
|
||||||
<RoomAvatar room={space} height={32} width={32} />
|
|
||||||
<div>
|
|
||||||
<h1>{ _t("Explore rooms") }</h1>
|
|
||||||
<div><RoomName room={space} /></div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
|
|
||||||
<div className="mx_Dialog_content">
|
|
||||||
{ _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
|
||||||
null,
|
|
||||||
{ a: sub => {
|
|
||||||
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{ sub }</AccessibleButton>;
|
|
||||||
} },
|
|
||||||
) }
|
|
||||||
|
|
||||||
<SpaceHierarchy
|
|
||||||
space={space}
|
|
||||||
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
|
||||||
showRoom(room, viaServers, autoJoin);
|
|
||||||
onFinished();
|
|
||||||
}}
|
|
||||||
initialText={initialText}
|
|
||||||
>
|
|
||||||
<AccessibleButton
|
|
||||||
onClick={onCreateRoomClick}
|
|
||||||
kind="primary"
|
|
||||||
className="mx_SpaceRoomDirectory_createRoom"
|
|
||||||
>
|
|
||||||
{ _t("Create room") }
|
|
||||||
</AccessibleButton>
|
|
||||||
</SpaceHierarchy>
|
|
||||||
</div>
|
|
||||||
</BaseDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SpaceRoomDirectory;
|
|
||||||
|
|
||||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
|
||||||
// but works with the objects we get from the public room list
|
|
||||||
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
|
||||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
|
||||||
}
|
|
|
@ -54,7 +54,7 @@ import {
|
||||||
showCreateNewSubspace,
|
showCreateNewSubspace,
|
||||||
showSpaceSettings,
|
showSpaceSettings,
|
||||||
} from "../../utils/space";
|
} from "../../utils/space";
|
||||||
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
|
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
|
||||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||||
import SpaceStore from "../../stores/SpaceStore";
|
import SpaceStore from "../../stores/SpaceStore";
|
||||||
import FacePile from "../views/elements/FacePile";
|
import FacePile from "../views/elements/FacePile";
|
||||||
|
|
|
@ -2846,22 +2846,18 @@
|
||||||
"You don't have permission": "You don't have permission",
|
"You don't have permission": "You don't have permission",
|
||||||
"This room is suggested as a good one to join": "This room is suggested as a good one to join",
|
"This room is suggested as a good one to join": "This room is suggested as a good one to join",
|
||||||
"Suggested": "Suggested",
|
"Suggested": "Suggested",
|
||||||
"Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
|
|
||||||
"%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rooms and %(numSpaces)s spaces",
|
|
||||||
"%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces",
|
|
||||||
"%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space",
|
|
||||||
"%(count)s rooms and 1 space|one": "%(count)s room and 1 space",
|
|
||||||
"Select a room below first": "Select a room below first",
|
"Select a room below first": "Select a room below first",
|
||||||
"Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later",
|
"Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later",
|
||||||
"Removing...": "Removing...",
|
"Removing...": "Removing...",
|
||||||
"Mark as not suggested": "Mark as not suggested",
|
"Mark as not suggested": "Mark as not suggested",
|
||||||
"Mark as suggested": "Mark as suggested",
|
"Mark as suggested": "Mark as suggested",
|
||||||
|
"Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
|
||||||
"No results found": "No results found",
|
"No results found": "No results found",
|
||||||
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
|
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
|
||||||
|
"Results": "Results",
|
||||||
|
"Rooms and spaces": "Rooms and spaces",
|
||||||
"Space": "Space",
|
"Space": "Space",
|
||||||
"Search names and descriptions": "Search names and descriptions",
|
"Search names and descriptions": "Search names and descriptions",
|
||||||
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
|
||||||
"Create room": "Create room",
|
|
||||||
"Private space": "Private space",
|
"Private space": "Private space",
|
||||||
"<inviter/> invites you": "<inviter/> invites you",
|
"<inviter/> invites you": "<inviter/> invites you",
|
||||||
"To view %(spaceName)s, turn on the <a>Spaces beta</a>": "To view %(spaceName)s, turn on the <a>Spaces beta</a>",
|
"To view %(spaceName)s, turn on the <a>Spaces beta</a>": "To view %(spaceName)s, turn on the <a>Spaces beta</a>",
|
||||||
|
|
|
@ -19,7 +19,7 @@ import { ListIteratee, Many, sortBy, throttle } from "lodash";
|
||||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { ISpaceSummaryRoom } from "matrix-js-sdk/src/@types/spaces";
|
import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||||
import { IRoomCapability } from "matrix-js-sdk/src/client";
|
import { IRoomCapability } from "matrix-js-sdk/src/client";
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
|
||||||
export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour");
|
export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour");
|
||||||
// Space Room ID/HOME_SPACE will be emitted when a Space's children change
|
// Space Room ID/HOME_SPACE will be emitted when a Space's children change
|
||||||
|
|
||||||
export interface ISuggestedRoom extends ISpaceSummaryRoom {
|
export interface ISuggestedRoom extends IHierarchyRoom {
|
||||||
viaServers: string[];
|
viaServers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,18 +303,20 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||||
|
|
||||||
public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise<ISuggestedRoom[]> => {
|
public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise<ISuggestedRoom[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, limit);
|
const { rooms } = await this.matrixClient.getRoomHierarchy(space.roomId, limit, 1, true);
|
||||||
|
|
||||||
const viaMap = new EnhancedMap<string, Set<string>>();
|
const viaMap = new EnhancedMap<string, Set<string>>();
|
||||||
data.events.forEach(ev => {
|
rooms.forEach(room => {
|
||||||
if (ev.type === EventType.SpaceChild && ev.content.via?.length) {
|
room.children_state.forEach(ev => {
|
||||||
ev.content.via.forEach(via => {
|
if (ev.type === EventType.SpaceChild && ev.content.via?.length) {
|
||||||
viaMap.getOrCreate(ev.state_key, new Set()).add(via);
|
ev.content.via.forEach(via => {
|
||||||
});
|
viaMap.getOrCreate(ev.state_key, new Set()).add(via);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.rooms.filter(roomInfo => {
|
return rooms.filter(roomInfo => {
|
||||||
return roomInfo.room_type !== RoomType.Space
|
return roomInfo.room_type !== RoomType.Space
|
||||||
&& this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join";
|
&& this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join";
|
||||||
}).map(roomInfo => ({
|
}).map(roomInfo => ({
|
||||||
|
|
|
@ -85,9 +85,8 @@ export function createTestClient() {
|
||||||
generateClientSecret: () => "t35tcl1Ent5ECr3T",
|
generateClientSecret: () => "t35tcl1Ent5ECr3T",
|
||||||
isGuest: () => false,
|
isGuest: () => false,
|
||||||
isCryptoEnabled: () => false,
|
isCryptoEnabled: () => false,
|
||||||
getSpaceSummary: jest.fn().mockReturnValue({
|
getRoomHierarchy: jest.fn().mockReturnValue({
|
||||||
rooms: [],
|
rooms: [],
|
||||||
events: [],
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Used by various internal bits we aren't concerned with (yet)
|
// Used by various internal bits we aren't concerned with (yet)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue