Merge remote-tracking branch 'upstream/livekit' into SimonBrandner/feat/e2ee

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner
2023-07-25 16:00:12 +02:00
54 changed files with 1117 additions and 864 deletions

View File

@@ -31,6 +31,15 @@ limitations under the License.
margin-bottom: 32px;
}
.disconnectedButtons {
display: grid;
gap: 50px;
}
.rageshakeButton {
grid-column: 2;
}
.callEndedButton {
margin-top: 54px;
margin-left: 30px;

View File

@@ -28,15 +28,20 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
import { RageshakeButton } from "../settings/RageshakeButton";
export function CallEndedView({
client,
isPasswordlessUser,
endedCallId,
leaveError,
reconnect,
}: {
client: MatrixClient;
isPasswordlessUser: boolean;
endedCallId: string;
leaveError?: Error;
reconnect: () => void;
}) {
const { t } = useTranslation();
const history = useHistory();
@@ -76,6 +81,7 @@ export function CallEndedView({
},
[endedCallId, history, isPasswordlessUser, starRating]
);
const createAccountDialog = isPasswordlessUser && (
<div className={styles.callEndedContent}>
<Trans>
@@ -138,6 +144,59 @@ export function CallEndedView({
</div>
);
const renderBody = () => {
if (leaveError) {
return (
<>
<main className={styles.main}>
<Headline className={styles.headline}>
<Trans>You were disconnected from the call</Trans>
</Headline>
<div className={styles.disconnectedButtons}>
<Button size="lg" variant="default" onClick={reconnect}>
{t("Reconnect")}
</Button>
<div className={styles.rageshakeButton}>
<RageshakeButton description="***Call disconnected***" />
</div>
</div>
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">
{t("Return to home screen")}
</Link>
</Body>
</>
);
} else {
return (
<>
<main className={styles.main}>
<Headline className={styles.headline}>
{surveySubmitted
? t("{{displayName}}, your call has ended.", {
displayName,
})
: t("{{displayName}}, your call has ended.", {
displayName,
}) +
"\n" +
t("How did it go?")}
</Headline>
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">
{t("Not now, return to home screen")}
</Link>
</Body>
</>
);
}
};
return (
<>
<Header>
@@ -146,29 +205,7 @@ export function CallEndedView({
</LeftNav>
<RightNav />
</Header>
<div className={styles.container}>
<main className={styles.main}>
<Headline className={styles.headline}>
{surveySubmitted
? t("{{displayName}}, your call has ended.", {
displayName,
})
: t("{{displayName}}, your call has ended.", {
displayName,
}) +
"\n" +
t("How did it go?")}
</Headline>
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">
{t("Not now, return to home screen")}
</Link>
</Body>
</div>
<div className={styles.container}>{renderBody()}</div>
</>
);
}

View File

@@ -36,7 +36,12 @@ import { E2EEConfig, UserChoices } from "../livekit/useLiveKit";
import { findDeviceByName } from "../media-utils";
import { OpenIDLoader } from "../livekit/OpenIDLoader";
import { ActiveCall } from "./InCallView";
import { Config } from "../config/Config";
/**
* If there already is this many participants in the call, we automatically mute
* the user
*/
const MUTE_PARTICIPANT_COUNT = 8;
declare global {
interface Window {
@@ -164,42 +169,47 @@ export function GroupCallView({
useSentryGroupCallHandler(groupCall);
const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
const history = useHistory();
const onLeave = useCallback(async () => {
setLeft(true);
const onLeave = useCallback(
async (leaveError?: Error) => {
setLeaveError(leaveError);
setLeft(true);
let participantCount = 0;
for (const deviceMap of groupCall.participants.values()) {
participantCount += deviceMap.size;
}
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,
sendInstantly
);
// 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,
sendInstantly
);
leave();
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
widget.api.setAlwaysOnScreen(false);
PosthogAnalytics.instance.logout();
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
}
leave();
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
widget.api.setAlwaysOnScreen(false);
PosthogAnalytics.instance.logout();
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
}
if (
!isPasswordlessUser &&
!isEmbedded &&
!PosthogAnalytics.instance.isEnabled()
) {
history.push("/");
}
}, [groupCall, leave, isPasswordlessUser, isEmbedded, history]);
if (
!isPasswordlessUser &&
!isEmbedded &&
!PosthogAnalytics.instance.isEnabled()
) {
history.push("/");
}
},
[groupCall, leave, isPasswordlessUser, isEmbedded, history]
);
useEffect(() => {
if (widget && state === GroupCallState.Entered) {
@@ -222,6 +232,12 @@ export function GroupCallView({
undefined
);
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
groupCall.enter();
}, [groupCall]);
const livekitServiceURL =
groupCall.livekitServiceURL ?? Config.get().livekit?.livekit_service_url;
if (!livekitServiceURL) {
@@ -234,7 +250,7 @@ export function GroupCallView({
return (
<OpenIDLoader
client={client}
livekitServiceURL={livekitServiceURL}
groupCall={groupCall}
roomName={`${groupCall.room.roomId}-${groupCall.groupCallId}`}
>
<ActiveCall
@@ -259,13 +275,16 @@ export function GroupCallView({
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded)
(PosthogAnalytics.instance.isEnabled() && !isEmbedded) ||
leaveError
) {
return (
<CallEndedView
endedCallId={groupCall.groupCallId}
client={client}
isPasswordlessUser={isPasswordlessUser}
leaveError={leaveError}
reconnect={onReconnect}
/>
);
} else {
@@ -291,6 +310,7 @@ export function GroupCallView({
setE2EEConfig(e2eeConfig);
enter();
}}
initWithMutedAudio={participants.size > MUTE_PARTICIPANT_COUNT}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
/>

View File

@@ -24,7 +24,7 @@ import {
} from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays";
import classNames from "classnames";
import { Room, Track } from "livekit-client";
import { DisconnectReason, Room, RoomEvent, 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";
@@ -33,6 +33,7 @@ import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { OverlayTriggerState } from "@react-stately/overlays";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { logger } from "matrix-js-sdk/src/logger";
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
@@ -83,6 +84,8 @@ import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
import { useSFUConfig } from "../livekit/OpenIDLoader";
import { E2EELock } from "../E2EELock";
import { useEventEmitterThree } from "../useEvents";
import { useWakeLock } from "../useWakeLock";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -121,7 +124,7 @@ export interface InCallViewProps {
groupCall: GroupCall;
livekitRoom: Room;
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
onLeave: () => void;
onLeave: (error?: Error) => void;
unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership;
@@ -139,6 +142,7 @@ export function InCallView({
}: InCallViewProps) {
const { t } = useTranslation();
usePreventScroll();
useWakeLock();
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
@@ -195,6 +199,23 @@ export function InCallView({
async (muted) => await localParticipant.setMicrophoneEnabled(!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(livekitRoom, RoomEvent.Disconnected, onDisconnected);
useEffect(() => {
widget?.api.transport.send(
layout === "freedom"
@@ -391,7 +412,7 @@ export function InCallView({
}
buttons.push(
<HangupButton key="6" onPress={onLeave} data-testid="incall_leave" />
<HangupButton key="6" onPress={onLeavePress} data-testid="incall_leave" />
);
footer = <div className={styles.footer}>{buttons}</div>;
}

View File

@@ -35,6 +35,7 @@ interface Props {
onEnter: (userChoices: UserChoices, e2eeConfig?: E2EEConfig) => void;
isEmbedded: boolean;
hideHeader: boolean;
initWithMutedAudio: boolean;
}
export function LobbyView(props: Props) {
@@ -81,6 +82,7 @@ export function LobbyView(props: Props) {
<div className={styles.joinRoomContent}>
<VideoPreview
matrixInfo={props.matrixInfo}
initWithMutedAudio={props.initWithMutedAudio}
onUserChoicesChanged={setUserChoices}
/>
{enableE2EE && (

View File

@@ -1,44 +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 { useLocation, useHistory } from "react-router-dom";
import { Config } from "../config/Config";
import { LoadingView } from "../FullScreenView";
// A component that, when loaded, redirects the client to a full room URL
// based on the current URL being an abbreviated room URL
export function RoomRedirect() {
const { pathname } = useLocation();
const history = useHistory();
useEffect(() => {
let roomId = pathname;
if (pathname.startsWith("/")) {
roomId = roomId.substring(1, roomId.length);
}
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
roomId = `#${roomId}:${Config.defaultServerName()}`;
}
history.replace(`/room/${roomId.toLowerCase()}`);
}, [pathname, history]);
return <LoadingView />;
}

View File

@@ -40,10 +40,15 @@ export type MatrixInfo = {
interface Props {
matrixInfo: MatrixInfo;
initWithMutedAudio: boolean;
onUserChoicesChanged: (choices: UserChoices) => void;
}
export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
export function VideoPreview({
matrixInfo,
initWithMutedAudio,
onUserChoicesChanged,
}: Props) {
const { client } = useClient();
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
@@ -64,12 +69,9 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
// Create local media tracks.
const [videoEnabled, setVideoEnabled] = useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = useState<boolean>(true);
// we store if the tracks are currently initializing to not show them as muted.
// showing them as muted while they are not yet available makes the buttons flicker undesirable during startup.
const [initializingVideo, setInitializingVideo] = useState<boolean>(true);
const [initializingAudio, setInitializingAudio] = useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = useState<boolean>(
!initWithMutedAudio
);
// The settings are updated as soon as the device changes. We wrap the settings value in a ref to store their initial value.
// Not changing the device options prohibits the usePreviewTracks hook to recreate the tracks.
@@ -99,32 +101,23 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
const requestPermissions = !!audioTrack && !!videoTrack;
const mediaSwitcher = useMediaDevicesSwitcher(
undefined,
{
videoTrack,
audioTrack,
},
{ videoTrack, audioTrack },
requestPermissions
);
const { videoIn, audioIn } = mediaSwitcher;
const videoEl = React.useRef(null);
// pretend the video is available until the initialization is over
const videoAvailableAndEnabled =
videoEnabled && (!!videoTrack || initializingVideo);
const audioAvailableAndEnabled =
audioEnabled && (!!videoTrack || initializingAudio);
useEffect(() => {
// Effect to update the settings
onUserChoicesChanged({
video: {
selectedId: videoIn.selectedId,
enabled: videoAvailableAndEnabled,
enabled: videoEnabled,
},
audio: {
selectedId: audioIn.selectedId,
enabled: audioAvailableAndEnabled,
enabled: audioEnabled,
},
});
}, [
@@ -133,24 +126,18 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
videoEnabled,
audioIn.selectedId,
audioEnabled,
videoAvailableAndEnabled,
audioAvailableAndEnabled,
videoTrack,
audioTrack,
]);
useEffect(() => {
// Effect to update the initial device selection for the ui elements based on the current preview track.
if (!videoIn.selectedId || videoIn.selectedId == "") {
if (videoTrack) {
setInitializingVideo(false);
}
videoTrack?.getDeviceId().then((videoId) => {
videoIn.setSelected(videoId ?? "default");
});
}
if (!audioIn.selectedId || audioIn.selectedId == "") {
if (audioTrack) {
setInitializingAudio(false);
}
audioTrack?.getDeviceId().then((audioId) => {
// getDeviceId() can return undefined for audio devices. This happens if
// the devices list uses "default" as the device id for the current
@@ -197,13 +184,13 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
)}
<div className={styles.previewButtons}>
<MicButton
muted={!audioAvailableAndEnabled}
onPress={() => setAudioEnabled(!audioAvailableAndEnabled)}
muted={!audioEnabled}
onPress={() => setAudioEnabled(!audioEnabled)}
disabled={!audioTrack}
/>
<VideoButton
muted={!videoAvailableAndEnabled}
onPress={() => setVideoEnabled(!videoAvailableAndEnabled)}
muted={!videoEnabled}
onPress={() => setVideoEnabled(!videoEnabled)}
disabled={!videoTrack}
/>
<SettingsButton onPress={openSettings} />

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import * as Sentry from "@sentry/react";
import {
GroupCallEvent,
GroupCallState,
@@ -170,7 +171,7 @@ export function useGroupCall(
isScreensharing: false,
screenshareFeeds: [],
requestingScreenshare: false,
participants: new Map(),
participants: getParticipants(groupCall),
hasLocalParticipant: false,
});
@@ -331,6 +332,7 @@ export function useGroupCall(
}
function onError(e: GroupCallError): void {
Sentry.captureException(e);
if (e.code === GroupCallErrorCode.UnknownDevice) {
const unknownDeviceError = e as GroupCallUnknownDeviceError;
addUnencryptedEventUser(unknownDeviceError.userId);