diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index d697a70d..f63e9816 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -77,9 +77,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", @@ -99,7 +101,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", @@ -116,6 +118,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" } diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index cdbc0297..4b9a1e2b 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -22,11 +22,10 @@ import * as Sentry from "@sentry/react"; 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; @@ -99,37 +98,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 ( @@ -141,10 +114,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 89a72002..58fa0595 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 c45edbc2..863ea6a8 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 { @@ -83,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"; import { useWakeLock } from "../useWakeLock"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -115,7 +117,7 @@ export interface InCallViewProps { groupCall: GroupCall; livekitRoom: Room; participants: Map>; - onLeave: () => void; + onLeave: (error?: Error) => void; unencryptedEventsFromUsers: Set; hideHeader: boolean; otelGroupCallMembership?: OTelGroupCallMembership; @@ -190,6 +192,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 +405,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..5a621b6c --- /dev/null +++ b/src/settings/RageshakeButton.module.css @@ -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; +} diff --git a/src/settings/RageshakeButton.tsx b/src/settings/RageshakeButton.tsx new file mode 100644 index 00000000..599c565d --- /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 (sending) { + logsComponent = {t("Sending…")}; + } else if (sent) { + logsComponent =
{t("Thanks!")}
; + } else { + let caption = t("Send debug logs"); + if (error) { + caption = t("Retry sending logs"); + } + + logsComponent = ( + + ); + } + + return
{logsComponent}
; +}; 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]); +};