Refactor element call lobby + skip lobby (#12057)
* Refactor ElementCall to use the widget lobby. - expose skip lobby - use the widget.data to build the widget url Signed-off-by: Timo K <toger5@hotmail.de> * Use shiftKey click to skip the lobby Signed-off-by: Timo K <toger5@hotmail.de> * remove Lobby component Signed-off-by: Timo K <toger5@hotmail.de> * update tests + remove EW lobby related tests Signed-off-by: Timo K <toger5@hotmail.de> * remove lobby device button tests Signed-off-by: Timo K <toger5@hotmail.de> * i18n Signed-off-by: Timo K <toger5@hotmail.de> * use voip participant label Signed-off-by: Timo K <toger5@hotmail.de> * update tests Signed-off-by: Timo K <toger5@hotmail.de> * fix rounded corners in pip Signed-off-by: Timo K <toger5@hotmail.de> * allow joining call in legacy room header (without banner) Signed-off-by: Timo K <toger5@hotmail.de> * Introduce new connection states for calls. And use them for integrated lobby. Signed-off-by: Timo K <toger5@hotmail.de> * New room header call join Fix broken top container element call. Signed-off-by: Timo K <toger5@hotmail.de> * i18n Signed-off-by: Timo K <toger5@hotmail.de> * Fix closing element call in lobby view. (should destroy call if there the user never managed to connect (not clicked join in lobby) Signed-off-by: Timo K <toger5@hotmail.de> * all cases for connection state Signed-off-by: Timo K <toger5@hotmail.de> * add correct LiveContentSummary labels Signed-off-by: Timo K <toger5@hotmail.de> * Theme widget loading (no rounded corner) destroy call when switching room while a call is loading. Signed-off-by: Timo K <toger5@hotmail.de> * temp Signed-off-by: Timo K <toger5@hotmail.de> * usei view room dispatcher instead of emitter Signed-off-by: Timo K <toger5@hotmail.de> * tidy up Signed-off-by: Timo K <toger5@hotmail.de> * returnToLobby + remove StartCallView Signed-off-by: Timo K <toger5@hotmail.de> * comment cleanup Signed-off-by: Timo K <toger5@hotmail.de> * disconnect ongoing calls before making widget sticky. Signed-off-by: Timo K <toger5@hotmail.de> * linter + jitsi as videoChannel Signed-off-by: Timo K <toger5@hotmail.de> * stickyPromise type Signed-off-by: Timo K <toger5@hotmail.de> * fix legacy call (jistsi, cisco, bbb) reopen when clicking call button Signed-off-by: Timo K <toger5@hotmail.de> * fix tests and connect resolves Signed-off-by: Timo K <toger5@hotmail.de> * fix "waits for messaging when connecting" test Signed-off-by: Timo K <toger5@hotmail.de> * Allow to skip awaiting Call session events. This option is used in tests to spare mocking the events emitted when EC updates the room state Signed-off-by: Timo K <toger5@hotmail.de> * add sticky test Signed-off-by: Timo K <toger5@hotmail.de> * add test for looby tile rendering Signed-off-by: Timo K <toger5@hotmail.de> * fix flaky test Signed-off-by: Timo K <toger5@hotmail.de> * add reconnect after disconnect test (video room) Signed-off-by: Timo K <toger5@hotmail.de> * add shift click test to call toast Signed-off-by: Timo K <toger5@hotmail.de> * test for allowVoipWithNoMedia in widget url Signed-off-by: Timo K <toger5@hotmail.de> * fix e2e tests to search for the right element Signed-off-by: Timo K <toger5@hotmail.de> * destroy call after test so next test does not fail Signed-off-by: Timo K <toger5@hotmail.de> * new call test (connection failed) Signed-off-by: Timo K <toger5@hotmail.de> * reset to real timers Signed-off-by: Timo K <toger5@hotmail.de> * dont use skipSessionAwait for tests Signed-off-by: Timo K <toger5@hotmail.de> * code quality (sonar) Signed-off-by: Timo K <toger5@hotmail.de> * refactor call.disconnect tests (dont use skipSessionAwait) Signed-off-by: Timo K <toger5@hotmail.de> * miscellaneous cleanup Signed-off-by: Timo K <toger5@hotmail.de> * only send call notify after the call has been joined (not when just opening the lobby) Signed-off-by: Timo K <toger5@hotmail.de> * update call notify tests to expect notify on connect. Not on widget creation. Signed-off-by: Timo K <toger5@hotmail.de> * Update playwright/e2e/room/room-header.spec.ts Co-authored-by: Robin <robin@robin.town> * Update src/components/views/voip/CallView.tsx Co-authored-by: Robin <robin@robin.town> * review rename connect -> start isVideoRoom not dependant on feature flags rename allOtherCallsDisconnected -> disconnectAllOtherCalls Signed-off-by: Timo K <toger5@hotmail.de> * check for EC widget Signed-off-by: Timo K <toger5@hotmail.de> * dep array Signed-off-by: Timo K <toger5@hotmail.de> * rename in spyOn Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
parent
3f7e21e08d
commit
a370a5cfa4
28 changed files with 693 additions and 767 deletions
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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")))}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue