Merge pull request #1285 from vector-im/dbkr/react_to_livekit_disconnect

Add disconnected screen for when livekit disconnects from the call
This commit is contained in:
David Baker
2023-07-25 09:44:40 +01:00
committed by GitHub
11 changed files with 267 additions and 91 deletions

View File

@@ -77,9 +77,11 @@
"Profile": "Profile", "Profile": "Profile",
"Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha dismissed": "Recaptcha dismissed",
"Recaptcha not loaded": "Recaptcha not loaded", "Recaptcha not loaded": "Recaptcha not loaded",
"Reconnect": "Reconnect",
"Register": "Register", "Register": "Register",
"Registering…": "Registering…", "Registering…": "Registering…",
"Remove": "Remove", "Remove": "Remove",
"Retry sending logs": "Retry sending logs",
"Return to home screen": "Return to home screen", "Return to home screen": "Return to home screen",
"Select an option": "Select an option", "Select an option": "Select an option",
"Send debug logs": "Send debug logs", "Send debug logs": "Send debug logs",
@@ -99,7 +101,7 @@
"Submitting…": "Submitting…", "Submitting…": "Submitting…",
"Take me Home": "Take me Home", "Take me Home": "Take me Home",
"Thanks, we received your feedback!": "Thanks, we received your feedback!", "Thanks, we received your feedback!": "Thanks, we received your feedback!",
"Thanks! We'll get right on it.": "Thanks! We'll get right on it.", "Thanks!": "Thanks!",
"This call already exists, would you like to join?": "This call already exists, would you like to join?", "This call already exists, would you like to join?": "This call already exists, would you like to join?",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>", "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>",
"Turn off camera": "Turn off camera", "Turn off camera": "Turn off camera",
@@ -116,6 +118,7 @@
"Walkie-talkie call name": "Walkie-talkie call name", "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.", "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", "Yes, join call": "Yes, join call",
"You were disconnected from the call": "You were disconnected from the call",
"Your feedback": "Your feedback", "Your feedback": "Your feedback",
"Your recent calls": "Your recent calls" "Your recent calls": "Your recent calls"
} }

View File

@@ -22,11 +22,10 @@ import * as Sentry from "@sentry/react";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import { LinkButton, Button } from "./button"; import { LinkButton, Button } from "./button";
import { useSubmitRageshake } from "./settings/submit-rageshake";
import { ErrorMessage } from "./input/Input";
import styles from "./FullScreenView.module.css"; import styles from "./FullScreenView.module.css";
import { translatedError, TranslatedError } from "./TranslatedError"; import { TranslatedError } from "./TranslatedError";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { RageshakeButton } from "./settings/RageshakeButton";
interface FullScreenViewProps { interface FullScreenViewProps {
className?: string; className?: string;
@@ -99,37 +98,11 @@ export function ErrorView({ error }: ErrorViewProps) {
export function CrashView() { export function CrashView() {
const { t } = useTranslation(); const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendDebugLogs = useCallback(() => {
submitRageshake({
description: "**Soft Crash**",
sendLogs: true,
});
}, [submitRageshake]);
const onReload = useCallback(() => { const onReload = useCallback(() => {
window.location.href = "/"; window.location.href = "/";
}, []); }, []);
let logsComponent: JSX.Element | null = null;
if (sent) {
logsComponent = <div>{t("Thanks! We'll get right on it.")}</div>;
} else if (sending) {
logsComponent = <div>{t("Sending…")}</div>;
} else if (Config.get().rageshake?.submit_url) {
logsComponent = (
<Button
size="lg"
variant="default"
onPress={sendDebugLogs}
className={styles.wideButton}
>
{t("Send debug logs")}
</Button>
);
}
return ( return (
<FullScreenView> <FullScreenView>
<Trans> <Trans>
@@ -141,10 +114,7 @@ export function CrashView() {
</Trans> </Trans>
)} )}
<div className={styles.sendLogsSection}>{logsComponent}</div> <RageshakeButton description="***Soft Crash***" />
{error && (
<ErrorMessage error={translatedError("Couldn't send debug logs!", t)} />
)}
<Button <Button
size="lg" size="lg"
variant="default" variant="default"

View File

@@ -30,6 +30,7 @@ import {
MuteMicrophoneTracker, MuteMicrophoneTracker,
UndecryptableToDeviceEventTracker, UndecryptableToDeviceEventTracker,
QualitySurveyEventTracker, QualitySurveyEventTracker,
CallDisconnectedEventTracker,
} from "./PosthogEvents"; } from "./PosthogEvents";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
@@ -437,4 +438,5 @@ export class PosthogAnalytics {
public eventMuteCamera = new MuteCameraTracker(); public eventMuteCamera = new MuteCameraTracker();
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker(); public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
public eventQualitySurvey = new QualitySurveyEventTracker(); public eventQualitySurvey = new QualitySurveyEventTracker();
public eventCallDisconnected = new CallDisconnectedEventTracker();
} }

View File

@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { DisconnectReason } from "livekit-client";
import { import {
IPosthogEvent, IPosthogEvent,
PosthogAnalytics, PosthogAnalytics,
@@ -181,3 +183,17 @@ export class QualitySurveyEventTracker {
}); });
} }
} }
interface CallDisconnectedEvent {
eventName: "CallDisconnected";
reason?: DisconnectReason;
}
export class CallDisconnectedEventTracker {
track(reason?: DisconnectReason) {
PosthogAnalytics.instance.trackEvent<CallDisconnectedEvent>({
eventName: "CallDisconnected",
reason,
});
}
}

View File

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

View File

@@ -28,15 +28,20 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput"; import { StarRatingInput } from "../input/StarRatingInput";
import { RageshakeButton } from "../settings/RageshakeButton";
export function CallEndedView({ export function CallEndedView({
client, client,
isPasswordlessUser, isPasswordlessUser,
endedCallId, endedCallId,
leaveError,
reconnect,
}: { }: {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
endedCallId: string; endedCallId: string;
leaveError?: Error;
reconnect: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
@@ -76,6 +81,7 @@ export function CallEndedView({
}, },
[endedCallId, history, isPasswordlessUser, starRating] [endedCallId, history, isPasswordlessUser, starRating]
); );
const createAccountDialog = isPasswordlessUser && ( const createAccountDialog = isPasswordlessUser && (
<div className={styles.callEndedContent}> <div className={styles.callEndedContent}>
<Trans> <Trans>
@@ -138,15 +144,33 @@ export function CallEndedView({
</div> </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 ( return (
<> <>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav />
</Header>
<div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Headline className={styles.headline}> <Headline className={styles.headline}>
{surveySubmitted {surveySubmitted
@@ -168,7 +192,20 @@ export function CallEndedView({
{t("Not now, return to home screen")} {t("Not now, return to home screen")}
</Link> </Link>
</Body> </Body>
</div> </>
);
}
};
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav />
</Header>
<div className={styles.container}>{renderBody()}</div>
</> </>
); );
} }

View File

@@ -163,9 +163,12 @@ export function GroupCallView({
useSentryGroupCallHandler(groupCall); useSentryGroupCallHandler(groupCall);
const [left, setLeft] = useState(false); const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
const history = useHistory(); const history = useHistory();
const onLeave = useCallback(async () => { const onLeave = useCallback(
async (leaveError?: Error) => {
setLeaveError(leaveError);
setLeft(true); setLeft(true);
let participantCount = 0; let participantCount = 0;
@@ -198,7 +201,9 @@ export function GroupCallView({
) { ) {
history.push("/"); history.push("/");
} }
}, [groupCall, leave, isPasswordlessUser, isEmbedded, history]); },
[groupCall, leave, isPasswordlessUser, isEmbedded, history]
);
useEffect(() => { useEffect(() => {
if (widget && state === GroupCallState.Entered) { if (widget && state === GroupCallState.Entered) {
@@ -218,6 +223,12 @@ export function GroupCallView({
undefined undefined
); );
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
groupCall.enter();
}, [groupCall]);
if (error) { if (error) {
return <ErrorView error={error} />; return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered && userChoices) { } else if (state === GroupCallState.Entered && userChoices) {
@@ -248,13 +259,16 @@ export function GroupCallView({
// submitting anything. // submitting anything.
if ( if (
isPasswordlessUser || isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded) (PosthogAnalytics.instance.isEnabled() && !isEmbedded) ||
leaveError
) { ) {
return ( return (
<CallEndedView <CallEndedView
endedCallId={groupCall.groupCallId} endedCallId={groupCall.groupCallId}
client={client} client={client}
isPasswordlessUser={isPasswordlessUser} isPasswordlessUser={isPasswordlessUser}
leaveError={leaveError}
reconnect={onReconnect}
/> />
); );
} else { } else {

View File

@@ -24,7 +24,7 @@ import {
} from "@livekit/components-react"; } from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays"; import { usePreventScroll } from "@react-aria/overlays";
import classNames from "classnames"; 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 { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; 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 useMeasure from "react-use-measure";
import { OverlayTriggerState } from "@react-stately/overlays"; import { OverlayTriggerState } from "@react-stately/overlays";
import { JoinRule } from "matrix-js-sdk/src/@types/partials"; 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 type { IWidgetApiRequest } from "matrix-widget-api";
import { import {
@@ -83,6 +84,7 @@ import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout"; import { useLayoutStates } from "../video-grid/Layout";
import { useSFUConfig } from "../livekit/OpenIDLoader"; import { useSFUConfig } from "../livekit/OpenIDLoader";
import { E2EELock } from "../E2EELock"; import { E2EELock } from "../E2EELock";
import { useEventEmitterThree } from "../useEvents";
import { useWakeLock } from "../useWakeLock"; import { useWakeLock } from "../useWakeLock";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -115,7 +117,7 @@ export interface InCallViewProps {
groupCall: GroupCall; groupCall: GroupCall;
livekitRoom: Room; livekitRoom: Room;
participants: Map<RoomMember, Map<string, ParticipantInfo>>; participants: Map<RoomMember, Map<string, ParticipantInfo>>;
onLeave: () => void; onLeave: (error?: Error) => void;
unencryptedEventsFromUsers: Set<string>; unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean; hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
@@ -190,6 +192,23 @@ export function InCallView({
async (muted) => await localParticipant.setMicrophoneEnabled(!muted) 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(() => { useEffect(() => {
widget?.api.transport.send( widget?.api.transport.send(
layout === "freedom" layout === "freedom"
@@ -386,7 +405,7 @@ export function InCallView({
} }
buttons.push( 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>; footer = <div className={styles.footer}>{buttons}</div>;
} }

View File

@@ -0,0 +1,21 @@
/*
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.
*/
.rageshakeControl {
height: 50px;
text-align: center;
vertical-align: middle;
}

View File

@@ -0,0 +1,67 @@
/*
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 { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { Button } from "../button";
import { Config } from "../config/Config";
import styles from "./RageshakeButton.module.css";
import { useSubmitRageshake } from "./submit-rageshake";
interface Props {
description: string;
}
export const RageshakeButton = ({ description }: Props) => {
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const { t } = useTranslation();
const sendDebugLogs = useCallback(() => {
submitRageshake({
description,
sendLogs: true,
});
}, [submitRageshake, description]);
if (!Config.get().rageshake?.submit_url) return null;
let logsComponent: JSX.Element | null = null;
if (sending) {
logsComponent = <span>{t("Sending…")}</span>;
} else if (sent) {
logsComponent = <div>{t("Thanks!")}</div>;
} else {
let caption = t("Send debug logs");
if (error) {
caption = t("Retry sending logs");
}
logsComponent = (
<Button
size="lg"
variant="default"
onPress={sendDebugLogs}
className={styles.wideButton}
disabled={sending}
>
{caption}
</Button>
);
}
return <div className={styles.rageshakeControl}>{logsComponent}</div>;
};

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { useEffect } from "react"; import { useEffect } from "react";
import EventEmitter from "eventemitter3";
import type { import type {
Listener, Listener,
@@ -59,3 +60,20 @@ export const useTypedEventEmitter = <
}; };
}, [emitter, eventType, listener]); }, [emitter, eventType, listener]);
}; };
// Shortcut for registering a listener on an eventemitter3 EventEmitter (ie. what the LiveKit SDK uses)
export const useEventEmitterThree = <
EventType extends EventEmitter.ValidEventTypes,
T extends EventEmitter.EventNames<EventType>
>(
emitter: EventEmitter<EventType>,
eventType: T,
listener: EventEmitter.EventListener<EventType, T>
) => {
useEffect(() => {
emitter.on(eventType, listener);
return () => {
emitter.off(eventType, listener);
};
}, [emitter, eventType, listener]);
};