From 16757701963e0391a96057b94dbc1e4231754e2f Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 20 Jul 2023 17:55:50 +0100 Subject: [PATCH 1/5] Add disconnected screen for when livekit disconnects from the call eg. when it gives up retrying to connect Also refactors the rageshake button into a dedicated component that we can just put wherever we want. Fixes https://github.com/vector-im/element-call/issues/1268 --- src/FullScreenView.tsx | 36 +---------- src/room/CallEndedView.module.css | 9 +++ src/room/CallEndedView.tsx | 83 ++++++++++++++++++------- src/room/GroupCallView.tsx | 76 +++++++++++++--------- src/room/InCallView.tsx | 29 ++++++++- src/settings/RageshakeButton.module.css | 22 +++++++ src/settings/RageshakeButton.tsx | 67 ++++++++++++++++++++ 7 files changed, 232 insertions(+), 90 deletions(-) create mode 100644 src/settings/RageshakeButton.module.css create mode 100644 src/settings/RageshakeButton.tsx diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index 95a8a7ae..e3a5403c 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -21,11 +21,10 @@ import { Trans, useTranslation } from "react-i18next"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { LinkButton, Button } from "./button"; -import { useSubmitRageshake } from "./settings/submit-rageshake"; -import { ErrorMessage } from "./input/Input"; import styles from "./FullScreenView.module.css"; -import { translatedError, TranslatedError } from "./TranslatedError"; +import { TranslatedError } from "./TranslatedError"; import { Config } from "./config/Config"; +import { RageshakeButton } from "./settings/RageshakeButton"; interface FullScreenViewProps { className?: string; @@ -97,37 +96,11 @@ export function ErrorView({ error }: ErrorViewProps) { export function CrashView() { const { t } = useTranslation(); - const { submitRageshake, sending, sent, error } = useSubmitRageshake(); - - const sendDebugLogs = useCallback(() => { - submitRageshake({ - description: "**Soft Crash**", - sendLogs: true, - }); - }, [submitRageshake]); const onReload = useCallback(() => { window.location.href = "/"; }, []); - let logsComponent: JSX.Element | null = null; - if (sent) { - logsComponent =
{t("Thanks! We'll get right on it.")}
; - } else if (sending) { - logsComponent =
{t("Sending…")}
; - } else if (Config.get().rageshake?.submit_url) { - logsComponent = ( - - ); - } - return ( @@ -139,10 +112,7 @@ export function CrashView() { )} -
{logsComponent}
- {error && ( - - )} + +
+ +
+ + + + + {t("Return to home screen")} + + + + ); + } else { + return ( + <> +
+ + {surveySubmitted + ? t("{{displayName}}, your call has ended.", { + displayName, + }) + : t("{{displayName}}, your call has ended.", { + displayName, + }) + + "\n" + + t("How did it go?")} + + {!surveySubmitted && PosthogAnalytics.instance.isEnabled() + ? qualitySurveyDialog + : createAccountDialog} +
+ + + {t("Not now, return to home screen")} + + + + ); + } + }; + return ( <>
@@ -146,29 +205,7 @@ export function CallEndedView({
-
-
- - {surveySubmitted - ? t("{{displayName}}, your call has ended.", { - displayName, - }) - : t("{{displayName}}, your call has ended.", { - displayName, - }) + - "\n" + - t("How did it go?")} - - {!surveySubmitted && PosthogAnalytics.instance.isEnabled() - ? qualitySurveyDialog - : createAccountDialog} -
- - - {t("Not now, return to home screen")} - - -
+
{renderBody()}
); } diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 29d14b82..da7cb768 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -163,42 +163,47 @@ export function GroupCallView({ useSentryGroupCallHandler(groupCall); const [left, setLeft] = useState(false); + const [leaveError, setLeaveError] = useState(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 +223,12 @@ export function GroupCallView({ undefined ); + const onReconnect = useCallback(() => { + setLeft(false); + setLeaveError(undefined); + groupCall.enter(); + }, [groupCall]); + if (error) { return ; } else if (state === GroupCallState.Entered && userChoices) { @@ -248,13 +259,16 @@ export function GroupCallView({ // submitting anything. if ( isPasswordlessUser || - (PosthogAnalytics.instance.isEnabled() && !isEmbedded) + (PosthogAnalytics.instance.isEnabled() && !isEmbedded) || + leaveError ) { return ( ); } else { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b8284975..30458599 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 { @@ -114,7 +115,7 @@ export interface InCallViewProps { groupCall: GroupCall; livekitRoom: Room; participants: Map>; - onLeave: () => void; + onLeave: (error?: Error) => void; unencryptedEventsFromUsers: Set; hideHeader: boolean; otelGroupCallMembership?: OTelGroupCallMembership; @@ -188,6 +189,28 @@ export function InCallView({ async (muted) => await localParticipant.setMicrophoneEnabled(!muted) ); + const onDisconnected = useCallback( + (reason?: DisconnectReason) => { + 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]); + + useEffect(() => { + livekitRoom.on(RoomEvent.Disconnected, onDisconnected); + + return () => { + livekitRoom.off(RoomEvent.Disconnected, onDisconnected); + }; + }, [onDisconnected, livekitRoom]); + useEffect(() => { widget?.api.transport.send( layout === "freedom" @@ -384,7 +407,7 @@ export function InCallView({ } buttons.push( - + ); footer =
{buttons}
; } diff --git a/src/settings/RageshakeButton.module.css b/src/settings/RageshakeButton.module.css new file mode 100644 index 00000000..66d0e2d9 --- /dev/null +++ b/src/settings/RageshakeButton.module.css @@ -0,0 +1,22 @@ +/* +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; + width: 176px; + text-align: center; + vertical-align: middle; +} diff --git a/src/settings/RageshakeButton.tsx b/src/settings/RageshakeButton.tsx new file mode 100644 index 00000000..b6b045d8 --- /dev/null +++ b/src/settings/RageshakeButton.tsx @@ -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 (sent) { + logsComponent =
{t("Thanks!")}
; + } else { + let caption = t("Send debug logs"); + if (error) { + caption = t("Retry sending logs"); + } else if (sending) { + logsComponent = {t("Sending…")}; + } + + logsComponent = ( + + ); + } + + return
{logsComponent}
; +}; From 4071c9029cc9fb080a6d4cdae167be8081adbeb5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 20 Jul 2023 18:22:17 +0100 Subject: [PATCH 2/5] Track call disconnections --- src/analytics/PosthogAnalytics.ts | 2 ++ src/analytics/PosthogEvents.ts | 16 ++++++++++++++++ src/room/InCallView.tsx | 1 + 3 files changed, 19 insertions(+) diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 9ef2e70c..0ca23fb3 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -30,6 +30,7 @@ import { MuteMicrophoneTracker, UndecryptableToDeviceEventTracker, QualitySurveyEventTracker, + CallDisconnectedEventTracker, } from "./PosthogEvents"; import { Config } from "../config/Config"; import { getUrlParams } from "../UrlParams"; @@ -437,4 +438,5 @@ export class PosthogAnalytics { public eventMuteCamera = new MuteCameraTracker(); public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker(); public eventQualitySurvey = new QualitySurveyEventTracker(); + public eventCallDisconnected = new CallDisconnectedEventTracker(); } diff --git a/src/analytics/PosthogEvents.ts b/src/analytics/PosthogEvents.ts index f2fecb4e..97e25b6f 100644 --- a/src/analytics/PosthogEvents.ts +++ b/src/analytics/PosthogEvents.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { DisconnectReason } from "livekit-client"; + import { IPosthogEvent, PosthogAnalytics, @@ -181,3 +183,17 @@ export class QualitySurveyEventTracker { }); } } + +interface CallDisconnectedEvent { + eventName: "CallDisconnected"; + reason?: DisconnectReason; +} + +export class CallDisconnectedEventTracker { + track(reason?: DisconnectReason) { + PosthogAnalytics.instance.trackEvent({ + eventName: "CallDisconnected", + reason, + }); + } +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 30458599..604a5d95 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -191,6 +191,7 @@ export function InCallView({ 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) From e37783ad7e778c254b309a44111a42d799442b6a Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 20 Jul 2023 18:25:54 +0100 Subject: [PATCH 3/5] i18n --- public/locales/en-GB/app.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 5fe1ffa8..2973ac79 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -76,9 +76,11 @@ "Profile": "Profile", "Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha not loaded": "Recaptcha not loaded", + "Reconnect": "Reconnect", "Register": "Register", "Registering…": "Registering…", "Remove": "Remove", + "Retry sending logs": "Retry sending logs", "Return to home screen": "Return to home screen", "Select an option": "Select an option", "Send debug logs": "Send debug logs", @@ -98,7 +100,7 @@ "Submitting…": "Submitting…", "Take me Home": "Take me Home", "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 site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)", "Turn off camera": "Turn off camera", @@ -115,6 +117,7 @@ "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", "Your recent calls": "Your recent calls" } From 9fdafaf39522b171a2daa7ad0ba2c854beecb5f9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 24 Jul 2023 21:03:56 +0100 Subject: [PATCH 4/5] Add a useEventEmitterThree for livekit's event emitters and use it --- src/room/InCallView.tsx | 9 ++------- src/useEvents.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 604a5d95..0daa0282 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -84,6 +84,7 @@ import { useFullscreen } from "./useFullscreen"; import { useLayoutStates } from "../video-grid/Layout"; import { useSFUConfig } from "../livekit/OpenIDLoader"; import { E2EELock } from "../E2EELock"; +import { useEventEmitterThree } from "../useEvents"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); // There is currently a bug in Safari our our code with cloning and sending MediaStreams @@ -204,13 +205,7 @@ export function InCallView({ onLeave(); }, [onLeave]); - useEffect(() => { - livekitRoom.on(RoomEvent.Disconnected, onDisconnected); - - return () => { - livekitRoom.off(RoomEvent.Disconnected, onDisconnected); - }; - }, [onDisconnected, livekitRoom]); + useEventEmitterThree(livekitRoom, RoomEvent.Disconnected, onDisconnected); useEffect(() => { widget?.api.transport.send( diff --git a/src/useEvents.ts b/src/useEvents.ts index 91a03abd..669cf863 100644 --- a/src/useEvents.ts +++ b/src/useEvents.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { useEffect } from "react"; +import EventEmitter from "eventemitter3"; import type { Listener, @@ -59,3 +60,20 @@ export const useTypedEventEmitter = < }; }, [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 +>( + emitter: EventEmitter, + eventType: T, + listener: EventEmitter.EventListener +) => { + useEffect(() => { + emitter.on(eventType, listener); + return () => { + emitter.off(eventType, listener); + }; + }, [emitter, eventType, listener]); +}; From 8e6380db0b68b93bdd8feebf6ff4f83ef6435b17 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 24 Jul 2023 21:33:13 +0100 Subject: [PATCH 5/5] Fix logs component states Also just display text when sending which makes the button jump around a bit but avoids hardcoding the width --- src/settings/RageshakeButton.module.css | 1 - src/settings/RageshakeButton.tsx | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/settings/RageshakeButton.module.css b/src/settings/RageshakeButton.module.css index 66d0e2d9..5a621b6c 100644 --- a/src/settings/RageshakeButton.module.css +++ b/src/settings/RageshakeButton.module.css @@ -16,7 +16,6 @@ limitations under the License. .rageshakeControl { height: 50px; - width: 176px; text-align: center; vertical-align: middle; } diff --git a/src/settings/RageshakeButton.tsx b/src/settings/RageshakeButton.tsx index b6b045d8..599c565d 100644 --- a/src/settings/RageshakeButton.tsx +++ b/src/settings/RageshakeButton.tsx @@ -40,14 +40,14 @@ export const RageshakeButton = ({ description }: Props) => { if (!Config.get().rageshake?.submit_url) return null; let logsComponent: JSX.Element | null = null; - if (sent) { + if (sending) { + logsComponent = {t("Sending…")}; + } else if (sent) { logsComponent =
{t("Thanks!")}
; } else { let caption = t("Send debug logs"); if (error) { caption = t("Retry sending logs"); - } else if (sending) { - logsComponent = {t("Sending…")}; } logsComponent = (