mirror of
https://github.com/element-hq/element-web
synced 2024-11-26 03:05:51 +03:00
Refactor element call lobby + skip lobby (#12057)
* Refactor ElementCall to use the widget lobby. - expose skip lobby - use the widget.data to build the widget url Signed-off-by: Timo K <toger5@hotmail.de> * Use shiftKey click to skip the lobby Signed-off-by: Timo K <toger5@hotmail.de> * remove Lobby component Signed-off-by: Timo K <toger5@hotmail.de> * update tests + remove EW lobby related tests Signed-off-by: Timo K <toger5@hotmail.de> * remove lobby device button tests Signed-off-by: Timo K <toger5@hotmail.de> * i18n Signed-off-by: Timo K <toger5@hotmail.de> * use voip participant label Signed-off-by: Timo K <toger5@hotmail.de> * update tests Signed-off-by: Timo K <toger5@hotmail.de> * fix rounded corners in pip Signed-off-by: Timo K <toger5@hotmail.de> * allow joining call in legacy room header (without banner) Signed-off-by: Timo K <toger5@hotmail.de> * Introduce new connection states for calls. And use them for integrated lobby. Signed-off-by: Timo K <toger5@hotmail.de> * New room header call join Fix broken top container element call. Signed-off-by: Timo K <toger5@hotmail.de> * i18n Signed-off-by: Timo K <toger5@hotmail.de> * Fix closing element call in lobby view. (should destroy call if there the user never managed to connect (not clicked join in lobby) Signed-off-by: Timo K <toger5@hotmail.de> * all cases for connection state Signed-off-by: Timo K <toger5@hotmail.de> * add correct LiveContentSummary labels Signed-off-by: Timo K <toger5@hotmail.de> * Theme widget loading (no rounded corner) destroy call when switching room while a call is loading. Signed-off-by: Timo K <toger5@hotmail.de> * temp Signed-off-by: Timo K <toger5@hotmail.de> * usei view room dispatcher instead of emitter Signed-off-by: Timo K <toger5@hotmail.de> * tidy up Signed-off-by: Timo K <toger5@hotmail.de> * returnToLobby + remove StartCallView Signed-off-by: Timo K <toger5@hotmail.de> * comment cleanup Signed-off-by: Timo K <toger5@hotmail.de> * disconnect ongoing calls before making widget sticky. Signed-off-by: Timo K <toger5@hotmail.de> * linter + jitsi as videoChannel Signed-off-by: Timo K <toger5@hotmail.de> * stickyPromise type Signed-off-by: Timo K <toger5@hotmail.de> * fix legacy call (jistsi, cisco, bbb) reopen when clicking call button Signed-off-by: Timo K <toger5@hotmail.de> * fix tests and connect resolves Signed-off-by: Timo K <toger5@hotmail.de> * fix "waits for messaging when connecting" test Signed-off-by: Timo K <toger5@hotmail.de> * Allow to skip awaiting Call session events. This option is used in tests to spare mocking the events emitted when EC updates the room state Signed-off-by: Timo K <toger5@hotmail.de> * add sticky test Signed-off-by: Timo K <toger5@hotmail.de> * add test for looby tile rendering Signed-off-by: Timo K <toger5@hotmail.de> * fix flaky test Signed-off-by: Timo K <toger5@hotmail.de> * add reconnect after disconnect test (video room) Signed-off-by: Timo K <toger5@hotmail.de> * add shift click test to call toast Signed-off-by: Timo K <toger5@hotmail.de> * test for allowVoipWithNoMedia in widget url Signed-off-by: Timo K <toger5@hotmail.de> * fix e2e tests to search for the right element Signed-off-by: Timo K <toger5@hotmail.de> * destroy call after test so next test does not fail Signed-off-by: Timo K <toger5@hotmail.de> * new call test (connection failed) Signed-off-by: Timo K <toger5@hotmail.de> * reset to real timers Signed-off-by: Timo K <toger5@hotmail.de> * dont use skipSessionAwait for tests Signed-off-by: Timo K <toger5@hotmail.de> * code quality (sonar) Signed-off-by: Timo K <toger5@hotmail.de> * refactor call.disconnect tests (dont use skipSessionAwait) Signed-off-by: Timo K <toger5@hotmail.de> * miscellaneous cleanup Signed-off-by: Timo K <toger5@hotmail.de> * only send call notify after the call has been joined (not when just opening the lobby) Signed-off-by: Timo K <toger5@hotmail.de> * update call notify tests to expect notify on connect. Not on widget creation. Signed-off-by: Timo K <toger5@hotmail.de> * Update playwright/e2e/room/room-header.spec.ts Co-authored-by: Robin <robin@robin.town> * Update src/components/views/voip/CallView.tsx Co-authored-by: Robin <robin@robin.town> * review rename connect -> start isVideoRoom not dependant on feature flags rename allOtherCallsDisconnected -> disconnectAllOtherCalls Signed-off-by: Timo K <toger5@hotmail.de> * check for EC widget Signed-off-by: Timo K <toger5@hotmail.de> * dep array Signed-off-by: Timo K <toger5@hotmail.de> * rename in spyOn Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
parent
3f7e21e08d
commit
a370a5cfa4
28 changed files with 693 additions and 767 deletions
|
@ -178,8 +178,8 @@ test.describe("Room Header", () => {
|
||||||
|
|
||||||
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click();
|
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click();
|
||||||
|
|
||||||
// Assert that the video is rendered
|
// Assert that the call view is still visible
|
||||||
await expect(page.locator(".mx_CallView video")).toBeVisible();
|
await expect(page.locator(".mx_CallView")).toBeVisible();
|
||||||
|
|
||||||
// Assert that GELS is visible
|
// Assert that GELS is visible
|
||||||
await expect(
|
await expect(
|
||||||
|
|
|
@ -2564,6 +2564,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
room={this.state.room}
|
room={this.state.room}
|
||||||
resizing={this.state.resizing}
|
resizing={this.state.resizing}
|
||||||
waitForCall={isVideoRoom(this.state.room)}
|
waitForCall={isVideoRoom(this.state.room)}
|
||||||
|
skipLobby={this.context.roomViewStore.skipCallLobby() ?? false}
|
||||||
role="main"
|
role="main"
|
||||||
/>
|
/>
|
||||||
{previewBar}
|
{previewBar}
|
||||||
|
|
|
@ -43,6 +43,7 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({ roomId, call }) =>
|
||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
|
skipLobby: "shiftKey" in ev ? ev.shiftKey : false,
|
||||||
metricsTrigger: undefined,
|
metricsTrigger: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -95,6 +95,11 @@ interface IProps {
|
||||||
movePersistedElement?: MutableRefObject<(() => void) | undefined>;
|
movePersistedElement?: MutableRefObject<(() => void) | undefined>;
|
||||||
// An element to render after the iframe as an overlay
|
// An element to render after the iframe as an overlay
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
|
// If defined this async method will be called when the widget requests to become sticky.
|
||||||
|
// It will only become sticky once the returned promise resolves.
|
||||||
|
// This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately.
|
||||||
|
// This promise allows to do Widget B related cleanup before Widget A becomes sticky. (e.g. hangup a Voip call)
|
||||||
|
stickyPromise?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
@ -610,11 +615,11 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
"microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;";
|
"microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;";
|
||||||
|
|
||||||
const appTileBodyClass = classNames({
|
const appTileBodyClass = classNames({
|
||||||
// We don't want mx_AppTileBody (rounded corners) for call widgets
|
|
||||||
"mx_AppTileBody": true,
|
"mx_AppTileBody": true,
|
||||||
"mx_AppTileBody--large": !this.props.miniMode,
|
"mx_AppTileBody--large": !this.props.miniMode,
|
||||||
"mx_AppTileBody--mini": this.props.miniMode,
|
"mx_AppTileBody--mini": this.props.miniMode,
|
||||||
"mx_AppTileBody--loading": this.state.loading,
|
"mx_AppTileBody--loading": this.state.loading,
|
||||||
|
// We don't want mx_AppTileBody (rounded corners) for call widgets
|
||||||
"mx_AppTileBody--call": this.props.app.type === WidgetType.CALL.preferred,
|
"mx_AppTileBody--call": this.props.app.type === WidgetType.CALL.preferred,
|
||||||
});
|
});
|
||||||
const appTileBodyStyles: CSSProperties = {};
|
const appTileBodyStyles: CSSProperties = {};
|
||||||
|
|
|
@ -131,12 +131,14 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
|
||||||
switch (connectionState) {
|
switch (connectionState) {
|
||||||
case ConnectionState.Disconnected:
|
case ConnectionState.Disconnected:
|
||||||
return [_t("action|join"), "primary", connect];
|
return [_t("action|join"), "primary", connect];
|
||||||
case ConnectionState.Connecting:
|
|
||||||
return [_t("action|join"), "primary", null];
|
|
||||||
case ConnectionState.Connected:
|
case ConnectionState.Connected:
|
||||||
return [_t("action|leave"), "danger", disconnect];
|
return [_t("action|leave"), "danger", disconnect];
|
||||||
case ConnectionState.Disconnecting:
|
case ConnectionState.Disconnecting:
|
||||||
return [_t("action|leave"), "danger", null];
|
return [_t("action|leave"), "danger", null];
|
||||||
|
case ConnectionState.Connecting:
|
||||||
|
case ConnectionState.Lobby:
|
||||||
|
case ConnectionState.WidgetLoading:
|
||||||
|
return [_t("action|join"), "primary", null];
|
||||||
}
|
}
|
||||||
}, [connectionState, connect, disconnect]);
|
}, [connectionState, connect, disconnect]);
|
||||||
|
|
||||||
|
|
|
@ -143,16 +143,20 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}, [setBusy, room]);
|
}, [setBusy, room]);
|
||||||
|
|
||||||
const startElementCall = useCallback(() => {
|
const startElementCall = useCallback(
|
||||||
setBusy(true);
|
(skipLobby: boolean) => {
|
||||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
setBusy(true);
|
||||||
action: Action.ViewRoom,
|
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||||
room_id: room.roomId,
|
action: Action.ViewRoom,
|
||||||
view_call: true,
|
room_id: room.roomId,
|
||||||
metricsTrigger: undefined,
|
view_call: true,
|
||||||
});
|
skipLobby: skipLobby,
|
||||||
setBusy(false);
|
metricsTrigger: undefined,
|
||||||
}, [setBusy, room]);
|
});
|
||||||
|
setBusy(false);
|
||||||
|
},
|
||||||
|
[setBusy, room],
|
||||||
|
);
|
||||||
|
|
||||||
const { onClick, tooltip, disabled } = useMemo(() => {
|
const { onClick, tooltip, disabled } = useMemo(() => {
|
||||||
if (behavior instanceof DisabledWithReason) {
|
if (behavior instanceof DisabledWithReason) {
|
||||||
|
@ -173,7 +177,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
|
||||||
return {
|
return {
|
||||||
onClick: async (ev: ButtonEvent): Promise<void> => {
|
onClick: async (ev: ButtonEvent): Promise<void> => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
startElementCall();
|
startElementCall("shiftKey" in ev ? ev.shiftKey : false);
|
||||||
},
|
},
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
|
@ -202,7 +206,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
|
||||||
(ev: ButtonEvent) => {
|
(ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
startElementCall();
|
startElementCall("shiftKey" in ev ? ev.shiftKey : false);
|
||||||
},
|
},
|
||||||
[closeMenu, startElementCall],
|
[closeMenu, startElementCall],
|
||||||
);
|
);
|
||||||
|
@ -305,7 +309,7 @@ const CallButtons: FC<CallButtonsProps> = ({ room }) => {
|
||||||
} else {
|
} else {
|
||||||
return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call")));
|
return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call")));
|
||||||
}
|
}
|
||||||
} else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) {
|
} else if (hasLegacyCall || hasJitsiWidget) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))}
|
{makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))}
|
||||||
|
|
|
@ -51,7 +51,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
|
||||||
{" • "}
|
{" • "}
|
||||||
<span
|
<span
|
||||||
className="mx_LiveContentSummary_participants"
|
className="mx_LiveContentSummary_participants"
|
||||||
aria-label={_t("common|n_participants", { count: participantCount })}
|
aria-label={_t("voip|n_people_joined", { count: participantCount })}
|
||||||
>
|
>
|
||||||
{participantCount}
|
{participantCount}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -35,6 +35,14 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
|
||||||
text = _t("common|video");
|
text = _t("common|video");
|
||||||
active = false;
|
active = false;
|
||||||
break;
|
break;
|
||||||
|
case ConnectionState.WidgetLoading:
|
||||||
|
text = _t("common|loading");
|
||||||
|
active = false;
|
||||||
|
break;
|
||||||
|
case ConnectionState.Lobby:
|
||||||
|
text = _t("common|lobby");
|
||||||
|
active = false;
|
||||||
|
break;
|
||||||
case ConnectionState.Connecting:
|
case ConnectionState.Connecting:
|
||||||
text = _t("room|joining");
|
text = _t("room|joining");
|
||||||
active = true;
|
active = true;
|
||||||
|
|
|
@ -14,412 +14,59 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, ReactNode, useState, useContext, useEffect, useMemo, useRef, useCallback, AriaRole } from "react";
|
import React, { FC, useContext, useEffect, AriaRole, useCallback } from "react";
|
||||||
import classNames from "classnames";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
|
|
||||||
|
|
||||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||||
import type { ConnectionState } from "../../../models/Call";
|
import { Call, ConnectionState, ElementCall } from "../../../models/Call";
|
||||||
import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call";
|
import { useCall } from "../../../hooks/useCall";
|
||||||
import {
|
|
||||||
useCall,
|
|
||||||
useConnectionState,
|
|
||||||
useJoinCallButtonDisabledTooltip,
|
|
||||||
useParticipatingMembers,
|
|
||||||
} from "../../../hooks/useCall";
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import AppTile from "../elements/AppTile";
|
import AppTile from "../elements/AppTile";
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
|
||||||
import MediaDeviceHandler, { IMediaDevices } from "../../../MediaDeviceHandler";
|
|
||||||
import { CallStore } from "../../../stores/CallStore";
|
import { CallStore } from "../../../stores/CallStore";
|
||||||
import IconizedContextMenu, {
|
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||||
IconizedContextMenuOption,
|
|
||||||
IconizedContextMenuOptionList,
|
|
||||||
} from "../context_menus/IconizedContextMenu";
|
|
||||||
import { aboveRightOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
|
||||||
import { Alignment } from "../elements/Tooltip";
|
|
||||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
|
||||||
import FacePile from "../elements/FacePile";
|
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
|
||||||
|
|
||||||
interface DeviceButtonProps {
|
|
||||||
kind: string;
|
|
||||||
devices: MediaDeviceInfo[];
|
|
||||||
setDevice: (device: MediaDeviceInfo) => void;
|
|
||||||
deviceListLabel: string;
|
|
||||||
muted: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
toggle: () => void;
|
|
||||||
unmutedTitle: string;
|
|
||||||
mutedTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeviceButton: FC<DeviceButtonProps> = ({
|
|
||||||
kind,
|
|
||||||
devices,
|
|
||||||
setDevice,
|
|
||||||
deviceListLabel,
|
|
||||||
muted,
|
|
||||||
disabled,
|
|
||||||
toggle,
|
|
||||||
unmutedTitle,
|
|
||||||
mutedTitle,
|
|
||||||
}) => {
|
|
||||||
const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu();
|
|
||||||
const selectDevice = useCallback(
|
|
||||||
(device: MediaDeviceInfo) => {
|
|
||||||
setDevice(device);
|
|
||||||
closeMenu();
|
|
||||||
},
|
|
||||||
[setDevice, closeMenu],
|
|
||||||
);
|
|
||||||
|
|
||||||
let contextMenu: JSX.Element | null = null;
|
|
||||||
if (showMenu) {
|
|
||||||
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
|
||||||
contextMenu = (
|
|
||||||
<IconizedContextMenu {...aboveRightOf(buttonRect, undefined, 10)} onFinished={closeMenu}>
|
|
||||||
<IconizedContextMenuOptionList>
|
|
||||||
{devices.map((d) => (
|
|
||||||
<IconizedContextMenuOption key={d.deviceId} label={d.label} onClick={() => selectDevice(d)} />
|
|
||||||
))}
|
|
||||||
</IconizedContextMenuOptionList>
|
|
||||||
</IconizedContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!devices.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames("mx_CallView_deviceButtonWrapper", {
|
|
||||||
mx_CallView_deviceButtonWrapper_muted: muted,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AccessibleTooltipButton
|
|
||||||
className={`mx_CallView_deviceButton mx_CallView_deviceButton_${kind}`}
|
|
||||||
ref={buttonRef}
|
|
||||||
title={muted ? mutedTitle : unmutedTitle}
|
|
||||||
alignment={Alignment.Top}
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
{devices.length > 1 ? (
|
|
||||||
<ContextMenuButton
|
|
||||||
className="mx_CallView_deviceListButton"
|
|
||||||
onClick={openMenu}
|
|
||||||
isExpanded={showMenu}
|
|
||||||
label={deviceListLabel}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{contextMenu}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_FACES = 8;
|
|
||||||
|
|
||||||
interface LobbyProps {
|
|
||||||
room: Room;
|
|
||||||
connect: () => Promise<void>;
|
|
||||||
joinCallButtonDisabledTooltip?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => {
|
|
||||||
const [connecting, setConnecting] = useState(false);
|
|
||||||
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
|
|
||||||
|
|
||||||
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
|
|
||||||
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
|
|
||||||
|
|
||||||
const toggleAudio = useCallback(() => {
|
|
||||||
MediaDeviceHandler.startWithAudioMuted = !audioMuted;
|
|
||||||
setAudioMuted(!audioMuted);
|
|
||||||
}, [audioMuted, setAudioMuted]);
|
|
||||||
const toggleVideo = useCallback(() => {
|
|
||||||
MediaDeviceHandler.startWithVideoMuted = !videoMuted;
|
|
||||||
setVideoMuted(!videoMuted);
|
|
||||||
}, [videoMuted, setVideoMuted]);
|
|
||||||
|
|
||||||
// In case we can not fetch media devices we should mute the devices
|
|
||||||
const handleMediaDeviceFailing = (message: string): void => {
|
|
||||||
MediaDeviceHandler.startWithAudioMuted = true;
|
|
||||||
MediaDeviceHandler.startWithVideoMuted = true;
|
|
||||||
logger.warn(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [videoStream, audioInputs, videoInputs] = useAsyncMemo(
|
|
||||||
async (): Promise<[MediaStream | null, MediaDeviceInfo[], MediaDeviceInfo[]]> => {
|
|
||||||
let devices: IMediaDevices | undefined;
|
|
||||||
try {
|
|
||||||
devices = await MediaDeviceHandler.getDevices();
|
|
||||||
if (devices === undefined) {
|
|
||||||
handleMediaDeviceFailing("Could not access devices!");
|
|
||||||
return [null, [], []];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleMediaDeviceFailing(`Unable to get Media Devices: ${error}`);
|
|
||||||
return [null, [], []];
|
|
||||||
}
|
|
||||||
|
|
||||||
// We get the preview stream before requesting devices: this is because
|
|
||||||
// we need (in some browsers) an active media stream in order to get
|
|
||||||
// non-blank labels for the devices.
|
|
||||||
let stream: MediaStream | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (devices!.audioinput.length > 0) {
|
|
||||||
// Holding just an audio stream will be enough to get us all device labels, so
|
|
||||||
// if video is muted, don't bother requesting video.
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
video: !videoMuted && devices!.videoinput.length > 0 && { deviceId: videoInputId },
|
|
||||||
});
|
|
||||||
} else if (devices!.videoinput.length > 0) {
|
|
||||||
// We have to resort to a video stream, even if video is supposed to be muted.
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Failed to get stream for device ${videoInputId}`, e);
|
|
||||||
handleMediaDeviceFailing(`Have access to Device list but unable to read from Media Devices`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the devices now that we hold a stream
|
|
||||||
if (stream !== null) devices = await MediaDeviceHandler.getDevices();
|
|
||||||
|
|
||||||
// If video is muted, we don't actually want the stream, so we can get rid of it now.
|
|
||||||
if (videoMuted) {
|
|
||||||
stream?.getTracks().forEach((t) => t.stop());
|
|
||||||
stream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [stream, devices?.audioinput ?? [], devices?.videoinput ?? []];
|
|
||||||
},
|
|
||||||
[videoInputId, videoMuted],
|
|
||||||
[null, [], []],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
|
|
||||||
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
|
|
||||||
}, []);
|
|
||||||
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
|
|
||||||
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
|
|
||||||
setVideoInputId(device.deviceId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (videoStream) {
|
|
||||||
const videoElement = videoRef.current!;
|
|
||||||
videoElement.srcObject = videoStream;
|
|
||||||
videoElement.play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
videoStream.getTracks().forEach((track) => track.stop());
|
|
||||||
videoElement.srcObject = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [videoStream]);
|
|
||||||
|
|
||||||
const onConnectClick = useCallback(
|
|
||||||
async (ev: ButtonEvent): Promise<void> => {
|
|
||||||
ev.preventDefault();
|
|
||||||
setConnecting(true);
|
|
||||||
try {
|
|
||||||
await connect();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(e);
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[connect, setConnecting],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx_CallView_lobby">
|
|
||||||
{children}
|
|
||||||
<div className="mx_CallView_preview">
|
|
||||||
<MemberAvatar key={me.userId} member={me} size="200px" resizeMethod="scale" />
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
style={{ visibility: videoMuted ? "hidden" : undefined }}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
disablePictureInPicture
|
|
||||||
/>
|
|
||||||
<div className="mx_CallView_controls">
|
|
||||||
<DeviceButton
|
|
||||||
kind="audio"
|
|
||||||
devices={audioInputs}
|
|
||||||
setDevice={setAudioInput}
|
|
||||||
deviceListLabel={_t("voip|audio_devices")}
|
|
||||||
muted={audioMuted}
|
|
||||||
disabled={connecting}
|
|
||||||
toggle={toggleAudio}
|
|
||||||
unmutedTitle={_t("voip|disable_microphone")}
|
|
||||||
mutedTitle={_t("voip|enable_microphone")}
|
|
||||||
/>
|
|
||||||
<DeviceButton
|
|
||||||
kind="video"
|
|
||||||
devices={videoInputs}
|
|
||||||
setDevice={setVideoInput}
|
|
||||||
deviceListLabel={_t("voip|video_devices")}
|
|
||||||
muted={videoMuted}
|
|
||||||
disabled={connecting}
|
|
||||||
toggle={toggleVideo}
|
|
||||||
unmutedTitle={_t("voip|disable_camera")}
|
|
||||||
mutedTitle={_t("voip|enable_camera")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AccessibleTooltipButton
|
|
||||||
className="mx_CallView_connectButton"
|
|
||||||
kind="primary"
|
|
||||||
disabled={connecting || joinCallButtonDisabledTooltip !== undefined}
|
|
||||||
onClick={onConnectClick}
|
|
||||||
label={_t("action|join")}
|
|
||||||
tooltip={connecting ? _t("voip|connecting") : joinCallButtonDisabledTooltip}
|
|
||||||
alignment={Alignment.Bottom}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StartCallViewProps {
|
|
||||||
room: Room;
|
|
||||||
resizing: boolean;
|
|
||||||
call: Call | null;
|
|
||||||
setStartingCall: (value: boolean) => void;
|
|
||||||
role?: AriaRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StartCallView: FC<StartCallViewProps> = ({ room, resizing, call, setStartingCall, role }) => {
|
|
||||||
const cli = useContext(MatrixClientContext);
|
|
||||||
|
|
||||||
// Since connection has to be split across two different callbacks, we
|
|
||||||
// create a promise to communicate the results back to the caller
|
|
||||||
const connectDeferredRef = useRef<IDeferred<void>>();
|
|
||||||
if (connectDeferredRef.current === undefined) {
|
|
||||||
connectDeferredRef.current = defer();
|
|
||||||
}
|
|
||||||
const connectDeferred = connectDeferredRef.current!;
|
|
||||||
|
|
||||||
// Since the call might be null, we have to track connection state by hand.
|
|
||||||
// The alternative would be to split this component in two depending on
|
|
||||||
// whether we've received the call, so we could use the useConnectionState
|
|
||||||
// hook, but then React would remount the lobby when the call arrives.
|
|
||||||
const [connected, setConnected] = useState(() => call !== null && isConnected(call.connectionState));
|
|
||||||
useEffect(() => {
|
|
||||||
if (call !== null) {
|
|
||||||
const onConnectionState = (state: ConnectionState): void => setConnected(isConnected(state));
|
|
||||||
call.on(CallEvent.ConnectionState, onConnectionState);
|
|
||||||
return () => {
|
|
||||||
call.off(CallEvent.ConnectionState, onConnectionState);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [call]);
|
|
||||||
|
|
||||||
const connect = useCallback(async (): Promise<void> => {
|
|
||||||
setStartingCall(true);
|
|
||||||
await ElementCall.create(room);
|
|
||||||
await connectDeferred.promise;
|
|
||||||
}, [room, setStartingCall, connectDeferred]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async (): Promise<void> => {
|
|
||||||
// If the call was successfully started, connect automatically
|
|
||||||
if (call !== null) {
|
|
||||||
try {
|
|
||||||
// Disconnect from any other active calls first, since we don't yet support holding
|
|
||||||
await Promise.all([...CallStore.instance.activeCalls].map((call) => call.disconnect()));
|
|
||||||
await call.connect();
|
|
||||||
connectDeferred.resolve();
|
|
||||||
} catch (e) {
|
|
||||||
connectDeferred.reject(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [call, connectDeferred]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx_CallView" role={role}>
|
|
||||||
{connected ? null : <Lobby room={room} connect={connect} />}
|
|
||||||
{call !== null && (
|
|
||||||
<AppTile
|
|
||||||
app={call.widget}
|
|
||||||
room={room}
|
|
||||||
userId={cli.credentials.userId!}
|
|
||||||
creatorUserId={call.widget.creatorUserId}
|
|
||||||
waitForIframeLoad={call.widget.waitForIframeLoad}
|
|
||||||
showMenubar={false}
|
|
||||||
pointerEvents={resizing ? "none" : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface JoinCallViewProps {
|
interface JoinCallViewProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
resizing: boolean;
|
resizing: boolean;
|
||||||
call: Call;
|
call: Call;
|
||||||
|
skipLobby?: boolean;
|
||||||
role?: AriaRole;
|
role?: AriaRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, role }) => {
|
const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, skipLobby, role }) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
const connected = isConnected(useConnectionState(call));
|
|
||||||
const members = useParticipatingMembers(call);
|
|
||||||
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
|
|
||||||
|
|
||||||
const connect = useCallback(async (): Promise<void> => {
|
|
||||||
// Disconnect from any other active calls first, since we don't yet support holding
|
|
||||||
await Promise.all([...CallStore.instance.activeCalls].map((call) => call.disconnect()));
|
|
||||||
await call.connect();
|
|
||||||
}, [call]);
|
|
||||||
|
|
||||||
// We'll take this opportunity to tidy up our room state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// We'll take this opportunity to tidy up our room state
|
||||||
call.clean();
|
call.clean();
|
||||||
}, [call]);
|
}, [call]);
|
||||||
|
|
||||||
let lobby: JSX.Element | null = null;
|
useEffect(() => {
|
||||||
if (!connected) {
|
// Always update the widget data so that we don't ignore "skipLobby" accidentally.
|
||||||
let facePile: JSX.Element | null = null;
|
call.widget.data ??= {};
|
||||||
if (members.length) {
|
call.widget.data.skipLobby = skipLobby;
|
||||||
const shownMembers = members.slice(0, MAX_FACES);
|
}, [call.widget, skipLobby]);
|
||||||
const overflow = members.length > shownMembers.length;
|
|
||||||
|
|
||||||
facePile = (
|
useEffect(() => {
|
||||||
<div className="mx_CallView_participants">
|
if (call.connectionState === ConnectionState.Disconnected) {
|
||||||
{_t("voip|n_people_joined", { count: members.length })}
|
// immediately start the call
|
||||||
<FacePile members={shownMembers} size="24px" overflow={overflow} />
|
// (this will start the lobby view in the widget and connect to all required widget events)
|
||||||
</div>
|
call.start();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (): void => {
|
||||||
lobby = (
|
// If we are connected the widget is sticky and we do not want to destroy the call.
|
||||||
<Lobby
|
if (!call.connected) call.destroy();
|
||||||
room={room}
|
};
|
||||||
connect={connect}
|
}, [call]);
|
||||||
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip ?? undefined}
|
const disconnectAllOtherCalls: () => Promise<void> = useCallback(async () => {
|
||||||
>
|
// The stickyPromise has to resolve before the widget actually becomes sticky.
|
||||||
{facePile}
|
// We only let the widget become sticky after disconnecting all other active calls.
|
||||||
</Lobby>
|
const calls = [...CallStore.instance.activeCalls].filter(
|
||||||
|
(call) => SdkContextClass.instance.roomViewStore.getRoomId() !== call.roomId,
|
||||||
);
|
);
|
||||||
}
|
await Promise.all(calls.map(async (call) => await call.disconnect()));
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="mx_CallView" role={role}>
|
<div className="mx_CallView">
|
||||||
{lobby}
|
|
||||||
{/* We render the widget even if we're disconnected, so it stays loaded */}
|
|
||||||
<AppTile
|
<AppTile
|
||||||
app={call.widget}
|
app={call.widget}
|
||||||
room={room}
|
room={room}
|
||||||
|
@ -428,6 +75,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, role }) =>
|
||||||
waitForIframeLoad={call.widget.waitForIframeLoad}
|
waitForIframeLoad={call.widget.waitForIframeLoad}
|
||||||
showMenubar={false}
|
showMenubar={false}
|
||||||
pointerEvents={resizing ? "none" : undefined}
|
pointerEvents={resizing ? "none" : undefined}
|
||||||
|
stickyPromise={disconnectAllOtherCalls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -441,19 +89,21 @@ interface CallViewProps {
|
||||||
* button will create a call if there isn't already one.
|
* button will create a call if there isn't already one.
|
||||||
*/
|
*/
|
||||||
waitForCall: boolean;
|
waitForCall: boolean;
|
||||||
|
skipLobby?: boolean;
|
||||||
role?: AriaRole;
|
role?: AriaRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CallView: FC<CallViewProps> = ({ room, resizing, waitForCall, role }) => {
|
export const CallView: FC<CallViewProps> = ({ room, resizing, waitForCall, skipLobby, role }) => {
|
||||||
const call = useCall(room.roomId);
|
const call = useCall(room.roomId);
|
||||||
const [startingCall, setStartingCall] = useState(false);
|
|
||||||
|
|
||||||
if (call === null || startingCall) {
|
useEffect(() => {
|
||||||
if (waitForCall) return null;
|
if (call === null && !waitForCall) {
|
||||||
return (
|
ElementCall.create(room, skipLobby);
|
||||||
<StartCallView room={room} resizing={resizing} call={call} setStartingCall={setStartingCall} role={role} />
|
}
|
||||||
);
|
}, [call, room, skipLobby, waitForCall]);
|
||||||
|
if (call === null) {
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return <JoinCallView room={room} resizing={resizing} call={call} role={role} />;
|
return <JoinCallView room={room} resizing={resizing} call={call} skipLobby={skipLobby} role={role} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -44,6 +44,7 @@ interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
|
||||||
show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list
|
show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list
|
||||||
clear_search?: boolean; // Whether to clear the room list search
|
clear_search?: boolean; // Whether to clear the room list search
|
||||||
view_call?: boolean; // Whether to view the call or call lobby for the room
|
view_call?: boolean; // Whether to view the call or call lobby for the room
|
||||||
|
skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls)
|
||||||
opts?: JoinRoomPayload["opts"];
|
opts?: JoinRoomPayload["opts"];
|
||||||
|
|
||||||
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action
|
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action
|
||||||
|
|
|
@ -123,7 +123,9 @@ export const useRoomCall = (
|
||||||
|
|
||||||
const [canPinWidget, setCanPinWidget] = useState(false);
|
const [canPinWidget, setCanPinWidget] = useState(false);
|
||||||
const [widgetPinned, setWidgetPinned] = useState(false);
|
const [widgetPinned, setWidgetPinned] = useState(false);
|
||||||
const promptPinWidget = canPinWidget && !widgetPinned;
|
// We only want to prompt to pin the widget if it's not element call based.
|
||||||
|
const isECWidget = WidgetType.CALL.matches(widget?.type ?? "");
|
||||||
|
const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned;
|
||||||
|
|
||||||
const updateWidgetState = useCallback((): void => {
|
const updateWidgetState = useCallback((): void => {
|
||||||
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
|
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
|
||||||
|
@ -169,7 +171,7 @@ export const useRoomCall = (
|
||||||
if (widget && promptPinWidget) {
|
if (widget && promptPinWidget) {
|
||||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
||||||
} else {
|
} else {
|
||||||
placeCall(room, CallType.Voice, callType);
|
placeCall(room, CallType.Voice, callType, evt.shiftKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[promptPinWidget, room, widget, callType],
|
[promptPinWidget, room, widget, callType],
|
||||||
|
@ -180,7 +182,7 @@ export const useRoomCall = (
|
||||||
if (widget && promptPinWidget) {
|
if (widget && promptPinWidget) {
|
||||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
||||||
} else {
|
} else {
|
||||||
placeCall(room, CallType.Video, callType);
|
placeCall(room, CallType.Video, callType, evt.shiftKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[widget, promptPinWidget, room, callType],
|
[widget, promptPinWidget, room, callType],
|
||||||
|
|
|
@ -479,6 +479,7 @@
|
||||||
"legal": "Legal",
|
"legal": "Legal",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
|
"lobby": "Lobby",
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
"low_priority": "Low priority",
|
"low_priority": "Low priority",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
|
@ -492,10 +493,6 @@
|
||||||
"one": "%(count)s member",
|
"one": "%(count)s member",
|
||||||
"other": "%(count)s members"
|
"other": "%(count)s members"
|
||||||
},
|
},
|
||||||
"n_participants": {
|
|
||||||
"one": "1 participant",
|
|
||||||
"other": "%(count)s participants"
|
|
||||||
},
|
|
||||||
"n_rooms": {
|
"n_rooms": {
|
||||||
"one": "%(count)s room",
|
"one": "%(count)s room",
|
||||||
"other": "%(count)s rooms"
|
"other": "%(count)s rooms"
|
||||||
|
@ -3793,7 +3790,6 @@
|
||||||
"already_in_call_person": "You're already in a call with this person.",
|
"already_in_call_person": "You're already in a call with this person.",
|
||||||
"answered_elsewhere": "Answered Elsewhere",
|
"answered_elsewhere": "Answered Elsewhere",
|
||||||
"answered_elsewhere_description": "The call was answered on another device.",
|
"answered_elsewhere_description": "The call was answered on another device.",
|
||||||
"audio_devices": "Audio devices",
|
|
||||||
"call_failed": "Call Failed",
|
"call_failed": "Call Failed",
|
||||||
"call_failed_description": "The call could not be established",
|
"call_failed_description": "The call could not be established",
|
||||||
"call_failed_media": "Call failed because webcam or microphone could not be accessed. Check that:",
|
"call_failed_media": "Call failed because webcam or microphone could not be accessed. Check that:",
|
||||||
|
@ -3880,7 +3876,6 @@
|
||||||
"user_is_presenting": "%(sharerName)s is presenting",
|
"user_is_presenting": "%(sharerName)s is presenting",
|
||||||
"video_call": "Video call",
|
"video_call": "Video call",
|
||||||
"video_call_started": "Video call started",
|
"video_call_started": "Video call started",
|
||||||
"video_devices": "Video devices",
|
|
||||||
"voice_call": "Voice call",
|
"voice_call": "Voice call",
|
||||||
"you_are_presenting": "You are presenting"
|
"you_are_presenting": "You are presenting"
|
||||||
},
|
},
|
||||||
|
|
|
@ -32,12 +32,14 @@ import { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { CallMembership } from "matrix-js-sdk/src/matrixrtc/CallMembership";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
|
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types";
|
import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types";
|
||||||
|
|
||||||
import type EventEmitter from "events";
|
import type EventEmitter from "events";
|
||||||
import type { ClientWidgetApi } from "matrix-widget-api";
|
import type { ClientWidgetApi, IWidgetData } from "matrix-widget-api";
|
||||||
import type { IApp } from "../stores/WidgetStore";
|
import type { IApp } from "../stores/WidgetStore";
|
||||||
import SdkConfig, { DEFAULTS } from "../SdkConfig";
|
import SdkConfig, { DEFAULTS } from "../SdkConfig";
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
|
@ -54,6 +56,7 @@ import { FontWatcher } from "../settings/watchers/FontWatcher";
|
||||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||||
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
|
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
|
||||||
|
import { isVideoRoom } from "../utils/video-rooms";
|
||||||
|
|
||||||
const TIMEOUT_MS = 16000;
|
const TIMEOUT_MS = 16000;
|
||||||
|
|
||||||
|
@ -77,7 +80,12 @@ const waitForEvent = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ConnectionState {
|
export enum ConnectionState {
|
||||||
|
// Widget related states that are equivalent to disconnected,
|
||||||
|
// but hold additional information about the state of the widget.
|
||||||
|
Lobby = "lobby",
|
||||||
|
WidgetLoading = "widget_loading",
|
||||||
Disconnected = "disconnected",
|
Disconnected = "disconnected",
|
||||||
|
|
||||||
Connecting = "connecting",
|
Connecting = "connecting",
|
||||||
Connected = "connected",
|
Connected = "connected",
|
||||||
Disconnecting = "disconnecting",
|
Disconnecting = "disconnecting",
|
||||||
|
@ -188,7 +196,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
public abstract clean(): Promise<void>;
|
public abstract clean(): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contacts the widget to connect to the call.
|
* Contacts the widget to connect to the call or prompt the user to connect to the call.
|
||||||
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
|
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
|
||||||
* null to start muted.
|
* null to start muted.
|
||||||
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
|
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
|
||||||
|
@ -205,12 +213,16 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
protected abstract performDisconnection(): Promise<void>;
|
protected abstract performDisconnection(): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects the user to the call using the media devices set in
|
* Starts the communication between the widget and the call.
|
||||||
* MediaDeviceHandler. The widget associated with the call must be active
|
* The call then waits for the necessary requirements to actually perform the connection
|
||||||
|
* or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...)
|
||||||
|
* It uses the media devices set in MediaDeviceHandler.
|
||||||
|
* The widget associated with the call must be active
|
||||||
* for this to succeed.
|
* for this to succeed.
|
||||||
|
* Only call this if the call state is: ConnectionState.Disconnected.
|
||||||
*/
|
*/
|
||||||
public async connect(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
this.connectionState = ConnectionState.Connecting;
|
this.connectionState = ConnectionState.WidgetLoading;
|
||||||
|
|
||||||
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
|
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
|
||||||
(await MediaDeviceHandler.getDevices())!;
|
(await MediaDeviceHandler.getDevices())!;
|
||||||
|
@ -246,7 +258,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
|
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.connectionState = ConnectionState.Connecting;
|
||||||
try {
|
try {
|
||||||
await this.performConnection(audioInput, videoInput);
|
await this.performConnection(audioInput, videoInput);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -264,7 +276,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
* Disconnects the user from the call.
|
* Disconnects the user from the call.
|
||||||
*/
|
*/
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
if (this.connectionState !== ConnectionState.Connected) throw new Error("Not connected");
|
if (!this.connected) throw new Error("Not connected");
|
||||||
|
|
||||||
this.connectionState = ConnectionState.Disconnecting;
|
this.connectionState = ConnectionState.Disconnecting;
|
||||||
await this.performDisconnection();
|
await this.performDisconnection();
|
||||||
|
@ -460,6 +472,7 @@ export class JitsiCall extends Call {
|
||||||
audioInput: MediaDeviceInfo | null,
|
audioInput: MediaDeviceInfo | null,
|
||||||
videoInput: MediaDeviceInfo | null,
|
videoInput: MediaDeviceInfo | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.connectionState = ConnectionState.Lobby;
|
||||||
// Ensure that the messaging doesn't get stopped while we're waiting for responses
|
// Ensure that the messaging doesn't get stopped while we're waiting for responses
|
||||||
const dontStopMessaging = new Promise<void>((resolve, reject) => {
|
const dontStopMessaging = new Promise<void>((resolve, reject) => {
|
||||||
const messagingStore = WidgetMessagingStore.instance;
|
const messagingStore = WidgetMessagingStore.instance;
|
||||||
|
@ -539,7 +552,8 @@ export class JitsiCall extends Call {
|
||||||
}
|
}
|
||||||
|
|
||||||
public setDisconnected(): void {
|
public setDisconnected(): void {
|
||||||
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
// During tests this.messaging can be undefined
|
||||||
|
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
|
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
|
||||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
|
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
|
||||||
|
|
||||||
|
@ -615,6 +629,11 @@ export class JitsiCall extends Call {
|
||||||
|
|
||||||
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||||
this.setDisconnected();
|
this.setDisconnected();
|
||||||
|
// In video rooms we immediately want to restart the call after hangup
|
||||||
|
// The lobby will be shown again and it connects to all signals from EC and Jitsi.
|
||||||
|
if (isVideoRoom(this.room)) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -623,7 +642,7 @@ export class JitsiCall extends Call {
|
||||||
* (somewhat cheekily named)
|
* (somewhat cheekily named)
|
||||||
*/
|
*/
|
||||||
export class ElementCall extends Call {
|
export class ElementCall extends Call {
|
||||||
// TODO this is only there to support backwards compatiblity in timeline rendering
|
// TODO this is only there to support backwards compatibility in timeline rendering
|
||||||
// this should not be part of this class since it has nothing to do with it.
|
// this should not be part of this class since it has nothing to do with it.
|
||||||
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
|
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
|
||||||
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
|
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
|
||||||
|
@ -652,8 +671,11 @@ export class ElementCall extends Call {
|
||||||
// Splice together the Element Call URL for this call
|
// Splice together the Element Call URL for this call
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
embed: "true", // We're embedding EC within another application
|
embed: "true", // We're embedding EC within another application
|
||||||
preload: "true", // We want it to load in the background
|
// Template variables are used, so that this can be configured using the widget data.
|
||||||
skipLobby: "true", // Skip the lobby since we show a lobby component of our own
|
preload: "$preload", // We want it to load in the background.
|
||||||
|
skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own.
|
||||||
|
returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms)
|
||||||
|
perParticipantE2EE: "$perParticipantE2EE",
|
||||||
hideHeader: "true", // Hide the header since our room header is enough
|
hideHeader: "true", // Hide the header since our room header is enough
|
||||||
userId: client.getUserId()!,
|
userId: client.getUserId()!,
|
||||||
deviceId: client.getDeviceId()!,
|
deviceId: client.getDeviceId()!,
|
||||||
|
@ -664,8 +686,6 @@ export class ElementCall extends Call {
|
||||||
analyticsID,
|
analyticsID,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (client.isRoomEncrypted(roomId) && !SettingsStore.getValue("feature_disable_call_per_sender_encryption"))
|
|
||||||
params.append("perParticipantE2EE", "true");
|
|
||||||
if (SettingsStore.getValue("fallbackICEServerAllowed")) params.append("allowIceFallback", "true");
|
if (SettingsStore.getValue("fallbackICEServerAllowed")) params.append("allowIceFallback", "true");
|
||||||
if (SettingsStore.getValue("feature_allow_screen_share_only_mode"))
|
if (SettingsStore.getValue("feature_allow_screen_share_only_mode"))
|
||||||
params.append("allowVoipWithNoMedia", "true");
|
params.append("allowVoipWithNoMedia", "true");
|
||||||
|
@ -685,24 +705,46 @@ export class ElementCall extends Call {
|
||||||
|
|
||||||
const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!);
|
const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!);
|
||||||
url.pathname = "/room";
|
url.pathname = "/room";
|
||||||
url.hash = `#?${params.toString()}`;
|
const replacedUrl = params.toString().replace(/%24/g, "$");
|
||||||
|
url.hash = `#?${replacedUrl}`;
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createOrGetCallWidget(roomId: string, client: MatrixClient): IApp {
|
// Creates a new widget if there isn't any widget of typ Call in this room.
|
||||||
|
// Defaults for creating a new widget are: skipLobby = false, preload = false
|
||||||
|
// When there is already a widget the current widget configuration will be used or can be overwritten
|
||||||
|
// by passing the according parameters (skipLobby, preload).
|
||||||
|
//
|
||||||
|
// `preload` is deprecated. We used it for optimizing EC by using a custom EW call lobby and preloading the iframe.
|
||||||
|
// now it should always be false.
|
||||||
|
private static createOrGetCallWidget(
|
||||||
|
roomId: string,
|
||||||
|
client: MatrixClient,
|
||||||
|
skipLobby: boolean | undefined,
|
||||||
|
preload: boolean | undefined,
|
||||||
|
returnToLobby: boolean | undefined,
|
||||||
|
): IApp {
|
||||||
const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type));
|
const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type));
|
||||||
const url = ElementCall.generateWidgetUrl(client, roomId);
|
|
||||||
|
|
||||||
if (ecWidget) {
|
if (ecWidget) {
|
||||||
// always update the url because even if the widget is already created
|
// Always update the widget data because even if the widget is already created,
|
||||||
// we might have settings changes that update the widget.
|
// we might have settings changes that update the widget.
|
||||||
ecWidget.url = url.toString();
|
const overwrites: IWidgetData = {};
|
||||||
|
if (skipLobby !== undefined) {
|
||||||
|
overwrites.skipLobby = skipLobby;
|
||||||
|
}
|
||||||
|
if (preload !== undefined) {
|
||||||
|
overwrites.preload = preload;
|
||||||
|
}
|
||||||
|
if (returnToLobby !== undefined) {
|
||||||
|
overwrites.returnToLobby = returnToLobby;
|
||||||
|
}
|
||||||
|
ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, overwrites);
|
||||||
return ecWidget;
|
return ecWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
// To use Element Call without touching room state, we create a virtual
|
// To use Element Call without touching room state, we create a virtual
|
||||||
// widget (one that doesn't have a corresponding state event)
|
// widget (one that doesn't have a corresponding state event)
|
||||||
|
const url = ElementCall.generateWidgetUrl(client, roomId);
|
||||||
return WidgetStore.instance.addVirtualWidget(
|
return WidgetStore.instance.addVirtualWidget(
|
||||||
{
|
{
|
||||||
id: randomString(24), // So that it's globally unique
|
id: randomString(24), // So that it's globally unique
|
||||||
|
@ -711,13 +753,39 @@ export class ElementCall extends Call {
|
||||||
type: WidgetType.CALL.preferred,
|
type: WidgetType.CALL.preferred,
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
// waitForIframeLoad: false,
|
// waitForIframeLoad: false,
|
||||||
|
data: ElementCall.getWidgetData(
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
skipLobby: skipLobby ?? false,
|
||||||
|
preload: preload ?? false,
|
||||||
|
returnToLobby: returnToLobby ?? false,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
roomId,
|
roomId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static getWidgetData(
|
||||||
|
client: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
currentData: IWidgetData,
|
||||||
|
overwriteData: IWidgetData,
|
||||||
|
): IWidgetData {
|
||||||
|
let perParticipantE2EE = false;
|
||||||
|
if (client.isRoomEncrypted(roomId) && !SettingsStore.getValue("feature_disable_call_per_sender_encryption"))
|
||||||
|
perParticipantE2EE = true;
|
||||||
|
return {
|
||||||
|
...currentData,
|
||||||
|
...overwriteData,
|
||||||
|
perParticipantE2EE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private onCallEncryptionSettingsChange(): void {
|
private onCallEncryptionSettingsChange(): void {
|
||||||
this.widget.url = ElementCall.generateWidgetUrl(this.client, this.roomId).toString();
|
this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
|
@ -739,6 +807,7 @@ export class ElementCall extends Call {
|
||||||
|
|
||||||
public static get(room: Room): ElementCall | null {
|
public static get(room: Room): ElementCall | null {
|
||||||
// Only supported in the new group call experience or in video rooms.
|
// Only supported in the new group call experience or in video rooms.
|
||||||
|
|
||||||
if (
|
if (
|
||||||
SettingsStore.getValue("feature_group_calls") ||
|
SettingsStore.getValue("feature_group_calls") ||
|
||||||
(SettingsStore.getValue("feature_video_rooms") &&
|
(SettingsStore.getValue("feature_video_rooms") &&
|
||||||
|
@ -752,10 +821,16 @@ export class ElementCall extends Call {
|
||||||
// A call is present if we
|
// A call is present if we
|
||||||
// - have a widget: This means the create function was called.
|
// - have a widget: This means the create function was called.
|
||||||
// - or there is a running session where we have not yet created a widget for.
|
// - or there is a running session where we have not yet created a widget for.
|
||||||
// - or this this is a call room. Then we also always want to show a call.
|
// - or this is a call room. Then we also always want to show a call.
|
||||||
if (hasEcWidget || session.memberships.length !== 0 || room.isCallRoom()) {
|
if (hasEcWidget || session.memberships.length !== 0 || room.isCallRoom()) {
|
||||||
// create a widget for the case we are joining a running call and don't have on yet.
|
// create a widget for the case we are joining a running call and don't have on yet.
|
||||||
const availableOrCreatedWidget = ElementCall.createOrGetCallWidget(room.roomId, room.client);
|
const availableOrCreatedWidget = ElementCall.createOrGetCallWidget(
|
||||||
|
room.roomId,
|
||||||
|
room.client,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
isVideoRoom(room),
|
||||||
|
);
|
||||||
return new ElementCall(session, availableOrCreatedWidget, room.client);
|
return new ElementCall(session, availableOrCreatedWidget, room.client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -763,23 +838,20 @@ export class ElementCall extends Call {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async create(room: Room): Promise<void> {
|
public static async create(room: Room, skipLobby = false): Promise<void> {
|
||||||
const isVideoRoom =
|
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room));
|
||||||
SettingsStore.getValue("feature_video_rooms") &&
|
|
||||||
SettingsStore.getValue("feature_element_call_video_rooms") &&
|
|
||||||
room.isCallRoom();
|
|
||||||
ElementCall.createOrGetCallWidget(room.roomId, room.client);
|
|
||||||
WidgetStore.instance.emit(UPDATE_EVENT, null);
|
WidgetStore.instance.emit(UPDATE_EVENT, null);
|
||||||
|
}
|
||||||
|
|
||||||
// Send Call notify
|
protected async sendCallNotify(): Promise<void> {
|
||||||
|
const room = this.room;
|
||||||
const existingRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter(
|
const existingRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter(
|
||||||
// filter all memberships where the application is m.call and the call_id is ""
|
// filter all memberships where the application is m.call and the call_id is ""
|
||||||
(m) => m.application === "m.call" && m.callId === "",
|
(m) => m.application === "m.call" && m.callId === "",
|
||||||
);
|
);
|
||||||
|
|
||||||
const memberCount = getJoinedNonFunctionalMembers(room).length;
|
const memberCount = getJoinedNonFunctionalMembers(room).length;
|
||||||
if (!isVideoRoom && existingRoomCallMembers.length == 0) {
|
if (!isVideoRoom(room) && existingRoomCallMembers.length == 0) {
|
||||||
// send ringing event
|
// send ringing event
|
||||||
const content: ICallNotifyContent = {
|
const content: ICallNotifyContent = {
|
||||||
"application": "m.call",
|
"application": "m.call",
|
||||||
|
@ -796,30 +868,64 @@ export class ElementCall extends Call {
|
||||||
audioInput: MediaDeviceInfo | null,
|
audioInput: MediaDeviceInfo | null,
|
||||||
videoInput: MediaDeviceInfo | null,
|
videoInput: MediaDeviceInfo | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
// The JoinCall action is only send if the widget is waiting for it.
|
||||||
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
if (this.widget.data?.preload) {
|
||||||
audioInput: audioInput?.label ?? null,
|
try {
|
||||||
videoInput: videoInput?.label ?? null,
|
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
||||||
});
|
audioInput: audioInput?.label ?? null,
|
||||||
} catch (e) {
|
videoInput: videoInput?.label ?? null,
|
||||||
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
|
||||||
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
|
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
|
||||||
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
|
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
|
||||||
|
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||||
|
|
||||||
|
if (!this.widget.data?.skipLobby) {
|
||||||
|
// If we do not skip the lobby we need to wait until the widget has
|
||||||
|
// connected to matrixRTC. This is either observed through the session state
|
||||||
|
// or the MatrixRTCSessionManager session started event.
|
||||||
|
this.connectionState = ConnectionState.Lobby;
|
||||||
|
}
|
||||||
|
// TODO: if the widget informs us when the join button is clicked (widget action), so we can
|
||||||
|
// - set state to connecting
|
||||||
|
// - send call notify
|
||||||
|
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
|
||||||
|
if (session) {
|
||||||
|
await waitForEvent(
|
||||||
|
session,
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
(_, newMemberships: CallMembership[]) =>
|
||||||
|
newMemberships.some((m) => m.sender === this.client.getUserId()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await waitForEvent(
|
||||||
|
this.client.matrixRTC,
|
||||||
|
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||||
|
(roomId: string, session: MatrixRTCSession) =>
|
||||||
|
this.session.callId === session.callId && roomId === this.roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.sendCallNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async performDisconnection(): Promise<void> {
|
protected async performDisconnection(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
|
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
|
||||||
|
await waitForEvent(
|
||||||
|
this.session,
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
(_, newMemberships: CallMembership[]) =>
|
||||||
|
!newMemberships.some((m) => m.sender === this.client.getUserId()),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
|
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setDisconnected(): void {
|
public setDisconnected(): void {
|
||||||
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
|
||||||
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
|
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
|
||||||
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
|
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
|
||||||
super.setDisconnected();
|
super.setDisconnected();
|
||||||
|
@ -828,7 +934,7 @@ export class ElementCall extends Call {
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
ActiveWidgetStore.instance.destroyPersistentWidget(this.widget.id, this.widget.roomId);
|
ActiveWidgetStore.instance.destroyPersistentWidget(this.widget.id, this.widget.roomId);
|
||||||
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.widget.roomId);
|
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.widget.roomId);
|
||||||
|
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||||
this.session.off(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged);
|
this.session.off(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged);
|
||||||
this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded);
|
this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded);
|
||||||
|
|
||||||
|
@ -844,7 +950,7 @@ export class ElementCall extends Call {
|
||||||
}
|
}
|
||||||
|
|
||||||
private onRTCSessionEnded = (roomId: string, session: MatrixRTCSession): void => {
|
private onRTCSessionEnded = (roomId: string, session: MatrixRTCSession): void => {
|
||||||
// Don't destroy widget on hangup for video call rooms.
|
// Don't destroy the call on hangup for video call rooms.
|
||||||
if (roomId == this.roomId && !this.room.isCallRoom()) {
|
if (roomId == this.roomId && !this.room.isCallRoom()) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
@ -883,6 +989,11 @@ export class ElementCall extends Call {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||||
this.setDisconnected();
|
this.setDisconnected();
|
||||||
|
// In video rooms we immediately want to reconnect after hangup
|
||||||
|
// This starts the lobby again and connects to all signals from EC.
|
||||||
|
if (isVideoRoom(this.room)) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
|
private onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
|
||||||
|
|
|
@ -119,6 +119,10 @@ interface State {
|
||||||
* Whether we're viewing a call or call lobby in this room
|
* Whether we're viewing a call or call lobby in this room
|
||||||
*/
|
*/
|
||||||
viewingCall: boolean;
|
viewingCall: boolean;
|
||||||
|
/**
|
||||||
|
* If we want the call to skip the lobby and immediately join
|
||||||
|
*/
|
||||||
|
skipLobby?: boolean;
|
||||||
|
|
||||||
promptAskToJoin: boolean;
|
promptAskToJoin: boolean;
|
||||||
|
|
||||||
|
@ -462,6 +466,7 @@ export class RoomViewStore extends EventEmitter {
|
||||||
replyingToEvent: null,
|
replyingToEvent: null,
|
||||||
viaServers: payload.via_servers ?? [],
|
viaServers: payload.via_servers ?? [],
|
||||||
wasContextSwitch: payload.context_switch ?? false,
|
wasContextSwitch: payload.context_switch ?? false,
|
||||||
|
skipLobby: payload.skipLobby,
|
||||||
viewingCall:
|
viewingCall:
|
||||||
payload.view_call ??
|
payload.view_call ??
|
||||||
(payload.room_id === this.state.roomId
|
(payload.room_id === this.state.roomId
|
||||||
|
@ -513,6 +518,7 @@ export class RoomViewStore extends EventEmitter {
|
||||||
viaServers: payload.via_servers,
|
viaServers: payload.via_servers,
|
||||||
wasContextSwitch: payload.context_switch,
|
wasContextSwitch: payload.context_switch,
|
||||||
viewingCall: payload.view_call ?? false,
|
viewingCall: payload.view_call ?? false,
|
||||||
|
skipLobby: payload.skipLobby,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias);
|
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias);
|
||||||
|
@ -775,6 +781,10 @@ export class RoomViewStore extends EventEmitter {
|
||||||
return this.state.viewingCall;
|
return this.state.viewingCall;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public skipCallLobby(): boolean | undefined {
|
||||||
|
return this.state.skipLobby;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current state of the 'promptForAskToJoin' property.
|
* Gets the current state of the 'promptForAskToJoin' property.
|
||||||
*
|
*
|
||||||
|
|
|
@ -40,6 +40,9 @@ export interface IApp extends IWidget {
|
||||||
export function isAppWidget(widget: IWidget | IApp): widget is IApp {
|
export function isAppWidget(widget: IWidget | IApp): widget is IApp {
|
||||||
return "roomId" in widget && typeof widget.roomId === "string";
|
return "roomId" in widget && typeof widget.roomId === "string";
|
||||||
}
|
}
|
||||||
|
export function isVirtualWidget(widget: IApp): boolean {
|
||||||
|
return widget.eventId === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface IRoomWidgets {
|
interface IRoomWidgets {
|
||||||
widgets: IApp[];
|
widgets: IApp[];
|
||||||
|
@ -127,7 +130,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
|
||||||
// otherwise we are out of sync with the rest of the app with stale widget events during removal
|
// otherwise we are out of sync with the rest of the app with stale widget events during removal
|
||||||
Array.from(this.widgetMap.values()).forEach((app) => {
|
Array.from(this.widgetMap.values()).forEach((app) => {
|
||||||
if (app.roomId !== room.roomId) return; // skip - wrong room
|
if (app.roomId !== room.roomId) return; // skip - wrong room
|
||||||
if (app.eventId === undefined) {
|
if (isVirtualWidget(app)) {
|
||||||
// virtual widget - keep it
|
// virtual widget - keep it
|
||||||
roomInfo.widgets.push(app);
|
roomInfo.widgets.push(app);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -75,6 +75,7 @@ interface IAppTileProps {
|
||||||
waitForIframeLoad: boolean;
|
waitForIframeLoad: boolean;
|
||||||
whitelistCapabilities?: string[];
|
whitelistCapabilities?: string[];
|
||||||
userWidget: boolean;
|
userWidget: boolean;
|
||||||
|
stickyPromise?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Don't use this because it's wrong
|
// TODO: Don't use this because it's wrong
|
||||||
|
@ -160,6 +161,7 @@ export class StopGapWidget extends EventEmitter {
|
||||||
private kind: WidgetKind;
|
private kind: WidgetKind;
|
||||||
private readonly virtual: boolean;
|
private readonly virtual: boolean;
|
||||||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||||
|
private stickyPromise?: () => Promise<void>; // This promise will be called and needs to resolve before the widget will actually become sticky.
|
||||||
|
|
||||||
public constructor(private appTileProps: IAppTileProps) {
|
public constructor(private appTileProps: IAppTileProps) {
|
||||||
super();
|
super();
|
||||||
|
@ -176,6 +178,7 @@ export class StopGapWidget extends EventEmitter {
|
||||||
this.roomId = appTileProps.room?.roomId;
|
this.roomId = appTileProps.room?.roomId;
|
||||||
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
|
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
|
||||||
this.virtual = isAppWidget(app) && app.eventId === undefined;
|
this.virtual = isAppWidget(app) && app.eventId === undefined;
|
||||||
|
this.stickyPromise = appTileProps.stickyPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get eventListenerRoomId(): Optional<string> {
|
private get eventListenerRoomId(): Optional<string> {
|
||||||
|
@ -338,15 +341,17 @@ export class StopGapWidget extends EventEmitter {
|
||||||
|
|
||||||
this.messaging.on(
|
this.messaging.on(
|
||||||
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
|
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
|
||||||
(ev: CustomEvent<IStickyActionRequest>) => {
|
async (ev: CustomEvent<IStickyActionRequest>) => {
|
||||||
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
|
||||||
|
|
||||||
|
if (this.stickyPromise) await this.stickyPromise();
|
||||||
ActiveWidgetStore.instance.setWidgetPersistence(
|
ActiveWidgetStore.instance.setWidgetPersistence(
|
||||||
this.mockWidget.id,
|
this.mockWidget.id,
|
||||||
this.roomId ?? null,
|
this.roomId ?? null,
|
||||||
ev.detail.data.value,
|
ev.detail.data.value,
|
||||||
);
|
);
|
||||||
ev.preventDefault();
|
|
||||||
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -131,6 +131,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
|
||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: room?.roomId,
|
room_id: room?.roomId,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
|
skipLobby: "shiftKey" in e ? e.shiftKey : false,
|
||||||
metricsTrigger: undefined,
|
metricsTrigger: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -29,7 +29,12 @@ import { Action } from "../../dispatcher/actions";
|
||||||
* @param callType the type of call
|
* @param callType the type of call
|
||||||
* @param platformCallType the platform to pass the call on
|
* @param platformCallType the platform to pass the call on
|
||||||
*/
|
*/
|
||||||
export const placeCall = async (room: Room, callType: CallType, platformCallType: PlatformCallType): Promise<void> => {
|
export const placeCall = async (
|
||||||
|
room: Room,
|
||||||
|
callType: CallType,
|
||||||
|
platformCallType: PlatformCallType,
|
||||||
|
skipLobby: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
switch (platformCallType) {
|
switch (platformCallType) {
|
||||||
case "legacy_or_jitsi":
|
case "legacy_or_jitsi":
|
||||||
await LegacyCallHandler.instance.placeCall(room.roomId, callType);
|
await LegacyCallHandler.instance.placeCall(room.roomId, callType);
|
||||||
|
@ -43,6 +48,7 @@ export const placeCall = async (room: Room, callType: CallType, platformCallType
|
||||||
room_id: room.roomId,
|
room_id: room.roomId,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
metricsTrigger: undefined,
|
metricsTrigger: undefined,
|
||||||
|
skipLobby,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
@ -179,7 +179,7 @@ describe("PipContainer", () => {
|
||||||
} as unknown as ClientWidgetApi);
|
} as unknown as ClientWidgetApi);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true);
|
ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -141,7 +141,7 @@ describe("CallEvent", () => {
|
||||||
renderEvent();
|
renderEvent();
|
||||||
|
|
||||||
screen.getByText("@alice:example.org started a video call");
|
screen.getByText("@alice:example.org started a video call");
|
||||||
screen.getByLabelText("2 participants");
|
screen.getByLabelText("2 people joined");
|
||||||
|
|
||||||
// Test that the join button works
|
// Test that the join button works
|
||||||
const dispatcherSpy = jest.fn();
|
const dispatcherSpy = jest.fn();
|
||||||
|
@ -155,7 +155,7 @@ describe("CallEvent", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
defaultDispatcher.unregister(dispatcherRef);
|
||||||
await act(() => call.connect());
|
await act(() => call.start());
|
||||||
|
|
||||||
// Test that the leave button works
|
// Test that the leave button works
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Leave" }));
|
fireEvent.click(screen.getByRole("button", { name: "Leave" }));
|
||||||
|
|
|
@ -31,6 +31,10 @@ import EventEmitter from "events";
|
||||||
import { setupJestCanvasMock } from "jest-canvas-mock";
|
import { setupJestCanvasMock } from "jest-canvas-mock";
|
||||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
// 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 type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||||
|
@ -59,11 +63,11 @@ import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||||
import { Action } from "../../../../src/dispatcher/actions";
|
import { Action } from "../../../../src/dispatcher/actions";
|
||||||
import WidgetStore from "../../../../src/stores/WidgetStore";
|
import WidgetStore from "../../../../src/stores/WidgetStore";
|
||||||
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
||||||
import WidgetUtils from "../../../../src/utils/WidgetUtils";
|
|
||||||
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
|
|
||||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
|
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
|
||||||
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
|
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
|
||||||
import { UIComponent } from "../../../../src/settings/UIFeature";
|
import { UIComponent } from "../../../../src/settings/UIFeature";
|
||||||
|
import WidgetUtils from "../../../../src/utils/WidgetUtils";
|
||||||
|
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
|
||||||
|
|
||||||
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
|
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
|
||||||
shouldShowComponent: jest.fn(),
|
shouldShowComponent: jest.fn(),
|
||||||
|
@ -274,6 +278,7 @@ describe("LegacyRoomHeader", () => {
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: room.roomId,
|
room_id: room.roomId,
|
||||||
|
skipLobby: false,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -407,6 +412,7 @@ describe("LegacyRoomHeader", () => {
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: room.roomId,
|
room_id: room.roomId,
|
||||||
|
skipLobby: false,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -432,6 +438,7 @@ describe("LegacyRoomHeader", () => {
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: room.roomId,
|
room_id: room.roomId,
|
||||||
|
skipLobby: false,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -562,7 +569,20 @@ describe("LegacyRoomHeader", () => {
|
||||||
mockEnabledSettings(["feature_group_calls"]);
|
mockEnabledSettings(["feature_group_calls"]);
|
||||||
|
|
||||||
await withCall(async (call) => {
|
await withCall(async (call) => {
|
||||||
await call.connect();
|
// We set the call to skip lobby because otherwise the connection will wait until
|
||||||
|
// the user clicks the "join" button, inside the widget lobby which is hard to mock.
|
||||||
|
call.widget.data = { ...call.widget.data, skipLobby: true };
|
||||||
|
// The connect method will wait until the session actually connected. Otherwise it will timeout.
|
||||||
|
// Emitting SessionStarted will trigger the connect method to resolve.
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, {
|
||||||
|
room,
|
||||||
|
} as MatrixRTCSession),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
await call.start();
|
||||||
|
|
||||||
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!;
|
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!;
|
||||||
renderHeader({ viewingCall: true, activeCall: call });
|
renderHeader({ viewingCall: true, activeCall: call });
|
||||||
|
|
||||||
|
|
|
@ -331,12 +331,12 @@ describe("RoomHeader", () => {
|
||||||
|
|
||||||
it("clicking on ongoing (unpinned) call re-pins it", () => {
|
it("clicking on ongoing (unpinned) call re-pins it", () => {
|
||||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||||
// allow element calls
|
// allow calls
|
||||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
|
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
|
||||||
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
|
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
|
||||||
|
|
||||||
const widget = {};
|
const widget = { eventId: "some_id_so_it_is_interpreted_as_non_virtual_widget" };
|
||||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call);
|
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call);
|
||||||
|
|
||||||
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
const { container } = render(<RoomHeader room={room} />, getWrapper());
|
||||||
|
@ -405,7 +405,7 @@ describe("RoomHeader", () => {
|
||||||
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls using element calls for large rooms", async () => {
|
it("calls using element call for large rooms", async () => {
|
||||||
mockRoomMembers(room, 3);
|
mockRoomMembers(room, 3);
|
||||||
|
|
||||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
|
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
|
||||||
|
|
|
@ -56,6 +56,7 @@ import { UIComponent } from "../../../../src/settings/UIFeature";
|
||||||
import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore";
|
import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
|
import { ConnectionState } from "../../../../src/models/Call";
|
||||||
|
|
||||||
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
|
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
|
||||||
shouldShowComponent: jest.fn(),
|
shouldShowComponent: jest.fn(),
|
||||||
|
@ -235,20 +236,37 @@ describe("RoomTile", () => {
|
||||||
renderRoomTile();
|
renderRoomTile();
|
||||||
screen.getByText("Video");
|
screen.getByText("Video");
|
||||||
|
|
||||||
|
let completeWidgetLoading: () => void = () => {};
|
||||||
|
const widgetLoadingCompleted = new Promise<void>((resolve) => (completeWidgetLoading = resolve));
|
||||||
|
|
||||||
// Insert an await point in the connection method so we can inspect
|
// Insert an await point in the connection method so we can inspect
|
||||||
// the intermediate connecting state
|
// the intermediate connecting state
|
||||||
let completeConnection: () => void = () => {};
|
let completeConnection: () => void = () => {};
|
||||||
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
|
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
|
||||||
jest.spyOn(call, "performConnection").mockReturnValue(connectionCompleted);
|
|
||||||
|
let completeLobby: () => void = () => {};
|
||||||
|
const lobbyCompleted = new Promise<void>((resolve) => (completeLobby = resolve));
|
||||||
|
|
||||||
|
jest.spyOn(call, "performConnection").mockImplementation(async () => {
|
||||||
|
call.setConnectionState(ConnectionState.WidgetLoading);
|
||||||
|
await widgetLoadingCompleted;
|
||||||
|
call.setConnectionState(ConnectionState.Lobby);
|
||||||
|
await lobbyCompleted;
|
||||||
|
call.setConnectionState(ConnectionState.Connecting);
|
||||||
|
await connectionCompleted;
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
|
await screen.findByText("Loading…");
|
||||||
|
completeWidgetLoading();
|
||||||
|
await screen.findByText("Lobby");
|
||||||
|
completeLobby();
|
||||||
await screen.findByText("Joining…");
|
await screen.findByText("Joining…");
|
||||||
const joinedFound = screen.findByText("Joined");
|
|
||||||
completeConnection();
|
completeConnection();
|
||||||
await joinedFound;
|
await screen.findByText("Joined");
|
||||||
})(),
|
})(),
|
||||||
call.connect(),
|
call.start(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await Promise.all([screen.findByText("Video"), call.disconnect()]);
|
await Promise.all([screen.findByText("Video"), call.disconnect()]);
|
||||||
|
@ -274,12 +292,12 @@ describe("RoomTile", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
call.participants = new Map([alice]);
|
call.participants = new Map([alice]);
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
|
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
call.participants = new Map([alice, bob, carol]);
|
call.participants = new Map([alice, bob, carol]);
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText("4 participants").textContent).toBe("4");
|
expect(screen.getByLabelText("4 people joined").textContent).toBe("4");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
call.participants = new Map();
|
call.participants = new Map();
|
||||||
|
|
|
@ -38,8 +38,6 @@ import { CallView as _CallView } from "../../../../src/components/views/voip/Cal
|
||||||
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
||||||
import { CallStore } from "../../../../src/stores/CallStore";
|
import { CallStore } from "../../../../src/stores/CallStore";
|
||||||
import { Call, ConnectionState } from "../../../../src/models/Call";
|
import { Call, ConnectionState } from "../../../../src/models/Call";
|
||||||
import SdkConfig from "../../../../src/SdkConfig";
|
|
||||||
import MediaDeviceHandler from "../../../../src/MediaDeviceHandler";
|
|
||||||
|
|
||||||
const CallView = wrapInMatrixClientContext(_CallView);
|
const CallView = wrapInMatrixClientContext(_CallView);
|
||||||
|
|
||||||
|
@ -75,8 +73,10 @@ describe("CallView", () => {
|
||||||
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderView = async (): Promise<void> => {
|
const renderView = async (skipLobby = false): Promise<void> => {
|
||||||
render(<CallView room={room} resizing={false} waitForCall={false} />, { wrapper: TooltipProvider });
|
render(<CallView room={room} resizing={false} waitForCall={false} skipLobby={skipLobby} />, {
|
||||||
|
wrapper: TooltipProvider,
|
||||||
|
});
|
||||||
await act(() => Promise.resolve()); // Let effects settle
|
await act(() => Promise.resolve()); // Let effects settle
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -108,20 +108,6 @@ describe("CallView", () => {
|
||||||
expect(cleanSpy).toHaveBeenCalled();
|
expect(cleanSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows lobby and keeps widget loaded when disconnected", async () => {
|
|
||||||
await renderView();
|
|
||||||
screen.getByRole("button", { name: "Join" });
|
|
||||||
screen.getAllByText(/\bwidget\b/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only shows widget when connected", async () => {
|
|
||||||
await renderView();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Join" }));
|
|
||||||
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected));
|
|
||||||
expect(screen.queryByRole("button", { name: "Join" })).toBe(null);
|
|
||||||
screen.getAllByText(/\bwidget\b/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Fix I do not understand this test
|
* TODO: Fix I do not understand this test
|
||||||
*/
|
*/
|
||||||
|
@ -166,40 +152,17 @@ describe("CallView", () => {
|
||||||
expectAvatars([]);
|
expectAvatars([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("connects to the call when the join button is pressed", async () => {
|
it("automatically connects to the call when skipLobby is true", async () => {
|
||||||
await renderView();
|
const connectSpy = jest.spyOn(call, "start");
|
||||||
const connectSpy = jest.spyOn(call, "connect");
|
await renderView(true);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Join" }));
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 });
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables join button when the participant limit has been exceeded", async () => {
|
|
||||||
const bob = mkRoomMember(room.roomId, "@bob:example.org");
|
|
||||||
const carol = mkRoomMember(room.roomId, "@carol:example.org");
|
|
||||||
|
|
||||||
SdkConfig.put({
|
|
||||||
element_call: { participant_limit: 2, url: "", use_exclusively: false, brand: "Element Call" },
|
|
||||||
});
|
|
||||||
call.participants = new Map([
|
|
||||||
[bob, new Set("b")],
|
|
||||||
[carol, new Set("c")],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await renderView();
|
|
||||||
const connectSpy = jest.spyOn(call, "connect");
|
|
||||||
const joinButton = screen.getByRole("button", { name: "Join" });
|
|
||||||
expect(joinButton).toHaveAttribute("aria-disabled", "true");
|
|
||||||
fireEvent.click(joinButton);
|
|
||||||
await waitFor(() => expect(connectSpy).not.toHaveBeenCalled(), { interval: 1 });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("without an existing call", () => {
|
describe("without an existing call", () => {
|
||||||
it("creates and connects to a new call when the join button is pressed", async () => {
|
it("creates and connects to a new call when the join button is pressed", async () => {
|
||||||
await renderView();
|
|
||||||
expect(Call.get(room)).toBeNull();
|
expect(Call.get(room)).toBeNull();
|
||||||
|
await renderView(true);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Join" }));
|
|
||||||
await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull());
|
await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull());
|
||||||
const call = CallStore.instance.getCall(room.roomId)!;
|
const call = CallStore.instance.getCall(room.roomId)!;
|
||||||
|
|
||||||
|
@ -214,117 +177,4 @@ describe("CallView", () => {
|
||||||
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("device buttons", () => {
|
|
||||||
const fakeVideoInput1: MediaDeviceInfo = {
|
|
||||||
deviceId: "v1",
|
|
||||||
groupId: "v1",
|
|
||||||
label: "Webcam",
|
|
||||||
kind: "videoinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
};
|
|
||||||
const fakeVideoInput2: MediaDeviceInfo = {
|
|
||||||
deviceId: "v2",
|
|
||||||
groupId: "v2",
|
|
||||||
label: "Othercam",
|
|
||||||
kind: "videoinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
};
|
|
||||||
const fakeAudioInput1: MediaDeviceInfo = {
|
|
||||||
deviceId: "v1",
|
|
||||||
groupId: "v1",
|
|
||||||
label: "Headphones",
|
|
||||||
kind: "audioinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
};
|
|
||||||
const fakeAudioInput2: MediaDeviceInfo = {
|
|
||||||
deviceId: "v2",
|
|
||||||
groupId: "v2",
|
|
||||||
label: "Tailphones",
|
|
||||||
kind: "audioinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
it("hide when no devices are available", async () => {
|
|
||||||
await renderView();
|
|
||||||
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
|
|
||||||
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hide when no access to device list", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockRejectedValue("permission denied");
|
|
||||||
await renderView();
|
|
||||||
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
|
|
||||||
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
|
|
||||||
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
|
|
||||||
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hide when unknown error with device list", async () => {
|
|
||||||
const originalGetDevices = MediaDeviceHandler.getDevices;
|
|
||||||
MediaDeviceHandler.getDevices = () => Promise.reject("unknown error");
|
|
||||||
await renderView();
|
|
||||||
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
|
|
||||||
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
|
|
||||||
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
|
|
||||||
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
|
|
||||||
MediaDeviceHandler.getDevices = originalGetDevices;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("show without dropdown when only one device is available", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]);
|
|
||||||
|
|
||||||
await renderView();
|
|
||||||
screen.getByRole("button", { name: /camera/ });
|
|
||||||
expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("show with dropdown when multiple devices are available", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]);
|
|
||||||
|
|
||||||
await renderView();
|
|
||||||
screen.getByRole("button", { name: /microphone/ });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
|
||||||
screen.getByRole("menuitem", { name: "Headphones" });
|
|
||||||
screen.getByRole("menuitem", { name: "Tailphones" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets video device when selected", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]);
|
|
||||||
|
|
||||||
await renderView();
|
|
||||||
screen.getByRole("button", { name: /camera/ });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video devices" }));
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label }));
|
|
||||||
|
|
||||||
expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets audio device when selected", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]);
|
|
||||||
|
|
||||||
await renderView();
|
|
||||||
screen.getByRole("button", { name: /microphone/ });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label }));
|
|
||||||
|
|
||||||
expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set media muted if no access to audio device", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]);
|
|
||||||
mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected");
|
|
||||||
await renderView();
|
|
||||||
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
|
|
||||||
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("set media muted if no access to video device", async () => {
|
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]);
|
|
||||||
mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected");
|
|
||||||
await renderView();
|
|
||||||
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
|
|
||||||
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -140,6 +140,7 @@ const setUpWidget = (
|
||||||
audioMutedSpy: jest.SpyInstance<boolean, []>;
|
audioMutedSpy: jest.SpyInstance<boolean, []>;
|
||||||
videoMutedSpy: jest.SpyInstance<boolean, []>;
|
videoMutedSpy: jest.SpyInstance<boolean, []>;
|
||||||
} => {
|
} => {
|
||||||
|
call.widget.data = { ...call.widget, skipLobby: true };
|
||||||
const widget = new Widget(call.widget);
|
const widget = new Widget(call.widget);
|
||||||
|
|
||||||
const eventEmitter = new EventEmitter();
|
const eventEmitter = new EventEmitter();
|
||||||
|
@ -253,7 +254,7 @@ describe("JitsiCall", () => {
|
||||||
audioMutedSpy.mockReturnValue(true);
|
audioMutedSpy.mockReturnValue(true);
|
||||||
videoMutedSpy.mockReturnValue(true);
|
videoMutedSpy.mockReturnValue(true);
|
||||||
|
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
||||||
audioInput: null,
|
audioInput: null,
|
||||||
|
@ -266,7 +267,7 @@ describe("JitsiCall", () => {
|
||||||
audioMutedSpy.mockReturnValue(false);
|
audioMutedSpy.mockReturnValue(false);
|
||||||
videoMutedSpy.mockReturnValue(false);
|
videoMutedSpy.mockReturnValue(false);
|
||||||
|
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
||||||
audioInput: "Headphones",
|
audioInput: "Headphones",
|
||||||
|
@ -280,21 +281,63 @@ describe("JitsiCall", () => {
|
||||||
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
|
||||||
const connect = call.connect();
|
const connect = call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connecting);
|
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
|
||||||
|
|
||||||
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
|
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
|
||||||
await connect;
|
await connect;
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("doesn't stop messaging when connecting", async () => {
|
||||||
|
// Temporarily remove the messaging to simulate connecting while the
|
||||||
|
// widget is still initializing
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const oldSendMock = messaging.transport.send;
|
||||||
|
mocked(messaging.transport).send.mockImplementation(async (action: string): Promise<any> => {
|
||||||
|
if (action === ElementWidgetActions.JoinCall) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
messaging.emit(
|
||||||
|
`action:${ElementWidgetActions.JoinCall}`,
|
||||||
|
new CustomEvent("widgetapirequest", { detail: {} }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
|
||||||
|
const connect = call.start();
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
|
||||||
|
async function runTimers() {
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
}
|
||||||
|
async function runStopMessaging() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||||
|
}
|
||||||
|
runStopMessaging();
|
||||||
|
runTimers();
|
||||||
|
let connectError;
|
||||||
|
try {
|
||||||
|
await connect;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
connectError = e;
|
||||||
|
}
|
||||||
|
expect(connectError).toBeDefined();
|
||||||
|
// const connect2 = await connect;
|
||||||
|
// expect(connect2).toThrow();
|
||||||
|
messaging.transport.send = oldSendMock;
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("fails to connect if the widget returns an error", async () => {
|
it("fails to connect if the widget returns an error", async () => {
|
||||||
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
||||||
await expect(call.connect()).rejects.toBeDefined();
|
await expect(call.start()).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails to disconnect if the widget returns an error", async () => {
|
it("fails to disconnect if the widget returns an error", async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
||||||
await expect(call.disconnect()).rejects.toBeDefined();
|
await expect(call.disconnect()).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
@ -302,56 +345,55 @@ describe("JitsiCall", () => {
|
||||||
it("handles remote disconnection", async () => {
|
it("handles remote disconnection", async () => {
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
|
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
call.on(CallEvent.ConnectionState, callback);
|
||||||
|
|
||||||
messaging.emit(
|
messaging.emit(
|
||||||
`action:${ElementWidgetActions.HangupCall}`,
|
`action:${ElementWidgetActions.HangupCall}`,
|
||||||
new CustomEvent("widgetapirequest", { detail: {} }),
|
new CustomEvent("widgetapirequest", { detail: {} }),
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
|
await waitFor(() => {
|
||||||
});
|
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected),
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(
|
||||||
it("handles instant remote disconnection when connecting", async () => {
|
2,
|
||||||
mocked(messaging.transport).send.mockImplementation(async (action): Promise<any> => {
|
ConnectionState.WidgetLoading,
|
||||||
if (action === ElementWidgetActions.JoinCall) {
|
ConnectionState.Disconnected,
|
||||||
// Emit the hangup event *before* the join event to fully
|
|
||||||
// exercise the race condition
|
|
||||||
messaging.emit(
|
|
||||||
`action:${ElementWidgetActions.HangupCall}`,
|
|
||||||
new CustomEvent("widgetapirequest", { detail: {} }),
|
|
||||||
);
|
);
|
||||||
messaging.emit(
|
expect(callback).toHaveBeenNthCalledWith(3, ConnectionState.Connecting, ConnectionState.WidgetLoading);
|
||||||
`action:${ElementWidgetActions.JoinCall}`,
|
|
||||||
new CustomEvent("widgetapirequest", { detail: {} }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
});
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
// in video rooms we expect the call to immediately reconnect
|
||||||
await call.connect();
|
call.off(CallEvent.ConnectionState, callback);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
||||||
// Should disconnect on its own almost instantly
|
|
||||||
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnects", async () => {
|
it("disconnects", async () => {
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
await call.disconnect();
|
await call.disconnect();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnects when we leave the room", async () => {
|
it("disconnects when we leave the room", async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
room.emit(RoomEvent.MyMembership, room, "leave");
|
room.emit(RoomEvent.MyMembership, room, "leave");
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconnects after disconnect in video rooms", async () => {
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
await call.start();
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
|
await call.disconnect();
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
});
|
||||||
|
|
||||||
it("remains connected if we stay in the room", async () => {
|
it("remains connected if we stay in the room", async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
room.emit(RoomEvent.MyMembership, room, "join");
|
room.emit(RoomEvent.MyMembership, room, "join");
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
|
@ -377,7 +419,7 @@ describe("JitsiCall", () => {
|
||||||
|
|
||||||
// Now, stub out client.sendStateEvent so we can test our local echo
|
// Now, stub out client.sendStateEvent so we can test our local echo
|
||||||
client.sendStateEvent.mockReset();
|
client.sendStateEvent.mockReset();
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(call.participants).toEqual(
|
expect(call.participants).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
[alice, new Set(["alices_device"])],
|
[alice, new Set(["alices_device"])],
|
||||||
|
@ -391,7 +433,7 @@ describe("JitsiCall", () => {
|
||||||
|
|
||||||
it("updates room state when connecting and disconnecting", async () => {
|
it("updates room state when connecting and disconnecting", async () => {
|
||||||
const now1 = Date.now();
|
const now1 = Date.now();
|
||||||
await call.connect();
|
await call.start();
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() =>
|
() =>
|
||||||
expect(
|
expect(
|
||||||
|
@ -418,7 +460,7 @@ describe("JitsiCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repeatedly updates room state while connected", async () => {
|
it("repeatedly updates room state while connected", async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() =>
|
() =>
|
||||||
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
||||||
|
@ -448,11 +490,13 @@ describe("JitsiCall", () => {
|
||||||
const onConnectionState = jest.fn();
|
const onConnectionState = jest.fn();
|
||||||
call.on(CallEvent.ConnectionState, onConnectionState);
|
call.on(CallEvent.ConnectionState, onConnectionState);
|
||||||
|
|
||||||
await call.connect();
|
await call.start();
|
||||||
await call.disconnect();
|
await call.disconnect();
|
||||||
expect(onConnectionState.mock.calls).toEqual([
|
expect(onConnectionState.mock.calls).toEqual([
|
||||||
[ConnectionState.Connecting, ConnectionState.Disconnected],
|
[ConnectionState.WidgetLoading, ConnectionState.Disconnected],
|
||||||
[ConnectionState.Connected, ConnectionState.Connecting],
|
[ConnectionState.Connecting, ConnectionState.WidgetLoading],
|
||||||
|
[ConnectionState.Lobby, ConnectionState.Connecting],
|
||||||
|
[ConnectionState.Connected, ConnectionState.Lobby],
|
||||||
[ConnectionState.Disconnecting, ConnectionState.Connected],
|
[ConnectionState.Disconnecting, ConnectionState.Connected],
|
||||||
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
|
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
|
||||||
]);
|
]);
|
||||||
|
@ -464,7 +508,7 @@ describe("JitsiCall", () => {
|
||||||
const onParticipants = jest.fn();
|
const onParticipants = jest.fn();
|
||||||
call.on(CallEvent.Participants, onParticipants);
|
call.on(CallEvent.Participants, onParticipants);
|
||||||
|
|
||||||
await call.connect();
|
await call.start();
|
||||||
await call.disconnect();
|
await call.disconnect();
|
||||||
expect(onParticipants.mock.calls).toEqual([
|
expect(onParticipants.mock.calls).toEqual([
|
||||||
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
|
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
|
||||||
|
@ -477,7 +521,7 @@ describe("JitsiCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to spotlight layout when the widget becomes a PiP", async () => {
|
it("switches to spotlight layout when the widget becomes a PiP", async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
|
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
|
||||||
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
|
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
|
||||||
|
@ -521,7 +565,7 @@ describe("JitsiCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doesn't clean up valid devices", async () => {
|
it("doesn't clean up valid devices", async () => {
|
||||||
await call.connect();
|
await call.start();
|
||||||
await client.sendStateEvent(
|
await client.sendStateEvent(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
JitsiCall.MEMBER_EVENT_TYPE,
|
JitsiCall.MEMBER_EVENT_TYPE,
|
||||||
|
@ -582,11 +626,62 @@ describe("ElementCall", () => {
|
||||||
let room: Room;
|
let room: Room;
|
||||||
let alice: RoomMember;
|
let alice: RoomMember;
|
||||||
|
|
||||||
|
function setRoomMembers(memberIds: string[]) {
|
||||||
|
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
|
||||||
|
}
|
||||||
|
|
||||||
|
const callConnectProcedure: (call: ElementCall) => Promise<void> = async (call) => {
|
||||||
|
async function sessionConnect() {
|
||||||
|
await new Promise<void>((r) => {
|
||||||
|
setTimeout(() => r(), 400);
|
||||||
|
});
|
||||||
|
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, {
|
||||||
|
sessionId: undefined,
|
||||||
|
} as unknown as MatrixRTCSession);
|
||||||
|
call.session?.emit(
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
[],
|
||||||
|
[{ sender: client.getUserId() } as CallMembership],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function runTimers() {
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
}
|
||||||
|
sessionConnect();
|
||||||
|
const promise = call.start();
|
||||||
|
runTimers();
|
||||||
|
await promise;
|
||||||
|
};
|
||||||
|
const callDisconnectionProcedure: (call: ElementCall) => Promise<void> = async (call) => {
|
||||||
|
async function sessionDisconnect() {
|
||||||
|
await new Promise<void>((r) => {
|
||||||
|
setTimeout(() => r(), 400);
|
||||||
|
});
|
||||||
|
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, {
|
||||||
|
sessionId: undefined,
|
||||||
|
} as unknown as MatrixRTCSession);
|
||||||
|
call.session?.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);
|
||||||
|
}
|
||||||
|
async function runTimers() {
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
}
|
||||||
|
sessionDisconnect();
|
||||||
|
const promise = call.disconnect();
|
||||||
|
runTimers();
|
||||||
|
await promise;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
({ client, room, alice } = setUpClientRoomAndStores());
|
({ client, room, alice } = setUpClientRoomAndStores());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => cleanUpClientRoomAndStores(client, room));
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
cleanUpClientRoomAndStores(client, room);
|
||||||
|
});
|
||||||
|
|
||||||
describe("get", () => {
|
describe("get", () => {
|
||||||
it("finds no calls", () => {
|
it("finds no calls", () => {
|
||||||
|
@ -700,6 +795,28 @@ describe("ElementCall", () => {
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
||||||
expect(urlParams.get("analyticsID")).toBe("");
|
expect(urlParams.get("analyticsID")).toBe("");
|
||||||
|
call.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes feature_allow_screen_share_only_mode setting to allowVoipWithNoMedia url param", async () => {
|
||||||
|
// Now test with the preference set to true
|
||||||
|
const originalGetValue = SettingsStore.getValue;
|
||||||
|
SettingsStore.getValue = <T>(name: string, roomId?: string, excludeDefault?: boolean) => {
|
||||||
|
switch (name) {
|
||||||
|
case "feature_allow_screen_share_only_mode":
|
||||||
|
return true as T;
|
||||||
|
default:
|
||||||
|
return originalGetValue<T>(name, roomId, excludeDefault);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await ElementCall.create(room);
|
||||||
|
const call = Call.get(room);
|
||||||
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
||||||
|
expect(urlParams.get("allowVoipWithNoMedia")).toBe("true");
|
||||||
|
SettingsStore.getValue = originalGetValue;
|
||||||
|
call.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes empty analyticsID if the id is not in the account data", async () => {
|
it("passes empty analyticsID if the id is not in the account data", async () => {
|
||||||
|
@ -729,7 +846,7 @@ describe("ElementCall", () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(0);
|
jest.setSystemTime(0);
|
||||||
|
|
||||||
await ElementCall.create(room);
|
await ElementCall.create(room, true);
|
||||||
const maybeCall = ElementCall.get(room);
|
const maybeCall = ElementCall.get(room);
|
||||||
if (maybeCall === null) throw new Error("Failed to create call");
|
if (maybeCall === null) throw new Error("Failed to create call");
|
||||||
call = maybeCall;
|
call = maybeCall;
|
||||||
|
@ -738,41 +855,18 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
|
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
|
||||||
|
// TODO refactor initial device configuration to use the EW settings.
|
||||||
it("connects muted", async () => {
|
// Add tests for passing EW device configuration to the widget.
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
||||||
audioMutedSpy.mockReturnValue(true);
|
|
||||||
videoMutedSpy.mockReturnValue(true);
|
|
||||||
|
|
||||||
await call.connect();
|
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
|
||||||
audioInput: null,
|
|
||||||
videoInput: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("connects unmuted", async () => {
|
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
||||||
audioMutedSpy.mockReturnValue(false);
|
|
||||||
videoMutedSpy.mockReturnValue(false);
|
|
||||||
|
|
||||||
await call.connect();
|
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
|
||||||
audioInput: "Headphones",
|
|
||||||
videoInput: "Built-in webcam",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits for messaging when connecting", async () => {
|
it("waits for messaging when connecting", async () => {
|
||||||
// Temporarily remove the messaging to simulate connecting while the
|
// Temporarily remove the messaging to simulate connecting while the
|
||||||
// widget is still initializing
|
// widget is still initializing
|
||||||
|
|
||||||
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
|
||||||
const connect = call.connect();
|
const connect = callConnectProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connecting);
|
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
|
||||||
|
|
||||||
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
|
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
|
||||||
await connect;
|
await connect;
|
||||||
|
@ -780,12 +874,14 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails to connect if the widget returns an error", async () => {
|
it("fails to connect if the widget returns an error", async () => {
|
||||||
|
// we only send a JoinCall action if the widget is preloading
|
||||||
|
call.widget.data = { ...call.widget, preload: true };
|
||||||
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
||||||
await expect(call.connect()).rejects.toBeDefined();
|
await expect(call.start()).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails to disconnect if the widget returns an error", async () => {
|
it("fails to disconnect if the widget returns an error", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
||||||
await expect(call.disconnect()).rejects.toBeDefined();
|
await expect(call.disconnect()).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
@ -793,7 +889,7 @@ describe("ElementCall", () => {
|
||||||
it("handles remote disconnection", async () => {
|
it("handles remote disconnection", async () => {
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
|
|
||||||
messaging.emit(
|
messaging.emit(
|
||||||
|
@ -805,35 +901,35 @@ describe("ElementCall", () => {
|
||||||
|
|
||||||
it("disconnects", async () => {
|
it("disconnects", async () => {
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
await call.disconnect();
|
await callDisconnectionProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnects when we leave the room", async () => {
|
it("disconnects when we leave the room", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
room.emit(RoomEvent.MyMembership, room, "leave");
|
room.emit(RoomEvent.MyMembership, room, "leave");
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remains connected if we stay in the room", async () => {
|
it("remains connected if we stay in the room", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
room.emit(RoomEvent.MyMembership, room, "join");
|
room.emit(RoomEvent.MyMembership, room, "join");
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnects if the widget dies", async () => {
|
it("disconnects if the widget dies", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
|
||||||
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks layout", async () => {
|
it("tracks layout", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
expect(call.layout).toBe(Layout.Tile);
|
expect(call.layout).toBe(Layout.Tile);
|
||||||
|
|
||||||
messaging.emit(
|
messaging.emit(
|
||||||
|
@ -850,7 +946,7 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets layout", async () => {
|
it("sets layout", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
|
|
||||||
await call.setLayout(Layout.Spotlight);
|
await call.setLayout(Layout.Spotlight);
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
|
||||||
|
@ -860,13 +956,15 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits events when connection state changes", async () => {
|
it("emits events when connection state changes", async () => {
|
||||||
|
// const wait = jest.spyOn(CallModule, "waitForEvent");
|
||||||
const onConnectionState = jest.fn();
|
const onConnectionState = jest.fn();
|
||||||
call.on(CallEvent.ConnectionState, onConnectionState);
|
call.on(CallEvent.ConnectionState, onConnectionState);
|
||||||
|
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
await call.disconnect();
|
await callDisconnectionProcedure(call);
|
||||||
expect(onConnectionState.mock.calls).toEqual([
|
expect(onConnectionState.mock.calls).toEqual([
|
||||||
[ConnectionState.Connecting, ConnectionState.Disconnected],
|
[ConnectionState.WidgetLoading, ConnectionState.Disconnected],
|
||||||
|
[ConnectionState.Connecting, ConnectionState.WidgetLoading],
|
||||||
[ConnectionState.Connected, ConnectionState.Connecting],
|
[ConnectionState.Connected, ConnectionState.Connecting],
|
||||||
[ConnectionState.Disconnecting, ConnectionState.Connected],
|
[ConnectionState.Disconnecting, ConnectionState.Connected],
|
||||||
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
|
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
|
||||||
|
@ -887,7 +985,7 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits events when layout changes", async () => {
|
it("emits events when layout changes", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
const onLayout = jest.fn();
|
const onLayout = jest.fn();
|
||||||
call.on(CallEvent.Layout, onLayout);
|
call.on(CallEvent.Layout, onLayout);
|
||||||
|
|
||||||
|
@ -905,10 +1003,10 @@ describe("ElementCall", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ends the call immediately if the session ended", async () => {
|
it("ends the call immediately if the session ended", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
const onDestroy = jest.fn();
|
const onDestroy = jest.fn();
|
||||||
call.on(CallEvent.Destroy, onDestroy);
|
call.on(CallEvent.Destroy, onDestroy);
|
||||||
await call.disconnect();
|
await callDisconnectionProcedure(call);
|
||||||
// this will be called automatically
|
// this will be called automatically
|
||||||
// disconnect -> widget sends state event -> session manager notices no-one left
|
// disconnect -> widget sends state event -> session manager notices no-one left
|
||||||
client.matrixRTC.emit(
|
client.matrixRTC.emit(
|
||||||
|
@ -935,25 +1033,47 @@ describe("ElementCall", () => {
|
||||||
|
|
||||||
// should create call with perParticipantE2EE flag
|
// should create call with perParticipantE2EE flag
|
||||||
ElementCall.create(room);
|
ElementCall.create(room);
|
||||||
|
expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(true);
|
||||||
expect(addWidgetSpy.mock.calls[0][0].url).toContain("perParticipantE2EE=true");
|
|
||||||
ElementCall.get(room)?.destroy();
|
|
||||||
|
|
||||||
// should create call without perParticipantE2EE flag
|
// should create call without perParticipantE2EE flag
|
||||||
enabledSettings.add("feature_disable_call_per_sender_encryption");
|
enabledSettings.add("feature_disable_call_per_sender_encryption");
|
||||||
await ElementCall.create(room);
|
expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(false);
|
||||||
enabledSettings.delete("feature_disable_call_per_sender_encryption");
|
enabledSettings.delete("feature_disable_call_per_sender_encryption");
|
||||||
|
|
||||||
expect(addWidgetSpy.mock.calls[1][0].url).not.toContain("perParticipantE2EE=true");
|
|
||||||
|
|
||||||
client.isRoomEncrypted.mockClear();
|
client.isRoomEncrypted.mockClear();
|
||||||
addWidgetSpy.mockRestore();
|
addWidgetSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends notify event on connect in a room with more than two members", async () => {
|
||||||
|
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
|
||||||
|
await ElementCall.create(room);
|
||||||
|
await callConnectProcedure(Call.get(room) as ElementCall);
|
||||||
|
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
|
||||||
|
"application": "m.call",
|
||||||
|
"call_id": "",
|
||||||
|
"m.mentions": { room: true, user_ids: [] },
|
||||||
|
"notify_type": "notify",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("sends ring on create in a DM (two participants) room", async () => {
|
||||||
|
setRoomMembers(["@user:example.com", "@user2:example.com"]);
|
||||||
|
|
||||||
|
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
|
||||||
|
await ElementCall.create(room);
|
||||||
|
await callConnectProcedure(Call.get(room) as ElementCall);
|
||||||
|
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
|
||||||
|
"application": "m.call",
|
||||||
|
"call_id": "",
|
||||||
|
"m.mentions": { room: true, user_ids: [] },
|
||||||
|
"notify_type": "ring",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("instance in a video room", () => {
|
describe("instance in a video room", () => {
|
||||||
let call: ElementCall;
|
let call: ElementCall;
|
||||||
let widget: Widget;
|
let widget: Widget;
|
||||||
|
let messaging: Mocked<ClientWidgetApi>;
|
||||||
let audioMutedSpy: jest.SpyInstance<boolean, []>;
|
let audioMutedSpy: jest.SpyInstance<boolean, []>;
|
||||||
let videoMutedSpy: jest.SpyInstance<boolean, []>;
|
let videoMutedSpy: jest.SpyInstance<boolean, []>;
|
||||||
|
|
||||||
|
@ -968,49 +1088,71 @@ describe("ElementCall", () => {
|
||||||
if (maybeCall === null) throw new Error("Failed to create call");
|
if (maybeCall === null) throw new Error("Failed to create call");
|
||||||
call = maybeCall;
|
call = maybeCall;
|
||||||
|
|
||||||
({ widget, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
|
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
|
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
|
||||||
|
|
||||||
it("doesn't end the call when the last participant leaves", async () => {
|
it("doesn't end the call when the last participant leaves", async () => {
|
||||||
await call.connect();
|
await callConnectProcedure(call);
|
||||||
const onDestroy = jest.fn();
|
const onDestroy = jest.fn();
|
||||||
call.on(CallEvent.Destroy, onDestroy);
|
call.on(CallEvent.Destroy, onDestroy);
|
||||||
await call.disconnect();
|
await callDisconnectionProcedure(call);
|
||||||
expect(onDestroy).not.toHaveBeenCalled();
|
expect(onDestroy).not.toHaveBeenCalled();
|
||||||
call.off(CallEvent.Destroy, onDestroy);
|
call.off(CallEvent.Destroy, onDestroy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("connect to call with ongoing session", async () => {
|
||||||
|
// Mock membership getter used by `roomSessionForRoom`.
|
||||||
|
// This makes sure the roomSession will not be empty.
|
||||||
|
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockImplementation(() => [
|
||||||
|
{ fakeVal: "fake membership", getMsUntilExpiry: () => 1000 } as unknown as CallMembership,
|
||||||
|
]);
|
||||||
|
// Create ongoing session
|
||||||
|
const roomSession = MatrixRTCSession.roomSessionForRoom(client, room);
|
||||||
|
const roomSessionEmitSpy = jest.spyOn(roomSession, "emit");
|
||||||
|
|
||||||
|
// Make sure the created session ends up in the call.
|
||||||
|
// `getActiveRoomSession` will be used during `call.connect`
|
||||||
|
// `getRoomSession` will be used during `Call.get`
|
||||||
|
client.matrixRTC.getActiveRoomSession.mockImplementation(() => {
|
||||||
|
return roomSession;
|
||||||
|
});
|
||||||
|
client.matrixRTC.getRoomSession.mockImplementation(() => {
|
||||||
|
return roomSession;
|
||||||
|
});
|
||||||
|
|
||||||
|
await ElementCall.create(room);
|
||||||
|
const call = Call.get(room);
|
||||||
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
||||||
|
expect(call.session).toBe(roomSession);
|
||||||
|
await callConnectProcedure(call);
|
||||||
|
expect(roomSessionEmitSpy).toHaveBeenCalledWith(
|
||||||
|
"memberships_changed",
|
||||||
|
[],
|
||||||
|
[{ sender: "@alice:example.org" }],
|
||||||
|
);
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
|
call.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles remote disconnection and reconnect right after", async () => {
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
||||||
|
await callConnectProcedure(call);
|
||||||
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
|
|
||||||
|
messaging.emit(
|
||||||
|
`action:${ElementWidgetActions.HangupCall}`,
|
||||||
|
new CustomEvent("widgetapirequest", { detail: {} }),
|
||||||
|
);
|
||||||
|
// We want the call to be connecting after the hangup.
|
||||||
|
waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connecting), { interval: 5 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
describe("create call", () => {
|
describe("create call", () => {
|
||||||
function setRoomMembers(memberIds: string[]) {
|
|
||||||
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
|
|
||||||
}
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
setRoomMembers(["@user:example.com", "@user2:example.com", "@user4:example.com"]);
|
setRoomMembers(["@user:example.com", "@user2:example.com", "@user4:example.com"]);
|
||||||
});
|
});
|
||||||
it("sends notify event on create in a room with more than two members", async () => {
|
|
||||||
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
|
|
||||||
await ElementCall.create(room);
|
|
||||||
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
|
|
||||||
"application": "m.call",
|
|
||||||
"call_id": "",
|
|
||||||
"m.mentions": { room: true, user_ids: [] },
|
|
||||||
"notify_type": "notify",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("sends ring on create in a DM (two participants) room", async () => {
|
|
||||||
setRoomMembers(["@user:example.com", "@user2:example.com"]);
|
|
||||||
|
|
||||||
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
|
|
||||||
await ElementCall.create(room);
|
|
||||||
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
|
|
||||||
"application": "m.call",
|
|
||||||
"call_id": "",
|
|
||||||
"m.mentions": { room: true, user_ids: [] },
|
|
||||||
"notify_type": "ring",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("don't sent notify event if there are existing room call members", async () => {
|
it("don't sent notify event if there are existing room call members", async () => {
|
||||||
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([
|
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([
|
||||||
{ application: "m.call", callId: "" } as unknown as CallMembership,
|
{ application: "m.call", callId: "" } as unknown as CallMembership,
|
||||||
|
|
|
@ -100,7 +100,7 @@ describe("Algorithm", () => {
|
||||||
// End of setup
|
// End of setup
|
||||||
|
|
||||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
||||||
await call.connect();
|
await call.start();
|
||||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
|
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
|
||||||
await call.disconnect();
|
await call.disconnect();
|
||||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
||||||
|
|
|
@ -17,7 +17,8 @@ limitations under the License.
|
||||||
import { mocked, MockedObject } from "jest-mock";
|
import { mocked, MockedObject } from "jest-mock";
|
||||||
import { last } from "lodash";
|
import { last } from "lodash";
|
||||||
import { MatrixEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
|
import { MatrixEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { ClientWidgetApi } from "matrix-widget-api";
|
import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api";
|
||||||
|
import { waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import { stubClient, mkRoom, mkEvent } from "../../test-utils";
|
import { stubClient, mkRoom, mkEvent } from "../../test-utils";
|
||||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||||
|
@ -25,6 +26,7 @@ import { StopGapWidget } from "../../../src/stores/widgets/StopGapWidget";
|
||||||
import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
|
import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
|
||||||
import { VoiceBroadcastInfoEventType, VoiceBroadcastRecording } from "../../../src/voice-broadcast";
|
import { VoiceBroadcastInfoEventType, VoiceBroadcastRecording } from "../../../src/voice-broadcast";
|
||||||
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
||||||
|
import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore";
|
||||||
|
|
||||||
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
|
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
|
||||||
|
|
||||||
|
@ -114,3 +116,68 @@ describe("StopGapWidget", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("StopGapWidget with stickyPromise", () => {
|
||||||
|
let client: MockedObject<MatrixClient>;
|
||||||
|
let widget: StopGapWidget;
|
||||||
|
let messaging: MockedObject<ClientWidgetApi>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubClient();
|
||||||
|
client = mocked(MatrixClientPeg.safeGet());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
widget.stopMessaging();
|
||||||
|
});
|
||||||
|
it("should wait for the sticky promise to resolve before starting messaging", async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const getStickyPromise = async () => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
widget = new StopGapWidget({
|
||||||
|
app: {
|
||||||
|
id: "test",
|
||||||
|
creatorUserId: "@alice:example.org",
|
||||||
|
type: "example",
|
||||||
|
url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url",
|
||||||
|
roomId: "!1:example.org",
|
||||||
|
},
|
||||||
|
room: mkRoom(client, "!1:example.org"),
|
||||||
|
userId: "@alice:example.org",
|
||||||
|
creatorUserId: "@alice:example.org",
|
||||||
|
waitForIframeLoad: true,
|
||||||
|
userWidget: false,
|
||||||
|
stickyPromise: getStickyPromise,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPersistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence");
|
||||||
|
|
||||||
|
// Start messaging without an iframe, since ClientWidgetApi is mocked
|
||||||
|
widget.startMessaging(null as unknown as HTMLIFrameElement);
|
||||||
|
const emitSticky = async () => {
|
||||||
|
messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!);
|
||||||
|
messaging?.hasCapability.mockReturnValue(true);
|
||||||
|
// messaging.transport.reply will be called but transport is undefined in this test environment
|
||||||
|
// This just makes sure the call doesn't throw
|
||||||
|
Object.defineProperty(messaging, "transport", { value: { reply: () => {} } });
|
||||||
|
messaging.on.mock.calls.find(([event, listener]) => {
|
||||||
|
if (event === `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`) {
|
||||||
|
listener({ preventDefault: () => {}, detail: { data: { value: true } } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await emitSticky();
|
||||||
|
expect(setPersistenceSpy).not.toHaveBeenCalled();
|
||||||
|
// Advance the fake timer so that the sticky promise resolves
|
||||||
|
jest.runAllTimers();
|
||||||
|
// Use a real timer and wait for the next tick so the sticky promise can resolve
|
||||||
|
jest.useRealTimers();
|
||||||
|
|
||||||
|
waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -125,7 +125,7 @@ describe("IncomingCallEvent", () => {
|
||||||
|
|
||||||
screen.getByText("Video call started");
|
screen.getByText("Video call started");
|
||||||
screen.getByText("Video");
|
screen.getByText("Video");
|
||||||
screen.getByLabelText("3 participants");
|
screen.getByLabelText("3 people joined");
|
||||||
|
|
||||||
screen.getByRole("button", { name: "Join" });
|
screen.getByRole("button", { name: "Join" });
|
||||||
screen.getByRole("button", { name: "Close" });
|
screen.getByRole("button", { name: "Close" });
|
||||||
|
@ -167,6 +167,30 @@ describe("IncomingCallEvent", () => {
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||||
action: Action.ViewRoom,
|
action: Action.ViewRoom,
|
||||||
room_id: room.roomId,
|
room_id: room.roomId,
|
||||||
|
skipLobby: false,
|
||||||
|
view_call: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toastStore.dismissToast).toHaveBeenCalledWith(
|
||||||
|
getIncomingCallToastKey(notifyContent.call_id, room.roomId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
defaultDispatcher.unregister(dispatcherRef);
|
||||||
|
});
|
||||||
|
it("skips lobby when using shift key click", async () => {
|
||||||
|
renderToast();
|
||||||
|
|
||||||
|
const dispatcherSpy = jest.fn();
|
||||||
|
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Join" }), { shiftKey: true });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
room_id: room.roomId,
|
||||||
|
skipLobby: true,
|
||||||
view_call: true,
|
view_call: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue