Merge branch 'livekit' into valere/async_error_show_boundary

This commit is contained in:
Valere
2025-03-13 11:20:32 +01:00
58 changed files with 1191 additions and 427 deletions

View File

@@ -12,13 +12,15 @@ import {
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import {
type CallErrorRecoveryAction,
GroupCallErrorBoundary,
} from "./GroupCallErrorBoundary.tsx";
import {
ConnectionLostError,
E2EENotSupportedError,
@@ -28,8 +30,7 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([
{
@@ -63,7 +64,11 @@ test.each([
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary onError={onErrorMock}>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
@@ -88,14 +93,18 @@ test("should render the error page with link back to home", async () => {
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary onError={onErrorMock}>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
expect(screen.getByText(/Domain: example.com/i)).toBeInTheDocument();
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
expect(
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
).toBeInTheDocument();
@@ -108,39 +117,7 @@ test("should render the error page with link back to home", async () => {
expect(asFragment()).toMatchSnapshot();
});
test("should have a reconnect button for ConnectionLostError", async () => {
const user = userEvent.setup();
const reconnectCallback = vi.fn();
const TestComponent = (): ReactNode => {
throw new ConnectionLostError();
};
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={reconnectCallback}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection lost");
await screen.findByRole("button", { name: "Reconnect" });
await screen.findByRole("button", { name: "Return to home screen" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Reconnect" }));
expect(reconnectCallback).toHaveBeenCalledOnce();
expect(reconnectCallback).toHaveBeenCalledWith("reconnect");
});
test("Action handling should reset error state", async () => {
test("ConnectionLostError: Action handling should reset error state", async () => {
const user = userEvent.setup();
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
@@ -150,30 +127,46 @@ test("Action handling should reset error state", async () => {
return <div>HELLO</div>;
};
const reconnectCallbackSpy = vi.fn();
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(() => {
setFailState(false);
}, [setFailState]);
const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
},
[setFailState],
);
return (
<BrowserRouter>
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
<GroupCallErrorBoundary
recoveryActionHandler={reconnectCallback}
widget={null}
>
<TestComponent fail={failState} />
</GroupCallErrorBoundary>
</BrowserRouter>
);
};
render(<WrapComponent />);
const { asFragment } = render(<WrapComponent />);
// Should fail first
await screen.findByText("Connection lost");
await screen.findByRole("button", { name: "Reconnect" });
await screen.findByRole("button", { name: "Return to home screen" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Reconnect" }));
// reconnect should have reset the error, thus rendering should be ok
await screen.findByText("HELLO");
expect(reconnectCallbackSpy).toHaveBeenCalledOnce();
expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect");
});
describe("Rageshake button", () => {
@@ -190,7 +183,11 @@ describe("Rageshake button", () => {
render(
<BrowserRouter>
<GroupCallErrorBoundary onError={vi.fn()}>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
@@ -214,29 +211,43 @@ describe("Rageshake button", () => {
});
});
test("should show async error with useElementCallErrorContext", async () => {
// const error = new MatrixRTCFocusMissingError("example.com");
test("should have a close button in widget mode", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const TestComponent = (): ReactNode => {
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useEffect(() => {
setTimeout(() => {
showGroupCallErrorBoundary(new ConnectionLostError());
});
}, [showGroupCallErrorBoundary]);
return <div>Hello</div>;
throw error;
};
const mockWidget = {
api: {
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
},
} as unknown as WidgetHelpers;
const user = userEvent.setup();
const onErrorMock = vi.fn();
render(
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundaryContextProvider>
<GroupCallErrorBoundary onError={onErrorMock}>
<TestComponent />
</GroupCallErrorBoundary>
</GroupCallErrorBoundaryContextProvider>
<GroupCallErrorBoundary
widget={mockWidget}
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection lost");
await screen.findByText("Call is not supported");
await screen.findByRole("button", { name: "Close" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
});

View File

@@ -31,6 +31,7 @@ import {
} from "../utils/errors.ts";
import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
@@ -38,17 +39,18 @@ export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
interface ErrorPageProps {
error: ElementCallError;
recoveryActionHandler?: RecoveryActionHandler;
recoveryActionHandler: RecoveryActionHandler;
resetError: () => void;
widget: WidgetHelpers | null;
}
const ErrorPage: FC<ErrorPageProps> = ({
error,
recoveryActionHandler,
widget,
}: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
// let title: string;
let icon: ComponentType<SVGAttributes<SVGElement>>;
switch (error.category) {
case ErrorCategory.CONFIGURATION_ISSUE:
@@ -68,7 +70,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
if (error instanceof ConnectionLostError) {
actions.push({
label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler?.("reconnect"),
onClick: () => recoveryActionHandler("reconnect"),
});
}
@@ -78,6 +80,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
Icon={icon}
title={error.localisedTitle}
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
widget={widget}
>
<p>
{error.localisedMessage ?? (
@@ -101,38 +104,16 @@ const ErrorPage: FC<ErrorPageProps> = ({
interface BoundaryProps {
children: ReactNode | (() => ReactNode);
recoveryActionHandler?: RecoveryActionHandler;
recoveryActionHandler: RecoveryActionHandler;
onError?: (error: unknown) => void;
widget: WidgetHelpers | null;
}
/**
* An ErrorBoundary component that handles ElementCalls errors that can occur during a group call.
* It is based on the sentry ErrorBoundary component, that will log the error to sentry.
*
* The error fallback will show an error page with:
* - a description of the error
* - a button to go back the home screen
* - optional call-to-action buttons (ex: reconnect for connection lost)
* - A rageshake button for unknown errors
*
* For async errors the `useCallErrorBoundary` hook should be used to show the error page
* ```
* const { showGroupCallErrorBoundary } = useCallErrorBoundary();
* ... some async code
* catch(error) {
* showGroupCallErrorBoundary(error);
* }
* ...
* ```
* @param recoveryActionHandler
* @param onError
* @param children
* @constructor
*/
export const GroupCallErrorBoundary = ({
recoveryActionHandler,
onError,
children,
widget,
}: BoundaryProps): ReactElement => {
const fallbackRenderer: FallbackRender = useCallback(
({ error, resetError }): ReactElement => {
@@ -142,16 +123,17 @@ export const GroupCallErrorBoundary = ({
: new UnknownCallError(error instanceof Error ? error : new Error());
return (
<ErrorPage
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
resetError();
recoveryActionHandler?.(action);
recoveryActionHandler(action);
}}
/>
);
},
[recoveryActionHandler],
[recoveryActionHandler, widget],
);
return (

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { it } from "vitest";
import { it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { type ReactElement, useCallback } from "react";
import userEvent from "@testing-library/user-event";
@@ -37,7 +37,7 @@ it("should show async error", async () => {
render(
<BrowserRouter>
<GroupCallErrorBoundaryContextProvider>
<GroupCallErrorBoundary>
<GroupCallErrorBoundary widget={null} recoveryActionHandler={vi.fn()}>
<TestComponent />
</GroupCallErrorBoundary>
</GroupCallErrorBoundaryContextProvider>

View File

@@ -15,12 +15,16 @@ import {
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import {
Room as LivekitRoom,
isE2EESupported as isE2EESupportedBrowser,
Room,
} from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule } from "matrix-js-sdk/src/matrix";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { useNavigate } from "react-router-dom";
import type { IWidgetApiRequest } from "matrix-widget-api";
@@ -54,9 +58,16 @@ import { usePageTitle } from "../usePageTitle";
import {
E2EENotSupportedError,
ElementCallError,
ErrorCode,
RTCSessionError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import {
useNewMembershipManagerSetting as useNewMembershipManagerSetting,
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
@@ -120,6 +131,20 @@ export const GroupCallViewInner: FC<Props> = ({
};
}, [rtcSession]);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
showGroupCallErrorBoundary(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
),
);
},
);
useEffect(() => {
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
@@ -128,11 +153,14 @@ export const GroupCallViewInner: FC<Props> = ({
);
}, [client, rtcSession.room]);
const room = rtcSession.room as Room;
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(rtcSession.room);
const roomAvatar = useRoomAvatar(rtcSession.room);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
usePageTitle(roomName);
const matrixInfo = useMemo((): MatrixInfo => {
@@ -140,21 +168,13 @@ export const GroupCallViewInner: FC<Props> = ({
userId: client.getUserId()!,
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: rtcSession.room.roomId,
roomId: room.roomId,
roomName,
roomAlias: rtcSession.room.getCanonicalAlias(),
roomAlias: room.getCanonicalAlias(),
roomAvatar,
e2eeSystem,
};
}, [
client,
displayName,
avatarUrl,
rtcSession.room,
roomName,
roomAvatar,
e2eeSystem,
]);
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
@@ -166,15 +186,18 @@ export const GroupCallViewInner: FC<Props> = ({
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(rtcSession, perParticipantE2EE);
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
showGroupCallErrorBoundary(e);
@@ -199,7 +222,7 @@ export const GroupCallViewInner: FC<Props> = ({
// permissions and give you device names unless you specify a kind, but
// here we want all kinds of devices. This needs a fix in livekit-client
// for the following name-matching logic to do anything useful.
const devices = await Room.getLocalDevices(undefined, true);
const devices = await LivekitRoom.getLocalDevices(undefined, true);
if (audioInput) {
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
@@ -239,7 +262,11 @@ export const GroupCallViewInner: FC<Props> = ({
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSessionOrError(rtcSession, perParticipantE2EE);
await enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
@@ -252,13 +279,21 @@ export const GroupCallViewInner: FC<Props> = ({
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSessionOrError(rtcSession, perParticipantE2EE);
await enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
}
} else {
void enterRTCSessionOrError(rtcSession, perParticipantE2EE);
void enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
}
}
}, [
@@ -270,6 +305,7 @@ export const GroupCallViewInner: FC<Props> = ({
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,
]);
const [left, setLeft] = useState(false);
@@ -287,7 +323,7 @@ export const GroupCallViewInner: FC<Props> = ({
// Otherwise the iFrame gets killed before the callEnded event got tracked.
const posthogRequest = new Promise((resolve) => {
PosthogAnalytics.instance.eventCallEnded.track(
rtcSession.room.roomId,
room.roomId,
rtcSession.memberships.length,
sendInstantly,
rtcSession,
@@ -316,11 +352,12 @@ export const GroupCallViewInner: FC<Props> = ({
});
},
[
leaveSoundContext,
widget,
rtcSession,
room.roomId,
isPasswordlessUser,
confineToRoom,
leaveSoundContext,
navigate,
],
);
@@ -346,7 +383,7 @@ export const GroupCallViewInner: FC<Props> = ({
}
}, [widget, isJoined, rtcSession]);
const joinRule = useJoinRule(rtcSession.room);
const joinRule = useJoinRule(room);
const [shareModalOpen, setInviteModalOpen] = useState(false);
const onDismissInviteModal = useCallback(
@@ -367,7 +404,7 @@ export const GroupCallViewInner: FC<Props> = ({
const shareModal = (
<InviteModal
room={rtcSession.room}
room={room}
open={shareModalOpen}
onDismiss={onDismissInviteModal}
/>
@@ -380,7 +417,11 @@ export const GroupCallViewInner: FC<Props> = ({
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() =>
void enterRTCSessionOrError(rtcSession, perParticipantE2EE)
void enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
)
}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
@@ -438,9 +479,7 @@ export const GroupCallViewInner: FC<Props> = ({
}
} else if (left && widget !== null) {
// Left in widget mode:
if (!returnToLobby) {
body = null;
}
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
body = null;
} else {
@@ -449,10 +488,15 @@ export const GroupCallViewInner: FC<Props> = ({
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={(action) => {
if (action == "reconnect") {
setLeft(false);
enterRTCSessionOrError(rtcSession, perParticipantE2EE).catch((e) => {
enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}

View File

@@ -182,6 +182,7 @@ export const RoomPage: FC = () => {
<ErrorView
Icon={UnknownSolidIcon}
title={t("error.call_not_found")}
widget={widget}
>
<Trans i18nKey="error.call_not_found_description">
<p>
@@ -199,6 +200,7 @@ export const RoomPage: FC = () => {
<ErrorView
Icon={groupCallState.error.icon}
title={groupCallState.error.message}
widget={widget}
>
<p>{groupCallState.error.messageBody}</p>
{groupCallState.error.reason && (
@@ -212,7 +214,7 @@ export const RoomPage: FC = () => {
</FullScreenView>
);
} else {
return <ErrorPage error={groupCallState.error} />;
return <ErrorPage widget={widget} error={groupCallState.error} />;
}
default:
return <> </>;
@@ -223,7 +225,7 @@ export const RoomPage: FC = () => {
if (loading || isRegistering) {
content = <LoadingPage />;
} else if (error) {
content = <ErrorPage error={error} />;
content = <ErrorPage widget={widget} error={error} />;
} else if (!client) {
content = <RoomAuthView />;
} else if (!roomIdOrAlias) {

View File

@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`should have a reconnect button for ConnectionLostError 1`] = `
exports[`ConnectionLostError: Action handling should reset error state 1`] = `
<DocumentFragment>
<div
class="page"
@@ -150,7 +150,7 @@ exports[`should have a reconnect button for ConnectionLostError 1`] = `
</DocumentFragment>
`;
exports[`should render the error page 1`] = `
exports[`should have a close button in widget mode 1`] = `
<DocumentFragment>
<div
class="page"
@@ -287,13 +287,13 @@ exports[`should render the error page 1`] = `
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_FOCUS).
</p>
<button
class="_button_i91xf_17 homeLink"
data-kind="tertiary"
class="_button_i91xf_17"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Return to home screen
Close
</button>
</div>
</div>