2021-03-02 14:11:38 +00:00
|
|
|
/*
|
|
|
|
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.
|
|
|
|
*/
|
|
|
|
|
2021-05-05 16:25:29 +00:00
|
|
|
import React, {ReactNode, useMemo, useState} from "react";
|
2021-03-24 17:02:12 +00:00
|
|
|
import {Room} from "matrix-js-sdk/src/models/room";
|
|
|
|
import {MatrixClient} from "matrix-js-sdk/src/client";
|
2021-03-02 14:11:38 +00:00
|
|
|
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
|
2021-03-19 11:36:36 +00:00
|
|
|
import classNames from "classnames";
|
|
|
|
import {sortBy} from "lodash";
|
2021-03-02 14:11:38 +00:00
|
|
|
|
|
|
|
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
|
|
|
import dis from "../../dispatcher/dispatcher";
|
|
|
|
import {_t} from "../../languageHandler";
|
2021-05-06 12:05:58 +00:00
|
|
|
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
2021-03-02 14:11:38 +00:00
|
|
|
import BaseDialog from "../views/dialogs/BaseDialog";
|
2021-03-19 11:36:36 +00:00
|
|
|
import Spinner from "../views/elements/Spinner";
|
2021-03-02 14:11:38 +00:00
|
|
|
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";
|
2021-03-04 02:06:46 +00:00
|
|
|
import {mediaFromMxc} from "../../customisations/Media";
|
2021-03-19 11:36:36 +00:00
|
|
|
import InfoTooltip from "../views/elements/InfoTooltip";
|
|
|
|
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
|
|
|
import {useStateToggle} from "../../hooks/useStateToggle";
|
2021-05-04 11:27:27 +00:00
|
|
|
import {getOrder} from "../../stores/SpaceStore";
|
2021-05-05 16:25:29 +00:00
|
|
|
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
2021-05-12 09:05:53 +00:00
|
|
|
import {linkifyElement} from "../../HtmlUtils";
|
2021-03-02 14:11:38 +00:00
|
|
|
|
2021-03-26 09:44:52 +00:00
|
|
|
interface IHierarchyProps {
|
2021-03-02 14:11:38 +00:00
|
|
|
space: Room;
|
|
|
|
initialText?: string;
|
2021-03-26 09:44:52 +00:00
|
|
|
refreshToken?: any;
|
2021-05-05 16:25:29 +00:00
|
|
|
additionalButtons?: ReactNode;
|
2021-03-26 09:44:52 +00:00
|
|
|
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/* eslint-disable camelcase */
|
|
|
|
export interface ISpaceSummaryRoom {
|
|
|
|
canonical_alias?: string;
|
|
|
|
aliases: string[];
|
|
|
|
avatar_url?: string;
|
|
|
|
guest_can_join: boolean;
|
|
|
|
name?: string;
|
|
|
|
num_joined_members: number
|
|
|
|
room_id: string;
|
|
|
|
topic?: string;
|
|
|
|
world_readable: boolean;
|
|
|
|
num_refs: number;
|
|
|
|
room_type: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface ISpaceSummaryEvent {
|
|
|
|
room_id: string;
|
|
|
|
event_id: string;
|
|
|
|
origin_server_ts: number;
|
|
|
|
type: string;
|
|
|
|
state_key: string;
|
|
|
|
content: {
|
|
|
|
order?: string;
|
2021-03-04 13:04:58 +00:00
|
|
|
suggested?: boolean;
|
2021-03-02 14:11:38 +00:00
|
|
|
auto_join?: boolean;
|
2021-05-20 17:47:12 +00:00
|
|
|
via?: string[];
|
2021-03-02 14:11:38 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
/* eslint-enable camelcase */
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
interface ITileProps {
|
|
|
|
room: ISpaceSummaryRoom;
|
|
|
|
suggested?: boolean;
|
|
|
|
selected?: boolean;
|
|
|
|
numChildRooms?: number;
|
|
|
|
hasPermissions?: boolean;
|
|
|
|
onViewRoomClick(autoJoin: boolean): void;
|
|
|
|
onToggleClick?(): void;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
const Tile: React.FC<ITileProps> = ({
|
|
|
|
room,
|
|
|
|
suggested,
|
|
|
|
selected,
|
|
|
|
hasPermissions,
|
|
|
|
onToggleClick,
|
|
|
|
onViewRoomClick,
|
|
|
|
numChildRooms,
|
2021-03-02 14:11:38 +00:00
|
|
|
children,
|
|
|
|
}) => {
|
2021-05-24 17:57:24 +00:00
|
|
|
const cli = MatrixClientPeg.get();
|
2021-05-25 16:26:43 +00:00
|
|
|
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
2021-05-24 17:57:24 +00:00
|
|
|
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
2021-03-19 11:36:36 +00:00
|
|
|
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
2021-03-02 14:11:38 +00:00
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
2021-03-02 14:11:38 +00:00
|
|
|
|
2021-05-06 12:05:58 +00:00
|
|
|
const onPreviewClick = (ev: ButtonEvent) => {
|
|
|
|
ev.preventDefault();
|
|
|
|
ev.stopPropagation();
|
|
|
|
onViewRoomClick(false);
|
|
|
|
}
|
|
|
|
const onJoinClick = (ev: ButtonEvent) => {
|
|
|
|
ev.preventDefault();
|
|
|
|
ev.stopPropagation();
|
|
|
|
onViewRoomClick(true);
|
|
|
|
}
|
2021-03-19 11:36:36 +00:00
|
|
|
|
|
|
|
let button;
|
2021-05-24 17:57:24 +00:00
|
|
|
if (joinedRoom) {
|
2021-03-19 11:36:36 +00:00
|
|
|
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
2021-03-25 13:24:16 +00:00
|
|
|
{ _t("View") }
|
2021-03-19 11:36:36 +00:00
|
|
|
</AccessibleButton>;
|
|
|
|
} else if (onJoinClick) {
|
|
|
|
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
|
|
|
{ _t("Join") }
|
|
|
|
</AccessibleButton>;
|
|
|
|
}
|
|
|
|
|
|
|
|
let checkbox;
|
|
|
|
if (onToggleClick) {
|
|
|
|
if (hasPermissions) {
|
|
|
|
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
2021-03-02 14:11:38 +00:00
|
|
|
} else {
|
2021-03-19 11:36:36 +00:00
|
|
|
checkbox = <TextWithTooltip
|
|
|
|
tooltip={_t("You don't have permission")}
|
|
|
|
onClick={ev => { ev.stopPropagation() }}
|
|
|
|
>
|
|
|
|
<StyledCheckbox disabled={true} />
|
|
|
|
</TextWithTooltip>;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-24 17:57:24 +00:00
|
|
|
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}
|
|
|
|
/>;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
let description = _t("%(count)s members", { count: room.num_joined_members });
|
2021-05-19 12:00:46 +00:00
|
|
|
if (numChildRooms !== undefined) {
|
2021-03-19 11:36:36 +00:00
|
|
|
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
|
|
|
}
|
2021-05-24 17:57:24 +00:00
|
|
|
|
2021-05-25 16:26:43 +00:00
|
|
|
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
2021-05-24 17:57:24 +00:00
|
|
|
if (topic) {
|
|
|
|
description += " · " + topic;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
let suggestedSection;
|
|
|
|
if (suggested) {
|
|
|
|
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
|
|
|
{ _t("Suggested") }
|
|
|
|
</InfoTooltip>;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const content = <React.Fragment>
|
2021-05-24 17:57:24 +00:00
|
|
|
{ avatar }
|
2021-03-19 11:36:36 +00:00
|
|
|
<div className="mx_SpaceRoomDirectory_roomTile_name">
|
|
|
|
{ name }
|
|
|
|
{ suggestedSection }
|
|
|
|
</div>
|
2021-03-02 14:11:38 +00:00
|
|
|
|
2021-05-12 09:05:53 +00:00
|
|
|
<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();
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
2021-03-19 11:36:36 +00:00
|
|
|
{ description }
|
2021-03-02 14:11:38 +00:00
|
|
|
</div>
|
|
|
|
<div className="mx_SpaceRoomDirectory_actions">
|
2021-03-19 11:36:36 +00:00
|
|
|
{ button }
|
|
|
|
{ checkbox }
|
2021-03-02 14:11:38 +00:00
|
|
|
</div>
|
|
|
|
</React.Fragment>;
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
let childToggle;
|
|
|
|
let childSection;
|
|
|
|
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) {
|
|
|
|
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
|
|
|
|
{ children }
|
|
|
|
</div>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return <>
|
|
|
|
<AccessibleButton
|
|
|
|
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
|
|
|
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
|
|
|
})}
|
2021-03-24 15:26:56 +00:00
|
|
|
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
2021-03-19 11:36:36 +00:00
|
|
|
>
|
|
|
|
{ content }
|
|
|
|
{ childToggle }
|
|
|
|
</AccessibleButton>
|
|
|
|
{ childSection }
|
|
|
|
</>;
|
2021-03-02 14:11:38 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
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>;
|
2021-03-24 17:02:12 +00:00
|
|
|
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
|
2021-03-02 14:11:38 +00:00
|
|
|
parents: Set<string>;
|
2021-03-19 11:36:36 +00:00
|
|
|
selectedMap?: Map<string, Set<string>>;
|
|
|
|
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
|
|
|
onToggleClick?(parentId: string, childId: string): void;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export const HierarchyLevel = ({
|
|
|
|
spaceId,
|
|
|
|
rooms,
|
|
|
|
relations,
|
|
|
|
parents,
|
2021-03-19 11:36:36 +00:00
|
|
|
selectedMap,
|
|
|
|
onViewRoomClick,
|
|
|
|
onToggleClick,
|
2021-03-02 14:11:38 +00:00
|
|
|
}: IHierarchyLevelProps) => {
|
|
|
|
const cli = MatrixClientPeg.get();
|
|
|
|
const space = cli.getRoom(spaceId);
|
2021-03-26 09:59:02 +00:00
|
|
|
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
2021-03-19 11:36:36 +00:00
|
|
|
|
2021-05-04 11:27:27 +00:00
|
|
|
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 getOrder(ev.content.order, null, ev.state_key);
|
|
|
|
});
|
2021-03-19 11:36:36 +00:00
|
|
|
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
|
|
|
|
const roomId = ev.state_key;
|
|
|
|
if (!rooms.has(roomId)) return result;
|
2021-03-02 14:11:38 +00:00
|
|
|
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 => (
|
2021-03-19 11:36:36 +00:00
|
|
|
<Tile
|
2021-03-02 14:11:38 +00:00
|
|
|
key={roomId}
|
|
|
|
room={rooms.get(roomId)}
|
2021-03-19 11:36:36 +00:00
|
|
|
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
|
|
|
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
|
|
|
onViewRoomClick={(autoJoin) => {
|
|
|
|
onViewRoomClick(roomId, autoJoin);
|
2021-03-02 14:11:38 +00:00
|
|
|
}}
|
2021-03-19 11:36:36 +00:00
|
|
|
hasPermissions={hasPermissions}
|
|
|
|
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
2021-03-02 14:11:38 +00:00
|
|
|
/>
|
|
|
|
))
|
|
|
|
}
|
|
|
|
|
|
|
|
{
|
|
|
|
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
|
2021-03-19 11:36:36 +00:00
|
|
|
<Tile
|
2021-03-02 14:11:38 +00:00
|
|
|
key={roomId}
|
2021-03-19 11:36:36 +00:00
|
|
|
room={rooms.get(roomId)}
|
|
|
|
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
|
2021-05-27 09:11:28 +00:00
|
|
|
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
|
2021-03-19 11:36:36 +00:00
|
|
|
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
|
|
|
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
|
|
|
onViewRoomClick={(autoJoin) => {
|
|
|
|
onViewRoomClick(roomId, autoJoin);
|
2021-03-02 14:11:38 +00:00
|
|
|
}}
|
2021-03-19 11:36:36 +00:00
|
|
|
hasPermissions={hasPermissions}
|
|
|
|
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
2021-03-02 14:11:38 +00:00
|
|
|
>
|
|
|
|
<HierarchyLevel
|
|
|
|
spaceId={roomId}
|
|
|
|
rooms={rooms}
|
|
|
|
relations={relations}
|
|
|
|
parents={newParents}
|
2021-03-19 11:36:36 +00:00
|
|
|
selectedMap={selectedMap}
|
|
|
|
onViewRoomClick={onViewRoomClick}
|
|
|
|
onToggleClick={onToggleClick}
|
2021-03-02 14:11:38 +00:00
|
|
|
/>
|
2021-03-19 11:36:36 +00:00
|
|
|
</Tile>
|
2021-03-02 14:11:38 +00:00
|
|
|
))
|
|
|
|
}
|
|
|
|
</React.Fragment>
|
|
|
|
};
|
|
|
|
|
2021-03-24 17:02:12 +00:00
|
|
|
// mutate argument refreshToken to force a reload
|
|
|
|
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
2021-04-26 15:06:42 +00:00
|
|
|
null,
|
2021-03-24 17:02:12 +00:00
|
|
|
ISpaceSummaryRoom[],
|
2021-04-26 15:06:42 +00:00
|
|
|
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
|
|
|
Map<string, Set<string>>?,
|
|
|
|
Map<string, Set<string>>?,
|
|
|
|
] | [Error] => {
|
2021-03-02 14:11:38 +00:00
|
|
|
// TODO pagination
|
2021-03-24 17:02:12 +00:00
|
|
|
return useAsyncMemo(async () => {
|
2021-03-02 14:11:38 +00:00
|
|
|
try {
|
|
|
|
const data = await cli.getSpaceSummary(space.roomId);
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
|
|
|
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
2021-03-02 14:11:38 +00:00
|
|
|
const viaMap = new EnhancedMap<string, Set<string>>();
|
|
|
|
data.events.map((ev: ISpaceSummaryEvent) => {
|
|
|
|
if (ev.type === EventType.SpaceChild) {
|
2021-03-19 11:36:36 +00:00
|
|
|
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
|
|
|
|
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
2021-05-20 17:47:12 +00:00
|
|
|
if (Array.isArray(ev.content.via)) {
|
2021-03-02 14:11:38 +00:00
|
|
|
const set = viaMap.getOrCreate(ev.state_key, new Set());
|
2021-05-20 17:47:12 +00:00
|
|
|
ev.content.via.forEach(via => set.add(via));
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2021-04-26 15:06:42 +00:00
|
|
|
return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
2021-03-02 14:11:38 +00:00
|
|
|
} catch (e) {
|
|
|
|
console.error(e); // TODO
|
2021-04-26 15:06:42 +00:00
|
|
|
return [e];
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
2021-04-26 15:06:42 +00:00
|
|
|
}, [space, refreshToken], [undefined]);
|
2021-03-24 17:02:12 +00:00
|
|
|
};
|
|
|
|
|
2021-03-26 09:44:52 +00:00
|
|
|
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|
|
|
space,
|
|
|
|
initialText = "",
|
|
|
|
showRoom,
|
|
|
|
refreshToken,
|
2021-05-05 16:25:29 +00:00
|
|
|
additionalButtons,
|
2021-03-26 09:44:52 +00:00
|
|
|
children,
|
|
|
|
}) => {
|
2021-03-24 17:02:12 +00:00
|
|
|
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>>
|
|
|
|
|
2021-04-26 15:06:42 +00:00
|
|
|
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
2021-03-02 14:11:38 +00:00
|
|
|
|
|
|
|
const roomsMap = useMemo(() => {
|
|
|
|
if (!rooms) return null;
|
2021-03-19 11:36:36 +00:00
|
|
|
const lcQuery = query.toLowerCase().trim();
|
2021-03-02 14:11:38 +00:00
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
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);
|
2021-03-02 14:11:38 +00:00
|
|
|
});
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
// 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]);
|
2021-03-02 14:11:38 +00:00
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
const [error, setError] = useState("");
|
|
|
|
const [removing, setRemoving] = useState(false);
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
2021-04-26 15:11:07 +00:00
|
|
|
if (summaryError) {
|
|
|
|
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
|
|
|
}
|
|
|
|
|
2021-03-02 14:11:38 +00:00
|
|
|
let content;
|
|
|
|
if (roomsMap) {
|
2021-05-27 09:11:28 +00:00
|
|
|
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
2021-03-19 11:36:36 +00:00
|
|
|
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 });
|
|
|
|
}
|
|
|
|
|
2021-05-05 16:25:29 +00:00
|
|
|
let manageButtons;
|
2021-03-19 11:36:36 +00:00
|
|
|
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][];
|
|
|
|
});
|
|
|
|
|
2021-05-05 16:25:29 +00:00
|
|
|
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);
|
2021-05-18 10:54:45 +00:00
|
|
|
parentChildMap.get(parentId).delete(childId);
|
|
|
|
if (parentChildMap.get(parentId).size > 0) {
|
|
|
|
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
|
|
|
} else {
|
|
|
|
parentChildMap.delete(parentId);
|
|
|
|
}
|
2021-03-19 11:36:36 +00:00
|
|
|
}
|
2021-05-05 16:25:29 +00:00
|
|
|
} 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)));
|
2021-03-19 11:36:36 +00:00
|
|
|
}
|
2021-05-05 16:25:29 +00:00
|
|
|
} catch (e) {
|
|
|
|
setError("Failed to update some suggestions. Try again later");
|
2021-03-19 11:36:36 +00:00
|
|
|
}
|
2021-05-05 16:25:29 +00:00
|
|
|
setSaving(false);
|
|
|
|
}}
|
|
|
|
kind="primary_outline"
|
|
|
|
disabled={disabled}
|
|
|
|
>
|
|
|
|
{ saving
|
|
|
|
? _t("Saving...")
|
|
|
|
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
|
|
|
}
|
|
|
|
</Button>
|
|
|
|
</>;
|
2021-03-19 11:36:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
let results;
|
|
|
|
if (roomsMap.size) {
|
2021-03-26 09:59:02 +00:00
|
|
|
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
|
|
|
|
2021-03-19 11:36:36 +00:00
|
|
|
results = <>
|
|
|
|
<HierarchyLevel
|
|
|
|
spaceId={space.roomId}
|
|
|
|
rooms={roomsMap}
|
|
|
|
relations={parentChildMap}
|
|
|
|
parents={new Set()}
|
|
|
|
selectedMap={selected}
|
2021-03-26 09:59:02 +00:00
|
|
|
onToggleClick={hasPermissions ? (parentId, childId) => {
|
2021-03-19 11:36:36 +00:00
|
|
|
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))));
|
2021-03-26 09:59:02 +00:00
|
|
|
} : undefined}
|
2021-03-19 11:36:36 +00:00
|
|
|
onViewRoomClick={(roomId, autoJoin) => {
|
|
|
|
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
|
|
|
}}
|
|
|
|
/>
|
2021-03-26 09:44:52 +00:00
|
|
|
{ children && <hr /> }
|
2021-03-19 11:36:36 +00:00
|
|
|
</>;
|
|
|
|
} 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 }
|
2021-05-05 16:25:29 +00:00
|
|
|
<span>
|
|
|
|
{ additionalButtons }
|
|
|
|
{ manageButtons }
|
|
|
|
</span>
|
2021-03-19 11:36:36 +00:00
|
|
|
</div>
|
|
|
|
{ error && <div className="mx_SpaceRoomDirectory_error">
|
|
|
|
{ error }
|
|
|
|
</div> }
|
|
|
|
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
|
|
|
{ results }
|
2021-03-26 09:44:52 +00:00
|
|
|
{ children }
|
2021-03-19 11:36:36 +00:00
|
|
|
</AutoHideScrollbar>
|
|
|
|
</>;
|
2021-03-26 09:44:52 +00:00
|
|
|
} else {
|
2021-04-26 15:06:42 +00:00
|
|
|
content = <Spinner />;
|
2021-03-02 14:11:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO loading state/error state
|
2021-03-26 09:44:52 +00:00
|
|
|
return <>
|
|
|
|
<SearchBox
|
|
|
|
className="mx_textinput_icon mx_textinput_search"
|
2021-05-11 16:01:31 +00:00
|
|
|
placeholder={ _t("Search names and descriptions") }
|
2021-03-26 09:44:52 +00:00
|
|
|
onSearch={setQuery}
|
|
|
|
autoFocus={true}
|
|
|
|
initialValue={initialText}
|
|
|
|
/>
|
|
|
|
|
|
|
|
{ content }
|
|
|
|
</>;
|
|
|
|
};
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
2021-03-02 14:11:38 +00:00
|
|
|
return (
|
|
|
|
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
|
|
|
|
<div className="mx_Dialog_content">
|
2021-03-26 09:44:52 +00:00
|
|
|
{ _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>
|
2021-03-02 14:11:38 +00:00
|
|
|
</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 room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
|
|
|
}
|