diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 32eb055f07e..ac2e419e0d2 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -71,6 +71,7 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-1-5x); cursor: pointer; user-select: none; + font: var(--cpd-font-body-sm-medium); /* RoomAvatar doesn't pass classes down to avatar So set style here @@ -83,6 +84,12 @@ Please see LICENSE files in the repository root for full details. color: $primary-content; background: var(--cpd-color-bg-subtle-primary); } + + &.mx_FacePile_toggled { + background: var(--cpd-color-bg-success-subtle); + color: var(--cpd-color-text-action-accent); + font: var(--cpd-font-body-sm-semibold); + } } .mx_RoomHeader .mx_BaseAvatar { @@ -93,3 +100,7 @@ Please see LICENSE files in the repository root for full details. /* Workaround for https://github.com/element-hq/compound/issues/331 */ min-width: 240px; } + +.mx_RoomHeader .mx_RoomHeader_toggled { + color: var(--cpd-color-icon-accent-primary); +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 78d14d39dfb..c9b465a11c4 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -66,7 +66,7 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; -import RoomHeader from "../views/rooms/RoomHeader"; +import RoomHeader from "../views/rooms/RoomHeader/RoomHeader"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from "../../effects/utils"; diff --git a/src/components/structures/WaitingForThirdPartyRoomView.tsx b/src/components/structures/WaitingForThirdPartyRoomView.tsx index 7787a03bf26..065ed39cdb1 100644 --- a/src/components/structures/WaitingForThirdPartyRoomView.tsx +++ b/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -11,7 +11,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import ResizeNotifier from "../../utils/ResizeNotifier"; import ErrorBoundary from "../views/elements/ErrorBoundary"; -import RoomHeader from "../views/rooms/RoomHeader"; +import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx"; import ScrollPanel from "./ScrollPanel"; import EventTileBubble from "../views/messages/EventTileBubble"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index 49416a8d381..5eecd9a3ba7 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -9,9 +9,12 @@ Please see LICENSE files in the repository root for full details. import React, { FC, HTMLAttributes, ReactNode } from "react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; import { AvatarStack, Tooltip } from "@vector-im/compound-web"; +import classNames from "classnames"; import MemberAvatar from "../avatars/MemberAvatar"; import AccessibleButton, { ButtonEvent } from "./AccessibleButton"; +import { useToggled } from "../rooms/RoomHeader/toggle/useToggled"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; interface IProps extends Omit, "onChange"> { members: RoomMember[]; @@ -57,8 +60,14 @@ const FacePile: FC = ({ ); + const toggled = useToggled(RightPanelPhases.MemberList); + const classes = classNames({ + mx_FacePile: true, + mx_FacePile_toggled: toggled, + }); + const content = ( - + {pileContents} {children} diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx deleted file mode 100644 index afe68ee5bec..00000000000 --- a/src/components/views/rooms/RoomHeader.tsx +++ /dev/null @@ -1,408 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 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, { useCallback, useMemo, useState } from "react"; -import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; -import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; -import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call"; -import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; -import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid"; -import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid"; -import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid"; -import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; -import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; -import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; -import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; -import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; - -import { useRoomName } from "../../../hooks/useRoomName"; -import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers"; -import { _t } from "../../../languageHandler"; -import { Flex } from "../../utils/Flex"; -import { Box } from "../../utils/Box"; -import { getPlatformCallTypeProps, useRoomCall } from "../../../hooks/room/useRoomCall"; -import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications"; -import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; -import SdkConfig from "../../../SdkConfig"; -import { useFeatureEnabled } from "../../../hooks/useSettings"; -import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus"; -import { E2EStatus } from "../../../utils/ShieldUtils"; -import FacePile from "../elements/FacePile"; -import { useRoomState } from "../../../hooks/useRoomState"; -import RoomAvatar from "../avatars/RoomAvatar"; -import { formatCount } from "../../../utils/FormattingUtils"; -import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; -import PosthogTrackers from "../../../PosthogTrackers"; -import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton"; -import { RoomKnocksBar } from "./RoomKnocksBar"; -import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; -import { notificationLevelToIndicator } from "../../../utils/notifications"; -import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton"; -import { ButtonEvent } from "../elements/AccessibleButton"; -import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator"; -import { IOOBData } from "../../../stores/ThreepidInviteStore"; -import { MainSplitContentType } from "../../structures/RoomView"; -import defaultDispatcher from "../../../dispatcher/dispatcher.ts"; -import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; - -export default function RoomHeader({ - room, - additionalButtons, - oobData, -}: { - room: Room; - additionalButtons?: ViewRoomOpts["buttons"]; - oobData?: IOOBData; -}): JSX.Element { - const client = useMatrixClientContext(); - - const roomName = useRoomName(room); - const joinRule = useRoomState(room, (state) => state.getJoinRule()); - - const members = useRoomMembers(room, 2500); - const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); - - const { - voiceCallDisabledReason, - voiceCallClick, - videoCallDisabledReason, - videoCallClick, - toggleCallMaximized: toggleCall, - isViewingCall, - isConnectedToCall, - hasActiveCallSession, - callOptions, - showVoiceCallButton, - showVideoCallButton, - } = useRoomCall(room); - - const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); - /** - * A special mode where only Element Call is used. In this case we want to - * hide the voice call button - */ - const useElementCallExclusively = useMemo(() => { - return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled; - }, [groupCallsEnabled]); - - const threadNotifications = useRoomThreadNotifications(room); - const globalNotificationState = useGlobalNotificationState(); - - const dmMember = useDmMember(room); - const isDirectMessage = !!dmMember; - const e2eStatus = useEncryptionStatus(client, room); - - const notificationsEnabled = useFeatureEnabled("feature_notifications"); - - const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join"); - - const videoClick = useCallback( - (ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]), - [callOptions, videoCallClick], - ); - - const toggleCallButton = ( - - - - - - ); - - const joinCallButton = ( - - - - ); - - const callIconWithTooltip = ( - - - - ); - - const [menuOpen, setMenuOpen] = useState(false); - - const onOpenChange = useCallback( - (newOpen: boolean) => { - if (!videoCallDisabledReason) setMenuOpen(newOpen); - }, - [videoCallDisabledReason], - ); - - const startVideoCallButton = ( - <> - {/* Can be either a menu or just a button depending on the number of call options.*/} - {callOptions.length > 1 ? ( - - {callIconWithTooltip} - - } - side="left" - align="start" - > - {callOptions.map((option) => { - const { label, children } = getPlatformCallTypeProps(option); - return ( - videoCallClick(ev, option)} - Icon={VideoCallIcon} - onSelect={() => {} /* Dummy handler since we want the click event.*/} - /> - ); - })} - - ) : ( - - {callIconWithTooltip} - - )} - - ); - let voiceCallButton: JSX.Element | undefined = ( - - voiceCallClick(ev, callOptions[0])} - > - - - - ); - const closeLobbyButton = ( - - - - - - ); - let videoCallButton: JSX.Element | undefined = startVideoCallButton; - if (isConnectedToCall) { - videoCallButton = toggleCallButton; - } else if (isViewingCall) { - videoCallButton = closeLobbyButton; - } - - if (!showVideoCallButton) { - videoCallButton = undefined; - } - if (!showVoiceCallButton) { - voiceCallButton = undefined; - } - - const roomContext = useScopedRoomContext("mainSplitContentType"); - const isVideoRoom = calcIsVideoRoom(room); - const showChatButton = - isVideoRoom || - roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget || - roomContext.mainSplitContentType === MainSplitContentType.Call; - - const onAvatarClick = (): void => { - defaultDispatcher.dispatch({ - action: "open_room_settings", - initial_tab_id: RoomSettingsTab.General, - }); - }; - - return ( - <> - - - {/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */} - - - - - {additionalButtons?.map((props) => { - const label = props.label(); - - return ( - - { - event.stopPropagation(); - props.onClick(); - }} - > - {typeof props.icon === "function" ? props.icon() : props.icon} - - - ); - })} - - {isViewingCall && } - - {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( - joinCallButton - ) : ( - <> - {!isVideoRoom && videoCallButton} - {!useElementCallExclusively && !isVideoRoom && voiceCallButton} - - )} - - {showChatButton && } - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); - PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); - }} - aria-label={_t("common|threads")} - > - - - - {notificationsEnabled && ( - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel); - }} - aria-label={_t("notifications|enable_prompt_toast_title")} - > - - - - )} - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary); - }} - aria-label={_t("right_panel|room_summary_card|title")} - > - - - - - {!isDirectMessage && ( - - { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); - e.stopPropagation(); - }} - aria-label={_t("common|n_members", { count: memberCount })} - > - {formatCount(memberCount)} - - - )} - - {askToJoinEnabled && } - - ); -} diff --git a/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/src/components/views/rooms/RoomHeader/RoomHeader.tsx new file mode 100644 index 00000000000..206b7f24133 --- /dev/null +++ b/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -0,0 +1,412 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 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, { useCallback, useMemo, useState } from "react"; +import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; +import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; +import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call"; +import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; +import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid"; +import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid"; +import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid"; +import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; +import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; +import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; + +import { useRoomName } from "../../../../hooks/useRoomName.ts"; +import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases.ts"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx"; +import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts"; +import { _t } from "../../../../languageHandler.tsx"; +import { Flex } from "../../../utils/Flex.tsx"; +import { Box } from "../../../utils/Box.tsx"; +import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx"; +import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts"; +import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts"; +import SdkConfig from "../../../../SdkConfig.ts"; +import { useFeatureEnabled } from "../../../../hooks/useSettings.ts"; +import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts"; +import { E2EStatus } from "../../../../utils/ShieldUtils.ts"; +import FacePile from "../../elements/FacePile.tsx"; +import { useRoomState } from "../../../../hooks/useRoomState.ts"; +import RoomAvatar from "../../avatars/RoomAvatar.tsx"; +import { formatCount } from "../../../../utils/FormattingUtils.ts"; +import RightPanelStore from "../../../../stores/right-panel/RightPanelStore.ts"; +import PosthogTrackers from "../../../../PosthogTrackers.ts"; +import { VideoRoomChatButton } from "./VideoRoomChatButton.tsx"; +import { RoomKnocksBar } from "../RoomKnocksBar.tsx"; +import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms.ts"; +import { notificationLevelToIndicator } from "../../../../utils/notifications.ts"; +import { CallGuestLinkButton } from "./CallGuestLinkButton.tsx"; +import { ButtonEvent } from "../../elements/AccessibleButton.tsx"; +import WithPresenceIndicator, { useDmMember } from "../../avatars/WithPresenceIndicator.tsx"; +import { IOOBData } from "../../../../stores/ThreepidInviteStore.ts"; +import { MainSplitContentType } from "../../../structures/RoomView.tsx"; +import defaultDispatcher from "../../../../dispatcher/dispatcher.ts"; +import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog.tsx"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx"; +import { CurrentRightPanelPhaseContextProvider } from "../../../../contexts/CurrentRightPanelPhaseContext.tsx"; + +export default function RoomHeader({ + room, + additionalButtons, + oobData, +}: { + room: Room; + additionalButtons?: ViewRoomOpts["buttons"]; + oobData?: IOOBData; +}): JSX.Element { + const client = useMatrixClientContext(); + + const roomName = useRoomName(room); + const joinRule = useRoomState(room, (state) => state.getJoinRule()); + + const members = useRoomMembers(room, 2500); + const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); + + const { + voiceCallDisabledReason, + voiceCallClick, + videoCallDisabledReason, + videoCallClick, + toggleCallMaximized: toggleCall, + isViewingCall, + isConnectedToCall, + hasActiveCallSession, + callOptions, + showVoiceCallButton, + showVideoCallButton, + } = useRoomCall(room); + + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + /** + * A special mode where only Element Call is used. In this case we want to + * hide the voice call button + */ + const useElementCallExclusively = useMemo(() => { + return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled; + }, [groupCallsEnabled]); + + const threadNotifications = useRoomThreadNotifications(room); + const globalNotificationState = useGlobalNotificationState(); + + const dmMember = useDmMember(room); + const isDirectMessage = !!dmMember; + const e2eStatus = useEncryptionStatus(client, room); + + const notificationsEnabled = useFeatureEnabled("feature_notifications"); + + const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join"); + + const videoClick = useCallback( + (ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]), + [callOptions, videoCallClick], + ); + + const toggleCallButton = ( + + + + + + ); + + const joinCallButton = ( + + + + ); + + const callIconWithTooltip = ( + + + + ); + + const [menuOpen, setMenuOpen] = useState(false); + + const onOpenChange = useCallback( + (newOpen: boolean) => { + if (!videoCallDisabledReason) setMenuOpen(newOpen); + }, + [videoCallDisabledReason], + ); + + const startVideoCallButton = ( + <> + {/* Can be either a menu or just a button depending on the number of call options.*/} + {callOptions.length > 1 ? ( + + {callIconWithTooltip} + + } + side="left" + align="start" + > + {callOptions.map((option) => { + const { label, children } = getPlatformCallTypeProps(option); + return ( + videoCallClick(ev, option)} + Icon={VideoCallIcon} + onSelect={() => {} /* Dummy handler since we want the click event.*/} + /> + ); + })} + + ) : ( + + {callIconWithTooltip} + + )} + + ); + let voiceCallButton: JSX.Element | undefined = ( + + voiceCallClick(ev, callOptions[0])} + > + + + + ); + const closeLobbyButton = ( + + + + + + ); + let videoCallButton: JSX.Element | undefined = startVideoCallButton; + if (isConnectedToCall) { + videoCallButton = toggleCallButton; + } else if (isViewingCall) { + videoCallButton = closeLobbyButton; + } + + if (!showVideoCallButton) { + videoCallButton = undefined; + } + if (!showVoiceCallButton) { + voiceCallButton = undefined; + } + + const roomContext = useScopedRoomContext("mainSplitContentType"); + const isVideoRoom = calcIsVideoRoom(room); + const showChatButton = + isVideoRoom || + roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget || + roomContext.mainSplitContentType === MainSplitContentType.Call; + + const onAvatarClick = (): void => { + defaultDispatcher.dispatch({ + action: "open_room_settings", + initial_tab_id: RoomSettingsTab.General, + }); + }; + + return ( + <> + + + + {/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */} + + + + + {additionalButtons?.map((props) => { + const label = props.label(); + + return ( + + { + event.stopPropagation(); + props.onClick(); + }} + > + {typeof props.icon === "function" ? props.icon() : props.icon} + + + ); + })} + + {isViewingCall && } + + {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( + joinCallButton + ) : ( + <> + {!isVideoRoom && videoCallButton} + {!useElementCallExclusively && !isVideoRoom && voiceCallButton} + + )} + + {showChatButton && } + + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); + PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); + }} + aria-label={_t("common|threads")} + > + + + + {notificationsEnabled && ( + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel); + }} + aria-label={_t("notifications|enable_prompt_toast_title")} + > + + + + )} + + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary); + }} + aria-label={_t("right_panel|room_summary_card|title")} + > + + + + + {!isDirectMessage && ( + + { + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); + e.stopPropagation(); + }} + aria-label={_t("common|n_members", { count: memberCount })} + > + {formatCount(memberCount)} + + + )} + + {askToJoinEnabled && } + + + ); +} diff --git a/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx b/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx index 0dbf62dfda3..b1b8ae071e2 100644 --- a/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx +++ b/src/components/views/rooms/RoomHeader/VideoRoomChatButton.tsx @@ -18,6 +18,7 @@ import { NotificationLevel } from "../../../../stores/notifications/Notification import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases"; import { SDKContext } from "../../../../contexts/SDKContext"; import { ButtonEvent } from "../../elements/AccessibleButton"; +import { ToggleableIcon } from "./toggle/ToggleableIcon"; /** * Display a button to toggle timeline for video rooms @@ -54,7 +55,7 @@ export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => { onClick={onClick} indicator={displayUnreadIndicator ? "default" : undefined} > - + ); diff --git a/src/components/views/rooms/RoomHeader/toggle/ToggleableIcon.tsx b/src/components/views/rooms/RoomHeader/toggle/ToggleableIcon.tsx new file mode 100644 index 00000000000..f90679b7442 --- /dev/null +++ b/src/components/views/rooms/RoomHeader/toggle/ToggleableIcon.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2025 New Vector Ltd. + +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 from "react"; +import classNames from "classnames"; + +import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases"; +import { useToggled } from "./useToggled"; + +type Props = { + Icon: React.ComponentType>; + phase: RightPanelPhases; +}; + +/** + * Use this component for room header icons that toggle different right panel phases. + * Will add a class to the icon when the specified phase is on. + */ +export function ToggleableIcon({ Icon, phase }: Props): React.ReactElement { + const toggled = useToggled(phase); + const highlightClass = classNames({ + mx_RoomHeader_toggled: toggled, + }); + + return ; +} diff --git a/src/components/views/rooms/RoomHeader/toggle/useToggled.tsx b/src/components/views/rooms/RoomHeader/toggle/useToggled.tsx new file mode 100644 index 00000000000..9d3a5ed0cb5 --- /dev/null +++ b/src/components/views/rooms/RoomHeader/toggle/useToggled.tsx @@ -0,0 +1,23 @@ +/* +Copyright 2025 New Vector Ltd. + +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 { useContext } from "react"; + +import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases"; +import { CurrentRightPanelPhaseContext } from "../../../../../contexts/CurrentRightPanelPhaseContext"; + +/** + * Hook to easily track whether a given right panel phase is toggled on/off. + */ +export function useToggled(phase: RightPanelPhases): boolean { + const context = useContext(CurrentRightPanelPhaseContext); + if (!context) { + return false; + } + const { currentPhase, isPanelOpen } = context; + return !!(isPanelOpen && currentPhase === phase); +} diff --git a/src/contexts/CurrentRightPanelPhaseContext.tsx b/src/contexts/CurrentRightPanelPhaseContext.tsx new file mode 100644 index 00000000000..1ab3eafc25a --- /dev/null +++ b/src/contexts/CurrentRightPanelPhaseContext.tsx @@ -0,0 +1,34 @@ +/* +Copyright 2025 New Vector Ltd. + +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, { createContext } from "react"; + +import { useCurrentPhase } from "../hooks/right-panel/useCurrentPhase"; +import { RightPanelPhases } from "../stores/right-panel/RightPanelStorePhases"; + +type Context = { + isPanelOpen: boolean; + currentPhase: RightPanelPhases | null; +}; + +export const CurrentRightPanelPhaseContext = createContext(null); + +type Props = { + roomId: string; +}; + +export const CurrentRightPanelPhaseContextProvider: React.FC> = ({ + roomId, + children, +}) => { + const { currentPhase, isOpen } = useCurrentPhase(roomId); + return ( + + {children} + + ); +}; diff --git a/src/hooks/right-panel/useCurrentPhase.ts b/src/hooks/right-panel/useCurrentPhase.ts new file mode 100644 index 00000000000..9560612873d --- /dev/null +++ b/src/hooks/right-panel/useCurrentPhase.ts @@ -0,0 +1,45 @@ +/* +Copyright 2025 New Vector Ltd. + +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 { useContext, useState } from "react"; + +import { SDKContext } from "../../contexts/SDKContext"; +import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; +import { useEventEmitter } from "../useEventEmitter"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; + +/** + * Returns: + * - state which will always reflect the currently active right panel phase or null. + * - boolean state representing whether any panel is open or not. + * @param roomId room id if available. + */ +export function useCurrentPhase(roomId?: string): { currentPhase: RightPanelPhases | null; isOpen: boolean } { + const sdkContext = useContext(SDKContext); + + const getCurrentPhase = (): RightPanelPhases | null => { + const card = roomId + ? sdkContext.rightPanelStore.currentCardForRoom(roomId) + : sdkContext.rightPanelStore.currentCard; + return card.phase; + }; + + const getIsOpen = (): boolean => { + const isOpen = roomId ? sdkContext.rightPanelStore.isOpenForRoom(roomId) : sdkContext.rightPanelStore.isOpen; + return isOpen; + }; + + const [currentPhase, setCurrentPhase] = useState(getCurrentPhase()); + const [isOpen, setIsOpen] = useState(getIsOpen()); + + useEventEmitter(sdkContext.rightPanelStore, UPDATE_EVENT, () => { + setCurrentPhase(getCurrentPhase()); + setIsOpen(getIsOpen()); + }); + + return { currentPhase, isOpen }; +} diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index ea9b722071d..42077daded9 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -239,7 +239,7 @@ export default class RightPanelStore extends ReadyWatchingStore { * @param cardState The state within the phase. */ public showOrHidePhase(phase: RightPanelPhases, cardState?: Partial): void { - if (this.currentCard.phase == phase && !cardState && this.isOpen) { + if (this.currentCard.phase === phase && !cardState && this.isOpen) { this.togglePanel(null); } else { this.setCard({ phase, state: cardState }); diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 65a755058bf..eb775bdcbec 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -110,6 +110,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 style="--cpd-icon-button-size: 100%;" > ({ + useCurrentPhase: () => { + return { currentPhase: "foo", isOpen: false }; + }, +})); function getWrapper(): RenderOptions { return { diff --git a/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap similarity index 99% rename from test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap rename to test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap index 3db3fb67fbf..6d0c2dc3e4a 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap @@ -105,6 +105,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = ` style="--cpd-icon-button-size: 100%;" > renders button with an unread marker when room style="--cpd-icon-button-size: 100%;" >