diff --git a/res/css/_components.scss b/res/css/_components.scss index f6c63630465..566b84a7c8f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -28,8 +28,8 @@ @import "./structures/_RoomView.scss"; @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; +@import "./structures/_SpaceHierarchy.scss"; @import "./structures/_SpacePanel.scss"; -@import "./structures/_SpaceRoomDirectory.scss"; @import "./structures/_SpaceRoomView.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_ToastContainer.scss"; diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceHierarchy.scss similarity index 85% rename from res/css/structures/_SpaceRoomDirectory.scss rename to res/css/structures/_SpaceHierarchy.scss index 88e6a3f4942..74dfb5da6bc 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceHierarchy.scss @@ -14,21 +14,6 @@ See the License for the specific language governing permissions and 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_Dialog_title { display: flex; @@ -68,7 +53,7 @@ limitations under the License. margin: 24px 0 16px; } - .mx_SpaceRoomDirectory_noResults { + .mx_SpaceHierarchy_noResults { text-align: center; > div { @@ -78,13 +63,19 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_listHeader { + .mx_SpaceHierarchy_listHeader { display: flex; min-height: 32px; align-items: center; font-size: $font-15px; line-height: $font-24px; color: $primary-fg-color; + margin-bottom: 12px; + + > h4 { + font-weight: $font-semi-bold; + margin: 0; + } .mx_AccessibleButton { padding: 4px 12px; @@ -105,7 +96,7 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_error { + .mx_SpaceHierarchy_error { position: relative; font-weight: $font-semi-bold; color: $notice-primary-color; @@ -124,13 +115,14 @@ limitations under the License. background-image: url("$(res)/img/element-icons/warning-badge.svg"); } } -} -.mx_SpaceRoomDirectory_list { - margin-top: 16px; - padding-bottom: 40px; + .mx_SpaceHierarchy_list { + list-style: none; + padding: 0; + margin: 0; + } - .mx_SpaceRoomDirectory_roomCount { + .mx_SpaceHierarchy_roomCount { > h3 { display: inline; font-weight: $font-semi-bold; @@ -147,13 +139,13 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_subspace { + .mx_SpaceHierarchy_subspace { .mx_BaseAvatar_image { border-radius: 8px; } } - .mx_SpaceRoomDirectory_subspace_toggle { + .mx_SpaceHierarchy_subspace_toggle { position: absolute; left: -1px; top: 10px; @@ -177,17 +169,17 @@ limitations under the License. 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); } } - .mx_SpaceRoomDirectory_subspace_children { + .mx_SpaceHierarchy_subspace_children { position: relative; padding-left: 12px; } - .mx_SpaceRoomDirectory_roomTile { + .mx_SpaceHierarchy_roomTile { position: relative; padding: 8px 16px; border-radius: 8px; @@ -204,7 +196,7 @@ limitations under the License. grid-column: 1; } - .mx_SpaceRoomDirectory_roomTile_name { + .mx_SpaceHierarchy_roomTile_name { font-weight: $font-semi-bold; font-size: $font-15px; line-height: $font-18px; @@ -232,7 +224,7 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_roomTile_info { + .mx_SpaceHierarchy_roomTile_info { font-size: $font-14px; line-height: $font-18px; color: $secondary-fg-color; @@ -244,7 +236,7 @@ limitations under the License. overflow: hidden; } - .mx_SpaceRoomDirectory_actions { + .mx_SpaceHierarchy_actions { text-align: right; margin-left: 20px; grid-column: 3; @@ -278,12 +270,12 @@ limitations under the License. } } - li.mx_SpaceRoomDirectory_roomTileWrapper { + li.mx_SpaceHierarchy_roomTileWrapper { list-style: none; } - .mx_SpaceRoomDirectory_roomTile, - .mx_SpaceRoomDirectory_subspace_children { + .mx_SpaceHierarchy_roomTile, + .mx_SpaceHierarchy_subspace_children { &::before { content: ""; position: absolute; @@ -295,8 +287,8 @@ limitations under the License. } } - .mx_SpaceRoomDirectory_actions { - .mx_SpaceRoomDirectory_actionsText { + .mx_SpaceHierarchy_actions { + .mx_SpaceHierarchy_actionsText { font-weight: normal; font-size: $font-12px; line-height: $font-15px; @@ -311,7 +303,7 @@ limitations under the License. margin: 20px 0; } - .mx_SpaceRoomDirectory_createRoom { + .mx_SpaceHierarchy_createRoom { display: block; margin: 16px auto 0; width: max-content; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 76183f69317..6e6271d5e77 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -363,11 +363,6 @@ $SpaceRoomViewInnerWidth: 428px; width: max-content; 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 { diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 84e8de82214..8d8609d1cfd 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -833,6 +833,6 @@ export default class RoomDirectory extends React.Component { // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // 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); } diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx new file mode 100644 index 00000000000..a0d4d9c42ac --- /dev/null +++ b/src/components/structures/SpaceHierarchy.tsx @@ -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 = ({ + 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 = + { _t("View") } + ; + } else if (onJoinClick) { + button = + { _t("Join") } + ; + } + + let checkbox; + if (onToggleClick) { + if (hasPermissions) { + checkbox = ; + } else { + checkbox = { ev.stopPropagation(); }} + > + + ; + } + } + + let avatar; + if (joinedRoom) { + avatar = ; + } else { + avatar = ; + } + + 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 = + { _t("Suggested") } + ; + } + + const content = + { avatar } +
+ { name } + { suggestedSection } +
+ +
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 } +
+
+ { button } + { checkbox } +
+
; + + 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 =
{ + ev.stopPropagation(); + toggleShowChildren(); + }} + />; + + if (showChildren) { + const onChildrenKeyDown = (e) => { + if (e.key === Key.ARROW_LEFT) { + e.preventDefault(); + e.stopPropagation(); + ref.current?.focus(); + } + }; + + childSection =
+ { children } +
; + } + + 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(".mx_SpaceHierarchy_roomTile")?.focus(); + } else { + toggleShowChildren(); + } + break; + } + + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }; + } + + return
  • + + { content } + { childToggle } + + { childSection } +
  • ; +}; + +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; + hierarchy: RoomHierarchy; + parents: Set; + selectedMap?: Map>; + 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 + { + childRooms.map(room => ( + { + 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 => ( + { + 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} + > + + + )) + } + ; +}; + +const INITIAL_PAGE_SIZE = 20; + +export const useSpaceSummary = (space: Room): { + loading: boolean; + rooms: IHierarchyRoom[]; + hierarchy: RoomHierarchy; + loadMore(pageSize?: number): Promise ; +} => { + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [hierarchy, setHierarchy] = useState(); + + 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(); + 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>; + setSelected: Dispatch>>>; + setError: Dispatch>; +} + +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> = AccessibleButton; + let props = {}; + if (!selectedRelations.length) { + Button = AccessibleTooltipButton; + props = { + tooltip: _t("Select a room below first"), + yOffset: -40, + }; + } + + return <> + + + ; +}; + +const SpaceHierarchy = ({ + space, + initialText = "", + showRoom, + additionalButtons, +}: IProps) => { + const cli = useContext(MatrixClientContext); + const [query, setQuery] = useState(initialText); + + const [selected, setSelected] = useState(new Map>()); // Map> + + const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space); + + const filteredRoomSet = useMemo>(() => { + 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(); + 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

    { _t("Your server does not support showing space hierarchies.") }

    ; + } + + 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 + { ({ onKeyDownHandler }) => { + let content: JSX.Element; + let loader: JSX.Element; + + if (loading && !rooms.length) { + content = ; + } else { + const hasPermissions = space?.getMyMembership() === "join" && + space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + let results: JSX.Element; + if (filteredRoomSet.size) { + results = <> + { + showRoom(cli, hierarchy, roomId, autoJoin); + }} + /> + ; + + if (hierarchy.canLoadMore) { + loader =
    + +
    ; + } + } else { + results =
    +

    { _t("No results found") }

    +
    { _t("You may want to try a different search or check for typos.") }
    +
    ; + } + + content = <> +
    +

    { query.trim() ? _t("Results") : _t("Rooms and spaces") }

    + + { additionalButtons } + { hasPermissions && ( + + ) } + +
    + { error &&
    + { error } +
    } +
      + { results } +
    + { loader } + ; + } + + return <> + + + { content } + ; + } } +
    ; +}; + +export default SpaceHierarchy; diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx deleted file mode 100644 index 27b70c68414..00000000000 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ /dev/null @@ -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 = ({ - 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 = - { _t("View") } - ; - } else if (onJoinClick) { - button = - { _t("Join") } - ; - } - - let checkbox; - if (onToggleClick) { - if (hasPermissions) { - checkbox = ; - } else { - checkbox = { ev.stopPropagation(); }} - > - - ; - } - } - - let avatar; - if (joinedRoom) { - avatar = ; - } else { - avatar = ; - } - - 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 = - { _t("Suggested") } - ; - } - - const content = - { avatar } -
    - { name } - { suggestedSection } -
    - -
    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 } -
    -
    - { button } - { checkbox } -
    -
    ; - - 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 =
    { - ev.stopPropagation(); - toggleShowChildren(); - }} - />; - - if (showChildren) { - const onChildrenKeyDown = (e) => { - if (e.key === Key.ARROW_LEFT) { - e.preventDefault(); - e.stopPropagation(); - ref.current?.focus(); - } - }; - - childSection =
    - { children } -
    ; - } - - 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(".mx_SpaceRoomDirectory_roomTile")?.focus(); - } else { - toggleShowChildren(); - } - break; - } - - if (handled) { - e.preventDefault(); - e.stopPropagation(); - } - }; - } - - return
  • - - { content } - { childToggle } - - { childSection } -
  • ; -}; - -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; - relations: Map>; - parents: Set; - selectedMap?: Map>; - 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 - { - childRooms.map(roomId => ( - { - onViewRoomClick(roomId, autoJoin); - }} - hasPermissions={hasPermissions} - onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} - /> - )) - } - - { - subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => ( - 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} - > - - - )) - } - ; -}; - -export const useSpaceSummary = (space: Room): [ - null, - ISpaceSummaryRoom[], - Map>?, - Map>?, - Map>?, -] | [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>(); - const childParentRelations = new EnhancedMap>(); - const viaMap = new EnhancedMap>(); - 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 = ({ - space, - initialText = "", - showRoom, - additionalButtons, - children, -}) => { - const cli = MatrixClientPeg.get(); - const userId = cli.getUserId(); - const [query, setQuery] = useState(initialText); - - const [selected, setSelected] = useState(new Map>()); // Map> - - const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space); - - const roomsMap = useMemo(() => { - if (!rooms) return null; - const lcQuery = query.toLowerCase().trim(); - - const roomsMap = new Map(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(); - 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

    { _t("Your server does not support showing space hierarchies.") }

    ; - } - - 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 - { ({ 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> = AccessibleButton; - let props = {}; - if (!selectedRelations.length) { - Button = AccessibleTooltipButton; - props = { - tooltip: _t("Select a room below first"), - yOffset: -40, - }; - } - - manageButtons = <> - - - ; - } - - let results; - if (roomsMap.size) { - const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); - - results = <> - { - 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 &&
    } - ; - } else { - results =
    -

    { _t("No results found") }

    -
    { _t("You may want to try a different search or check for typos.") }
    -
    ; - } - - content = <> -
    - { countsStr } - - { additionalButtons } - { manageButtons } - -
    - { error &&
    - { error } -
    } - - { results } - { children } - - ; - } else { - content = ; - } - - return <> - - - { content } - ; - } } -
    ; -}; - -interface IProps { - space: Room; - initialText?: string; - onFinished(): void; -} - -const SpaceRoomDirectory: React.FC = ({ space, onFinished, initialText }) => { - const onCreateRoomClick = () => { - dis.dispatch({ - action: 'view_create_room', - public: true, - }); - onFinished(); - }; - - const title = - -
    -

    { _t("Explore rooms") }

    -
    -
    -
    ; - - return ( - -
    - { _t("If you can't find the room you're looking for, ask for an invite or create a new room.", - null, - { a: sub => { - return { sub }; - } }, - ) } - - { - showRoom(room, viaServers, autoJoin); - onFinished(); - }} - initialText={initialText} - > - - { _t("Create room") } - - -
    -
    - ); -}; - -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); -} diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 7887e9b7445..767e0999c36 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -54,7 +54,7 @@ import { showCreateNewSubspace, showSpaceSettings, } from "../../utils/space"; -import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory"; +import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import MemberAvatar from "../views/avatars/MemberAvatar"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9f47bf2db3f..7fea360c1be 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2846,22 +2846,18 @@ "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", "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", "Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later", "Removing...": "Removing...", "Mark as not suggested": "Mark as not 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", "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", "Search names and descriptions": "Search names and descriptions", - "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", - "Create room": "Create room", "Private space": "Private space", " invites you": " invites you", "To view %(spaceName)s, turn on the Spaces beta": "To view %(spaceName)s, turn on the Spaces beta", diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index da18646d0f5..c08c66714ba 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -19,7 +19,7 @@ import { ListIteratee, Many, sortBy, throttle } from "lodash"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { Room } from "matrix-js-sdk/src/models/room"; 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 { 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"); // 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[]; } @@ -303,18 +303,20 @@ export class SpaceStoreClass extends AsyncStoreWithClient { public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise => { 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>(); - data.events.forEach(ev => { - if (ev.type === EventType.SpaceChild && ev.content.via?.length) { - ev.content.via.forEach(via => { - viaMap.getOrCreate(ev.state_key, new Set()).add(via); - }); - } + rooms.forEach(room => { + room.children_state.forEach(ev => { + if (ev.type === EventType.SpaceChild && ev.content.via?.length) { + 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 && this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join"; }).map(roomInfo => ({ diff --git a/test/test-utils.js b/test/test-utils.js index f62df53c3a5..803d97c163f 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -85,9 +85,8 @@ export function createTestClient() { generateClientSecret: () => "t35tcl1Ent5ECr3T", isGuest: () => false, isCryptoEnabled: () => false, - getSpaceSummary: jest.fn().mockReturnValue({ + getRoomHierarchy: jest.fn().mockReturnValue({ rooms: [], - events: [], }), // Used by various internal bits we aren't concerned with (yet)