diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 552d43f982d..bb8dd491823 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -23,7 +23,6 @@ import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/ico import { Icon as VerifiedIcon } from "@vector-im/compound-design-tokens/icons/verified.svg"; import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg"; import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.svg"; -import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { useRoomName } from "../../../hooks/useRoomName"; @@ -35,13 +34,12 @@ import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMember import { _t } from "../../../languageHandler"; import { Flex } from "../../utils/Flex"; import { Box } from "../../utils/Box"; -import { useRoomCallStatus } from "../../../hooks/room/useRoomCallStatus"; +import { useRoomCall } from "../../../hooks/room/useRoomCall"; import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; import SdkConfig from "../../../SdkConfig"; import { useFeatureEnabled } from "../../../hooks/useSettings"; -import { placeCall } from "../../../utils/room/placeCall"; import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus"; import { E2EStatus } from "../../../utils/ShieldUtils"; import FacePile from "../elements/FacePile"; @@ -74,7 +72,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { const members = useRoomMembers(room, 2500); const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); - const { voiceCallDisabledReason, voiceCallType, videoCallDisabledReason, videoCallType } = useRoomCallStatus(room); + const { voiceCallDisabledReason, voiceCallClick, videoCallDisabledReason, videoCallClick } = useRoomCall(room); const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); /** @@ -170,11 +168,8 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { { - evt.stopPropagation(); - placeCall(room, CallType.Voice, voiceCallType); - }} + aria-label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!} + onClick={voiceCallClick} > @@ -183,11 +178,8 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { { - evt.stopPropagation(); - placeCall(room, CallType.Video, videoCallType); - }} + aria-label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!} + onClick={videoCallClick} > @@ -199,7 +191,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { evt.stopPropagation(); RightPanelStore.instance.showOrHidePanel(RightPanelPhases.ThreadPanel); }} - title={_t("common|threads")} + aria-label={_t("common|threads")} > @@ -212,7 +204,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { evt.stopPropagation(); RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel); }} - title={_t("Notifications")} + aria-label={_t("Notifications")} > diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts new file mode 100644 index 00000000000..c369f5ab152 --- /dev/null +++ b/src/hooks/room/useRoomCall.ts @@ -0,0 +1,219 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/matrix"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; + +import { useFeatureEnabled } from "../useSettings"; +import SdkConfig from "../../SdkConfig"; +import { useEventEmitter, useEventEmitterState } from "../useEventEmitter"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; +import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard"; +import { WidgetType } from "../../widgets/WidgetType"; +import { useCall } from "../useCall"; +import { useRoomMemberCount } from "../useRoomMembers"; +import { ElementCall } from "../../models/Call"; +import { placeCall } from "../../utils/room/placeCall"; +import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; +import { useRoomState } from "../useRoomState"; +import { _t } from "../../languageHandler"; +import { isManagedHybridWidget } from "../../widgets/ManagedHybrid"; +import { IApp } from "../../stores/WidgetStore"; + +export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; + +const enum State { + NoCall, + NoOneHere, + NoPermission, + Unpinned, + Ongoing, +} + +/** + * Utility hook for resolving state and click handlers for Voice & Video call buttons in the room header + * @param room the room to track + * @returns the call button attributes for the given room + */ +export const useRoomCall = ( + room: Room, +): { + voiceCallDisabledReason: string | null; + voiceCallClick(evt: React.MouseEvent): void; + videoCallDisabledReason: string | null; + videoCallClick(evt: React.MouseEvent): void; +} => { + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + const useElementCallExclusively = useMemo(() => { + return SdkConfig.get("element_call").use_exclusively; + }, []); + + const hasLegacyCall = useEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.CallsChanged, + () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, + ); + + const widgets = useWidgets(room); + const jitsiWidget = useMemo(() => widgets.find((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]); + const hasJitsiWidget = !!jitsiWidget; + const managedHybridWidget = useMemo(() => widgets.find(isManagedHybridWidget), [widgets]); + const hasManagedHybridWidget = !!managedHybridWidget; + + const groupCall = useCall(room.roomId); + const hasGroupCall = groupCall !== null; + + const memberCount = useRoomMemberCount(room); + + const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [ + room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), + room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), + ]); + + const callType = useMemo((): PlatformCallType => { + if (groupCallsEnabled) { + if (hasGroupCall) { + return "jitsi_or_element_call"; + } + if (mayCreateElementCalls && hasJitsiWidget) { + return "jitsi_or_element_call"; + } + if (useElementCallExclusively) { + return "element_call"; + } + if (memberCount <= 2) { + return "legacy_or_jitsi"; + } + if (mayCreateElementCalls) { + return "element_call"; + } + } + return "legacy_or_jitsi"; + }, [ + groupCallsEnabled, + hasGroupCall, + mayCreateElementCalls, + hasJitsiWidget, + useElementCallExclusively, + memberCount, + ]); + + let widget: IApp | undefined; + if (callType === "legacy_or_jitsi") { + widget = jitsiWidget ?? managedHybridWidget; + } else if (callType === "element_call") { + widget = groupCall?.widget; + } else { + widget = groupCall?.widget ?? jitsiWidget; + } + + const [canPinWidget, setCanPinWidget] = useState(false); + const [widgetPinned, setWidgetPinned] = useState(false); + const promptPinWidget = canPinWidget && !widgetPinned; + + const updateWidgetState = useCallback((): void => { + setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top)); + setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top)); + }, [room, widget]); + + useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState); + useEffect(() => { + updateWidgetState(); + }, [room, jitsiWidget, groupCall, updateWidgetState]); + + const state = useMemo((): State => { + if (hasGroupCall || hasJitsiWidget || hasManagedHybridWidget) { + return promptPinWidget ? State.Unpinned : State.Ongoing; + } + if (hasLegacyCall) { + return State.Ongoing; + } + + if (memberCount <= 1) { + return State.NoOneHere; + } + + if (!mayCreateElementCalls && !mayEditWidgets) { + return State.NoPermission; + } + + return State.NoCall; + }, [ + hasGroupCall, + hasJitsiWidget, + hasLegacyCall, + hasManagedHybridWidget, + mayCreateElementCalls, + mayEditWidgets, + memberCount, + promptPinWidget, + ]); + + const voiceCallClick = useCallback( + (evt: React.MouseEvent): void => { + evt.stopPropagation(); + if (widget && promptPinWidget) { + WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); + } else { + placeCall(room, CallType.Voice, callType); + } + }, + [promptPinWidget, room, widget, callType], + ); + const videoCallClick = useCallback( + (evt: React.MouseEvent): void => { + evt.stopPropagation(); + if (widget && promptPinWidget) { + WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); + } else { + placeCall(room, CallType.Video, callType); + } + }, + [widget, promptPinWidget, room, callType], + ); + + let voiceCallDisabledReason: string | null; + let videoCallDisabledReason: string | null; + switch (state) { + case State.NoPermission: + voiceCallDisabledReason = _t("You do not have permission to start voice calls"); + videoCallDisabledReason = _t("You do not have permission to start video calls"); + break; + case State.Ongoing: + voiceCallDisabledReason = _t("Ongoing call"); + videoCallDisabledReason = _t("Ongoing call"); + break; + case State.NoOneHere: + voiceCallDisabledReason = _t("There's no one here to call"); + videoCallDisabledReason = _t("There's no one here to call"); + break; + case State.Unpinned: + case State.NoCall: + voiceCallDisabledReason = null; + videoCallDisabledReason = null; + } + + /** + * We've gone through all the steps + */ + return { + voiceCallDisabledReason, + voiceCallClick, + videoCallDisabledReason, + videoCallClick, + }; +}; diff --git a/src/hooks/room/useRoomCallStatus.ts b/src/hooks/room/useRoomCallStatus.ts deleted file mode 100644 index 7afd1f9ce41..00000000000 --- a/src/hooks/room/useRoomCallStatus.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { useFeatureEnabled } from "../useSettings"; -import SdkConfig from "../../SdkConfig"; -import { useEventEmitterState, useTypedEventEmitterState } from "../useEventEmitter"; -import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; -import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard"; -import { WidgetType } from "../../widgets/WidgetType"; -import { useCall } from "../useCall"; -import { _t } from "../../languageHandler"; -import { useRoomMemberCount } from "../useRoomMembers"; -import { ElementCall } from "../../models/Call"; - -export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; - -const DEFAULT_DISABLED_REASON = null; -const DEFAULT_CALL_TYPE = "jitsi_or_element_call"; - -/** - * Reports the call capabilities for the current room - * @param room the room to track - * @returns the call status for a room - */ -export const useRoomCallStatus = ( - room: Room, -): { - voiceCallDisabledReason: string | null; - voiceCallType: PlatformCallType; - videoCallDisabledReason: string | null; - videoCallType: PlatformCallType; -} => { - const [voiceCallDisabledReason, setVoiceCallDisabledReason] = useState(DEFAULT_DISABLED_REASON); - const [videoCallDisabledReason, setVideoCallDisabledReason] = useState(DEFAULT_DISABLED_REASON); - const [voiceCallType, setVoiceCallType] = useState(DEFAULT_CALL_TYPE); - const [videoCallType, setVideoCallType] = useState(DEFAULT_CALL_TYPE); - - const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); - const useElementCallExclusively = useMemo(() => { - return SdkConfig.get("element_call").use_exclusively; - }, []); - - const hasLegacyCall = useEventEmitterState( - LegacyCallHandler.instance, - LegacyCallHandlerEvent.CallsChanged, - () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, - ); - - const widgets = useWidgets(room); - const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]); - - const hasGroupCall = useCall(room.roomId) !== null; - - const memberCount = useRoomMemberCount(room); - - const [mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( - room, - RoomStateEvent.Update, - useCallback( - () => [ - room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), - room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), - ], - [room], - ), - ); - - useEffect(() => { - // First reset all state to their default value - setVoiceCallDisabledReason(DEFAULT_DISABLED_REASON); - setVideoCallDisabledReason(DEFAULT_DISABLED_REASON); - setVoiceCallType(DEFAULT_CALL_TYPE); - setVideoCallType(DEFAULT_CALL_TYPE); - - // And then run the logic to figure out their correct state - if (groupCallsEnabled) { - if (useElementCallExclusively) { - if (hasGroupCall) { - setVideoCallDisabledReason(_t("Ongoing call")); - } else if (mayCreateElementCalls) { - setVideoCallType("element_call"); - } else { - setVideoCallDisabledReason(_t("You do not have permission to start video calls")); - } - } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { - setVoiceCallDisabledReason(_t("Ongoing call")); - setVideoCallDisabledReason(_t("Ongoing call")); - } else if (memberCount <= 1) { - setVoiceCallDisabledReason(_t("There's no one here to call")); - setVideoCallDisabledReason(_t("There's no one here to call")); - } else if (memberCount === 2) { - setVoiceCallType("legacy_or_jitsi"); - setVideoCallType("legacy_or_jitsi"); - } else if (mayEditWidgets) { - setVoiceCallType("legacy_or_jitsi"); - setVideoCallType(mayCreateElementCalls ? "jitsi_or_element_call" : "legacy_or_jitsi"); - } else { - setVoiceCallDisabledReason(_t("You do not have permission to start voice calls")); - if (mayCreateElementCalls) { - setVideoCallType("element_call"); - } else { - setVideoCallDisabledReason(_t("You do not have permission to start video calls")); - } - } - } else if (hasLegacyCall || hasJitsiWidget) { - setVoiceCallDisabledReason(_t("Ongoing call")); - setVideoCallDisabledReason(_t("Ongoing call")); - } else if (memberCount <= 1) { - setVoiceCallDisabledReason(_t("There's no one here to call")); - setVideoCallDisabledReason(_t("There's no one here to call")); - } else if (memberCount === 2 || mayEditWidgets) { - setVoiceCallType("legacy_or_jitsi"); - setVideoCallType("legacy_or_jitsi"); - } else { - setVoiceCallDisabledReason(_t("You do not have permission to start voice calls")); - setVideoCallDisabledReason(_t("You do not have permission to start video calls")); - } - }, [ - memberCount, - groupCallsEnabled, - hasGroupCall, - hasJitsiWidget, - hasLegacyCall, - mayCreateElementCalls, - mayEditWidgets, - useElementCallExclusively, - ]); - - /** - * We've gone through all the steps - */ - return { - voiceCallDisabledReason, - voiceCallType, - videoCallDisabledReason, - videoCallType, - }; -}; diff --git a/src/hooks/useRoomState.ts b/src/hooks/useRoomState.ts index 1c8c27c285a..dc3e4c2df15 100644 --- a/src/hooks/useRoomState.ts +++ b/src/hooks/useRoomState.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Room, RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { useTypedEventEmitter } from "./useEventEmitter"; @@ -28,19 +28,27 @@ export const useRoomState = ( room?: Room, mapper: Mapper = defaultMapper as Mapper, ): T => { + // Create a ref that stores mapper + const savedMapper = useRef(mapper); + + // Update ref.current value if mapper changes. + useEffect(() => { + savedMapper.current = mapper; + }, [mapper]); + const [value, setValue] = useState(room ? mapper(room.currentState) : (undefined as T)); const update = useCallback(() => { if (!room) return; - setValue(mapper(room.currentState)); - }, [room, mapper]); + setValue(savedMapper.current(room.currentState)); + }, [room]); useTypedEventEmitter(room?.currentState, RoomStateEvent.Update, update); useEffect(() => { update(); return () => { - setValue(room ? mapper(room.currentState) : (undefined as T)); + setValue(room ? savedMapper.current(room.currentState) : (undefined as T)); }; - }, [room, mapper, update]); + }, [room, update]); return value; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 797a57b58ac..fd8a4c513cb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1413,10 +1413,10 @@ "explore_rooms": "Explore Public Rooms", "create_room": "Create a Group Chat" }, - "Ongoing call": "Ongoing call", + "You do not have permission to start voice calls": "You do not have permission to start voice calls", "You do not have permission to start video calls": "You do not have permission to start video calls", + "Ongoing call": "Ongoing call", "There's no one here to call": "There's no one here to call", - "You do not have permission to start voice calls": "You do not have permission to start voice calls", "chat_effects": { "confetti_description": "Sends the given message with confetti", "confetti_message": "sends confetti", diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 4280e719476..fd092e7fbb4 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -29,10 +29,12 @@ import { UPDATE_EVENT } from "./AsyncStore"; interface IState {} export interface IApp extends IWidget { - roomId: string; - eventId?: string; // not present on virtual widgets + "roomId": string; + "eventId"?: string; // not present on virtual widgets // eslint-disable-next-line camelcase - avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 + "avatar_url"?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 + // Whether the widget was created from `widget_build_url` and thus is a call widget of some kind + "io.element.managed_hybrid"?: boolean; } export function isAppWidget(widget: IWidget | IApp): widget is IApp { diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 42a87507a4e..2d5396fd1f9 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -332,7 +332,7 @@ export default class WidgetUtils { client: MatrixClient, roomId: string, widgetId: string, - content: IWidget, + content: IWidget & Record, ): Promise { const addingWidget = !!content.url; diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts index b684b494ea6..a50a7f2725d 100644 --- a/src/utils/room/placeCall.ts +++ b/src/utils/room/placeCall.ts @@ -18,7 +18,7 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { Room } from "matrix-js-sdk/src/matrix"; import LegacyCallHandler from "../../LegacyCallHandler"; -import { PlatformCallType } from "../../hooks/room/useRoomCallStatus"; +import { PlatformCallType } from "../../hooks/room/useRoomCall"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../dispatcher/actions"; diff --git a/src/widgets/ManagedHybrid.ts b/src/widgets/ManagedHybrid.ts index e171a31af7e..ff06c295e6e 100644 --- a/src/widgets/ManagedHybrid.ts +++ b/src/widgets/ManagedHybrid.ts @@ -22,7 +22,7 @@ import { getCallBehaviourWellKnown } from "../utils/WellKnownUtils"; import WidgetUtils from "../utils/WidgetUtils"; import { IStoredLayout, WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import WidgetEchoStore from "../stores/WidgetEchoStore"; -import WidgetStore from "../stores/WidgetStore"; +import WidgetStore, { IApp } from "../stores/WidgetStore"; import SdkConfig from "../SdkConfig"; import DMRoomMap from "../utils/DMRoomMap"; @@ -97,7 +97,10 @@ export async function addManagedHybridWidget(roomId: string): Promise { // Add the widget try { - await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, widgetContent); + await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, { + ...widgetContent, + "io.element.managed_hybrid": true, + }); } catch (e) { logger.error(`Unable to add managed hybrid widget in room ${roomId}`, e); return; @@ -116,3 +119,7 @@ export async function addManagedHybridWidget(roomId: string): Promise { WidgetLayoutStore.instance.setContainerHeight(room, layout.container, layout.height); WidgetLayoutStore.instance.copyLayoutToRoom(room); } + +export function isManagedHybridWidget(widget: IApp): boolean { + return !!widget["io.element.managed_hybrid"]; +} diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index cdecd493b33..3476e24453c 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -15,12 +15,19 @@ limitations under the License. */ import React from "react"; -import userEvent from "@testing-library/user-event"; import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { EventType, JoinRule, MatrixClient, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; -import { getAllByTitle, getByLabelText, getByText, getByTitle, render, screen, waitFor } from "@testing-library/react"; - -import { mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils"; +import { + fireEvent, + getAllByLabelText, + getByLabelText, + getByText, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { filterConsole, mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils"; import RoomHeader from "../../../../src/components/views/rooms/RoomHeader"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -33,10 +40,13 @@ import dispatcher from "../../../../src/dispatcher/dispatcher"; import { CallStore } from "../../../../src/stores/CallStore"; import { Call, ElementCall } from "../../../../src/models/Call"; import * as ShieldUtils from "../../../../src/utils/ShieldUtils"; +import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore"; jest.mock("../../../../src/utils/ShieldUtils"); describe("RoomHeader", () => { + filterConsole("[getType] Room !1:example.org does not have an m.room.create event"); + let room: Room; const ROOM_ID = "!1:example.org"; @@ -94,7 +104,7 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - await userEvent.click(getByText(container, ROOM_ID)); + fireEvent.click(getByText(container, ROOM_ID)); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); }); @@ -184,7 +194,7 @@ describe("RoomHeader", () => { const facePile = getByLabelText(container, "4 members"); expect(facePile).toHaveTextContent("4"); - await userEvent.click(facePile); + fireEvent.click(facePile); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList }); }); @@ -195,7 +205,7 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - await userEvent.click(getByTitle(container, "Threads")); + fireEvent.click(getByLabelText(container, "Threads")); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel }); }); @@ -209,7 +219,7 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - await userEvent.click(getByTitle(container, "Notifications")); + fireEvent.click(getByLabelText(container, "Notifications")); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel }); }); @@ -220,7 +230,7 @@ describe("RoomHeader", () => { , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - for (const button of getAllByTitle(container, "There's no one here to call")) { + for (const button of getAllByLabelText(container, "There's no one here to call")) { expect(button).toBeDisabled(); } }); @@ -231,17 +241,17 @@ describe("RoomHeader", () => { , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - const voiceButton = getByTitle(container, "Voice call"); - const videoButton = getByTitle(container, "Video call"); + const voiceButton = getByLabelText(container, "Voice call"); + const videoButton = getByLabelText(container, "Video call"); expect(voiceButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - await userEvent.click(voiceButton); + fireEvent.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); - await userEvent.click(videoButton); + fireEvent.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); @@ -255,7 +265,7 @@ describe("RoomHeader", () => { , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - for (const button of getAllByTitle(container, "Ongoing call")) { + for (const button of getAllByLabelText(container, "Ongoing call")) { expect(button).toBeDisabled(); } }); @@ -268,8 +278,8 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - expect(getByTitle(container, "Voice call")).not.toBeDisabled(); - expect(getByTitle(container, "Video call")).not.toBeDisabled(); + expect(getByLabelText(container, "Voice call")).not.toBeDisabled(); + expect(getByLabelText(container, "Video call")).not.toBeDisabled(); }); it("disable calls in large rooms by default", () => { @@ -279,8 +289,12 @@ describe("RoomHeader", () => { , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - expect(getByTitle(container, "You do not have permission to start voice calls")).toBeDisabled(); - expect(getByTitle(container, "You do not have permission to start video calls")).toBeDisabled(); + expect( + getByLabelText(container, "You do not have permission to start voice calls", { selector: "button" }), + ).toBeDisabled(); + expect( + getByLabelText(container, "You do not have permission to start video calls", { selector: "button" }), + ).toBeDisabled(); }); }); @@ -290,6 +304,7 @@ describe("RoomHeader", () => { }); it("renders only the video call element", async () => { + mockRoomMembers(room, 3); jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); // allow element calls jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); @@ -301,28 +316,48 @@ describe("RoomHeader", () => { expect(screen.queryByTitle("Voice call")).toBeNull(); - const videoCallButton = getByTitle(container, "Video call"); + const videoCallButton = getByLabelText(container, "Video call"); expect(videoCallButton).not.toBeDisabled(); const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); - await userEvent.click(getByTitle(container, "Video call")); + fireEvent.click(getByLabelText(container, "Video call")); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); - it("can call if there's an ongoing call", () => { + it("can't call if there's an ongoing (pinned) call", () => { jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); // allow element calls jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true); - jest.spyOn(CallStore.instance, "getCall").mockReturnValue({} as Call); + jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget: {} } as Call); const { container } = render( , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - expect(getByTitle(container, "Ongoing call")).toBeDisabled(); + expect(getByLabelText(container, "Ongoing call")).toBeDisabled(); + }); + + it("clicking on ongoing (unpinned) call re-pins it", () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); + // allow element calls + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); + const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); + + const widget = {}; + jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call); + + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); + expect(getByLabelText(container, "Video call")).not.toBeDisabled(); + fireEvent.click(getByLabelText(container, "Video call")); + expect(spy).toHaveBeenCalledWith(room, widget, Container.Top); }); it("disables calling if there's a jitsi call", () => { @@ -335,7 +370,7 @@ describe("RoomHeader", () => { , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - for (const button of getAllByTitle(container, "Ongoing call")) { + for (const button of getAllByLabelText(container, "Ongoing call")) { expect(button).toBeDisabled(); } }); @@ -346,7 +381,7 @@ describe("RoomHeader", () => { , withClientContextRenderOptions(MatrixClientPeg.get()!), ); - for (const button of getAllByTitle(container, "There's no one here to call")) { + for (const button of getAllByLabelText(container, "There's no one here to call")) { expect(button).toBeDisabled(); } }); @@ -358,16 +393,16 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - const voiceButton = getByTitle(container, "Voice call"); - const videoButton = getByTitle(container, "Video call"); + const voiceButton = getByLabelText(container, "Voice call"); + const videoButton = getByLabelText(container, "Video call"); expect(voiceButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - await userEvent.click(voiceButton); + fireEvent.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); - await userEvent.click(videoButton); + fireEvent.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); @@ -384,16 +419,16 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - const voiceButton = getByTitle(container, "Voice call"); - const videoButton = getByTitle(container, "Video call"); + const voiceButton = getByLabelText(container, "Voice call"); + const videoButton = getByLabelText(container, "Video call"); expect(voiceButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled(); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - await userEvent.click(voiceButton); + fireEvent.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); - await userEvent.click(videoButton); + fireEvent.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); @@ -411,17 +446,13 @@ describe("RoomHeader", () => { withClientContextRenderOptions(MatrixClientPeg.get()!), ); - const voiceButton = getByTitle(container, "Voice call"); - const videoButton = getByTitle(container, "Video call"); + const voiceButton = getByLabelText(container, "Voice call"); + const videoButton = getByLabelText(container, "Video call"); expect(voiceButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled(); - const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); - await userEvent.click(voiceButton); - expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); - await userEvent.click(videoButton); + fireEvent.click(videoButton); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); }); diff --git a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap index 60f42b919de..3ac4decab92 100644 --- a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap @@ -35,28 +35,28 @@ exports[`RoomHeader does not show the face pile for DMs 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);" >