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:
Timo 2024-01-29 17:06:12 +01:00 committed by GitHub
parent 3f7e21e08d
commit a370a5cfa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 693 additions and 767 deletions

View file

@ -178,8 +178,8 @@ test.describe("Room Header", () => {
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click();
// Assert that the video is rendered
await expect(page.locator(".mx_CallView video")).toBeVisible();
// Assert that the call view is still visible
await expect(page.locator(".mx_CallView")).toBeVisible();
// Assert that GELS is visible
await expect(

View file

@ -2564,6 +2564,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
room={this.state.room}
resizing={this.state.resizing}
waitForCall={isVideoRoom(this.state.room)}
skipLobby={this.context.roomViewStore.skipCallLobby() ?? false}
role="main"
/>
{previewBar}

View file

@ -43,6 +43,7 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({ roomId, call }) =>
action: Action.ViewRoom,
room_id: roomId,
view_call: true,
skipLobby: "shiftKey" in ev ? ev.shiftKey : false,
metricsTrigger: undefined,
});
},

View file

@ -95,6 +95,11 @@ interface IProps {
movePersistedElement?: MutableRefObject<(() => void) | undefined>;
// An element to render after the iframe as an overlay
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 {
@ -610,11 +615,11 @@ export default class AppTile extends React.Component<IProps, IState> {
"microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;";
const appTileBodyClass = classNames({
// We don't want mx_AppTileBody (rounded corners) for call widgets
"mx_AppTileBody": true,
"mx_AppTileBody--large": !this.props.miniMode,
"mx_AppTileBody--mini": this.props.miniMode,
"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,
});
const appTileBodyStyles: CSSProperties = {};

View file

@ -131,12 +131,14 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
switch (connectionState) {
case ConnectionState.Disconnected:
return [_t("action|join"), "primary", connect];
case ConnectionState.Connecting:
return [_t("action|join"), "primary", null];
case ConnectionState.Connected:
return [_t("action|leave"), "danger", disconnect];
case ConnectionState.Disconnecting:
return [_t("action|leave"), "danger", null];
case ConnectionState.Connecting:
case ConnectionState.Lobby:
case ConnectionState.WidgetLoading:
return [_t("action|join"), "primary", null];
}
}, [connectionState, connect, disconnect]);

View file

@ -143,16 +143,20 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
setBusy(false);
}, [setBusy, room]);
const startElementCall = useCallback(() => {
setBusy(true);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: undefined,
});
setBusy(false);
}, [setBusy, room]);
const startElementCall = useCallback(
(skipLobby: boolean) => {
setBusy(true);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
skipLobby: skipLobby,
metricsTrigger: undefined,
});
setBusy(false);
},
[setBusy, room],
);
const { onClick, tooltip, disabled } = useMemo(() => {
if (behavior instanceof DisabledWithReason) {
@ -173,7 +177,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
startElementCall();
startElementCall("shiftKey" in ev ? ev.shiftKey : false);
},
disabled: false,
};
@ -202,7 +206,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
startElementCall();
startElementCall("shiftKey" in ev ? ev.shiftKey : false);
},
[closeMenu, startElementCall],
);
@ -305,7 +309,7 @@ const CallButtons: FC<CallButtonsProps> = ({ room }) => {
} else {
return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call")));
}
} else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) {
} else if (hasLegacyCall || hasJitsiWidget) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))}

View file

@ -51,7 +51,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
{" • "}
<span
className="mx_LiveContentSummary_participants"
aria-label={_t("common|n_participants", { count: participantCount })}
aria-label={_t("voip|n_people_joined", { count: participantCount })}
>
{participantCount}
</span>

View file

@ -35,6 +35,14 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
text = _t("common|video");
active = false;
break;
case ConnectionState.WidgetLoading:
text = _t("common|loading");
active = false;
break;
case ConnectionState.Lobby:
text = _t("common|lobby");
active = false;
break;
case ConnectionState.Connecting:
text = _t("room|joining");
active = true;

View file

@ -14,412 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, ReactNode, useState, useContext, useEffect, useMemo, useRef, useCallback, AriaRole } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import React, { FC, useContext, useEffect, AriaRole, useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { ConnectionState } from "../../../models/Call";
import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call";
import {
useCall,
useConnectionState,
useJoinCallButtonDisabledTooltip,
useParticipatingMembers,
} from "../../../hooks/useCall";
import { Call, ConnectionState, ElementCall } from "../../../models/Call";
import { useCall } from "../../../hooks/useCall";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
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 IconizedContextMenu, {
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>
);
};
import { SdkContextClass } from "../../../contexts/SDKContext";
interface JoinCallViewProps {
room: Room;
resizing: boolean;
call: Call;
skipLobby?: boolean;
role?: AriaRole;
}
const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, role }) => {
const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, skipLobby, role }) => {
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(() => {
// We'll take this opportunity to tidy up our room state
call.clean();
}, [call]);
let lobby: JSX.Element | null = null;
if (!connected) {
let facePile: JSX.Element | null = null;
if (members.length) {
const shownMembers = members.slice(0, MAX_FACES);
const overflow = members.length > shownMembers.length;
useEffect(() => {
// Always update the widget data so that we don't ignore "skipLobby" accidentally.
call.widget.data ??= {};
call.widget.data.skipLobby = skipLobby;
}, [call.widget, skipLobby]);
facePile = (
<div className="mx_CallView_participants">
{_t("voip|n_people_joined", { count: members.length })}
<FacePile members={shownMembers} size="24px" overflow={overflow} />
</div>
);
useEffect(() => {
if (call.connectionState === ConnectionState.Disconnected) {
// immediately start the call
// (this will start the lobby view in the widget and connect to all required widget events)
call.start();
}
lobby = (
<Lobby
room={room}
connect={connect}
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip ?? undefined}
>
{facePile}
</Lobby>
return (): void => {
// If we are connected the widget is sticky and we do not want to destroy the call.
if (!call.connected) call.destroy();
};
}, [call]);
const disconnectAllOtherCalls: () => Promise<void> = useCallback(async () => {
// The stickyPromise has to resolve before the widget actually becomes sticky.
// We only let the widget become sticky after disconnecting all other active calls.
const calls = [...CallStore.instance.activeCalls].filter(
(call) => SdkContextClass.instance.roomViewStore.getRoomId() !== call.roomId,
);
}
await Promise.all(calls.map(async (call) => await call.disconnect()));
}, []);
return (
<div className="mx_CallView" role={role}>
{lobby}
{/* We render the widget even if we're disconnected, so it stays loaded */}
<div className="mx_CallView">
<AppTile
app={call.widget}
room={room}
@ -428,6 +75,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call, role }) =>
waitForIframeLoad={call.widget.waitForIframeLoad}
showMenubar={false}
pointerEvents={resizing ? "none" : undefined}
stickyPromise={disconnectAllOtherCalls}
/>
</div>
);
@ -441,19 +89,21 @@ interface CallViewProps {
* button will create a call if there isn't already one.
*/
waitForCall: boolean;
skipLobby?: boolean;
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 [startingCall, setStartingCall] = useState(false);
if (call === null || startingCall) {
if (waitForCall) return null;
return (
<StartCallView room={room} resizing={resizing} call={call} setStartingCall={setStartingCall} role={role} />
);
useEffect(() => {
if (call === null && !waitForCall) {
ElementCall.create(room, skipLobby);
}
}, [call, room, skipLobby, waitForCall]);
if (call === null) {
return null;
} else {
return <JoinCallView room={room} resizing={resizing} call={call} role={role} />;
return <JoinCallView room={room} resizing={resizing} call={call} skipLobby={skipLobby} role={role} />;
}
};

View file

@ -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
clear_search?: boolean; // Whether to clear the room list search
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"];
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action

View file

@ -123,7 +123,9 @@ export const useRoomCall = (
const [canPinWidget, setCanPinWidget] = 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 => {
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
@ -169,7 +171,7 @@ export const useRoomCall = (
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} else {
placeCall(room, CallType.Voice, callType);
placeCall(room, CallType.Voice, callType, evt.shiftKey);
}
},
[promptPinWidget, room, widget, callType],
@ -180,7 +182,7 @@ export const useRoomCall = (
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
} else {
placeCall(room, CallType.Video, callType);
placeCall(room, CallType.Video, callType, evt.shiftKey);
}
},
[widget, promptPinWidget, room, callType],

View file

@ -479,6 +479,7 @@
"legal": "Legal",
"light": "Light",
"loading": "Loading…",
"lobby": "Lobby",
"location": "Location",
"low_priority": "Low priority",
"matrix": "Matrix",
@ -492,10 +493,6 @@
"one": "%(count)s member",
"other": "%(count)s members"
},
"n_participants": {
"one": "1 participant",
"other": "%(count)s participants"
},
"n_rooms": {
"one": "%(count)s room",
"other": "%(count)s rooms"
@ -3793,7 +3790,6 @@
"already_in_call_person": "You're already in a call with this person.",
"answered_elsewhere": "Answered Elsewhere",
"answered_elsewhere_description": "The call was answered on another device.",
"audio_devices": "Audio devices",
"call_failed": "Call Failed",
"call_failed_description": "The call could not be established",
"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",
"video_call": "Video call",
"video_call_started": "Video call started",
"video_devices": "Video devices",
"voice_call": "Voice call",
"you_are_presenting": "You are presenting"
},

View file

@ -32,12 +32,14 @@ import { IWidgetApiRequest } from "matrix-widget-api";
// eslint-disable-next-line no-restricted-imports
import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
// 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";
// eslint-disable-next-line no-restricted-imports
import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types";
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 SdkConfig, { DEFAULTS } from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
@ -54,6 +56,7 @@ import { FontWatcher } from "../settings/watchers/FontWatcher";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
import { isVideoRoom } from "../utils/video-rooms";
const TIMEOUT_MS = 16000;
@ -77,7 +80,12 @@ const waitForEvent = async (
};
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",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
@ -188,7 +196,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
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
* null to start muted.
* @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>;
/**
* Connects the user to the call using the media devices set in
* MediaDeviceHandler. The widget associated with the call must be active
* Starts the communication between the widget and the call.
* 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.
* Only call this if the call state is: ConnectionState.Disconnected.
*/
public async connect(): Promise<void> {
this.connectionState = ConnectionState.Connecting;
public async start(): Promise<void> {
this.connectionState = ConnectionState.WidgetLoading;
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
(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}`);
}
}
this.connectionState = ConnectionState.Connecting;
try {
await this.performConnection(audioInput, videoInput);
} catch (e) {
@ -264,7 +276,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
* Disconnects the user from the call.
*/
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;
await this.performDisconnection();
@ -460,6 +472,7 @@ export class JitsiCall extends Call {
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
this.connectionState = ConnectionState.Lobby;
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const messagingStore = WidgetMessagingStore.instance;
@ -539,7 +552,8 @@ export class JitsiCall extends Call {
}
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.Undock, this.onUndock);
@ -615,6 +629,11 @@ export class JitsiCall extends Call {
await this.messaging!.transport.reply(ev.detail, {}); // ack
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)
*/
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.
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
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
const params = new URLSearchParams({
embed: "true", // We're embedding EC within another application
preload: "true", // We want it to load in the background
skipLobby: "true", // Skip the lobby since we show a lobby component of our own
// Template variables are used, so that this can be configured using the widget data.
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
userId: client.getUserId()!,
deviceId: client.getDeviceId()!,
@ -664,8 +686,6 @@ export class ElementCall extends Call {
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("feature_allow_screen_share_only_mode"))
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!);
url.pathname = "/room";
url.hash = `#?${params.toString()}`;
const replacedUrl = params.toString().replace(/%24/g, "$");
url.hash = `#?${replacedUrl}`;
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 url = ElementCall.generateWidgetUrl(client, roomId);
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.
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;
}
// To use Element Call without touching room state, we create a virtual
// widget (one that doesn't have a corresponding state event)
const url = ElementCall.generateWidgetUrl(client, roomId);
return WidgetStore.instance.addVirtualWidget(
{
id: randomString(24), // So that it's globally unique
@ -711,13 +753,39 @@ export class ElementCall extends Call {
type: WidgetType.CALL.preferred,
url: url.toString(),
// waitForIframeLoad: false,
data: ElementCall.getWidgetData(
client,
roomId,
{},
{
skipLobby: skipLobby ?? false,
preload: preload ?? false,
returnToLobby: returnToLobby ?? false,
},
),
},
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 {
this.widget.url = ElementCall.generateWidgetUrl(this.client, this.roomId).toString();
this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {});
}
private constructor(
@ -739,6 +807,7 @@ export class ElementCall extends Call {
public static get(room: Room): ElementCall | null {
// Only supported in the new group call experience or in video rooms.
if (
SettingsStore.getValue("feature_group_calls") ||
(SettingsStore.getValue("feature_video_rooms") &&
@ -752,10 +821,16 @@ export class ElementCall extends Call {
// A call is present if we
// - 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 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()) {
// 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);
}
}
@ -763,23 +838,20 @@ export class ElementCall extends Call {
return null;
}
public static async create(room: Room): Promise<void> {
const isVideoRoom =
SettingsStore.getValue("feature_video_rooms") &&
SettingsStore.getValue("feature_element_call_video_rooms") &&
room.isCallRoom();
ElementCall.createOrGetCallWidget(room.roomId, room.client);
public static async create(room: Room, skipLobby = false): Promise<void> {
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room));
WidgetStore.instance.emit(UPDATE_EVENT, null);
}
// Send Call notify
protected async sendCallNotify(): Promise<void> {
const room = this.room;
const existingRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter(
// filter all memberships where the application is m.call and the call_id is ""
(m) => m.application === "m.call" && m.callId === "",
);
const memberCount = getJoinedNonFunctionalMembers(room).length;
if (!isVideoRoom && existingRoomCallMembers.length == 0) {
if (!isVideoRoom(room) && existingRoomCallMembers.length == 0) {
// send ringing event
const content: ICallNotifyContent = {
"application": "m.call",
@ -796,30 +868,64 @@ export class ElementCall extends Call {
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
} catch (e) {
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
// The JoinCall action is only send if the widget is waiting for it.
if (this.widget.data?.preload) {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
} 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.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> {
try {
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) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected(): void {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
super.setDisconnected();
@ -828,7 +934,7 @@ export class ElementCall extends Call {
public destroy(): void {
ActiveWidgetStore.instance.destroyPersistentWidget(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.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded);
@ -844,7 +950,7 @@ export class ElementCall extends Call {
}
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()) {
this.destroy();
}
@ -883,6 +989,11 @@ export class ElementCall extends Call {
ev.preventDefault();
await this.messaging!.transport.reply(ev.detail, {}); // ack
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> => {

View file

@ -119,6 +119,10 @@ interface State {
* Whether we're viewing a call or call lobby in this room
*/
viewingCall: boolean;
/**
* If we want the call to skip the lobby and immediately join
*/
skipLobby?: boolean;
promptAskToJoin: boolean;
@ -462,6 +466,7 @@ export class RoomViewStore extends EventEmitter {
replyingToEvent: null,
viaServers: payload.via_servers ?? [],
wasContextSwitch: payload.context_switch ?? false,
skipLobby: payload.skipLobby,
viewingCall:
payload.view_call ??
(payload.room_id === this.state.roomId
@ -513,6 +518,7 @@ export class RoomViewStore extends EventEmitter {
viaServers: payload.via_servers,
wasContextSwitch: payload.context_switch,
viewingCall: payload.view_call ?? false,
skipLobby: payload.skipLobby,
});
try {
const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias);
@ -775,6 +781,10 @@ export class RoomViewStore extends EventEmitter {
return this.state.viewingCall;
}
public skipCallLobby(): boolean | undefined {
return this.state.skipLobby;
}
/**
* Gets the current state of the 'promptForAskToJoin' property.
*

View file

@ -40,6 +40,9 @@ export interface IApp extends IWidget {
export function isAppWidget(widget: IWidget | IApp): widget is IApp {
return "roomId" in widget && typeof widget.roomId === "string";
}
export function isVirtualWidget(widget: IApp): boolean {
return widget.eventId === undefined;
}
interface IRoomWidgets {
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
Array.from(this.widgetMap.values()).forEach((app) => {
if (app.roomId !== room.roomId) return; // skip - wrong room
if (app.eventId === undefined) {
if (isVirtualWidget(app)) {
// virtual widget - keep it
roomInfo.widgets.push(app);
} else {

View file

@ -75,6 +75,7 @@ interface IAppTileProps {
waitForIframeLoad: boolean;
whitelistCapabilities?: string[];
userWidget: boolean;
stickyPromise?: () => Promise<void>;
}
// TODO: Don't use this because it's wrong
@ -160,6 +161,7 @@ export class StopGapWidget extends EventEmitter {
private kind: WidgetKind;
private readonly virtual: boolean;
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) {
super();
@ -176,6 +178,7 @@ export class StopGapWidget extends EventEmitter {
this.roomId = appTileProps.room?.roomId;
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
this.virtual = isAppWidget(app) && app.eventId === undefined;
this.stickyPromise = appTileProps.stickyPromise;
}
private get eventListenerRoomId(): Optional<string> {
@ -338,15 +341,17 @@ export class StopGapWidget extends EventEmitter {
this.messaging.on(
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
(ev: CustomEvent<IStickyActionRequest>) => {
async (ev: CustomEvent<IStickyActionRequest>) => {
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(
this.mockWidget.id,
this.roomId ?? null,
ev.detail.data.value,
);
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
}
},
);

View file

@ -131,6 +131,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element {
action: Action.ViewRoom,
room_id: room?.roomId,
view_call: true,
skipLobby: "shiftKey" in e ? e.shiftKey : false,
metricsTrigger: undefined,
});
},

View file

@ -29,7 +29,12 @@ import { Action } from "../../dispatcher/actions";
* @param callType the type of call
* @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) {
case "legacy_or_jitsi":
await LegacyCallHandler.instance.placeCall(room.roomId, callType);
@ -43,6 +48,7 @@ export const placeCall = async (room: Room, callType: CallType, platformCallType
room_id: room.roomId,
view_call: true,
metricsTrigger: undefined,
skipLobby,
});
break;

View file

@ -179,7 +179,7 @@ describe("PipContainer", () => {
} as unknown as ClientWidgetApi);
await act(async () => {
await call.connect();
await call.start();
ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true);
});

View file

@ -141,7 +141,7 @@ describe("CallEvent", () => {
renderEvent();
screen.getByText("@alice:example.org started a video call");
screen.getByLabelText("2 participants");
screen.getByLabelText("2 people joined");
// Test that the join button works
const dispatcherSpy = jest.fn();
@ -155,7 +155,7 @@ describe("CallEvent", () => {
}),
);
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.connect());
await act(() => call.start());
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));

View file

@ -31,6 +31,10 @@ import EventEmitter from "events";
import { setupJestCanvasMock } from "jest-canvas-mock";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
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 { 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 WidgetStore from "../../../../src/stores/WidgetStore";
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 { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
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", () => ({
shouldShowComponent: jest.fn(),
@ -274,6 +278,7 @@ describe("LegacyRoomHeader", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
}),
);
@ -407,6 +412,7 @@ describe("LegacyRoomHeader", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
}),
);
@ -432,6 +438,7 @@ describe("LegacyRoomHeader", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
}),
);
@ -562,7 +569,20 @@ describe("LegacyRoomHeader", () => {
mockEnabledSettings(["feature_group_calls"]);
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))!;
renderHeader({ viewingCall: true, activeCall: call });

View file

@ -331,12 +331,12 @@ describe("RoomHeader", () => {
it("clicking on ongoing (unpinned) call re-pins it", () => {
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
// allow element calls
// allow calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
const widget = {};
const widget = { eventId: "some_id_so_it_is_interpreted_as_non_virtual_widget" };
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call);
const { container } = render(<RoomHeader room={room} />, getWrapper());
@ -405,7 +405,7 @@ describe("RoomHeader", () => {
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);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {

View file

@ -56,6 +56,7 @@ import { UIComponent } from "../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../src/models/Call";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
@ -235,20 +236,37 @@ describe("RoomTile", () => {
renderRoomTile();
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
// the intermediate connecting state
let completeConnection: () => void = () => {};
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([
(async () => {
await screen.findByText("Loading…");
completeWidgetLoading();
await screen.findByText("Lobby");
completeLobby();
await screen.findByText("Joining…");
const joinedFound = screen.findByText("Joined");
completeConnection();
await joinedFound;
await screen.findByText("Joined");
})(),
call.connect(),
call.start(),
]);
await Promise.all([screen.findByText("Video"), call.disconnect()]);
@ -274,12 +292,12 @@ describe("RoomTile", () => {
act(() => {
call.participants = new Map([alice]);
});
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
act(() => {
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(() => {
call.participants = new Map();

View file

@ -38,8 +38,6 @@ import { CallView as _CallView } from "../../../../src/components/views/voip/Cal
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../../src/stores/CallStore";
import { Call, ConnectionState } from "../../../../src/models/Call";
import SdkConfig from "../../../../src/SdkConfig";
import MediaDeviceHandler from "../../../../src/MediaDeviceHandler";
const CallView = wrapInMatrixClientContext(_CallView);
@ -75,8 +73,10 @@ describe("CallView", () => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
});
const renderView = async (): Promise<void> => {
render(<CallView room={room} resizing={false} waitForCall={false} />, { wrapper: TooltipProvider });
const renderView = async (skipLobby = false): Promise<void> => {
render(<CallView room={room} resizing={false} waitForCall={false} skipLobby={skipLobby} />, {
wrapper: TooltipProvider,
});
await act(() => Promise.resolve()); // Let effects settle
};
@ -108,20 +108,6 @@ describe("CallView", () => {
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
*/
@ -166,40 +152,17 @@ describe("CallView", () => {
expectAvatars([]);
});
it("connects to the call when the join button is pressed", async () => {
await renderView();
const connectSpy = jest.spyOn(call, "connect");
fireEvent.click(screen.getByRole("button", { name: "Join" }));
it("automatically connects to the call when skipLobby is true", async () => {
const connectSpy = jest.spyOn(call, "start");
await renderView(true);
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", () => {
it("creates and connects to a new call when the join button is pressed", async () => {
await renderView();
expect(Call.get(room)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await renderView(true);
await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull());
const call = CallStore.instance.getCall(room.roomId)!;
@ -214,117 +177,4 @@ describe("CallView", () => {
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();
});
});
});

View file

@ -140,6 +140,7 @@ const setUpWidget = (
audioMutedSpy: jest.SpyInstance<boolean, []>;
videoMutedSpy: jest.SpyInstance<boolean, []>;
} => {
call.widget.data = { ...call.widget, skipLobby: true };
const widget = new Widget(call.widget);
const eventEmitter = new EventEmitter();
@ -253,7 +254,7 @@ describe("JitsiCall", () => {
audioMutedSpy.mockReturnValue(true);
videoMutedSpy.mockReturnValue(true);
await call.connect();
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: null,
@ -266,7 +267,7 @@ describe("JitsiCall", () => {
audioMutedSpy.mockReturnValue(false);
videoMutedSpy.mockReturnValue(false);
await call.connect();
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: "Headphones",
@ -280,21 +281,63 @@ describe("JitsiCall", () => {
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.connect();
expect(call.connectionState).toBe(ConnectionState.Connecting);
const connect = call.start();
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
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 () => {
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 () => {
await call.connect();
await call.start();
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
@ -302,56 +345,55 @@ describe("JitsiCall", () => {
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
const callback = jest.fn();
call.on(CallEvent.ConnectionState, callback);
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
});
it("handles instant remote disconnection when connecting", async () => {
mocked(messaging.transport).send.mockImplementation(async (action): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
// Emit the hangup event *before* the join event to fully
// exercise the race condition
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
await waitFor(() => {
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected),
expect(callback).toHaveBeenNthCalledWith(
2,
ConnectionState.WidgetLoading,
ConnectionState.Disconnected,
);
messaging.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
}
return {};
expect(callback).toHaveBeenNthCalledWith(3, ConnectionState.Connecting, ConnectionState.WidgetLoading);
});
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
// Should disconnect on its own almost instantly
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
// in video rooms we expect the call to immediately reconnect
call.off(CallEvent.ConnectionState, callback);
});
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await call.connect();
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "leave");
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 () => {
await call.connect();
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "join");
expect(call.connectionState).toBe(ConnectionState.Connected);
@ -377,7 +419,7 @@ describe("JitsiCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.connect();
await call.start();
expect(call.participants).toEqual(
new Map([
[alice, new Set(["alices_device"])],
@ -391,7 +433,7 @@ describe("JitsiCall", () => {
it("updates room state when connecting and disconnecting", async () => {
const now1 = Date.now();
await call.connect();
await call.start();
await waitFor(
() =>
expect(
@ -418,7 +460,7 @@ describe("JitsiCall", () => {
});
it("repeatedly updates room state while connected", async () => {
await call.connect();
await call.start();
await waitFor(
() =>
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
@ -448,11 +490,13 @@ describe("JitsiCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await call.connect();
await call.start();
await call.disconnect();
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connecting, ConnectionState.Disconnected],
[ConnectionState.Connected, ConnectionState.Connecting],
[ConnectionState.WidgetLoading, ConnectionState.Disconnected],
[ConnectionState.Connecting, ConnectionState.WidgetLoading],
[ConnectionState.Lobby, ConnectionState.Connecting],
[ConnectionState.Connected, ConnectionState.Lobby],
[ConnectionState.Disconnecting, ConnectionState.Connected],
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
]);
@ -464,7 +508,7 @@ describe("JitsiCall", () => {
const onParticipants = jest.fn();
call.on(CallEvent.Participants, onParticipants);
await call.connect();
await call.start();
await call.disconnect();
expect(onParticipants.mock.calls).toEqual([
[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 () => {
await call.connect();
await call.start();
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
@ -521,7 +565,7 @@ describe("JitsiCall", () => {
});
it("doesn't clean up valid devices", async () => {
await call.connect();
await call.start();
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
@ -582,11 +626,62 @@ describe("ElementCall", () => {
let room: Room;
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(() => {
jest.useFakeTimers();
({ client, room, alice } = setUpClientRoomAndStores());
});
afterEach(() => cleanUpClientRoomAndStores(client, room));
afterEach(() => {
jest.useRealTimers();
cleanUpClientRoomAndStores(client, room);
});
describe("get", () => {
it("finds no calls", () => {
@ -700,6 +795,28 @@ describe("ElementCall", () => {
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
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 () => {
@ -729,7 +846,7 @@ describe("ElementCall", () => {
jest.useFakeTimers();
jest.setSystemTime(0);
await ElementCall.create(room);
await ElementCall.create(room, true);
const maybeCall = ElementCall.get(room);
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
@ -738,41 +855,18 @@ describe("ElementCall", () => {
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("connects muted", async () => {
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",
});
});
// TODO refactor initial device configuration to use the EW settings.
// Add tests for passing EW device configuration to the widget.
it("waits for messaging when connecting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.connect();
expect(call.connectionState).toBe(ConnectionState.Connecting);
const connect = callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
@ -780,12 +874,14 @@ describe("ElementCall", () => {
});
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! >:("));
await expect(call.connect()).rejects.toBeDefined();
await expect(call.start()).rejects.toBeDefined();
});
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! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
@ -793,7 +889,7 @@ describe("ElementCall", () => {
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(
@ -805,35 +901,35 @@ describe("ElementCall", () => {
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
await callDisconnectionProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await call.connect();
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "leave");
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await call.connect();
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "join");
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("disconnects if the widget dies", async () => {
await call.connect();
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("tracks layout", async () => {
await call.connect();
await callConnectProcedure(call);
expect(call.layout).toBe(Layout.Tile);
messaging.emit(
@ -850,7 +946,7 @@ describe("ElementCall", () => {
});
it("sets layout", async () => {
await call.connect();
await callConnectProcedure(call);
await call.setLayout(Layout.Spotlight);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
@ -860,13 +956,15 @@ describe("ElementCall", () => {
});
it("emits events when connection state changes", async () => {
// const wait = jest.spyOn(CallModule, "waitForEvent");
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await call.connect();
await call.disconnect();
await callConnectProcedure(call);
await callDisconnectionProcedure(call);
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connecting, ConnectionState.Disconnected],
[ConnectionState.WidgetLoading, ConnectionState.Disconnected],
[ConnectionState.Connecting, ConnectionState.WidgetLoading],
[ConnectionState.Connected, ConnectionState.Connecting],
[ConnectionState.Disconnecting, ConnectionState.Connected],
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
@ -887,7 +985,7 @@ describe("ElementCall", () => {
});
it("emits events when layout changes", async () => {
await call.connect();
await callConnectProcedure(call);
const onLayout = jest.fn();
call.on(CallEvent.Layout, onLayout);
@ -905,10 +1003,10 @@ describe("ElementCall", () => {
});
it("ends the call immediately if the session ended", async () => {
await call.connect();
await callConnectProcedure(call);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await call.disconnect();
await callDisconnectionProcedure(call);
// this will be called automatically
// disconnect -> widget sends state event -> session manager notices no-one left
client.matrixRTC.emit(
@ -935,25 +1033,47 @@ describe("ElementCall", () => {
// should create call with perParticipantE2EE flag
ElementCall.create(room);
expect(addWidgetSpy.mock.calls[0][0].url).toContain("perParticipantE2EE=true");
ElementCall.get(room)?.destroy();
expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(true);
// should create call without perParticipantE2EE flag
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");
expect(addWidgetSpy.mock.calls[1][0].url).not.toContain("perParticipantE2EE=true");
client.isRoomEncrypted.mockClear();
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", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
@ -968,49 +1088,71 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("doesn't end the call when the last participant leaves", async () => {
await call.connect();
await callConnectProcedure(call);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await call.disconnect();
await callDisconnectionProcedure(call);
expect(onDestroy).not.toHaveBeenCalled();
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", () => {
function setRoomMembers(memberIds: string[]) {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
}
beforeEach(async () => {
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 () => {
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([
{ application: "m.call", callId: "" } as unknown as CallMembership,

View file

@ -100,7 +100,7 @@ describe("Algorithm", () => {
// End of setup
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
await call.connect();
await call.start();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
await call.disconnect();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);

View file

@ -17,7 +17,8 @@ limitations under the License.
import { mocked, MockedObject } from "jest-mock";
import { last } from "lodash";
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 { MatrixClientPeg } from "../../../src/MatrixClientPeg";
@ -25,6 +26,7 @@ import { StopGapWidget } from "../../../src/stores/widgets/StopGapWidget";
import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
import { VoiceBroadcastInfoEventType, VoiceBroadcastRecording } from "../../../src/voice-broadcast";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore";
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 });
});
});

View file

@ -125,7 +125,7 @@ describe("IncomingCallEvent", () => {
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByLabelText("3 participants");
screen.getByLabelText("3 people joined");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Close" });
@ -167,6 +167,30 @@ describe("IncomingCallEvent", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
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,
}),
);