Merge branch 'livekit' into copy-alias

This commit is contained in:
Robin Townsend
2023-07-26 10:50:29 -04:00
25 changed files with 459 additions and 145 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

@@ -32,10 +32,17 @@ import { CallEndedView } from "./CallEndedView";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { UserChoices } from "../livekit/useLiveKit";
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 +171,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) {
@@ -218,6 +230,21 @@ export function GroupCallView({
const [userChoices, setUserChoices] = useState<UserChoices | undefined>(
undefined
);
const [e2eeConfig, setE2EEConfig] = useState<E2EEConfig | undefined>(
undefined
);
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
groupCall.enter();
}, [groupCall]);
const livekitServiceURL =
groupCall.livekitServiceURL ?? Config.get().livekit?.livekit_service_url;
if (!livekitServiceURL) {
return <ErrorView error={new Error("No livekit_service_url defined")} />;
}
if (error) {
return <ErrorView error={error} />;
@@ -236,6 +263,7 @@ export function GroupCallView({
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
hideHeader={hideHeader}
userChoices={userChoices}
e2eeConfig={e2eeConfig}
otelGroupCallMembership={otelGroupCallMembership}
/>
</OpenIDLoader>
@@ -249,13 +277,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 {
@@ -276,11 +307,12 @@ export function GroupCallView({
return (
<LobbyView
matrixInfo={matrixInfo}
onEnter={(choices: UserChoices) => {
onEnter={(choices: UserChoices, e2eeConfig?: E2EEConfig) => {
setUserChoices(choices);
setE2EEConfig(e2eeConfig);
enter();
}}
muteAudio={participants.size > 8}
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 {
@@ -77,12 +78,13 @@ import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { UserChoices, useLiveKit } from "../livekit/useLiveKit";
import { E2EEConfig, UserChoices, useLiveKit } from "../livekit/useLiveKit";
import { useMediaDevicesSwitcher } from "../livekit/useMediaDevicesSwitcher";
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 ?? {});
@@ -93,16 +95,25 @@ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export interface ActiveCallProps extends Omit<InCallViewProps, "livekitRoom"> {
userChoices: UserChoices;
e2eeConfig?: E2EEConfig;
}
export function ActiveCall(props: ActiveCallProps) {
const sfuConfig = useSFUConfig();
const livekitRoom = useLiveKit(props.userChoices, sfuConfig);
const livekitRoom = useLiveKit(
props.userChoices,
sfuConfig,
props.e2eeConfig
);
if (!livekitRoom) {
return null;
}
if (props.e2eeConfig && !livekitRoom.isE2EEEnabled) {
livekitRoom.setE2EEEnabled(!!props.e2eeConfig);
}
return (
<RoomContext.Provider value={livekitRoom}>
<InCallView {...props} livekitRoom={livekitRoom} />
@@ -115,7 +126,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;
@@ -190,6 +201,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"
@@ -386,7 +414,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

@@ -66,3 +66,9 @@ limitations under the License.
.copyButton:last-child {
margin-bottom: 0;
}
.passwordField {
width: 320px !important;
margin-bottom: 20px;
flex: 0;
}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useRef, useEffect, useState } from "react";
import { useRef, useEffect, useState, useCallback, ChangeEvent } from "react";
import { Trans, useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css";
@@ -25,21 +25,25 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography";
import { useLocationNavigation } from "../useLocationNavigation";
import { MatrixInfo, VideoPreview } from "./VideoPreview";
import { UserChoices } from "../livekit/useLiveKit";
import { E2EEConfig, UserChoices } from "../livekit/useLiveKit";
import { InputField } from "../input/Input";
import { useEnableE2EE } from "../settings/useSetting";
interface Props {
matrixInfo: MatrixInfo;
onEnter: (userChoices: UserChoices) => void;
onEnter: (userChoices: UserChoices, e2eeConfig?: E2EEConfig) => void;
isEmbedded: boolean;
hideHeader: boolean;
muteAudio: boolean;
initWithMutedAudio: boolean;
}
export function LobbyView(props: Props) {
const { t } = useTranslation();
useLocationNavigation();
const [enableE2EE] = useEnableE2EE();
const joinCallButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (joinCallButtonRef.current) {
@@ -50,6 +54,17 @@ export function LobbyView(props: Props) {
const [userChoices, setUserChoices] = useState<UserChoices | undefined>(
undefined
);
const [e2eeSharedKey, setE2EESharedKey] = useState<string | undefined>(
undefined
);
const onE2EESharedKeyChanged = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setE2EESharedKey(value === "" ? undefined : value);
},
[setE2EESharedKey]
);
return (
<div className={styles.room}>
@@ -67,15 +82,29 @@ export function LobbyView(props: Props) {
<div className={styles.joinRoomContent}>
<VideoPreview
matrixInfo={props.matrixInfo}
muteAudio={props.muteAudio}
initWithMutedAudio={props.initWithMutedAudio}
onUserChoicesChanged={setUserChoices}
/>
{enableE2EE && (
<InputField
className={styles.passwordField}
label={t("Password (if none, E2EE is disabled)")}
type="text"
onChange={onE2EESharedKeyChanged}
value={e2eeSharedKey}
/>
)}
<Trans>
<Button
ref={joinCallButtonRef}
className={styles.copyButton}
size="lg"
onPress={() => props.onEnter(userChoices!)}
onPress={() =>
props.onEnter(
userChoices!,
e2eeSharedKey ? { sharedKey: e2eeSharedKey } : undefined
)
}
data-testid="lobby_joinCall"
>
Join call now

View File

@@ -41,13 +41,13 @@ export type MatrixInfo = {
interface Props {
matrixInfo: MatrixInfo;
muteAudio: boolean;
initWithMutedAudio: boolean;
onUserChoicesChanged: (choices: UserChoices) => void;
}
export function VideoPreview({
matrixInfo,
muteAudio,
initWithMutedAudio,
onUserChoicesChanged,
}: Props) {
const { client } = useClient();
@@ -70,13 +70,9 @@ export function VideoPreview({
// Create local media tracks.
const [videoEnabled, setVideoEnabled] = useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = useState<boolean>(!muteAudio);
useEffect(() => {
if (muteAudio) {
setAudioEnabled(false);
}
}, [muteAudio]);
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.

View File

@@ -171,7 +171,7 @@ export function useGroupCall(
isScreensharing: false,
screenshareFeeds: [],
requestingScreenshare: false,
participants: new Map(),
participants: getParticipants(groupCall),
hasLocalParticipant: false,
});