Files
element-web/src/components/views/rooms/RoomListHeader.tsx
David Langley 491f0cd08a Change license (#13)
* Copyright headers 1

* Licence headers 2

* Copyright Headers 3

* Copyright Headers 4

* Copyright Headers 5

* Copyright Headers 6

* Copyright headers 7

* Add copyright headers for html and config file

* Replace license files and update package.json

* Update with CLA

* lint
2024-09-09 13:57:16 +00:00

427 lines
17 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 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 { EventType, RoomType, Room, RoomEvent, ClientEvent } from "matrix-js-sdk/src/matrix";
import React, { useContext, useEffect, useState } from "react";
import { Tooltip } from "@vector-im/compound-web";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { useEventEmitterState, useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { _t } from "../../../languageHandler";
import PosthogTrackers from "../../../PosthogTrackers";
import { UIComponent } from "../../../settings/UIFeature";
import {
getMetaSpaceName,
MetaSpace,
SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
} from "../../../utils/space";
import {
ChevronFace,
ContextMenuTooltipButton,
useContextMenu,
MenuProps,
ContextMenuButton,
} from "../../structures/ContextMenu";
import { BetaPill } from "../beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import InlineSpinner from "../elements/InlineSpinner";
import { HomeButtonContextMenu } from "../spaces/SpacePanel";
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX;
const top = elementRect.bottom + window.scrollY + 12;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
// Long-running actions that should trigger a spinner
enum PendingActionType {
JoinRoom,
BulkRedact,
}
const usePendingActions = (): Map<PendingActionType, Set<string>> => {
const cli = useContext(MatrixClientContext);
const [actions, setActions] = useState(new Map<PendingActionType, Set<string>>());
const addAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
keys.add(key);
setActions(new Map(actions).set(type, keys));
};
const removeAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
if (keys.delete(key)) {
setActions(new Map(actions).set(type, keys));
}
};
useDispatcher(defaultDispatcher, (payload) => {
switch (payload.action) {
case Action.JoinRoom:
addAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
removeAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.BulkRedactStart:
addAction(PendingActionType.BulkRedact, payload.roomId);
break;
case Action.BulkRedactEnd:
removeAction(PendingActionType.BulkRedact, payload.roomId);
break;
}
});
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => removeAction(PendingActionType.JoinRoom, room.roomId));
return actions;
};
interface IProps {
onVisibilityChange?(): void;
}
const RoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
const cli = useContext(MatrixClientContext);
const [mainMenuDisplayed, mainMenuHandle, openMainMenu, closeMainMenu] = useContextMenu<HTMLDivElement>();
const [plusMenuDisplayed, plusMenuHandle, openPlusMenu, closePlusMenu] = useContextMenu<HTMLDivElement>();
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const pendingActions = usePendingActions();
const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
useEffect(() => {
if (mainMenuDisplayed && !canShowMainMenu) {
// Space changed under us and we no longer has a main menu to draw
closeMainMenu();
}
}, [closeMainMenu, canShowMainMenu, mainMenuDisplayed]);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
useEffect(() => {
onVisibilityChange?.();
}, [onVisibilityChange]);
const canExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const canCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const canCreateSpaces = shouldShowComponent(UIComponent.CreateSpaces);
const hasPermissionToAddSpaceChild = activeSpace?.currentState?.maySendStateEvent(
EventType.SpaceChild,
cli.getUserId()!,
);
const canAddSubRooms = hasPermissionToAddSpaceChild && canCreateRooms;
const canAddSubSpaces = hasPermissionToAddSpaceChild && canCreateSpaces;
// If the user can't do anything on the plus menu, don't show it. This aims to target the
// plus menu shown on the Home tab primarily: the user has options to use the menu for
// communities and spaces, but is at risk of no options on the Home tab.
const canShowPlusMenu = canCreateRooms || canExploreRooms || canCreateSpaces || activeSpace;
let contextMenu: JSX.Element | undefined;
if (mainMenuDisplayed && mainMenuHandle.current) {
let ContextMenuComponent;
if (activeSpace) {
ContextMenuComponent = SpaceContextMenu;
} else {
ContextMenuComponent = HomeButtonContextMenu;
}
contextMenu = (
<ContextMenuComponent
{...contextMenuBelow(mainMenuHandle.current.getBoundingClientRect())}
space={activeSpace!}
onFinished={closeMainMenu}
hideHeader={true}
/>
);
} else if (plusMenuDisplayed && activeSpace) {
let inviteOption: JSX.Element | undefined;
if (shouldShowSpaceInvite(activeSpace)) {
inviteOption = (
<IconizedContextMenuOption
label={_t("action|invite")}
iconClassName="mx_RoomListHeader_iconInvite"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showSpaceInvite(activeSpace);
closePlusMenu();
}}
/>
);
}
let newRoomOptions: JSX.Element | undefined;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()!)) {
newRoomOptions = (
<>
<IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewRoom"
label={_t("action|new_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
label={_t("action|new_video_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{inviteOption}
{newRoomOptions}
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
iconClassName="mx_RoomListHeader_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
closePlusMenu();
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
}}
/>
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
iconClassName="mx_RoomListHeader_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showAddExistingRooms(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubRooms}
title={!canAddSubRooms ? _t("spaces|error_no_permission_add_room") : undefined}
/>
{canCreateSpaces && (
<IconizedContextMenuOption
label={_t("room_list|add_space_label")}
iconClassName="mx_RoomListHeader_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewSubspace(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubSpaces}
title={!canAddSubSpaces ? _t("spaces|error_no_permission_add_space") : undefined}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
} else if (plusMenuDisplayed) {
let newRoomOpts: JSX.Element | undefined;
let joinRoomOpt: JSX.Element | undefined;
if (canCreateRooms) {
newRoomOpts = (
<>
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
iconClassName="mx_RoomListHeader_iconStartChat"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: "view_create_chat" });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
closePlusMenu();
}}
/>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_RoomListHeader_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: "view_create_room" });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({
action: "view_create_room",
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
});
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
if (canExploreRooms) {
joinRoomOpt = (
<IconizedContextMenuOption
label={_t("room_list|join_public_room_label")}
iconClassName="mx_RoomListHeader_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.ViewRoomDirectory });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
closePlusMenu();
}}
/>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{newRoomOpts}
{joinRoomOpt}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
let title: string;
if (activeSpace && spaceName) {
title = spaceName;
} else {
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
}
const pendingActionSummary = [...pendingActions.entries()]
.filter(([type, keys]) => keys.size > 0)
.map(([type, keys]) => {
switch (type) {
case PendingActionType.JoinRoom:
return _t("room_list|joining_rooms_status", { count: keys.size });
case PendingActionType.BulkRedact:
return _t("room_list|redacting_messages_status", { count: keys.size });
}
})
.join("\n");
let contextMenuButton: JSX.Element = <div className="mx_RoomListHeader_contextLessTitle">{title}</div>;
if (canShowMainMenu) {
const commonProps = {
ref: mainMenuHandle,
onClick: openMainMenu,
isExpanded: mainMenuDisplayed,
className: "mx_RoomListHeader_contextMenuButton",
children: title,
};
if (!!activeSpace) {
contextMenuButton = (
<ContextMenuButton
{...commonProps}
label={_t("room_list|space_menu_label", { spaceName: spaceName ?? activeSpace.name })}
/>
);
} else {
contextMenuButton = <ContextMenuTooltipButton {...commonProps} title={_t("room_list|home_menu_label")} />;
}
}
return (
<aside className="mx_RoomListHeader" aria-label={_t("room|context_menu|title")}>
{contextMenuButton}
{pendingActionSummary ? (
<Tooltip label={pendingActionSummary} isTriggerInteractive={false}>
<InlineSpinner />
</Tooltip>
) : null}
{canShowPlusMenu && (
<ContextMenuTooltipButton
ref={plusMenuHandle}
onClick={openPlusMenu}
isExpanded={plusMenuDisplayed}
className="mx_RoomListHeader_plusButton"
title={_t("action|add")}
/>
)}
{contextMenu}
</aside>
);
};
export default RoomListHeader;