Merge pull request #1348 from vector-im/dbkr/matrixrtcsession

Switch to the MatrixRTCSession layer in js-sdk
This commit is contained in:
David Baker
2023-09-12 16:33:02 +01:00
committed by GitHub
22 changed files with 556 additions and 1112 deletions

View File

@@ -59,7 +59,7 @@
"i18next-http-backend": "^1.4.4",
"livekit-client": "^1.12.3",
"lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#b698217445318f453e0b1086364a33113eaa85d9",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#6836720e1e1c2cb01d49d6e5fcfc01afc14834ca",
"matrix-widget-api": "^1.3.1",
"mermaid": "^9.0.0",
"normalize.css": "^8.0.1",

View File

@@ -1,7 +1,7 @@
{
"{{count, number}}|one": "{{count, number}}",
"{{count, number}}|other": "{{count, number}}",
"{{count}} stars|one": "{{count}} star",
"{{count}} stars|one": "{{count}} stars",
"{{count}} stars|other": "{{count}} stars",
"{{displayName}} is presenting": "{{displayName}} is presenting",
"{{displayName}}, your call has ended.": "{{displayName}}, your call has ended.",
@@ -46,7 +46,6 @@
"Exit full screen": "Exit full screen",
"Expose developer settings in the settings window.": "Expose developer settings in the settings window.",
"Feedback": "Feedback",
"Fetching group call timed out.": "Fetching group call timed out.",
"Full screen": "Full screen",
"Go": "Go",
"Grid": "Grid",
@@ -54,7 +53,6 @@
"How did it go?": "How did it go?",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"Include debug logs": "Include debug logs",
"Incompatible versions": "Incompatible versions",
"Inspector": "Inspector",
"Join call": "Join call",
"Join call now": "Join call now",
@@ -72,7 +70,6 @@
"Not encrypted": "Not encrypted",
"Not now, return to home screen": "Not now, return to home screen",
"Not registered yet? <2>Create an account</2>": "Not registered yet? <2>Create an account</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>",
"Password": "Password",
"Passwords must match": "Passwords must match",
"Profile": "Profile",
@@ -118,7 +115,6 @@
"Waiting for other participants…": "Waiting for other participants…",
"Walkie-talkie call": "Walkie-talkie call",
"Walkie-talkie call name": "Walkie-talkie call name",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.",
"Yes, join call": "Yes, join call",
"You were disconnected from the call": "You were disconnected from the call",
"Your feedback": "Your feedback",

View File

@@ -1,60 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { FC, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Modal, ModalContent } from "./Modal";
import { Body } from "./typography/Typography";
interface Props {
userIds: Set<string>;
room: Room;
onClose: () => void;
}
export const IncompatibleVersionModal: FC<Props> = ({
userIds,
room,
onClose,
...rest
}) => {
const { t } = useTranslation();
const userLis = useMemo(
() => [...userIds].map((u) => <li>{room.getMember(u)?.name ?? u}</li>),
[userIds, room]
);
return (
<Modal
title={t("Incompatible versions")}
isDismissable
onClose={onClose}
{...rest}
>
<ModalContent>
<Body>
<Trans>
Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul>
</Trans>
</Body>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,23 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Focus } from "matrix-js-sdk/src/matrixrtc/focus";
export interface LivekitFocus extends Focus {
type: "livekit";
livekit_service_url: string;
livekit_alias: string;
}

View File

@@ -1,92 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { GroupCall } from "matrix-js-sdk";
import {
OpenIDClientParts,
SFUConfig,
getSFUConfigWithOpenID,
} from "./openIDSFU";
import { ErrorView, LoadingView } from "../FullScreenView";
interface Props {
client: OpenIDClientParts;
groupCall: GroupCall;
roomName: string;
children: ReactNode;
}
const SFUConfigContext = createContext<SFUConfig | undefined>(undefined);
export const useSFUConfig = () => useContext(SFUConfigContext);
export function OpenIDLoader({ client, groupCall, roomName, children }: Props) {
const [state, setState] = useState<
SFUConfigLoading | SFUConfigLoaded | SFUConfigFailed
>({ kind: "loading" });
useEffect(() => {
(async () => {
try {
const result = await getSFUConfigWithOpenID(
client,
groupCall,
roomName
);
setState({ kind: "loaded", sfuConfig: result });
} catch (e) {
logger.error("Failed to fetch SFU config: ", e);
setState({ kind: "failed", error: e as Error });
}
})();
}, [client, groupCall, roomName]);
switch (state.kind) {
case "loading":
return <LoadingView />;
case "failed":
return <ErrorView error={state.error} />;
case "loaded":
return (
<SFUConfigContext.Provider value={state.sfuConfig}>
{children}
</SFUConfigContext.Provider>
);
}
}
type SFUConfigLoading = {
kind: "loading";
};
type SFUConfigLoaded = {
kind: "loaded";
sfuConfig: SFUConfig;
};
type SFUConfigFailed = {
kind: "failed";
error: Error;
};

View File

@@ -14,82 +14,78 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { GroupCall, IOpenIDToken, MatrixClient } from "matrix-js-sdk";
import { IOpenIDToken, MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useEffect, useState } from "react";
import { Config } from "../config/Config";
import { LivekitFocus } from "./LivekitFocus";
import { useActiveFocus } from "../room/useActiveFocus";
export interface SFUConfig {
url: string;
jwt: string;
}
export function sfuConfigEquals(a?: SFUConfig, b?: SFUConfig): boolean {
if (a === undefined && b === undefined) return true;
if (a === undefined || b === undefined) return false;
return a.jwt === b.jwt && a.url === b.url;
}
// The bits we need from MatrixClient
export type OpenIDClientParts = Pick<
MatrixClient,
"getOpenIdToken" | "getDeviceId"
>;
export function useOpenIDSFU(
client: OpenIDClientParts,
rtcSession: MatrixRTCSession
) {
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
const activeFocus = useActiveFocus(rtcSession);
useEffect(() => {
(async () => {
const sfuConfig = activeFocus
? await getSFUConfigWithOpenID(client, activeFocus)
: undefined;
setSFUConfig(sfuConfig);
})();
}, [client, activeFocus]);
return sfuConfig;
}
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
groupCall: GroupCall,
roomName: string
): Promise<SFUConfig> {
activeFocus: LivekitFocus
): Promise<SFUConfig | undefined> {
const openIdToken = await client.getOpenIdToken();
logger.debug("Got openID token", openIdToken);
// if the call has a livekit service URL, try it.
if (groupCall.livekitServiceURL) {
try {
logger.info(
`Trying to get JWT from call's configured URL of ${groupCall.livekitServiceURL}...`
);
const sfuConfig = await getLiveKitJWT(
client,
groupCall.livekitServiceURL,
roomName,
openIdToken
);
logger.info(`Got JWT from call state event URL.`);
return sfuConfig;
} catch (e) {
logger.warn(
`Failed to get JWT from group call's configured URL of ${groupCall.livekitServiceURL}.`,
e
);
}
}
// otherwise, try our configured one and, if it works, update the call's service URL in the state event
// NB. This wuill update it for everyone so we may end up with multiple clients updating this when they
// join at similar times, but we don't have a huge number of options here.
const urlFromConf = Config.get().livekit!.livekit_service_url;
logger.info(`Trying livekit service URL from our config: ${urlFromConf}...`);
try {
logger.info(
`Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`
);
const sfuConfig = await getLiveKitJWT(
client,
urlFromConf,
roomName,
activeFocus.livekit_service_url,
activeFocus.livekit_alias,
openIdToken
);
logger.info(
`Got JWT, updating call livekit service URL with: ${urlFromConf}...`
);
try {
await groupCall.updateLivekitServiceURL(urlFromConf);
logger.info(`Call livekit service URL updated.`);
} catch (e) {
logger.warn(
`Failed to update call livekit service URL: continuing anyway.`
);
}
logger.info(`Got JWT from call's active focus URL.`);
return sfuConfig;
} catch (e) {
logger.error("Failed to get JWT from URL defined in Config.", e);
throw e;
logger.warn(
`Failed to get JWT from RTC session's active focus URL of ${activeFocus.livekit_service_url}.`,
e
);
return undefined;
}
}

View File

@@ -0,0 +1,100 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ConnectionState, Room, RoomEvent } from "livekit-client";
import { useCallback, useEffect, useRef, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { SFUConfig, sfuConfigEquals } from "./openIDSFU";
/*
* Additional values for states that a call can be in, beyond what livekit
* provides in ConnectionState. Also reconnects the call if the SFU Config
* changes.
*/
export enum ECAddonConnectionState {
// We are switching from one focus to another (or between livekit room aliases on the same focus)
ECSwitchingFocus = "ec_switching_focus",
// The call has just been initialised and is waiting for credentials to arrive before attempting
// to connect. This distinguishes from the 'Disconected' state which is now just for when livekit
// gives up on connectivity and we consider the call to have failed.
ECWaiting = "ec_waiting",
}
export type ECConnectionState = ConnectionState | ECAddonConnectionState;
// This is mostly necessary because an empty useRef is an empty object
// which is truthy, so we can't just use Boolean(currentSFUConfig.current)
function sfuConfigValid(sfuConfig?: SFUConfig): boolean {
return Boolean(sfuConfig?.url) && Boolean(sfuConfig?.jwt);
}
export function useECConnectionState(
livekitRoom?: Room,
sfuConfig?: SFUConfig
): ECConnectionState {
const [connState, setConnState] = useState(
sfuConfig && livekitRoom
? livekitRoom.state
: ECAddonConnectionState.ECWaiting
);
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
const onConnStateChanged = useCallback((state: ConnectionState) => {
if (state == ConnectionState.Connected) setSwitchingFocus(false);
setConnState(state);
}, []);
useEffect(() => {
const oldRoom = livekitRoom;
if (livekitRoom) {
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
}
return () => {
if (oldRoom)
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
};
}, [livekitRoom, onConnStateChanged]);
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
// Id we are transitioning from a valid config to another valid one, we need
// to explicitly switch focus
useEffect(() => {
if (
sfuConfigValid(sfuConfig) &&
sfuConfigValid(currentSFUConfig.current) &&
!sfuConfigEquals(currentSFUConfig.current, sfuConfig)
) {
logger.info(
`SFU config changed! URL was ${currentSFUConfig.current?.url} now ${sfuConfig?.url}`
);
(async () => {
setSwitchingFocus(true);
await livekitRoom?.disconnect();
await livekitRoom?.connect(sfuConfig!.url, sfuConfig!.jwt);
})();
}
currentSFUConfig.current = Object.assign({}, sfuConfig);
}, [sfuConfig, livekitRoom]);
return isSwitchingFocus ? ECAddonConnectionState.ECSwitchingFocus : connState;
}

View File

@@ -22,7 +22,7 @@ import {
RoomOptions,
setLogLevel,
} from "livekit-client";
import { useConnectionState, useLiveKitRoom } from "@livekit/components-react";
import { useLiveKitRoom } from "@livekit/components-react";
import { useEffect, useMemo, useRef } from "react";
import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/src/logger";
@@ -35,6 +35,10 @@ import {
MediaDevices,
useMediaDevices,
} from "./MediaDevicesContext";
import {
ECConnectionState,
useECConnectionState,
} from "./useECConnectionState";
export type E2EEConfig = {
sharedKey: string;
@@ -42,11 +46,16 @@ export type E2EEConfig = {
setLogLevel("debug");
interface UseLivekitResult {
livekitRoom?: Room;
connState: ECConnectionState;
}
export function useLiveKit(
muteStates: MuteStates,
sfuConfig?: SFUConfig,
e2eeConfig?: E2EEConfig
): Room | undefined {
): UseLivekitResult {
const e2eeOptions = useMemo(() => {
if (!e2eeConfig?.sharedKey) return undefined;
@@ -101,7 +110,7 @@ export function useLiveKit(
room: roomWithoutProps,
});
const connectionState = useConnectionState(roomWithoutProps);
const connectionState = useECConnectionState(room, sfuConfig);
useEffect(() => {
// Sync the requested mute states with LiveKit's mute states. We do it this
@@ -149,5 +158,8 @@ export function useLiveKit(
}
}, [room, devices, connectionState]);
return room;
return {
connState: connectionState,
livekitRoom: room,
};
}

View File

@@ -172,7 +172,6 @@ export async function initClient(
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
useLivekitForGroupCalls: true,
});
try {

View File

@@ -16,8 +16,8 @@ limitations under the License.
import { ReactNode } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useTranslation } from "react-i18next";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
@@ -26,7 +26,7 @@ interface Props {
client: MatrixClient;
roomIdOrAlias: string;
viaServers: string[];
children: (groupCall: GroupCall) => ReactNode;
children: (rtcSession: MatrixRTCSession) => ReactNode;
createPtt: boolean;
}
@@ -53,7 +53,7 @@ export function GroupCallLoader({
</FullScreenView>
);
case "loaded":
return <>{children(groupCallState.groupCall)}</>;
return <>{children(groupCallState.rtcSession)}</>;
case "failed":
return <ErrorView error={groupCallState.error} />;
}

View File

@@ -16,29 +16,28 @@ limitations under the License.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { Room } from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule, RoomMember } from "matrix-js-sdk/src/matrix";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../media-utils";
import { OpenIDLoader } from "../livekit/OpenIDLoader";
import { ActiveCall } from "./InCallView";
import { Config } from "../config/Config";
import { MuteStates, useMuteStates } from "./MuteStates";
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
import {
useManageRoomSharedKey,
useIsRoomE2EE,
@@ -52,7 +51,7 @@ import { ShareModal } from "./ShareModal";
declare global {
interface Window {
groupCall?: GroupCall;
rtcSession?: MatrixRTCSession;
}
}
@@ -62,7 +61,7 @@ interface Props {
isEmbedded: boolean;
preload: boolean;
hideHeader: boolean;
groupCall: GroupCall;
rtcSession: MatrixRTCSession;
}
export function GroupCallView({
@@ -71,43 +70,43 @@ export function GroupCallView({
isEmbedded,
preload,
hideHeader,
groupCall,
rtcSession,
}: Props) {
const { state, error, enter, leave, participants, otelGroupCallMembership } =
useGroupCall(groupCall, client);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
const e2eeSharedKey = useManageRoomSharedKey(groupCall.room.roomId);
const isRoomE2EE = useIsRoomE2EE(groupCall.room.roomId);
const e2eeSharedKey = useManageRoomSharedKey(rtcSession.room.roomId);
const isRoomE2EE = useIsRoomE2EE(rtcSession.room.roomId);
const { t } = useTranslation();
useEffect(() => {
window.groupCall = groupCall;
window.rtcSession = rtcSession;
return () => {
delete window.groupCall;
delete window.rtcSession;
};
}, [groupCall]);
}, [rtcSession]);
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(groupCall.room);
const roomAvatar = useRoomAvatar(groupCall.room);
const roomEncrypted = useIsRoomE2EE(groupCall.room.roomId)!;
const roomName = useRoomName(rtcSession.room);
const roomAvatar = useRoomAvatar(rtcSession.room);
const roomEncrypted = useIsRoomE2EE(rtcSession.room.roomId)!;
const matrixInfo = useMemo((): MatrixInfo => {
return {
userId: client.getUserId()!,
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: groupCall.room.roomId,
roomId: rtcSession.room.roomId,
roomName,
roomAlias: groupCall.room.getCanonicalAlias(),
roomAlias: rtcSession.room.getCanonicalAlias(),
roomAvatar,
roomEncrypted,
};
}, [
displayName,
avatarUrl,
groupCall,
rtcSession,
roomName,
roomAvatar,
roomEncrypted,
@@ -116,18 +115,22 @@ export function GroupCallView({
const participatingMembers = useMemo(() => {
const members: RoomMember[] = [];
for (const [member, deviceMap] of participants.entries()) {
// Count each member only once, regardless of how many devices they use
if (deviceMap.size > 0) members.push(member);
// Count each member only once, regardless of how many devices they use
const addedUserIds = new Set<string>();
for (const membership of memberships) {
if (!addedUserIds.has(membership.member.userId)) {
addedUserIds.add(membership.member.userId);
members.push(membership.member);
}
}
return members;
}, [participants]);
}, [memberships]);
const deviceContext = useMediaDevices();
const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext;
const muteStates = useMuteStates(participants.size);
const muteStates = useMuteStates(memberships.length);
const latestMuteStates = useRef<MuteStates>();
latestMuteStates.current = muteStates;
@@ -184,10 +187,13 @@ export function GroupCallView({
}
}
await enter();
enterRTCSession(rtcSession);
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
// we only have room sessions right now, so call ID is the emprty string - we use the room ID
PosthogAnalytics.instance.eventCallStarted.track(
rtcSession.room.roomId
);
await Promise.all([
widget!.api.setAlwaysOnScreen(true),
@@ -200,19 +206,18 @@ export function GroupCallView({
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
}, [groupCall, preload, enter]);
}, [rtcSession, preload]);
useEffect(() => {
if (isEmbedded && !preload) {
// In embedded mode, bypass the lobby and just enter the call straight away
enter();
enterRTCSession(rtcSession);
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
// use the room ID as above
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
}
}, [groupCall, isEmbedded, preload, enter]);
useSentryGroupCallHandler(groupCall);
}, [rtcSession, isEmbedded, preload]);
const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
@@ -223,21 +228,16 @@ export function GroupCallView({
setLeaveError(leaveError);
setLeft(true);
let participantCount = 0;
for (const deviceMap of groupCall.participants.values()) {
participantCount += deviceMap.size;
}
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
// therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget;
PosthogAnalytics.instance.eventCallEnded.track(
groupCall.groupCallId,
participantCount,
rtcSession.room.roomId,
rtcSession.memberships.length,
sendInstantly
);
leave();
leaveRTCSession(rtcSession);
if (widget) {
// we need to wait until the callEnded event is tracked. Otherwise the iFrame gets killed before the callEnded event got tracked.
await new Promise((resolve) => window.setTimeout(resolve, 10)); // 10ms
@@ -254,13 +254,13 @@ export function GroupCallView({
history.push("/");
}
},
[groupCall, leave, isPasswordlessUser, isEmbedded, history]
[rtcSession, isPasswordlessUser, isEmbedded, history]
);
useEffect(() => {
if (widget && state === GroupCallState.Entered) {
if (widget && isJoined) {
const onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
leave();
leaveRTCSession(rtcSession);
await widget!.api.transport.reply(ev.detail, {});
widget!.api.setAlwaysOnScreen(false);
};
@@ -269,7 +269,7 @@ export function GroupCallView({
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
};
}
}, [groupCall, state, leave]);
}, [isJoined, rtcSession]);
const [e2eeEnabled] = useEnableE2EE();
@@ -281,10 +281,10 @@ export function GroupCallView({
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
groupCall.enter();
}, [groupCall]);
enterRTCSession(rtcSession);
}, [rtcSession]);
const joinRule = useJoinRule(groupCall.room);
const joinRule = useJoinRule(rtcSession.room);
const { modalState: shareModalState, modalProps: shareModalProps } =
useModalTriggerState();
@@ -311,40 +311,27 @@ export function GroupCallView({
return <ErrorView error={new Error("You need to enable E2EE to join.")} />;
}
const livekitServiceURL =
groupCall.livekitServiceURL ?? Config.get().livekit?.livekit_service_url;
if (!livekitServiceURL) {
return <ErrorView error={new Error("No livekit_service_url defined")} />;
}
const shareModal = shareModalState.isOpen && (
<ShareModal roomId={groupCall.room.roomId} {...shareModalProps} />
<ShareModal roomId={rtcSession.room.roomId} {...shareModalProps} />
);
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
if (isJoined) {
return (
<OpenIDLoader
client={client}
groupCall={groupCall}
roomName={`${groupCall.room.roomId}-${groupCall.groupCallId}`}
>
<>
{shareModal}
<ActiveCall
client={client}
matrixInfo={matrixInfo}
groupCall={groupCall}
participants={participants}
rtcSession={rtcSession}
participatingMembers={participatingMembers}
onLeave={onLeave}
hideHeader={hideHeader}
muteStates={muteStates}
e2eeConfig={e2eeConfig}
otelGroupCallMembership={otelGroupCallMembership}
//otelGroupCallMembership={otelGroupCallMembership}
onShareClick={onShareClick}
/>
</OpenIDLoader>
</>
);
} else if (left) {
// The call ended view is shown for two reasons: prompting guests to create
@@ -360,7 +347,7 @@ export function GroupCallView({
) {
return (
<CallEndedView
endedCallId={groupCall.groupCallId}
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
leaveError={leaveError}
@@ -389,7 +376,7 @@ export function GroupCallView({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => enter()}
onEnter={() => enterRTCSession(rtcSession)}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
participatingMembers={participatingMembers}

View File

@@ -23,16 +23,16 @@ import {
useTracks,
} from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays";
import { DisconnectReason, Room, RoomEvent, Track } from "livekit-client";
import { ConnectionState, Room, Track } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room";
import { Ref, useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { OverlayTriggerState } from "@react-stately/overlays";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomEventCallbacks } from "livekit-client/dist/src/room/Room";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { ReactComponent as LogoMark } from "../icons/LogoMark.svg";
import { ReactComponent as LogoType } from "../icons/LogoType.svg";
@@ -50,19 +50,14 @@ import {
TileDescriptor,
VideoGrid,
} from "../video-grid/VideoGrid";
import {
useShowInspector,
useShowConnectionStats,
} from "../settings/useSetting";
import { useShowConnectionStats } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ElementWidgetActions, widget } from "../widget";
import { GroupCallInspector } from "./GroupCallInspector";
import styles from "./InCallView.module.css";
import { ParticipantInfo } from "./useGroupCall";
import { ItemData, TileContent, VideoTile } from "../video-grid/VideoTile";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
@@ -72,14 +67,14 @@ import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { E2EEConfig, useLiveKit } from "../livekit/useLiveKit";
import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
import { useSFUConfig } from "../livekit/OpenIDLoader";
import { useEventEmitterThree } from "../useEvents";
import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs";
import { MuteStates } from "./MuteStates";
import { MatrixInfo } from "./VideoPreview";
import { ShareButton } from "../button/ShareButton";
import { LayoutToggle } from "./LayoutToggle";
import { ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -87,13 +82,18 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export interface ActiveCallProps extends Omit<InCallViewProps, "livekitRoom"> {
export interface ActiveCallProps
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
e2eeConfig?: E2EEConfig;
}
export function ActiveCall(props: ActiveCallProps) {
const sfuConfig = useSFUConfig();
const livekitRoom = useLiveKit(props.muteStates, sfuConfig, props.e2eeConfig);
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLiveKit(
props.muteStates,
sfuConfig,
props.e2eeConfig
);
if (!livekitRoom) {
return null;
@@ -105,7 +105,7 @@ export function ActiveCall(props: ActiveCallProps) {
return (
<RoomContext.Provider value={livekitRoom}>
<InCallView {...props} livekitRoom={livekitRoom} />
<InCallView {...props} livekitRoom={livekitRoom} connState={connState} />
</RoomContext.Provider>
);
}
@@ -113,34 +113,42 @@ export function ActiveCall(props: ActiveCallProps) {
export interface InCallViewProps {
client: MatrixClient;
matrixInfo: MatrixInfo;
groupCall: GroupCall;
rtcSession: MatrixRTCSession;
livekitRoom: Room;
muteStates: MuteStates;
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
participatingMembers: RoomMember[];
onLeave: (error?: Error) => void;
hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null;
}
export function InCallView({
client,
matrixInfo,
groupCall,
rtcSession,
livekitRoom,
muteStates,
participants,
participatingMembers,
onLeave,
hideHeader,
otelGroupCallMembership,
connState,
onShareClick,
}: InCallViewProps) {
const { t } = useTranslation();
usePreventScroll();
useWakeLock();
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
const boundsValid = bounds.height > 0;
@@ -157,7 +165,7 @@ export function InCallView({
screenSharingTracks.length > 0
);
const [showInspector] = useShowInspector();
//const [showInspector] = useShowInspector();
const [showConnectionStats] = useShowConnectionStats();
const { hideScreensharing } = useUrlParams();
@@ -184,27 +192,10 @@ export function InCallView({
(muted) => muteStates.audio.setEnabled?.(!muted)
);
const onDisconnected = useCallback(
(reason?: DisconnectReason) => {
PosthogAnalytics.instance.eventCallDisconnected.track(reason);
logger.info("Disconnected from livekit call with reason ", reason);
onLeave(
new Error("Disconnected from LiveKit call with reason " + reason)
);
},
[onLeave]
);
const onLeavePress = useCallback(() => {
onLeave();
}, [onLeave]);
useEventEmitterThree<RoomEvent.Disconnected, RoomEventCallbacks>(
livekitRoom,
RoomEvent.Disconnected,
onDisconnected
);
useEffect(() => {
widget?.api.transport.send(
layout === "grid"
@@ -245,7 +236,7 @@ export function InCallView({
const reducedControls = boundsValid && bounds.width <= 340;
const noControls = reducedControls && bounds.height <= 400;
const items = useParticipantTiles(livekitRoom, participants);
const items = useParticipantTiles(livekitRoom, rtcSession.room);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(items);
@@ -319,7 +310,7 @@ export function InCallView({
const {
modalState: rageshakeRequestModalState,
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId);
} = useRageshakeRequestModal(rtcSession.room.roomId);
const {
modalState: settingsModalState,
@@ -433,24 +424,24 @@ export function InCallView({
{renderContent()}
{footer}
</div>
{otelGroupCallMembership && (
{/*otelGroupCallMembership && (
<GroupCallInspector
client={client}
groupCall={groupCall}
otelGroupCallMembership={otelGroupCallMembership}
show={showInspector}
/>
)}
)*/}
{rageshakeRequestModalState.isOpen && !noControls && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomId={groupCall.room.roomId}
roomId={rtcSession.room.roomId}
/>
)}
{settingsModalState.isOpen && (
<SettingsModal
client={client}
roomId={groupCall.room.roomId}
roomId={rtcSession.room.roomId}
{...settingsModalProps}
/>
)}
@@ -458,25 +449,36 @@ export function InCallView({
);
}
function findMatrixMember(
room: MatrixRoom,
id: string
): RoomMember | undefined {
if (!id) return undefined;
const parts = id.split(":");
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
if (parts.length < 3) {
logger.warn(
"Livekit participants ID doesn't look like a userId:deviceId combination"
);
return undefined;
}
parts.pop();
const userId = parts.join(":");
return room.getMember(userId) ?? undefined;
}
function useParticipantTiles(
livekitRoom: Room,
participants: Map<RoomMember, Map<string, ParticipantInfo>>
matrixRoom: MatrixRoom
): TileDescriptor<ItemData>[] {
const sfuParticipants = useParticipants({
room: livekitRoom,
});
const items = useMemo(() => {
// The IDs of the participants who published membership event to the room (i.e. are present from Matrix perspective).
const matrixParticipants: Map<string, RoomMember> = new Map(
[...participants.entries()].flatMap(([user, devicesMap]) => {
return [...devicesMap.keys()].map((deviceId) => [
`${user.userId}:${deviceId}`,
user,
]);
})
);
const hasPresenter =
sfuParticipants.find((p) => p.isScreenShareEnabled) !== undefined;
let allGhosts = true;
@@ -492,7 +494,14 @@ function useParticipantTiles(
: false;
const id = sfuParticipant.identity;
const member = matrixParticipants.get(id);
const member = findMatrixMember(matrixRoom, id);
// We always start with a local participant wit the empty string as their ID before we're
// connected, this is fine and we'll be in "all ghosts" mode.
if (id !== "" && member === undefined) {
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`
);
}
allGhosts &&= member === undefined;
const userMediaTile = {
@@ -544,7 +553,7 @@ function useParticipantTiles(
// If every item is a ghost, that probably means we're still connecting and
// shouldn't bother showing anything yet
return allGhosts ? [] : tiles;
}, [participants, sfuParticipants]);
}, [matrixRoom, sfuParticipants]);
return items;
}

View File

@@ -15,8 +15,8 @@ limitations under the License.
*/
import { FC, useEffect, useState, useCallback } from "react";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useClientLegacy } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
@@ -73,10 +73,10 @@ export const RoomPage: FC = () => {
]);
const groupCallView = useCallback(
(groupCall: GroupCall) => (
(rtcSession: MatrixRTCSession) => (
<GroupCallView
client={client!}
groupCall={groupCall}
rtcSession={rtcSession}
isPasswordlessUser={passwordlessUser}
isEmbedded={isEmbedded}
preload={preload}

View File

@@ -0,0 +1,68 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useCallback, useEffect, useState } from "react";
import { deepCompare } from "matrix-js-sdk/src/utils";
import { LivekitFocus } from "../livekit/LivekitFocus";
function getActiveFocus(
rtcSession: MatrixRTCSession
): LivekitFocus | undefined {
const oldestMembership = rtcSession.getOldestMembership();
return oldestMembership?.getActiveFoci()[0] as LivekitFocus;
}
/**
* Gets the currently active (livekit) focus for a MatrixRTC session
* This logic is specific to livekit foci where the whole call must use one
* and the same focus.
*/
export function useActiveFocus(
rtcSession: MatrixRTCSession
): LivekitFocus | undefined {
const [activeFocus, setActiveFocus] = useState(() =>
getActiveFocus(rtcSession)
);
const onMembershipsChanged = useCallback(() => {
const newActiveFocus = getActiveFocus(rtcSession);
if (!deepCompare(activeFocus, newActiveFocus)) {
setActiveFocus(newActiveFocus);
}
}, [activeFocus, rtcSession]);
useEffect(() => {
rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged
);
return () => {
rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged
);
};
});
return activeFocus;
}

View File

@@ -1,629 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useEffect, useState } from "react";
import * as Sentry from "@sentry/react";
import {
GroupCallEvent,
GroupCallState,
GroupCall,
GroupCallError,
GroupCallStatsReportEvent,
GroupCallStatsReport,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
import { IWidgetApiRequest } from "matrix-widget-api";
import { MatrixClient, RoomStateEvent } from "matrix-js-sdk";
import {
ByteSentStatsReport,
ConnectionStatsReport,
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { usePageUnload } from "./usePageUnload";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { TranslatedError, translatedError } from "../TranslatedError";
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { ElementCallOpenTelemetry } from "../otel/otel";
import { checkForParallelCalls } from "./checkForParallelCalls";
enum ConnectionState {
EstablishingCall = "establishing call", // call hasn't been established yet
WaitMedia = "wait_media", // call is set up, waiting for ICE to connect
Connected = "connected", // media is flowing
}
export interface ParticipantInfo {
connectionState: ConnectionState;
presenter: boolean;
}
interface UseGroupCallReturnType {
state: GroupCallState;
localCallFeed?: CallFeed;
activeSpeaker?: CallFeed;
userMediaFeeds: CallFeed[];
microphoneMuted: boolean;
localVideoMuted: boolean;
error?: TranslatedError;
initLocalCallFeed: () => void;
enter: () => Promise<void>;
leave: () => void;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
setMicrophoneMuted: (muted: boolean) => void;
requestingScreenshare: boolean;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
hasLocalParticipant: boolean;
otelGroupCallMembership?: OTelGroupCallMembership;
}
interface State {
state: GroupCallState;
localCallFeed?: CallFeed;
activeSpeaker?: CallFeed;
userMediaFeeds: CallFeed[];
error?: TranslatedError;
microphoneMuted: boolean;
localVideoMuted: boolean;
screenshareFeeds: CallFeed[];
isScreensharing: boolean;
requestingScreenshare: boolean;
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
hasLocalParticipant: boolean;
}
// This is a bit of a hack, but we keep the opentelemetry tracker object at the file
// level so that it doesn't pop in & out of existence as react mounts & unmounts
// components. The right solution is probably for this to live in the js-sdk and have
// the same lifetime as groupcalls themselves.
let groupCallOTelMembership: OTelGroupCallMembership | undefined;
let groupCallOTelMembershipGroupCallId: string;
function getParticipants(
groupCall: GroupCall
): Map<RoomMember, Map<string, ParticipantInfo>> {
const participants = new Map<RoomMember, Map<string, ParticipantInfo>>();
for (const [member, participantsStateMap] of groupCall.participants) {
const participantInfoMap = new Map<string, ParticipantInfo>();
participants.set(member, participantInfoMap);
for (const [deviceId, participant] of participantsStateMap) {
const feed = groupCall.userMediaFeeds.find(
(f) => f.userId === member.userId && f.deviceId === deviceId
);
let connectionState: ConnectionState;
// If we allow calls without media, we have no feeds and cannot read the connection status from them.
// @TODO: The connection state should generally not be determined by the feed.
if (
groupCall.allowCallWithoutVideoAndAudio &&
!feed &&
!participant.screensharing
) {
connectionState = ConnectionState.Connected;
} else {
connectionState = feed
? feed.connected
? ConnectionState.Connected
: ConnectionState.WaitMedia
: ConnectionState.EstablishingCall;
}
participantInfoMap.set(deviceId, {
connectionState,
presenter: participant.screensharing,
});
}
}
return participants;
}
export function useGroupCall(
groupCall: GroupCall,
client: MatrixClient
): UseGroupCallReturnType {
const [
{
state,
localCallFeed,
activeSpeaker,
userMediaFeeds,
error,
microphoneMuted,
localVideoMuted,
isScreensharing,
screenshareFeeds,
participants,
hasLocalParticipant,
requestingScreenshare,
},
setState,
] = useState<State>({
state: GroupCallState.LocalCallFeedUninitialized,
userMediaFeeds: [],
microphoneMuted: false,
localVideoMuted: false,
isScreensharing: false,
screenshareFeeds: [],
requestingScreenshare: false,
participants: getParticipants(groupCall),
hasLocalParticipant: false,
});
if (groupCallOTelMembershipGroupCallId !== groupCall.groupCallId) {
if (groupCallOTelMembership) groupCallOTelMembership.dispose();
// If the user disables analytics, this will stay around until they leave the call
// so analytics will be disabled once they leave.
if (ElementCallOpenTelemetry.instance) {
groupCallOTelMembership = new OTelGroupCallMembership(groupCall, client);
groupCallOTelMembershipGroupCallId = groupCall.groupCallId;
} else {
groupCallOTelMembership = undefined;
}
}
const updateState = useCallback(
(state: Partial<State>) => setState((prev) => ({ ...prev, ...state })),
[setState]
);
const doNothingMediaActionCallback = useCallback(
(details: MediaSessionActionDetails) => {},
[]
);
const leaveCall = useCallback(() => {
groupCallOTelMembership?.onLeaveCall();
groupCall.leave();
}, [groupCall]);
useEffect(() => {
// disable the media action keys, otherwise audio elements get paused when
// the user presses media keys or unplugs headphones, etc.
// Note there are actions for muting / unmuting a microphone & hanging up
// which we could wire up.
const mediaActions: MediaSessionAction[] = [
"play",
"pause",
"stop",
"nexttrack",
"previoustrack",
];
for (const mediaAction of mediaActions) {
navigator.mediaSession?.setActionHandler(
mediaAction,
doNothingMediaActionCallback
);
}
return () => {
for (const mediaAction of mediaActions) {
navigator.mediaSession?.setActionHandler(mediaAction, null);
}
};
}, [doNothingMediaActionCallback]);
useEffect(() => {
function onGroupCallStateChanged() {
updateState({
state: groupCall.state,
localCallFeed: groupCall.localCallFeed,
activeSpeaker: groupCall.activeSpeaker,
userMediaFeeds: [...groupCall.userMediaFeeds],
microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(),
screenshareFeeds: [...groupCall.screenshareFeeds],
});
}
const prevUserMediaFeeds = new Set<CallFeed>();
function onUserMediaFeedsChanged(userMediaFeeds: CallFeed[]): void {
for (const feed of prevUserMediaFeeds) {
feed.off(CallFeedEvent.ConnectedChanged, onConnectedChanged);
}
prevUserMediaFeeds.clear();
for (const feed of userMediaFeeds) {
feed.on(CallFeedEvent.ConnectedChanged, onConnectedChanged);
prevUserMediaFeeds.add(feed);
}
updateState({
userMediaFeeds: [...userMediaFeeds],
participants: getParticipants(groupCall),
});
}
const prevScreenshareFeeds = new Set<CallFeed>();
function onScreenshareFeedsChanged(screenshareFeeds: CallFeed[]): void {
for (const feed of prevScreenshareFeeds) {
feed.off(CallFeedEvent.ConnectedChanged, onConnectedChanged);
}
prevScreenshareFeeds.clear();
for (const feed of screenshareFeeds) {
feed.on(CallFeedEvent.ConnectedChanged, onConnectedChanged);
prevScreenshareFeeds.add(feed);
}
updateState({
screenshareFeeds: [...screenshareFeeds],
});
}
function onConnectedChanged(connected: boolean): void {
updateState({
participants: getParticipants(groupCall),
});
}
function onActiveSpeakerChanged(activeSpeaker: CallFeed | undefined): void {
updateState({
activeSpeaker: activeSpeaker,
});
}
function onLocalMuteStateChanged(
microphoneMuted: boolean,
localVideoMuted: boolean
): void {
updateState({
microphoneMuted,
localVideoMuted,
});
}
function onLocalScreenshareStateChanged(
isScreensharing: boolean,
_localScreenshareFeed?: CallFeed,
localDesktopCapturerSourceId?: string
): void {
updateState({
isScreensharing,
});
}
function onCallsChanged(): void {
updateState({ participants: getParticipants(groupCall) });
}
function onParticipantsChanged(): void {
updateState({
participants: getParticipants(groupCall),
hasLocalParticipant: groupCall.hasLocalParticipant(),
});
}
function onError(e: GroupCallError): void {
Sentry.captureException(e);
}
function onConnectionStatsReport(
report: GroupCallStatsReport<ConnectionStatsReport>
): void {
groupCallOTelMembership?.onConnectionStatsReport(report);
}
function onByteSentStatsReport(
report: GroupCallStatsReport<ByteSentStatsReport>
): void {
groupCallOTelMembership?.onByteSentStatsReport(report);
}
function onSummaryStatsReport(
report: GroupCallStatsReport<SummaryStatsReport>
): void {
groupCallOTelMembership?.onSummaryStatsReport(report);
}
function onCallFeedStatsReport(
report: GroupCallStatsReport<CallFeedReport>
): void {
groupCallOTelMembership?.onCallFeedStatsReport(report);
}
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
groupCall.on(
GroupCallEvent.ScreenshareFeedsChanged,
onScreenshareFeedsChanged
);
groupCall.on(GroupCallEvent.ActiveSpeakerChanged, onActiveSpeakerChanged);
groupCall.on(GroupCallEvent.LocalMuteStateChanged, onLocalMuteStateChanged);
groupCall.on(
GroupCallEvent.LocalScreenshareStateChanged,
onLocalScreenshareStateChanged
);
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
groupCall.on(GroupCallEvent.Error, onError);
groupCall.on(
GroupCallStatsReportEvent.ConnectionStats,
onConnectionStatsReport
);
groupCall.on(
GroupCallStatsReportEvent.ByteSentStats,
onByteSentStatsReport
);
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
groupCall.on(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.on(
RoomStateEvent.Update,
checkForParallelCalls
);
updateState({
error: undefined,
state: groupCall.state,
localCallFeed: groupCall.localCallFeed,
activeSpeaker: groupCall.activeSpeaker,
userMediaFeeds: [...groupCall.userMediaFeeds],
microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(),
screenshareFeeds: [...groupCall.screenshareFeeds],
participants: getParticipants(groupCall),
hasLocalParticipant: groupCall.hasLocalParticipant(),
});
return () => {
groupCall.removeListener(
GroupCallEvent.GroupCallStateChanged,
onGroupCallStateChanged
);
groupCall.removeListener(
GroupCallEvent.UserMediaFeedsChanged,
onUserMediaFeedsChanged
);
groupCall.removeListener(
GroupCallEvent.ScreenshareFeedsChanged,
onScreenshareFeedsChanged
);
groupCall.removeListener(
GroupCallEvent.ActiveSpeakerChanged,
onActiveSpeakerChanged
);
groupCall.removeListener(
GroupCallEvent.LocalMuteStateChanged,
onLocalMuteStateChanged
);
groupCall.removeListener(
GroupCallEvent.LocalScreenshareStateChanged,
onLocalScreenshareStateChanged
);
groupCall.removeListener(GroupCallEvent.CallsChanged, onCallsChanged);
groupCall.removeListener(
GroupCallEvent.ParticipantsChanged,
onParticipantsChanged
);
groupCall.removeListener(GroupCallEvent.Error, onError);
groupCall.removeListener(
GroupCallStatsReportEvent.ConnectionStats,
onConnectionStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.ByteSentStats,
onByteSentStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.SummaryStats,
onSummaryStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.off(
RoomStateEvent.Update,
checkForParallelCalls
);
leaveCall();
};
}, [groupCall, updateState, leaveCall]);
usePageUnload(() => {
leaveCall();
});
const initLocalCallFeed = useCallback(
() => groupCall.initLocalCallFeed(),
[groupCall]
);
const enter = useCallback(async () => {
if (
groupCall.state !== GroupCallState.LocalCallFeedUninitialized &&
groupCall.state !== GroupCallState.LocalCallFeedInitialized
) {
return;
}
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
groupCallOTelMembership?.onJoinCall();
await groupCall.enter().catch((error) => {
console.error(error);
updateState({ error });
});
}, [groupCall, updateState]);
const toggleLocalVideoMuted = useCallback(() => {
const toggleToMute = !groupCall.isLocalVideoMuted();
groupCall.setLocalVideoMuted(toggleToMute);
groupCallOTelMembership?.onToggleLocalVideoMuted(toggleToMute);
// TODO: These explict posthog calls should be unnecessary now with the posthog otel exporter?
PosthogAnalytics.instance.eventMuteCamera.track(
toggleToMute,
groupCall.groupCallId
);
}, [groupCall]);
const setMicrophoneMuted = useCallback(
(setMuted: boolean) => {
groupCall.setMicrophoneMuted(setMuted);
groupCallOTelMembership?.onSetMicrophoneMuted(setMuted);
PosthogAnalytics.instance.eventMuteMicrophone.track(
setMuted,
groupCall.groupCallId
);
},
[groupCall]
);
const toggleMicrophoneMuted = useCallback(() => {
const toggleToMute = !groupCall.isMicrophoneMuted();
groupCallOTelMembership?.onToggleMicrophoneMuted(toggleToMute);
setMicrophoneMuted(toggleToMute);
}, [groupCall, setMicrophoneMuted]);
const toggleScreensharing = useCallback(async () => {
groupCallOTelMembership?.onToggleScreensharing(!groupCall.isScreensharing);
if (!groupCall.isScreensharing()) {
// toggling on
updateState({ requestingScreenshare: true });
try {
await groupCall.setScreensharingEnabled(true, {
audio: true,
throwOnFail: true,
});
updateState({ requestingScreenshare: false });
} catch (e) {
// this will fail in Electron because getDisplayMedia just throws a permission
// error, so if we have a widget API, try requesting via that.
if (widget) {
const reply = await widget.api.transport.send(
ElementWidgetActions.ScreenshareRequest,
{}
);
if (!reply.pending) {
updateState({ requestingScreenshare: false });
}
}
}
} else {
// toggling off
groupCall.setScreensharingEnabled(false);
}
}, [groupCall, updateState]);
const onScreenshareStart = useCallback(
async (ev: CustomEvent<IWidgetApiRequest>) => {
updateState({ requestingScreenshare: false });
const data = ev.detail.data as unknown as ScreenshareStartData;
await groupCall.setScreensharingEnabled(true, {
desktopCapturerSourceId: data.desktopCapturerSourceId as string,
audio: !data.desktopCapturerSourceId,
});
await widget?.api.transport.reply(ev.detail, {});
},
[groupCall, updateState]
);
const onScreenshareStop = useCallback(
async (ev: CustomEvent<IWidgetApiRequest>) => {
updateState({ requestingScreenshare: false });
await groupCall.setScreensharingEnabled(false);
await widget?.api.transport.reply(ev.detail, {});
},
[groupCall, updateState]
);
useEffect(() => {
if (widget) {
widget.lazyActions.on(
ElementWidgetActions.ScreenshareStart,
onScreenshareStart
);
widget.lazyActions.on(
ElementWidgetActions.ScreenshareStop,
onScreenshareStop
);
return () => {
widget?.lazyActions.off(
ElementWidgetActions.ScreenshareStart,
onScreenshareStart
);
widget?.lazyActions.off(
ElementWidgetActions.ScreenshareStop,
onScreenshareStop
);
};
}
}, [onScreenshareStart, onScreenshareStop]);
const { t } = useTranslation();
useEffect(() => {
if (window.RTCPeerConnection === undefined) {
const error = translatedError(
"WebRTC is not supported or is being blocked in this browser.",
t
);
console.error(error);
updateState({ error });
}
}, [t, updateState]);
return {
state,
localCallFeed,
activeSpeaker,
userMediaFeeds,
microphoneMuted,
localVideoMuted,
error,
initLocalCallFeed,
enter,
leave: leaveCall,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
setMicrophoneMuted,
requestingScreenshare,
isScreensharing,
screenshareFeeds,
participants,
hasLocalParticipant,
otelGroupCallMembership: groupCallOTelMembership,
};
}

View File

@@ -15,32 +15,23 @@ limitations under the License.
*/
import { useState, useEffect } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
GroupCallType,
GroupCallIntent,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync";
import { useTranslation } from "react-i18next";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { randomString } from "matrix-js-sdk/src/randomstring";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { setLocalStorageItem } from "../useLocalStorage";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { translatedError } from "../TranslatedError";
import { widget } from "../widget";
import { useEnableE2EE } from "../settings/useSetting";
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
export type GroupCallLoaded = {
kind: "loaded";
groupCall: GroupCall;
rtcSession: MatrixRTCSession;
};
export type GroupCallLoadFailed = {
@@ -130,61 +121,12 @@ export const useLoadGroupCall = (
}
};
const fetchOrCreateGroupCall = async (): Promise<GroupCall> => {
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
const room = await fetchOrCreateRoom();
logger.debug(`Fetched / joined room ${roomIdOrAlias}`);
let groupCall = client.getGroupCallForRoom(room.roomId);
logger.debug("Got group call", groupCall?.groupCallId);
if (groupCall) {
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
return groupCall;
}
if (
!widget &&
room.currentState.mayClientSendStateEvent(
EventType.GroupCallPrefix,
client
)
) {
// The call doesn't exist, but we can create it
console.log(
`No call found in ${roomIdOrAlias}: creating ${
createPtt ? "PTT" : "video"
} call`
);
groupCall = await client.createGroupCall(
room.roomId,
createPtt ? GroupCallType.Voice : GroupCallType.Video,
createPtt,
GroupCallIntent.Room
);
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
return groupCall;
}
// We don't have permission to create the call, so all we can do is wait
// for one to come in
return new Promise((resolve, reject) => {
const onGroupCallIncoming = (groupCall: GroupCall) => {
if (groupCall?.room.roomId === room.roomId) {
clearTimeout(timeout);
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
client.off(
GroupCallEventHandlerEvent.Incoming,
onGroupCallIncoming
);
resolve(groupCall);
}
};
client.on(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
const timeout = setTimeout(() => {
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
reject(translatedError("Fetching group call timed out.", t));
}, 30000);
});
const rtcSession = client.matrixRTC.getRoomSession(room);
return rtcSession;
};
const waitForClientSyncing = async () => {
@@ -207,7 +149,7 @@ export const useLoadGroupCall = (
waitForClientSyncing()
.then(fetchOrCreateGroupCall)
.then((groupCall) => setState({ kind: "loaded", groupCall }))
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
.catch((error) => setState({ kind: "failed", error }));
}, [client, roomIdOrAlias, viaServers, createPtt, t, e2eeEnabled]);

View File

@@ -1,46 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useEffect } from "react";
import * as Sentry from "@sentry/react";
import { GroupCall, GroupCallEvent } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallEvent, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
export function useSentryGroupCallHandler(groupCall: GroupCall) {
useEffect(() => {
function onHangup(call: MatrixCall) {
if (call.hangupReason === "ice_failed") {
Sentry.captureException(new Error("Call hangup due to ICE failure."));
}
}
function onError(error: Error) {
Sentry.captureException(error);
}
if (groupCall) {
groupCall.on(CallEvent.Hangup, onHangup);
groupCall.on(GroupCallEvent.Error, onError);
}
return () => {
if (groupCall) {
groupCall.removeListener(CallEvent.Hangup, onHangup);
groupCall.removeListener(GroupCallEvent.Error, onError);
}
};
}, [groupCall]);
}

53
src/rtcSessionHelpers.ts Normal file
View File

@@ -0,0 +1,53 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { LivekitFocus } from "./livekit/LivekitFocus";
import { Config } from "./config/Config";
function makeFocus(livekitAlias: string): LivekitFocus {
const urlFromConf = Config.get().livekit!.livekit_service_url;
if (!urlFromConf) {
throw new Error("No livekit_service_url is configured!");
}
return {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
}
export function enterRTCSession(rtcSession: MatrixRTCSession) {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
//groupCallOTelMembership?.onJoinCall();
// right now we asume everything is a room-scoped call
const livekitAlias = rtcSession.room.roomId;
rtcSession.joinRoomSession([makeFocus(livekitAlias)]);
}
export function leaveRTCSession(rtcSession: MatrixRTCSession) {
//groupCallOTelMembership?.onLeaveCall();
rtcSession.leaveRoomSession();
}

View File

@@ -0,0 +1,50 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useCallback, useEffect, useState } from "react";
export function useMatrixRTCSessionJoinState(
rtcSession: MatrixRTCSession
): boolean {
const [isJoined, setJoined] = useState(rtcSession.isJoined());
const onJoinStateChanged = useCallback(() => {
logger.info(
`Session in room ${rtcSession.room.roomId} changed to ${
rtcSession.isJoined() ? "joined" : "left"
}`
);
setJoined(rtcSession.isJoined());
}, [rtcSession]);
useEffect(() => {
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, onJoinStateChanged);
return () => {
rtcSession.off(
MatrixRTCSessionEvent.JoinStateChanged,
onJoinStateChanged
);
};
}, [rtcSession, onJoinStateChanged]);
return isJoined;
}

View File

@@ -0,0 +1,52 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc/CallMembership";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useCallback, useEffect, useState } from "react";
export function useMatrixRTCSessionMemberships(
rtcSession: MatrixRTCSession
): CallMembership[] {
const [memberships, setMemberships] = useState(rtcSession.memberships);
const onMembershipsChanged = useCallback(() => {
logger.info(
`Memberships changed for call in room ${rtcSession.room.roomId} (${rtcSession.memberships.length} members)`
);
setMemberships(rtcSession.memberships);
}, [rtcSession]);
useEffect(() => {
rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged
);
return () => {
rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged
);
};
}, [rtcSession, onMembershipsChanged]);
return memberships;
}

View File

@@ -157,15 +157,6 @@ export const widget: WidgetHelpers | null = (() => {
timelineSupport: true,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: allowIceFallback,
// XXX: The client expects the list of foci in its constructor, but we don't
// know this until we fetch the config file. However, we can't wait to construct
// the client object or we'll miss the 'capabilities' request from the host app.
// As of writing this, I have made the embedded widget client send the 'contentLoaded'
// message so that we can use the widget API in less racy mode, but we need to change
// element-web to use waitForIFrameLoad=false. Once that change has rolled out,
// we can just start the client after we've fetched the config.
livekitServiceURL: undefined,
useLivekitForGroupCalls: true,
}
);
@@ -174,13 +165,6 @@ export const widget: WidgetHelpers | null = (() => {
// wait for the config file to be ready (we load very early on so it might not
// be otherwise)
await Config.init();
const livekit = Config.get().livekit;
const focus = livekit?.livekit_service_url;
// Now we've fetched the config, be evil and use the getter to inject the focus
// into the client (see above XXX).
if (focus) {
client.setLivekitServiceURL(livekit.livekit_service_url);
}
await client.startClient();
resolve(client);
})();

View File

@@ -2288,10 +2288,10 @@
clsx "^2.0.0"
usehooks-ts "^2.9.1"
"@matrix-org/matrix-sdk-crypto-js@^0.1.1":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.4.tgz#c13c7c8c3a1d8da08e6ad195d25e5e61cc402df7"
integrity sha512-OxG84iSeR89zYLFeb+DCaFtZT+DDiIu+kTkqY8OYfhE5vpGLFX2sDVBRrAdos1IUqEoboDloDBR9+yU7hNRyog==
"@matrix-org/matrix-sdk-crypto-wasm@^1.2.3-alpha.0":
version "1.2.3-alpha.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-1.2.3-alpha.0.tgz#f6f93e3ee44c5f1e0e255badd26f4a7d3fb1dab8"
integrity sha512-BFLqfq/WbYZ+83r4UWLhwtBYvTp5DKTHNeWUSDBVvudFtqBvkntNAAUz+xmhmO1XkyNm+sBaElxF8IS9S8zdww==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14"
@@ -11624,29 +11624,29 @@ matrix-events-sdk@0.0.1:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#b698217445318f453e0b1086364a33113eaa85d9":
version "26.2.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b698217445318f453e0b1086364a33113eaa85d9"
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#6836720e1e1c2cb01d49d6e5fcfc01afc14834ca":
version "28.0.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6836720e1e1c2cb01d49d6e5fcfc01afc14834ca"
dependencies:
"@babel/runtime" "^7.12.5"
"@matrix-org/matrix-sdk-crypto-js" "^0.1.1"
"@matrix-org/matrix-sdk-crypto-wasm" "^1.2.3-alpha.0"
another-json "^0.2.0"
bs58 "^5.0.0"
content-type "^1.0.4"
jwt-decode "^3.1.2"
loglevel "^1.7.1"
matrix-events-sdk "0.0.1"
matrix-widget-api "^1.3.1"
matrix-widget-api "^1.6.0"
oidc-client-ts "^2.2.4"
p-retry "4"
sdp-transform "^2.14.1"
unhomoglyph "^1.0.6"
uuid "9"
matrix-widget-api@^1.3.1:
version "1.4.0"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.4.0.tgz#e426ec16a013897f3a4a9c2bff423f54ab0ba745"
integrity sha512-dw0dRylGQzDUoiaY/g5xx1tBbS7aoov31PRtFMAvG58/4uerYllV9Gfou7w+I1aglwB6hihTREzKltVjARWV6A==
matrix-widget-api@^1.3.1, matrix-widget-api@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.6.0.tgz#f0075411edffc6de339580ade7e6e6e6edb01af4"
integrity sha512-VXIJyAZ/WnBmT4C7ePqevgMYGneKMCP/0JuCOqntSsaNlCRHJvwvTxmqUU+ufOpzIF5gYNyIrAjbgrEbK3iqJQ==
dependencies:
"@types/events" "^3.0.0"
events "^3.2.0"