Merge pull request #11576 from matrix-org/t3chguy/cr/72

Make video & voice call buttons pin conference widget if unpinned
This commit is contained in:
Andy Balaam 2023-09-19 12:16:14 +01:00 committed by GitHub
commit f9f2e79fd9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 332 additions and 227 deletions

View file

@ -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 {
<Tooltip label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}>
<IconButton
disabled={!!voiceCallDisabledReason}
title={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}
onClick={(evt) => {
evt.stopPropagation();
placeCall(room, CallType.Voice, voiceCallType);
}}
aria-label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}
onClick={voiceCallClick}
>
<VoiceCallIcon />
</IconButton>
@ -183,11 +178,8 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
<Tooltip label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}>
<IconButton
disabled={!!videoCallDisabledReason}
title={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}
onClick={(evt) => {
evt.stopPropagation();
placeCall(room, CallType.Video, videoCallType);
}}
aria-label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}
onClick={videoCallClick}
>
<VideoCallIcon />
</IconButton>
@ -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")}
>
<ThreadsIcon />
</IconButton>
@ -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")}
>
<NotificationsIcon />
</IconButton>

View file

@ -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,
};
};

View file

@ -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<string | null>(DEFAULT_DISABLED_REASON);
const [videoCallDisabledReason, setVideoCallDisabledReason] = useState<string | null>(DEFAULT_DISABLED_REASON);
const [voiceCallType, setVoiceCallType] = useState<PlatformCallType>(DEFAULT_CALL_TYPE);
const [videoCallType, setVideoCallType] = useState<PlatformCallType>(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,
};
};

View file

@ -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 = <T extends any = RoomState>(
room?: Room,
mapper: Mapper<T> = defaultMapper as Mapper<T>,
): 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<T>(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;
};

View file

@ -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 video calls": "You do not have permission to start video calls",
"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",
"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",
"chat_effects": {
"confetti_description": "Sends the given message with confetti",
"confetti_message": "sends confetti",

View file

@ -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 {

View file

@ -332,7 +332,7 @@ export default class WidgetUtils {
client: MatrixClient,
roomId: string,
widgetId: string,
content: IWidget,
content: IWidget & Record<string, any>,
): Promise<void> {
const addingWidget = !!content.url;

View file

@ -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";

View file

@ -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<void> {
// 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<void> {
WidgetLayoutStore.instance.setContainerHeight(room, layout.container, layout.height);
WidgetLayoutStore.instance.copyLayoutToRoom(room);
}
export function isManagedHybridWidget(widget: IApp): boolean {
return !!widget["io.element.managed_hybrid"];
}

View file

@ -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 {
fireEvent,
getAllByLabelText,
getByLabelText,
getByText,
render,
screen,
waitFor,
} from "@testing-library/react";
import { mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils";
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", () => {
<RoomHeader room={room} />,
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", () => {
<RoomHeader room={room} />,
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", () => {
<RoomHeader room={room} />,
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", () => {
<RoomHeader room={room} />,
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(
<RoomHeader room={room} />,
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(
<RoomHeader room={room} />,
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", () => {
<RoomHeader room={room} />,
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", () => {
<RoomHeader room={room} />,
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 }));
});
});

View file

@ -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);"
>
<button
aria-label="There's no one here to call"
class="_icon-button_1segd_17"
data-state="closed"
disabled=""
style="--cpd-icon-button-size: 32px;"
title="There's no one here to call"
>
<div />
</button>
<button
aria-label="There's no one here to call"
class="_icon-button_1segd_17"
data-state="closed"
disabled=""
style="--cpd-icon-button-size: 32px;"
title="There's no one here to call"
>
<div />
</button>
<button
aria-label="Threads"
class="_icon-button_1segd_17"
data-state="closed"
style="--cpd-icon-button-size: 32px;"
title="Threads"
>
<div />
</button>