/* 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 Please see LICENSE files in the repository root for full details. */ import React, { MouseEvent, ComponentProps, ComponentType, createRef, InputHTMLAttributes, LegacyRef, RefObject, } from "react"; import classNames from "classnames"; import { Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; import RoomAvatar from "../avatars/RoomAvatar"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import { SpaceKey } from "../../../stores/spaces"; import SpaceTreeLevelLayoutStore from "../../../stores/spaces/SpaceTreeLevelLayoutStore"; import NotificationBadge from "../rooms/NotificationBadge"; import { _t } from "../../../languageHandler"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { toRightOf, useContextMenu } from "../../structures/ContextMenu"; import AccessibleButton, { ButtonEvent, ButtonProps as AccessibleButtonProps } from "../elements/AccessibleButton"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { NotificationState } from "../../../stores/notifications/NotificationState"; import SpaceContextMenu from "../context_menus/SpaceContextMenu"; import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; type ButtonProps = Omit< AccessibleButtonProps, "title" | "onClick" | "size" | "element" > & { space?: Room; spaceKey?: SpaceKey; className?: string; selected?: boolean; label: string; contextMenuTooltip?: string; notificationState?: NotificationState; isNarrow?: boolean; size: string; innerRef?: RefObject; ContextMenuComponent?: ComponentType>; onClick?(ev?: ButtonEvent): void; }; export const SpaceButton = ({ space, spaceKey: _spaceKey, className, selected, label, contextMenuTooltip, notificationState, size, isNarrow, children, innerRef, ContextMenuComponent, ...props }: ButtonProps): JSX.Element => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(innerRef); const [onFocus, isActive, ref] = useRovingTabIndex(handle); const tabIndex = isActive ? 0 : -1; const spaceKey = _spaceKey ?? space?.roomId; let avatar = (
); if (space) { avatar = ; } let notifBadge; if (spaceKey && notificationState) { let ariaLabel = _t("a11y_jump_first_unread_room"); if (space?.getMyMembership() === KnownMembership.Invite) { ariaLabel = _t("a11y|jump_first_invite"); } const jumpToNotification = (ev: MouseEvent): void => { ev.stopPropagation(); ev.preventDefault(); SpaceStore.instance.setActiveRoomInSpace(spaceKey); }; notifBadge = (
); } let contextMenu: JSX.Element | undefined; if (menuDisplayed && handle.current && ContextMenuComponent) { contextMenu = ( ); } const viewSpaceHome = (): void => // space is set here because of the assignment condition of onClick defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: space!.roomId }); const activateSpace = (): void => { if (spaceKey) SpaceStore.instance.setActiveSpace(spaceKey); }; const onClick = props.onClick ?? (selected && space ? viewSpaceHome : activateSpace); return ( {children}
{avatar} {notifBadge}
{!isNarrow && {label}} {ContextMenuComponent && ( )} {contextMenu}
); }; interface IItemProps extends InputHTMLAttributes { space: Room; activeSpaces: SpaceKey[]; isNested?: boolean; isPanelCollapsed?: boolean; onExpand?: () => void; parents?: Set; innerRef?: LegacyRef; dragHandleProps?: DraggableProvidedDragHandleProps | null; } interface IItemState { name: string; collapsed: boolean; childSpaces: Room[]; } export class SpaceItem extends React.PureComponent { private buttonRef = createRef(); public constructor(props: IItemProps) { super(props); const collapsed = SpaceTreeLevelLayoutStore.instance.getSpaceCollapsedState( props.space.roomId, this.props.parents, !props.isNested, // default to collapsed for root items ); this.state = { name: this.props.space.name, collapsed, childSpaces: this.childSpaces, }; } public componentDidMount(): void { SpaceStore.instance.on(this.props.space.roomId, this.onSpaceUpdate); this.props.space.on(RoomEvent.Name, this.onRoomNameChange); } public componentWillUnmount(): void { SpaceStore.instance.off(this.props.space.roomId, this.onSpaceUpdate); this.props.space.off(RoomEvent.Name, this.onRoomNameChange); } private onSpaceUpdate = (): void => { this.setState({ childSpaces: this.childSpaces, }); }; private onRoomNameChange = (): void => { this.setState({ name: this.props.space.name, }); }; private get childSpaces(): Room[] { return SpaceStore.instance .getChildSpaces(this.props.space.roomId) .filter((s) => !this.props.parents?.has(s.roomId)); } private get isCollapsed(): boolean { return this.state.collapsed || !!this.props.isPanelCollapsed; } private toggleCollapse = (evt: ButtonEvent): void => { if (this.props.onExpand && this.isCollapsed) { this.props.onExpand(); } const newCollapsedState = !this.isCollapsed; SpaceTreeLevelLayoutStore.instance.setSpaceCollapsedState( this.props.space.roomId, this.props.parents, newCollapsedState, ); this.setState({ collapsed: newCollapsedState }); // don't bubble up so encapsulating button for space // doesn't get triggered evt.stopPropagation(); }; private onKeyDown = (ev: React.KeyboardEvent): void => { let handled = true; const action = getKeyBindingsManager().getRoomListAction(ev); const hasChildren = this.state.childSpaces?.length; switch (action) { case KeyBindingAction.CollapseRoomListSection: if (hasChildren && !this.isCollapsed) { this.toggleCollapse(ev); } else { const parentItem = this.buttonRef?.current?.parentElement?.parentElement; const parentButton = parentItem?.previousElementSibling as HTMLElement; parentButton?.focus(); } break; case KeyBindingAction.ExpandRoomListSection: if (hasChildren) { if (this.isCollapsed) { this.toggleCollapse(ev); } else { const childLevel = this.buttonRef?.current?.nextElementSibling; const firstSpaceItemChild = childLevel?.querySelector(".mx_SpaceItem"); firstSpaceItemChild?.querySelector(".mx_SpaceButton")?.focus(); } } break; default: handled = false; } if (handled) { ev.stopPropagation(); ev.preventDefault(); } }; public render(): React.ReactNode { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, dragHandleProps, ...otherProps } = this.props; const collapsed = this.isCollapsed; const itemClasses = classNames(this.props.className, { mx_SpaceItem: true, mx_SpaceItem_narrow: isPanelCollapsed, collapsed: collapsed, hasSubSpaces: this.state.childSpaces?.length, }); const isInvite = space.getMyMembership() === KnownMembership.Invite; const notificationState = isInvite ? StaticNotificationState.forSymbol("!", NotificationLevel.Highlight) : SpaceStore.instance.getNotificationState(space.roomId); const hasChildren = this.state.childSpaces?.length; let childItems; if (hasChildren && !collapsed) { childItems = ( ); } const toggleCollapseButton = hasChildren ? ( ) : null; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { tabIndex, ...restDragHandleProps } = dragHandleProps || {}; const selected = activeSpaces.includes(space.roomId); return (
  • {toggleCollapseButton} {childItems}
  • ); } } interface ITreeLevelProps { spaces: Room[]; activeSpaces: SpaceKey[]; isNested?: boolean; parents: Set; } const SpaceTreeLevel: React.FC = ({ spaces, activeSpaces, isNested, parents }) => { return (
      {spaces.map((s) => { return ( ); })}
    ); };