diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 9ec6921ae2..6d1ea9f8eb 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -125,30 +125,45 @@ export default function RoomHeader({ ); + const joinCallButton = ( - + + + ); - const [menuOpen, setMenuOpen] = useState(false); + 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 ? ( videoCallClick(ev, option)} Icon={VideoCallIcon} onSelect={() => {} /* Dummy handler since we want the click event.*/} @@ -195,7 +211,7 @@ export default function RoomHeader({ ); const closeLobbyButton = ( - + @@ -296,7 +312,7 @@ export default function RoomHeader({ {((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && } - {hasActiveCallSession && !isConnectedToCall ? ( + {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( joinCallButton ) : ( <> diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts index fb76bdd87d..8d9045825c 100644 --- a/src/hooks/room/useRoomCall.ts +++ b/src/hooks/room/useRoomCall.ts @@ -61,6 +61,7 @@ const enum State { NoPermission, Unpinned, Ongoing, + NotJoined, } /** @@ -176,7 +177,7 @@ export const useRoomCall = ( if (activeCalls.find((call) => call.roomId != room.roomId)) { return State.Ongoing; } - if (hasGroupCall || hasJitsiWidget || hasManagedHybridWidget) { + if (hasGroupCall && (hasJitsiWidget || hasManagedHybridWidget)) { return promptPinWidget ? State.Unpinned : State.Ongoing; } if (hasLegacyCall) { @@ -243,6 +244,7 @@ export const useRoomCall = ( videoCallDisabledReason = _t("voip|disabled_no_one_here"); break; case State.Unpinned: + case State.NotJoined: case State.NoCall: voiceCallDisabledReason = null; videoCallDisabledReason = null; diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index 67aadaf4b5..87cc556f3a 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -74,14 +74,14 @@ export const useParticipatingMembers = (call: Call): RoomMember[] => { }, [participants]); }; -export const useFull = (call: Call): boolean => { +export const useFull = (call: Call | null): boolean => { return ( useParticipantCount(call) >= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit!) ); }; -export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => { +export const useJoinCallButtonDisabledTooltip = (call: Call | null): string | null => { const isFull = useFull(call); const state = useConnectionState(call); diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 6e239404ef..5fc64fc3de 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; -import { Button } from "@vector-im/compound-web"; +import { Button, Tooltip, TooltipProvider } from "@vector-im/compound-web"; import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg"; import { _t } from "../languageHandler"; @@ -41,30 +41,37 @@ import { useDispatcher } from "../hooks/useDispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { Call } from "../models/Call"; import { AudioID } from "../LegacyCallHandler"; -import { useTypedEventEmitter } from "../hooks/useEventEmitter"; +import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter"; import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton"; +import { CallStore, CallStoreEvent } from "../stores/CallStore"; export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; const MAX_RING_TIME_MS = 10 * 1000; interface JoinCallButtonWithCallProps { onClick: (e: ButtonEvent) => void; - call: Call; + call: Call | null; + disabledTooltip: string | undefined; } -function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps): JSX.Element { - const disabledTooltip = useJoinCallButtonDisabledTooltip(call); +function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element { + let disTooltip = disabledTooltip; + const disabledBecauseFullTooltip = useJoinCallButtonDisabledTooltip(call); + disTooltip = disabledTooltip ?? disabledBecauseFullTooltip ?? undefined; return ( - + + + ); } @@ -77,7 +84,11 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined; const call = useCall(roomId); const audio = useMemo(() => document.getElementById(AudioID.Ring) as HTMLMediaElement, []); - + const [activeCalls, setActiveCalls] = useState(Array.from(CallStore.instance.activeCalls)); + useEventEmitter(CallStore.instance, CallStoreEvent.ActiveCalls, () => { + setActiveCalls(Array.from(CallStore.instance.activeCalls)); + }); + const otherCallIsOngoing = activeCalls.find((call) => call.roomId !== roomId); // Start ringing if not already. useEffect(() => { const isRingToast = (notifyEvent.getContent() as unknown as { notify_type: string })["notify_type"] == "ring"; @@ -157,7 +168,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { ); return ( - +
@@ -178,25 +189,17 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { /> )} - {call ? ( - - ) : ( - - )} + -
+ ); } diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index b13da1e4fe..f051738c8a 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -55,7 +55,9 @@ import { Call, ElementCall } from "../../../../src/models/Call"; import * as ShieldUtils from "../../../../src/utils/ShieldUtils"; import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; - +import * as UseCall from "../../../../src/hooks/useCall"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; +import WidgetStore, { IApp } from "../../../../src/stores/WidgetStore"; jest.mock("../../../../src/utils/ShieldUtils"); function getWrapper(): RenderOptions { @@ -322,25 +324,30 @@ describe("RoomHeader", () => { // allow element calls jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true); - - jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget: {}, on: () => {} } as unknown as Call); - + const widget = { type: "m.jitsi" } as IApp; + jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ + widget, + on: () => {}, + } as unknown as Call); + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]); const { container } = render(, getWrapper()); expect(getByLabelText(container, "Ongoing call")).toHaveAttribute("aria-disabled", "true"); }); it("clicking on ongoing (unpinned) call re-pins it", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); + mockRoomMembers(room, 3); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); // allow calls jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); - const widget = {}; + const widget = { type: "m.jitsi" } as IApp; jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget, on: () => {}, } as unknown as Call); + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]); const { container } = render(, getWrapper()); expect(getByLabelText(container, "Video call")).not.toHaveAttribute("aria-disabled", "true"); @@ -431,6 +438,57 @@ describe("RoomHeader", () => { fireEvent.click(videoButton); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); + + it("buttons are disabled if there is an ongoing call", async () => { + mockRoomMembers(room, 3); + + jest.spyOn(CallStore.prototype, "activeCalls", "get").mockReturnValue( + new Set([{ roomId: "some_other_room" } as Call]), + ); + const { container } = render(, getWrapper()); + + const [videoButton, voiceButton] = getAllByLabelText(container, "Ongoing call"); + + expect(voiceButton).toHaveAttribute("aria-disabled", "true"); + expect(videoButton).toHaveAttribute("aria-disabled", "true"); + }); + + it("join button is shown if there is an ongoing call", async () => { + mockRoomMembers(room, 3); + jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3); + const { container } = render(, getWrapper()); + const joinButton = getByLabelText(container, "Join"); + expect(joinButton).not.toHaveAttribute("aria-disabled", "true"); + }); + + it("join button is disabled if there is an other ongoing call", async () => { + mockRoomMembers(room, 3); + jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3); + jest.spyOn(CallStore.prototype, "activeCalls", "get").mockReturnValue( + new Set([{ roomId: "some_other_room" } as Call]), + ); + const { container } = render(, getWrapper()); + const joinButton = getByLabelText(container, "Ongoing call"); + + expect(joinButton).toHaveAttribute("aria-disabled", "true"); + }); + + it("close lobby button is shown", async () => { + mockRoomMembers(room, 3); + + jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true); + const { container } = render(, getWrapper()); + getByLabelText(container, "Close lobby"); + }); + + it("close lobby button is shown if there is an ongoing call but we are viewing the lobby", async () => { + mockRoomMembers(room, 3); + jest.spyOn(UseCall, "useParticipantCount").mockReturnValue(3); + jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true); + + const { container } = render(, getWrapper()); + getByLabelText(container, "Close lobby"); + }); }); describe("public room", () => {