Files
ThreadNet-Web/apps/web/src/components/views/spaces/SpacePanel.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

508 lines
20 KiB
TypeScript
Raw Normal View History

2021-02-26 10:23:09 +00:00
/*
2026-05-06 09:34:36 +01:00
Copyright 2026 Element Creations Ltd.
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
2021-02-26 10:23:09 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2021-02-26 10:23:09 +00:00
*/
2021-08-17 17:59:26 +02:00
import React, {
2025-02-05 13:25:06 +00:00
type ComponentProps,
type Dispatch,
type ReactNode,
type RefCallback,
type SetStateAction,
type JSX,
useCallback,
2021-08-17 17:59:26 +02:00
useEffect,
useLayoutEffect,
useRef,
useState,
2026-05-06 09:34:36 +01:00
useContext,
2021-08-17 17:59:26 +02:00
} from "react";
2025-02-05 13:25:06 +00:00
import { DragDropContext, Draggable, Droppable, type DroppableProvidedProps } from "react-beautiful-dnd";
2021-02-26 10:23:09 +00:00
import classNames from "classnames";
2025-02-05 13:25:06 +00:00
import { type Room } from "matrix-js-sdk/src/matrix";
import {
FavouriteSolidIcon,
HomeSolidIcon,
RoomIcon,
VideoCallSolidIcon,
UserProfileSolidIcon,
PlusIcon,
ChevronRightIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
2026-05-06 09:34:36 +01:00
import { useCreateAutoDisposedViewModel, UserMenu } from "@element-hq/web-shared-components";
2021-02-26 10:23:09 +00:00
2021-06-16 09:44:37 +01:00
import { _t } from "../../../languageHandler";
import { useContextMenu } from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu";
import { SpaceButton, SpaceItem } from "./SpaceTreeLevel";
import { useEventEmitter, useEventEmitterState } from "../../../hooks/useEventEmitter";
2021-11-11 13:07:41 +00:00
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
getMetaSpaceName,
2021-11-11 13:07:41 +00:00
MetaSpace,
2025-02-05 13:25:06 +00:00
type SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES,
2021-11-11 13:07:41 +00:00
} from "../../../stores/spaces";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import {
RoomNotificationStateStore,
UPDATE_STATUS_INDICATOR,
} from "../../../stores/notifications/RoomNotificationStateStore";
2025-02-05 13:25:06 +00:00
import type SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import UIStore from "../../../stores/UIStore";
2021-11-26 15:41:04 +00:00
import QuickSettingsButton from "./QuickSettingsButton";
import { useSettingValue } from "../../../hooks/useSettings";
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
2021-12-07 09:32:00 +00:00
import { useDispatcher } from "../../../hooks/useDispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
2025-02-05 13:25:06 +00:00
import { type ActionPayload } from "../../../dispatcher/payloads";
2021-12-07 09:32:00 +00:00
import { Action } from "../../../dispatcher/actions";
2025-02-05 13:25:06 +00:00
import { type NotificationState } from "../../../stores/notifications/NotificationState";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
2024-02-07 14:49:40 +01:00
import { ThreadsActivityCentre } from "./threads-activity-centre/";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
2024-05-15 11:44:17 +02:00
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
2025-11-05 07:24:26 +00:00
import { ModuleApi } from "../../../modules/Api.ts";
import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts";
2026-05-06 09:34:36 +01:00
import { UserMenuViewModel } from "../../../viewmodels/menus/UserMenuViewModel.ts";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import { SDKContext } from "../../../contexts/SDKContext.ts";
2026-07-06 20:28:34 +01:00
import { OwnProfileStore } from "../../../stores/OwnProfileStore.ts";
2021-02-26 10:23:09 +00:00
2021-11-11 13:07:41 +00:00
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
2021-07-28 17:39:56 +01:00
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
return SpaceStore.instance.invitedSpaces;
});
2021-11-11 13:07:41 +00:00
const [metaSpaces, actualSpaces] = useEventEmitterState<[MetaSpace[], Room[]]>(
SpaceStore.instance,
UPDATE_TOP_LEVEL_SPACES,
() => [SpaceStore.instance.enabledMetaSpaces, SpaceStore.instance.spacePanelSpaces],
);
const activeSpace = useEventEmitterState<SpaceKey>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
2021-07-28 17:39:56 +01:00
return SpaceStore.instance.activeSpace;
});
2021-11-11 13:07:41 +00:00
return [invites, metaSpaces, actualSpaces, activeSpace];
2021-02-26 10:23:09 +00:00
};
export const HomeButtonContextMenu: React.FC<ComponentProps<typeof SpaceContextMenu>> = ({
onFinished,
hideHeader,
...props
}) => {
2024-12-23 20:25:15 +00:00
const allRoomsInHome = useSettingValue("Spaces.allRoomsInHome");
return (
<IconizedContextMenu {...props} onFinished={onFinished} className="mx_SpacePanel_contextMenu" compact>
{!hideHeader && <div className="mx_SpacePanel_contextMenu_header">{_t("common|home")}</div>}
<IconizedContextMenuOptionList first>
<IconizedContextMenuCheckbox
label={_t("settings|sidebar|metaspaces_home_all_rooms")}
active={allRoomsInHome}
onClick={() => {
onFinished();
2021-07-30 12:20:02 +01:00
SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
};
2021-11-11 13:07:41 +00:00
interface IMetaSpaceButtonProps extends ComponentProps<typeof SpaceButton> {
selected: boolean;
isPanelCollapsed: boolean;
icon: JSX.Element;
}
2021-11-11 13:07:41 +00:00
type MetaSpaceButtonProps = Pick<IMetaSpaceButtonProps, "selected" | "isPanelCollapsed">;
const MetaSpaceButton: React.FC<IMetaSpaceButtonProps> = ({ selected, isPanelCollapsed, size = "32px", ...props }) => {
2021-08-09 14:01:34 +01:00
return (
<li
className={classNames("mx_SpaceItem", {
collapsed: isPanelCollapsed,
})}
role="treeitem"
aria-selected={selected}
2021-08-09 14:01:34 +01:00
>
<SpaceButton {...props} selected={selected} isNarrow={isPanelCollapsed} size={size} />
</li>
);
};
const getHomeNotificationState = (): NotificationState => {
return SpaceStore.instance.allRoomsInHome
? RoomNotificationStateStore.instance.globalState
: SpaceStore.instance.getNotificationState(MetaSpace.Home);
};
const HomeButton: React.FC<MetaSpaceButtonProps> = ({ selected, isPanelCollapsed }) => {
2021-11-11 13:07:41 +00:00
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const [notificationState, setNotificationState] = useState(getHomeNotificationState());
const updateNotificationState = useCallback(() => {
setNotificationState(getHomeNotificationState());
}, []);
useEffect(updateNotificationState, [updateNotificationState, allRoomsInHome]);
useEventEmitter(RoomNotificationStateStore.instance, UPDATE_STATUS_INDICATOR, updateNotificationState);
2021-11-11 13:07:41 +00:00
return (
<MetaSpaceButton
spaceKey={MetaSpace.Home}
selected={selected}
isPanelCollapsed={isPanelCollapsed}
label={getMetaSpaceName(MetaSpace.Home, allRoomsInHome)}
notificationState={notificationState}
2021-11-11 13:07:41 +00:00
ContextMenuComponent={HomeButtonContextMenu}
contextMenuTooltip={_t("common|options")}
size="32px"
icon={<HomeSolidIcon />}
2021-11-11 13:07:41 +00:00
/>
);
};
const FavouritesButton: React.FC<MetaSpaceButtonProps> = ({ selected, isPanelCollapsed }) => {
2021-11-11 13:07:41 +00:00
return (
<MetaSpaceButton
spaceKey={MetaSpace.Favourites}
selected={selected}
isPanelCollapsed={isPanelCollapsed}
label={getMetaSpaceName(MetaSpace.Favourites)}
2021-11-11 13:07:41 +00:00
notificationState={SpaceStore.instance.getNotificationState(MetaSpace.Favourites)}
size="32px"
icon={<FavouriteSolidIcon />}
2021-11-11 13:07:41 +00:00
/>
);
};
const PeopleButton: React.FC<MetaSpaceButtonProps> = ({ selected, isPanelCollapsed }) => {
2021-11-11 13:07:41 +00:00
return (
<MetaSpaceButton
spaceKey={MetaSpace.People}
selected={selected}
isPanelCollapsed={isPanelCollapsed}
label={getMetaSpaceName(MetaSpace.People)}
2021-11-11 13:07:41 +00:00
notificationState={SpaceStore.instance.getNotificationState(MetaSpace.People)}
size="32px"
icon={<UserProfileSolidIcon />}
2021-11-11 13:07:41 +00:00
/>
);
};
const OrphansButton: React.FC<MetaSpaceButtonProps> = ({ selected, isPanelCollapsed }) => {
2021-11-11 13:07:41 +00:00
return (
<MetaSpaceButton
spaceKey={MetaSpace.Orphans}
selected={selected}
isPanelCollapsed={isPanelCollapsed}
label={getMetaSpaceName(MetaSpace.Orphans)}
2021-11-11 13:07:41 +00:00
notificationState={SpaceStore.instance.getNotificationState(MetaSpace.Orphans)}
size="32px"
icon={<RoomIcon />}
2021-11-11 13:07:41 +00:00
/>
);
};
2024-03-25 19:35:31 +01:00
const VideoRoomsButton: React.FC<MetaSpaceButtonProps> = ({ selected, isPanelCollapsed }) => {
return (
<MetaSpaceButton
spaceKey={MetaSpace.VideoRooms}
selected={selected}
isPanelCollapsed={isPanelCollapsed}
label={getMetaSpaceName(MetaSpace.VideoRooms)}
notificationState={SpaceStore.instance.getNotificationState(MetaSpace.VideoRooms)}
size="32px"
icon={<VideoCallSolidIcon />}
2024-03-25 19:35:31 +01:00
/>
);
};
const CreateSpaceButton: React.FC<Pick<IInnerSpacePanelProps, "isPanelCollapsed" | "setPanelCollapsed">> = ({
isPanelCollapsed,
setPanelCollapsed,
}) => {
2024-12-06 17:49:32 +00:00
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu: JSX.Element | undefined;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onNewClick = menuDisplayed
? closeMenu
: () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
2021-08-09 14:01:34 +01:00
return (
<li
className={classNames("mx_SpaceItem mx_SpaceItem_new", {
2021-08-09 14:01:34 +01:00
collapsed: isPanelCollapsed,
})}
2021-08-09 14:01:34 +01:00
role="treeitem"
aria-selected={false}
2022-12-12 12:24:14 +01:00
>
<SpaceButton
data-testid="create-space-button"
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
2022-12-12 12:24:14 +01:00
})}
label={menuDisplayed ? _t("action|cancel") : _t("create_space|label")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
2023-07-07 13:37:26 +01:00
innerRef={handle}
size="32px"
icon={<PlusIcon />}
/>
{contextMenu}
</li>
);
};
2021-11-11 13:07:41 +00:00
const metaSpaceComponentMap: Record<MetaSpace, typeof HomeButton> = {
[MetaSpace.Home]: HomeButton,
[MetaSpace.Favourites]: FavouritesButton,
[MetaSpace.People]: PeopleButton,
[MetaSpace.Orphans]: OrphansButton,
2024-03-25 19:35:31 +01:00
[MetaSpace.VideoRooms]: VideoRoomsButton,
2021-11-11 13:07:41 +00:00
};
interface IInnerSpacePanelProps extends DroppableProvidedProps {
children?: ReactNode;
isPanelCollapsed: boolean;
setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
isDraggingOver: boolean;
innerRef: RefCallback<HTMLElement>;
}
2021-06-16 09:44:37 +01:00
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
({ children, isPanelCollapsed, setPanelCollapsed, isDraggingOver, innerRef, ...props }) => {
2021-11-11 13:07:41 +00:00
const [invites, metaSpaces, actualSpaces, activeSpace] = useSpaces();
2021-06-16 09:44:37 +01:00
const activeSpaces = activeSpace ? [activeSpace] : [];
2025-11-05 07:24:26 +00:00
const moduleSpaceItems = useModuleSpacePanelItems(ModuleApi.instance.extras);
2024-03-25 19:35:31 +01:00
const metaSpacesSection = metaSpaces
.filter((key) => !(key === MetaSpace.VideoRooms && !SettingsStore.getValue("feature_video_rooms")))
.map((key) => {
const Component = metaSpaceComponentMap[key];
return <Component key={key} selected={activeSpace === key} isPanelCollapsed={isPanelCollapsed} />;
});
2021-11-11 13:07:41 +00:00
return (
<IndicatorScrollbar
{...props}
wrappedRef={innerRef}
className="mx_SpaceTreeLevel"
style={
isDraggingOver
? {
pointerEvents: "none",
}
: undefined
}
as="ul"
role="tree"
aria-label={_t("common|spaces")}
>
2021-06-16 09:44:37 +01:00
{metaSpacesSection}
2021-11-11 13:07:41 +00:00
{invites.map((s) => (
2021-06-16 09:44:37 +01:00
<SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
))}
{actualSpaces.map((s, i) => (
2021-06-16 09:44:37 +01:00
<Draggable key={s.roomId} draggableId={s.roomId} index={i}>
{(provided, snapshot) => (
2021-06-16 09:44:37 +01:00
<SpaceItem
{...provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
2021-06-16 09:44:37 +01:00
key={s.roomId}
innerRef={provided.innerRef}
className={snapshot.isDragging ? "mx_SpaceItem_dragging" : undefined}
2021-06-16 09:44:37 +01:00
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
2022-12-12 12:24:14 +01:00
/>
)}
2021-06-16 09:44:37 +01:00
</Draggable>
))}
{children}
2025-11-05 07:24:26 +00:00
{moduleSpaceItems.map((item) => (
<li
key={item.spaceKey}
className={classNames("mx_SpaceItem", {
collapsed: isPanelCollapsed,
})}
role="treeitem"
aria-selected={false} // TODO
>
<SpaceButton
{...item}
isNarrow={isPanelCollapsed}
size="32px"
selected={activeSpace === item.spaceKey}
onClick={() => {
SpaceStore.instance.setActiveSpace(item.spaceKey);
item.onSelected?.();
}}
/>
</li>
))}
{shouldShowComponent(UIComponent.CreateSpaces) && (
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
)}
</IndicatorScrollbar>
);
2021-06-16 09:44:37 +01:00
},
);
const SpacePanel: React.FC = () => {
2026-05-06 09:34:36 +01:00
const client = useMatrixClientContext();
const [dragging, setDragging] = useState(false);
2021-02-26 10:23:09 +00:00
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
}, []);
2026-05-06 09:34:36 +01:00
const sdkContext = useContext(SDKContext);
2021-02-26 10:23:09 +00:00
2021-12-07 09:32:00 +00:00
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
if (payload.action === Action.ToggleSpacePanel) {
setPanelCollapsed(!isPanelCollapsed);
}
});
2026-05-06 09:34:36 +01:00
const userMenuVm = useCreateAutoDisposedViewModel(
() =>
new UserMenuViewModel(
2026-07-06 20:28:34 +01:00
{ ownProfileStore: OwnProfileStore.instance },
2026-05-06 09:34:36 +01:00
defaultDispatcher,
client,
isPanelCollapsed,
sdkContext.oidcClientStore.accountManagementEndpoint,
),
);
useDispatcher(defaultDispatcher, (payload) => {
if (payload.action === Action.ToggleUserMenu) {
userMenuVm.setOpen(!userMenuVm.getSnapshot().open);
}
});
useEffect(() => {
userMenuVm.setExpanded(!isPanelCollapsed);
}, [userMenuVm, isPanelCollapsed]);
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
{({ onKeyDownHandler, onDragEndHandler }) => (
<DragDropContext
onDragStart={() => {
setDragging(true);
}}
onDragEnd={(result) => {
setDragging(false);
if (!result.destination) return; // dropped outside the list
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
onDragEndHandler();
}}
>
<nav
className={classNames("mx_SpacePanel", {
collapsed: isPanelCollapsed,
})}
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ACTIVE_SPACE_BUTTON,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
ref={ref}
aria-label={_t("common|spaces")}
>
<UserMenu vm={userMenuVm} className="mx_UserMenu" />
2026-05-06 09:34:36 +01:00
<AccessibleButton
className={classNames("mx_SpacePanel_toggleCollapse", {
expanded: !isPanelCollapsed,
})}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
caption={
<KeyboardShortcut
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
/>
}
>
<ChevronRightIcon />
</AccessibleButton>
<Droppable droppableId="top-level-spaces">
{(provided, snapshot) => (
<InnerSpacePanel
{...provided.droppableProps}
isPanelCollapsed={isPanelCollapsed}
setPanelCollapsed={setPanelCollapsed}
isDraggingOver={snapshot.isDraggingOver}
innerRef={provided.innerRef}
>
{provided.placeholder}
</InnerSpacePanel>
)}
</Droppable>
2021-12-07 09:32:00 +00:00
<ThreadsActivityCentre displayButtonLabel={!isPanelCollapsed} />
<QuickSettingsButton isPanelCollapsed={isPanelCollapsed} />
</nav>
</DragDropContext>
)}
</RovingTabIndexProvider>
);
2021-02-26 10:23:09 +00:00
};
export default SpacePanel;