mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-08 04:19:11 +00:00
Merge branch 'livekit' into valere/async_error_show_boundary
This commit is contained in:
3
src/@types/global.d.ts
vendored
3
src/@types/global.d.ts
vendored
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global";
|
||||
import { type setLogLevel as setLKLogLevel } from "livekit-client";
|
||||
|
||||
import type { DurationFormat as PolyfillDurationFormat } from "@formatjs/intl-durationformat";
|
||||
import { type Controls } from "../controls";
|
||||
|
||||
@@ -18,6 +20,7 @@ declare global {
|
||||
|
||||
interface Window {
|
||||
controls: Controls;
|
||||
setLKLogLevel: typeof setLKLogLevel;
|
||||
}
|
||||
|
||||
interface HTMLElement {
|
||||
|
||||
@@ -72,7 +72,11 @@ export const App: FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<ClientProvider>
|
||||
<MediaDevicesProvider>
|
||||
<Sentry.ErrorBoundary fallback={ErrorPage}>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<ErrorPage error={error} widget={widget} />
|
||||
)}
|
||||
>
|
||||
<DisconnectedBanner />
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
|
||||
@@ -351,7 +351,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
}, [initClientState, onSync]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,13 +12,16 @@ import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
type SVGAttributes,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { RageshakeButton } from "./settings/RageshakeButton";
|
||||
import styles from "./ErrorView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { LinkButton } from "./button";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
||||
|
||||
interface Props {
|
||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
@@ -35,6 +38,7 @@ interface Props {
|
||||
*/
|
||||
fatal?: boolean;
|
||||
children: ReactNode;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const ErrorView: FC<Props> = ({
|
||||
@@ -43,6 +47,7 @@ export const ErrorView: FC<Props> = ({
|
||||
rageshake,
|
||||
fatal,
|
||||
children,
|
||||
widget,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
@@ -51,6 +56,46 @@ export const ErrorView: FC<Props> = ({
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
||||
widget,
|
||||
}): ReactElement => {
|
||||
// in widget mode we don't want to show the return home button but a close button
|
||||
const closeWidget = (): void => {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to send close action", e);
|
||||
})
|
||||
.finally(() => {
|
||||
widget.api.transport.stop();
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button kind="primary" onClick={closeWidget}>
|
||||
{t("action.close")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Whether the error is considered fatal or pathname is `/` then reload the all app.
|
||||
// If not then navigate to home page.
|
||||
const ReturnToHomeButton = (): ReactElement => {
|
||||
if (fatal || location.pathname === "/") {
|
||||
return (
|
||||
<Button kind="tertiary" className={styles.homeLink} onClick={onReload}>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<BigIcon className={styles.icon}>
|
||||
@@ -63,20 +108,11 @@ export const ErrorView: FC<Props> = ({
|
||||
{rageshake && (
|
||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||
)}
|
||||
{!confineToRoom &&
|
||||
(fatal || location.pathname === "/" ? (
|
||||
<Button
|
||||
kind="tertiary"
|
||||
className={styles.homeLink}
|
||||
onClick={onReload}
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
))}
|
||||
{widget ? (
|
||||
<CloseWidgetButton widget={widget} />
|
||||
) : (
|
||||
!confineToRoom && <ReturnToHomeButton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import styles from "./FullScreenView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { RichError } from "./RichError";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { type WidgetHelpers } from "./widget.ts";
|
||||
|
||||
interface FullScreenViewProps {
|
||||
className?: string;
|
||||
@@ -47,11 +48,12 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: Error | unknown;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
// Due to this component being used as the crash fallback for Sentry, which has
|
||||
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
|
||||
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
logger.error(error);
|
||||
@@ -63,7 +65,13 @@ export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||
{error instanceof RichError ? (
|
||||
error.richMessage
|
||||
) : (
|
||||
<ErrorView Icon={ErrorIcon} title={t("error.generic")} rageshake fatal>
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={ErrorIcon}
|
||||
title={t("error.generic")}
|
||||
rageshake
|
||||
fatal
|
||||
>
|
||||
<p>{t("error.generic_description")}</p>
|
||||
</ErrorView>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { widget } from "./widget.ts";
|
||||
|
||||
/**
|
||||
* An error consisting of a terse message to be logged to the console and a
|
||||
@@ -31,7 +32,11 @@ const OpenElsewhere: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={PopOutIcon}
|
||||
title={t("error.open_elsewhere")}
|
||||
>
|
||||
<p>
|
||||
{t("error.open_elsewhere_description", {
|
||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
|
||||
@@ -110,8 +110,8 @@ describe("UrlParams", () => {
|
||||
});
|
||||
|
||||
describe("returnToLobby", () => {
|
||||
it("is true in SPA mode", () => {
|
||||
expect(getUrlParams("?returnToLobby=false").returnToLobby).toBe(true);
|
||||
it("is false in SPA mode", () => {
|
||||
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to false in widget mode", () => {
|
||||
|
||||
@@ -264,7 +264,9 @@ export const getUrlParams = (
|
||||
"skipLobby",
|
||||
isWidget && intent === UserIntent.StartNewCall,
|
||||
),
|
||||
returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : true,
|
||||
// In SPA mode the user should always exit to the home screen when hanging
|
||||
// up, rather than being sent back to the lobby
|
||||
returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : false,
|
||||
theme: parser.getParam("theme"),
|
||||
viaServers: !isWidget ? parser.getParam("viaServers") : null,
|
||||
homeserver: !isWidget ? parser.getParam("homeserver") : null,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ConfigOptions,
|
||||
type ResolvedConfigOptions,
|
||||
} from "./ConfigOptions";
|
||||
import { isFailure } from "../utils/fetch";
|
||||
|
||||
export class Config {
|
||||
private static internalInstance: Config | undefined;
|
||||
@@ -28,7 +29,20 @@ export class Config {
|
||||
const internalInstance = new Config();
|
||||
Config.internalInstance = internalInstance;
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig("/config.json").then(
|
||||
let fetchTarget: string;
|
||||
|
||||
if (
|
||||
window.location.pathname.endsWith("/room/") ||
|
||||
window.location.pathname.endsWith("/room")
|
||||
) {
|
||||
// it looks like we are running in standalone mode so use the config at the root
|
||||
fetchTarget = new URL("/config.json", window.location.href).href;
|
||||
} else {
|
||||
// otherwise we are probably running as a widget so use the config in the same directory
|
||||
fetchTarget = "config.json";
|
||||
}
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||
(config) => {
|
||||
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
|
||||
},
|
||||
@@ -70,18 +84,15 @@ export class Config {
|
||||
private initPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
async function downloadConfig(
|
||||
configJsonFilename: string,
|
||||
): Promise<ConfigOptions> {
|
||||
const url = new URL(configJsonFilename, window.location.href);
|
||||
const res = await fetch(url);
|
||||
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
const response = await fetch(fetchTarget);
|
||||
|
||||
if (!res.ok || res.status === 404 || res.status === 0) {
|
||||
if (isFailure(response)) {
|
||||
// Lack of a config isn't an error, we should just use the defaults.
|
||||
// Also treat a blank config as no config, assuming the status code is 0, because we don't get 404s from file:
|
||||
// URIs so this is the only way we can not fail if the file doesn't exist when loading from a file:// URI.
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface ConfigOptions {
|
||||
/**
|
||||
* A link to the end-user license agreement (EULA)
|
||||
*/
|
||||
eula: string;
|
||||
eula?: string;
|
||||
|
||||
media_devices?: {
|
||||
/**
|
||||
@@ -131,6 +131,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
eula: string;
|
||||
media_devices: {
|
||||
enable_audio: boolean;
|
||||
enable_video: boolean;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { widget } from "../widget.ts";
|
||||
|
||||
export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,7 +24,7 @@ export const HomePage: FC = () => {
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementCallOpenTelemetry } from "./otel/otel";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -79,7 +80,7 @@ const Backend = {
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (isFailure(response)) {
|
||||
throw Error(`Failed to fetch ${url}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { test } from "vitest";
|
||||
import { test, vi } from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
@@ -60,7 +60,7 @@ test.each<[string, ConnectionError]>([
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GroupCallErrorBoundary>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()} widget={null}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</MemoryRouter>,
|
||||
|
||||
@@ -24,6 +24,8 @@ import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
initRageshake().catch((e) => {
|
||||
logger.error("Failed to initialize rageshake", e);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
|
||||
import EventEmitter from "events";
|
||||
|
||||
@@ -15,11 +15,17 @@ import { mockConfig } from "./utils/test";
|
||||
import { ElementWidgetActions, widget } from "./widget";
|
||||
import { ErrorCode } from "./utils/errors.ts";
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("./UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget"));
|
||||
vi.mock("./widget", () => ({
|
||||
...actualWidget,
|
||||
widget: {
|
||||
api: { transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() } },
|
||||
api: {
|
||||
setAlwaysOnScreen: (): void => {},
|
||||
transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() },
|
||||
},
|
||||
lazyActions: new EventEmitter(),
|
||||
},
|
||||
}));
|
||||
@@ -105,38 +111,50 @@ test("It joins the correct Session", async () => {
|
||||
{
|
||||
manageMediaKeys: false,
|
||||
useLegacyMemberEvents: false,
|
||||
useNewMembershipManager: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("leaveRTCSession closes the widget on a normal hangup", async () => {
|
||||
async function testLeaveRTCSession(
|
||||
cause: "user" | "error",
|
||||
expectClose: boolean,
|
||||
): Promise<void> {
|
||||
vi.clearAllMocks();
|
||||
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
|
||||
await leaveRTCSession(session, "user");
|
||||
await leaveRTCSession(session, cause);
|
||||
expect(session.leaveRoomSession).toHaveBeenCalled();
|
||||
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.HangupCall,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
if (expectClose) {
|
||||
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(widget!.api.transport.stop).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(widget!.api.transport.send).not.toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(widget!.api.transport.stop).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
test("leaveRTCSession closes the widget on a normal hangup", async () => {
|
||||
await testLeaveRTCSession("user", true);
|
||||
});
|
||||
|
||||
test("leaveRTCSession doesn't close the widget on a fatal error", async () => {
|
||||
vi.clearAllMocks();
|
||||
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
|
||||
await leaveRTCSession(session, "error");
|
||||
expect(session.leaveRoomSession).toHaveBeenCalled();
|
||||
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.HangupCall,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(widget!.api.transport.send).not.toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
await testLeaveRTCSession("error", false);
|
||||
});
|
||||
|
||||
test("leaveRTCSession doesn't close the widget when returning to lobby", async () => {
|
||||
getUrlParams.mockReturnValue({ returnToLobby: true });
|
||||
onTestFinished(() => void getUrlParams.mockReset());
|
||||
await testLeaveRTCSession("user", false);
|
||||
});
|
||||
|
||||
test("It fails with configuration error if no live kit url config is set in fallback", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
|
||||
import { MatrixRTCFocusMissingError } from "./utils/errors.ts";
|
||||
import { getUrlParams } from "./UrlParams.ts";
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
|
||||
@@ -96,6 +97,7 @@ async function makePreferredLivekitFoci(
|
||||
export async function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
encryptMedia: boolean,
|
||||
useNewMembershipManager = true,
|
||||
): Promise<void> {
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||
@@ -113,6 +115,7 @@ export async function enterRTCSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
{
|
||||
useNewMembershipManager,
|
||||
manageMediaKeys: encryptMedia,
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
@@ -124,6 +127,13 @@ export async function enterRTCSession(
|
||||
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
},
|
||||
);
|
||||
if (widget) {
|
||||
try {
|
||||
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
||||
} catch (e) {
|
||||
logger.error("Failed to send join action", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const widgetPostHangupProcedure = async (
|
||||
@@ -149,7 +159,7 @@ const widgetPostHangupProcedure = async (
|
||||
}
|
||||
// On a normal user hangup we can shut down and close the widget. But if an
|
||||
// error occurs we should keep the widget open until the user reads it.
|
||||
if (cause === "user") {
|
||||
if (cause === "user" && !getUrlParams().returnToLobby) {
|
||||
try {
|
||||
await widget.api.transport.send(ElementWidgetActions.Close, {});
|
||||
} catch (e) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
showNonMemberTiles as showNonMemberTilesSetting,
|
||||
showConnectionStats as showConnectionStatsSetting,
|
||||
useNewMembershipManagerSetting,
|
||||
} from "./settings";
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
@@ -38,6 +39,10 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
|
||||
showConnectionStatsSetting,
|
||||
);
|
||||
|
||||
const [useNewMembershipManager, setNewMembershipManager] = useSetting(
|
||||
useNewMembershipManagerSetting,
|
||||
);
|
||||
|
||||
const sfuUrl = useMemo((): URL | null => {
|
||||
if (livekitRoom?.engine.client.ws?.url) {
|
||||
// strip the URL params
|
||||
@@ -134,6 +139,20 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="useNewMembershipManager"
|
||||
type="checkbox"
|
||||
label={t("developer_mode.use_new_membership_manager")}
|
||||
checked={!!useNewMembershipManager}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setNewMembershipManager(event.target.checked);
|
||||
},
|
||||
[setNewMembershipManager],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{livekitRoom ? (
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -113,4 +113,8 @@ export const soundEffectVolumeSetting = new Setting<number>(
|
||||
0.5,
|
||||
);
|
||||
|
||||
export const useNewMembershipManagerSetting = new Setting<boolean>(
|
||||
"new-membership-manager",
|
||||
true,
|
||||
);
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
|
||||
@@ -9,10 +9,10 @@ import { type ComponentProps, useCallback, useEffect, useState } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
ClientEvent,
|
||||
type Crypto,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { getLogsForReport } from "./rageshake";
|
||||
import { useClient } from "../ClientContext";
|
||||
@@ -34,7 +34,7 @@ const gzip = async (text: string): Promise<Blob> => {
|
||||
* Collects crypto related information.
|
||||
*/
|
||||
async function collectCryptoInfo(
|
||||
cryptoApi: Crypto.CryptoApi,
|
||||
cryptoApi: CryptoApi,
|
||||
body: FormData,
|
||||
): Promise<void> {
|
||||
body.append("crypto_version", cryptoApi.getVersion());
|
||||
@@ -82,7 +82,7 @@ async function collectCryptoInfo(
|
||||
*/
|
||||
async function collectRecoveryInfo(
|
||||
client: MatrixClient,
|
||||
cryptoApi: Crypto.CryptoApi,
|
||||
cryptoApi: CryptoApi,
|
||||
body: FormData,
|
||||
): Promise<void> {
|
||||
const secretStorage = client.secretStorage;
|
||||
|
||||
@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { isFailure } from "./utils/fetch";
|
||||
|
||||
type SoundDefinition = { mp3?: string; ogg: string };
|
||||
|
||||
export type PrefetchedSounds<S extends string> = Promise<
|
||||
@@ -49,7 +51,7 @@ export async function prefetchSounds<S extends string>(
|
||||
const response = await fetch(
|
||||
preferredFormat === "ogg" ? ogg : (mp3 ?? ogg),
|
||||
);
|
||||
if (!response.ok) {
|
||||
if (isFailure(response)) {
|
||||
// If the sound doesn't load, it's not the end of the world. We won't play
|
||||
// the sound when requested, but it's better than failing the whole application.
|
||||
logger.warn(`Could not load sound ${name}, response was not okay`);
|
||||
|
||||
@@ -496,6 +496,10 @@ export class CallViewModel extends ViewModel {
|
||||
}
|
||||
return displaynameMap;
|
||||
}),
|
||||
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
||||
// than on Chrome/Firefox). This means it is important that we share() the result so that we
|
||||
// don't do this work more times than we need to. This is achieve through the state() operator:
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
40
src/utils/displayname-integration.test.ts
Normal file
40
src/utils/displayname-integration.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { shouldDisambiguate } from "./displayname";
|
||||
import { alice } from "./test-fixtures";
|
||||
import { mockMatrixRoom } from "./test";
|
||||
|
||||
// Ideally these tests would be in ./displayname.test.ts but I can't figure out how to
|
||||
// just spy on the removeHiddenChars() function without impacting the other tests.
|
||||
// So, these tests are in this separate test file.
|
||||
vi.mock("matrix-js-sdk/src/utils");
|
||||
|
||||
describe("shouldDisambiguate", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
let jsUtils: typeof import("matrix-js-sdk/src/utils");
|
||||
|
||||
beforeAll(async () => {
|
||||
jsUtils = await import("matrix-js-sdk/src/utils");
|
||||
vi.spyOn(jsUtils, "removeHiddenChars").mockImplementation((str) => str);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should only call removeHiddenChars once for a single displayname", () => {
|
||||
const room = mockMatrixRoom({});
|
||||
shouldDisambiguate(alice, [], room);
|
||||
expect(jsUtils.removeHiddenChars).toHaveBeenCalledTimes(1);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
shouldDisambiguate(alice, [], room);
|
||||
}
|
||||
expect(jsUtils.removeHiddenChars).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,36 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
removeDirectionOverrideChars,
|
||||
removeHiddenChars,
|
||||
removeHiddenChars as removeHiddenCharsUncached,
|
||||
} from "matrix-js-sdk/src/utils";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import type { CallMembership } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
|
||||
// To illustrate a simple benchmark:
|
||||
// Chrome: 10,000 calls took 2.599ms
|
||||
// Safari: 10,000 calls took 242ms
|
||||
// See: https://github.com/element-hq/element-call/issues/3065
|
||||
|
||||
const removeHiddenCharsCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Calls removeHiddenCharsUncached and caches the result
|
||||
*/
|
||||
function removeHiddenChars(str: string): string {
|
||||
if (removeHiddenCharsCache.has(str)) {
|
||||
return removeHiddenCharsCache.get(str)!;
|
||||
}
|
||||
const result = removeHiddenCharsUncached(str);
|
||||
// this is naive but should be good enough for our purposes
|
||||
if (removeHiddenCharsCache.size > 500) {
|
||||
removeHiddenCharsCache.clear();
|
||||
}
|
||||
removeHiddenCharsCache.set(str, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
|
||||
export function shouldDisambiguate(
|
||||
member: { rawDisplayName?: string; userId: string },
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum ErrorCode {
|
||||
*/
|
||||
MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS",
|
||||
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
|
||||
MEMBERSHIP_MANAGER_UNRECOVERABLE = "MEMBERSHIP_MANAGER_UNRECOVERABLE",
|
||||
/** LiveKit indicates that the server has hit its track limits */
|
||||
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||
@@ -24,6 +25,7 @@ export enum ErrorCategory {
|
||||
/** Calling is not supported, server misconfigured (JWT service missing, no MSC support ...)*/
|
||||
CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE",
|
||||
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
|
||||
RTC_SESSION_FAILURE = "RTC_SESSION_FAILURE",
|
||||
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
|
||||
UNKNOWN = "UNKNOWN",
|
||||
// SYSTEM_FAILURE / FEDERATION_FAILURE ..
|
||||
@@ -82,6 +84,11 @@ export class ConnectionLostError extends ElementCallError {
|
||||
}
|
||||
}
|
||||
|
||||
export class RTCSessionError extends ElementCallError {
|
||||
public constructor(code: ErrorCode, message: string) {
|
||||
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
|
||||
}
|
||||
}
|
||||
export class E2EENotSupportedError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
|
||||
30
src/utils/fetch.test.ts
Normal file
30
src/utils/fetch.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from "vitest";
|
||||
|
||||
import { isFailure } from "./fetch";
|
||||
|
||||
describe("isFailure", () => {
|
||||
it("returns false for a successful response", () => {
|
||||
expect(isFailure({ ok: true, url: "https://foo.com" } as Response)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for a failed response", () => {
|
||||
expect(isFailure({ ok: false, url: "https://foo.com" } as Response)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for a file:// URL with status 0", () => {
|
||||
expect(
|
||||
isFailure({ ok: false, url: "file://foo", status: 0 } as Response),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
25
src/utils/fetch.ts
Normal file
25
src/utils/fetch.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a fetch response is a failure in a way that works with file:// URLs
|
||||
* @param response the response to check
|
||||
* @returns true if the response is a failure, false otherwise
|
||||
*/
|
||||
export function isFailure(response: Response): boolean {
|
||||
// if response says it's okay, then it's not a failure
|
||||
if (response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// fetch will return status === 0 for a success on a file:// URL, so we special case it
|
||||
if (response.url.startsWith("file:") && response.status === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the cient initialization or
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
@@ -162,7 +162,6 @@ export async function initClient(
|
||||
);
|
||||
}
|
||||
|
||||
client.setGlobalErrorOnUnknownDevices(false);
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
|
||||
Reference in New Issue
Block a user