/* Copyright 2024 New Vector Ltd. Copyright 2021-2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { Dispatch, KeyboardEvent, KeyboardEventHandler, ReactElement, ReactNode, SetStateAction, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { Room, RoomEvent, ClientEvent, MatrixClient, MatrixError, EventType, RoomType, GuestAccess, HistoryVisibility, HierarchyRelation, HierarchyRoom, JoinRule, } from "matrix-js-sdk/src/matrix"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; import { KnownMembership, SpaceChildEventContent } from "matrix-js-sdk/src/types"; 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/spaces/SpaceStore"; import { Linkify, topicToHtml } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; import { IOOBData } from "../../stores/ThreepidInviteStore"; import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { getTopic } from "../../hooks/room/useTopic"; import { SdkContextClass } from "../../contexts/SDKContext"; import { getDisplayAliasForAliasSet } from "../../Rooms"; import SettingsStore from "../../settings/SettingsStore"; interface IProps { space: Room; initialText?: string; additionalButtons?: ReactNode; showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void; } interface ITileProps { room: HierarchyRoom; suggested?: boolean; selected?: boolean; numChildRooms?: number; hasPermissions?: boolean; children?: ReactNode; onViewRoomClick(): void; onJoinRoomClick(): Promise; onToggleClick?(): void; } const Tile: React.FC = ({ room, suggested, selected, hasPermissions, onToggleClick, onViewRoomClick, onJoinRoomClick, numChildRooms, children, }) => { const cli = useContext(MatrixClientContext); const joinedRoom = useTypedEventEmitterState(cli, ClientEvent.Room, () => { const cliRoom = cli?.getRoom(room.room_id); return cliRoom?.getMyMembership() === KnownMembership.Join ? cliRoom : undefined; }); const joinedRoomName = useTypedEventEmitterState(joinedRoom, RoomEvent.Name, (room) => room?.name); const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0] || (room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room")); const [showChildren, toggleShowChildren] = useStateToggle(true); const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex(); const [busy, setBusy] = useState(false); const onPreviewClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); onViewRoomClick(); }; const onJoinClick = async (ev: ButtonEvent): Promise => { setBusy(true); ev.preventDefault(); ev.stopPropagation(); try { await onJoinRoomClick(); await awaitRoomDownSync(cli, room.room_id); } finally { setBusy(false); } }; let button: ReactElement; if (busy) { button = ( ); } else if (joinedRoom || room.join_rule === JoinRule.Knock) { // If the room is knockable, show the "View" button even if we are not a member; that // allows us to reuse the "request to join" UX in RoomView. button = ( {_t("action|view")} ); } else { button = ( {_t("action|join")} ); } let checkbox: ReactElement | undefined; if (onToggleClick) { if (hasPermissions) { checkbox = ; } else { checkbox = ( { ev.stopPropagation(); }} > ); } } let avatar: ReactElement; if (joinedRoom) { avatar = ; } else { avatar = ( ); } let description = _t("common|n_members", { count: room.num_joined_members ?? 0 }); if (numChildRooms !== undefined) { description += " · " + _t("common|n_rooms", { count: numChildRooms }); } let topic: ReactNode | string | null; if (joinedRoom) { const topicObj = getTopic(joinedRoom); topic = topicToHtml(topicObj?.text, topicObj?.html); } else { topic = room.topic; } let topicSection: ReactNode | undefined; if (topic) { topicSection = ( {" · "} {topic} ); } let joinedSection: ReactElement | undefined; if (joinedRoom) { joinedSection =
{_t("common|joined")}
; } let suggestedSection: ReactElement | undefined; if (suggested && (!joinedRoom || hasPermissions)) { suggestedSection = {_t("space|suggested")}; } const content = (
{avatar}
{name} {joinedSection} {suggestedSection}
{description} {topicSection}
{button} {checkbox}
); let childToggle: JSX.Element | undefined; let childSection: JSX.Element | undefined; let onKeyDown: KeyboardEventHandler | undefined; 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: React.KeyboardEvent): void => { const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { case KeyBindingAction.ArrowLeft: e.preventDefault(); e.stopPropagation(); nodeRef.current?.focus(); break; } }; childSection = (
{children}
); } onKeyDown = (e) => { let handled = false; const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { case KeyBindingAction.ArrowLeft: if (showChildren) { handled = true; toggleShowChildren(); } break; case KeyBindingAction.ArrowRight: handled = true; if (showChildren) { const childSection = nodeRef.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, roomType?: RoomType): void => { 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) { defaultDispatcher.dispatch({ action: "require_registration" }); return; } } const roomAlias = getDisplayAliasForAliasSet(room?.canonical_alias ?? "", room?.aliases ?? []) || undefined; defaultDispatcher.dispatch({ action: Action.ViewRoom, should_peek: true, room_alias: roomAlias, room_id: roomId, 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("common|unnamed_room"), roomType, } as IOOBData, metricsTrigger: "RoomDirectory", }); }; export const joinRoom = async (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): Promise => { // 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()) { defaultDispatcher.dispatch({ action: "require_registration" }); return; } try { await cli.joinRoom(roomId, { viaServers: Array.from(hierarchy.viaMap.get(roomId) || []), }); } catch (err: unknown) { if (err instanceof MatrixError) { SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId); } else { logger.warn("Got a non-MatrixError while joining room", err); SdkContextClass.instance.roomViewStore.showJoinRoomError( new MatrixError({ error: _t("error|unknown"), }), roomId, ); } // rethrow error so that the caller can handle react to it too throw err; } defaultDispatcher.dispatch({ action: Action.JoinRoomReady, roomId, metricsTrigger: "SpaceHierarchy", }); }; interface IHierarchyLevelProps { root: HierarchyRoom; roomSet: Set; hierarchy: RoomHierarchy; parents: Set; selectedMap?: Map>; onViewRoomClick(roomId: string, roomType?: RoomType): void; onJoinRoomClick(roomId: string, parents: Set): Promise; onToggleClick?(parentId: string, childId: string): void; } export const toLocalRoom = (cli: MatrixClient, room: HierarchyRoom, hierarchy: RoomHierarchy): HierarchyRoom => { const history = cli.getRoomUpgradeHistory( room.room_id, true, SettingsStore.getValue("feature_dynamic_room_predecessors"), ); // Pick latest room that is actually part of the hierarchy let cliRoom: Room | null = null; for (let idx = history.length - 1; idx >= 0; --idx) { if (hierarchy.roomMap.get(history[idx].roomId)) { cliRoom = history[idx]; break; } } if (cliRoom) { return { ...room, room_id: cliRoom.roomId, room_type: cliRoom.getType(), name: cliRoom.name, topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic, avatar_url: cliRoom.getMxcAvatarUrl() ?? undefined, canonical_alias: cliRoom.getCanonicalAlias() ?? undefined, aliases: cliRoom.getAltAliases(), world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent() .history_visibility === HistoryVisibility.WorldReadable, guest_can_join: cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent().guest_access === GuestAccess.CanJoin, num_joined_members: cliRoom.getJoinedMemberCount(), }; } return room; }; export const HierarchyLevel: React.FC = ({ root, roomSet, hierarchy, parents, selectedMap, onViewRoomClick, onJoinRoomClick, onToggleClick, }) => { const cli = useContext(MatrixClientContext); const space = cli.getRoom(root.room_id); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId()); 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: HierarchyRelation) => { const room = hierarchy.roomMap.get(ev.state_key); if (room && roomSet.has(room)) { result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, hierarchy)); } return result; }, [[] as HierarchyRoom[], [] as HierarchyRoom[]], ); const newParents = new Set(parents).add(root.room_id); return ( {uniqBy(childRooms, "room_id").map((room) => ( onViewRoomClick(room.room_id, room.room_type as RoomType)} onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)} 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={() => onViewRoomClick(space.room_id, RoomType.Space)} onJoinRoomClick={() => onJoinRoomClick(space.room_id, newParents)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} > ))} ); }; const INITIAL_PAGE_SIZE = 20; export const useRoomHierarchy = ( space: Room, ): { loading: boolean; rooms?: HierarchyRoom[]; hierarchy?: RoomHierarchy; error?: Error; loadMore(pageSize?: number): Promise; } => { const [rooms, setRooms] = useState([]); const [hierarchy, setHierarchy] = useState(); const [error, setError] = useState(); const resetHierarchy = useCallback(() => { setError(undefined); const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE); hierarchy.load().then(() => { if (space !== hierarchy.root) return; // discard stale results setRooms(hierarchy.rooms ?? []); }, setError); setHierarchy(hierarchy); }, [space]); useEffect(resetHierarchy, [resetHierarchy]); useDispatcher(defaultDispatcher, (payload) => { if (payload.action === Action.UpdateSpaceHierarchy) { setRooms([]); // TODO resetHierarchy(); } }); const loadMore = useCallback( async (pageSize?: number): Promise => { if (!hierarchy || hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport || error) return; await hierarchy.load(pageSize).catch(setError); setRooms(hierarchy.rooms ?? []); }, [error, hierarchy], ); // Only return the hierarchy if it is for the space requested if (hierarchy?.root !== space) { return { loading: true, loadMore, }; } return { loading: hierarchy.loading, rooms, hierarchy, loadMore, error, }; }; const useIntersectionObserver = (callback: () => void): ((element: HTMLDivElement) => void) => { const handleObserver = (entries: IntersectionObserverEntry[]): void => { 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: React.FC = ({ hierarchy, selected, setSelected, setError }) => { 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]); }); const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { return hierarchy.isSuggested(parentId, childId); }); const disabled = !selectedRelations.length || removing || saving; let buttonText = _t("common|saving"); if (!saving) { buttonText = selectionAllSuggested ? _t("space|unmark_suggested") : _t("space|mark_suggested"); } const title = !selectedRelations.length ? _t("space|select_room_below") : undefined; return ( <> => { setRemoving(true); try { const userId = cli.getSafeUserId(); for (const [parentId, childId] of selectedRelations) { await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); // remove the child->parent relation too, if we have permission to. const childRoom = cli.getRoom(childId); const parentRelation = childRoom?.currentState.getStateEvents( EventType.SpaceParent, parentId, ); if ( childRoom?.currentState.maySendStateEvent(EventType.SpaceParent, userId) && Array.isArray(parentRelation?.getContent().via) ) { await cli.sendStateEvent(childId, EventType.SpaceParent, {}, parentId); } hierarchy.removeRelation(parentId, childId); } } catch { setError(_t("space|failed_remove_rooms")); } setRemoving(false); setSelected(new Map()); }} kind="danger_outline" disabled={disabled} aria-label={removing ? _t("redact|ongoing") : _t("action|remove")} title={title} placement="top" > {removing ? _t("redact|ongoing") : _t("action|remove")} => { 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: SpaceChildEventContent = { ...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 { setError("Failed to update some suggestions. Try again later"); } setSaving(false); setSelected(new Map()); }} kind="primary_outline" disabled={disabled} aria-label={buttonText} title={title} placement="top" > {buttonText} ); }; const SpaceHierarchy: React.FC = ({ space, initialText = "", showRoom, additionalButtons }) => { const cli = useContext(MatrixClientContext); const [query, setQuery] = useState(initialText); const [selected, setSelected] = useState(new Map>()); // Map> const { loading, rooms, hierarchy, loadMore, error: hierarchyError } = useRoomHierarchy(space); const filteredRoomSet = useMemo>(() => { if (!rooms?.length || !hierarchy) 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(""); let errorText = error; if (!error && hierarchyError) { errorText = _t("space|failed_load_rooms"); } const loaderRef = useIntersectionObserver(loadMore); if (!loading && hierarchy!.noSupport) { return

    {_t("space|incompatible_server_hierarchy")}

    ; } const onKeyDown = (ev: KeyboardEvent, state: IState): void => { const action = getKeyBindingsManager().getAccessibilityAction(ev); if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) { state.nodes[0]?.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; if (!hierarchy || (loading && !rooms?.length)) { content = ; } else { const hasPermissions = space?.getMyMembership() === KnownMembership.Join && space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId()); const root = hierarchy.roomMap.get(space.roomId); let results: JSX.Element | undefined; if (filteredRoomSet.size && root) { results = ( <> showRoom(cli, hierarchy, roomId, roomType)} onJoinRoomClick={async (roomId, parents) => { for (const parent of parents) { if (cli.getRoom(parent)?.getMyMembership() !== KnownMembership.Join) { await joinRoom(cli, hierarchy, parent); } } await joinRoom(cli, hierarchy, roomId); }} /> ); } else if (!hierarchy.canLoadMore) { results = (

    {_t("common|no_results_found")}

    {_t("space|no_search_result_hint")}
    ); } let loader: JSX.Element | undefined; if (hierarchy.canLoadMore) { loader = (
    ); } content = ( <>

    {query.trim() ? _t("space|title_when_query_available") : _t("space|title_when_query_unavailable")}

    {additionalButtons} {hasPermissions && ( )}
    {errorText &&
    {errorText}
    }
      {results}
    {loader} ); } return ( <> {content} ); }}
    ); }; export default SpaceHierarchy;