Merge branch 'livekit' into robin/posthog-logout
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 />} />
|
||||
|
||||
@@ -347,7 +347,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>
|
||||
)}
|
||||
|
||||
@@ -161,7 +161,12 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
height={20}
|
||||
aria-label={t("header_participants_label")}
|
||||
/>
|
||||
<Text as="span" size="sm" weight="medium">
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
data-testid="roomHeader_participants_count"
|
||||
>
|
||||
{t("participant_count", { count: participantCount ?? 0 })}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
ErrorIcon,
|
||||
HostIcon,
|
||||
PopOutIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import type { ComponentType, FC, ReactNode, SVGAttributes } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { type ElementCallError, ErrorCategory } from "./utils/errors.ts";
|
||||
import { widget } from "./widget.ts";
|
||||
|
||||
/**
|
||||
* An error consisting of a terse message to be logged to the console and a
|
||||
@@ -36,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",
|
||||
@@ -51,62 +51,3 @@ export class OpenElsewhereError extends RichError {
|
||||
super("App opened in another tab", <OpenElsewhere />);
|
||||
}
|
||||
}
|
||||
|
||||
const InsufficientCapacity: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorView Icon={HostIcon} title={t("error.insufficient_capacity")}>
|
||||
<p>{t("error.insufficient_capacity_description")}</p>
|
||||
</ErrorView>
|
||||
);
|
||||
};
|
||||
|
||||
export class InsufficientCapacityError extends RichError {
|
||||
public constructor() {
|
||||
super("Insufficient server capacity", <InsufficientCapacity />);
|
||||
}
|
||||
}
|
||||
|
||||
type ECErrorProps = {
|
||||
error: ElementCallError;
|
||||
};
|
||||
|
||||
const GenericECError: FC<{ error: ElementCallError }> = ({
|
||||
error,
|
||||
}: ECErrorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let title: string;
|
||||
let icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
switch (error.category) {
|
||||
case ErrorCategory.CONFIGURATION_ISSUE:
|
||||
title = t("error.call_is_not_supported");
|
||||
icon = HostIcon;
|
||||
break;
|
||||
default:
|
||||
title = t("error.generic");
|
||||
icon = ErrorIcon;
|
||||
}
|
||||
return (
|
||||
<ErrorView Icon={icon} title={title}>
|
||||
<p>
|
||||
{error.localisedMessage ?? (
|
||||
<Trans
|
||||
i18nKey="error.unexpected_ec_error"
|
||||
components={[<b />, <code />]}
|
||||
values={{ errorCode: error.code }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</ErrorView>
|
||||
);
|
||||
};
|
||||
|
||||
export class ElementCallRichError extends RichError {
|
||||
public ecError: ElementCallError;
|
||||
public constructor(ecError: ElementCallError) {
|
||||
super(ecError.message, <GenericECError error={ecError} />);
|
||||
this.ecError = ecError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -105,7 +105,15 @@ export interface UrlParams {
|
||||
/**
|
||||
* The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web.
|
||||
*/
|
||||
analyticsID: string | null;
|
||||
posthogUserId: string | null;
|
||||
/**
|
||||
* The Posthog API host. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
posthogApiHost: string | null;
|
||||
/**
|
||||
* The Posthog API key. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
posthogApiKey: string | null;
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
@@ -155,6 +163,20 @@ export interface UrlParams {
|
||||
* If it was a Join Call button, it would be `join_existing`.
|
||||
*/
|
||||
intent: string | null;
|
||||
|
||||
/**
|
||||
* The rageshake submit URL. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
rageshakeSubmitUrl: string | null;
|
||||
|
||||
/**
|
||||
* The Sentry DSN. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
sentryDsn: string | null;
|
||||
/**
|
||||
* The Sentry environment. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
sentryEnvironment: string | null;
|
||||
}
|
||||
|
||||
// This is here as a stopgap, but what would be far nicer is a function that
|
||||
@@ -257,18 +279,26 @@ export const getUrlParams = (
|
||||
lang: parser.getParam("lang"),
|
||||
fonts: parser.getAllParams("font"),
|
||||
fontScale: Number.isNaN(fontScale) ? null : fontScale,
|
||||
analyticsID: parser.getParam("analyticsID"),
|
||||
allowIceFallback: parser.getFlagParam("allowIceFallback"),
|
||||
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
|
||||
skipLobby: parser.getFlagParam(
|
||||
"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,
|
||||
intent,
|
||||
posthogApiHost: parser.getParam("posthogApiHost"),
|
||||
posthogApiKey: parser.getParam("posthogApiKey"),
|
||||
posthogUserId:
|
||||
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
|
||||
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
|
||||
sentryDsn: parser.getParam("sentryDsn"),
|
||||
sentryEnvironment: parser.getParam("sentryEnvironment"),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
91
src/analytics/PosthogAnalytics.test.ts
Normal file
91
src/analytics/PosthogAnalytics.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
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,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value or URL params", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses URL params if both set", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores URL params and does not create instance", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,11 +72,6 @@ interface PlatformProperties {
|
||||
cryptoVersion?: string;
|
||||
}
|
||||
|
||||
interface PosthogSettings {
|
||||
project_api_key?: string;
|
||||
api_host?: string;
|
||||
}
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
@@ -115,24 +110,27 @@ export class PosthogAnalytics {
|
||||
return this.internalInstance;
|
||||
}
|
||||
|
||||
public static resetInstance(): void {
|
||||
// Reset the singleton instance
|
||||
this.internalInstance = null;
|
||||
}
|
||||
|
||||
private constructor(private readonly posthog: PostHog) {
|
||||
const posthogConfig: PosthogSettings = {
|
||||
project_api_key: Config.get().posthog?.api_key,
|
||||
api_host: Config.get().posthog?.api_host,
|
||||
};
|
||||
let apiKey: string | undefined;
|
||||
let apiHost: string | undefined;
|
||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
||||
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
|
||||
apiKey = getUrlParams().posthogApiKey ?? undefined;
|
||||
apiHost = getUrlParams().posthogApiHost ?? undefined;
|
||||
} else if (import.meta.env.VITE_PACKAGE === "full") {
|
||||
// in full package it is the server responsible for the analytics
|
||||
apiKey = Config.get().posthog?.api_key;
|
||||
apiHost = Config.get().posthog?.api_host;
|
||||
}
|
||||
|
||||
if (posthogConfig.project_api_key && posthogConfig.api_host) {
|
||||
if (
|
||||
PosthogAnalytics.getPlatformProperties().matrixBackend === "embedded"
|
||||
) {
|
||||
const { analyticsID } = getUrlParams();
|
||||
// if the embedding platform (element web) already got approval to communicating with posthog
|
||||
// element call can also send events to posthog
|
||||
optInAnalytics.setValue(Boolean(analyticsID));
|
||||
}
|
||||
|
||||
this.posthog.init(posthogConfig.project_api_key, {
|
||||
api_host: posthogConfig.api_host,
|
||||
if (apiKey && apiHost) {
|
||||
this.posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
autocapture: false,
|
||||
mask_all_text: true,
|
||||
mask_all_element_attributes: true,
|
||||
@@ -275,7 +273,7 @@ export class PosthogAnalytics {
|
||||
const client: MatrixClient = window.matrixclient;
|
||||
let accountAnalyticsId: string | null;
|
||||
if (widget) {
|
||||
accountAnalyticsId = getUrlParams().analyticsID;
|
||||
accountAnalyticsId = getUrlParams().posthogUserId;
|
||||
} else {
|
||||
const accountData = await client.getAccountDataFromServer(
|
||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
export interface ConfigOptions {
|
||||
/**
|
||||
* The Posthog endpoint to which analytics data will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
*/
|
||||
posthog?: {
|
||||
api_key: string;
|
||||
@@ -15,6 +16,7 @@ export interface ConfigOptions {
|
||||
};
|
||||
/**
|
||||
* The Sentry endpoint to which crash data will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
*/
|
||||
sentry?: {
|
||||
DSN: string;
|
||||
@@ -22,6 +24,7 @@ export interface ConfigOptions {
|
||||
};
|
||||
/**
|
||||
* The rageshake server to which feedback and debug logs will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
*/
|
||||
rageshake?: {
|
||||
submit_url: string;
|
||||
@@ -29,7 +32,7 @@ export interface ConfigOptions {
|
||||
|
||||
/**
|
||||
* Sets the URL to send opentelemetry data to. If unset, opentelemetry will
|
||||
* be disabled.
|
||||
* be disabled. This is only used in the full package of Element Call.
|
||||
*/
|
||||
opentelemetry?: {
|
||||
collector_url: string;
|
||||
@@ -76,7 +79,7 @@ export interface ConfigOptions {
|
||||
/**
|
||||
* A link to the end-user license agreement (EULA)
|
||||
*/
|
||||
eula: string;
|
||||
eula?: string;
|
||||
|
||||
media_devices?: {
|
||||
/**
|
||||
@@ -131,6 +134,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} />
|
||||
|
||||
@@ -5,24 +5,158 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { Initializer } from "../src/initializer";
|
||||
import { mockConfig } from "./utils/test";
|
||||
|
||||
test("initBeforeReact sets font family from URL param", async () => {
|
||||
window.location.hash = "#?font=DejaVu Sans";
|
||||
await Initializer.initBeforeReact();
|
||||
expect(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-family",
|
||||
),
|
||||
).toBe('"DejaVu Sans"');
|
||||
});
|
||||
|
||||
test("initBeforeReact sets font scale from URL param", async () => {
|
||||
window.location.hash = "#?fontScale=1.2";
|
||||
await Initializer.initBeforeReact();
|
||||
expect(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-scale"),
|
||||
).toBe("1.2");
|
||||
const sentryInitSpy = vi.fn();
|
||||
|
||||
// Place the mock after the spy is defined
|
||||
vi.mock("@sentry/react", () => ({
|
||||
init: sentryInitSpy,
|
||||
reactRouterV7BrowserTracingIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Initializer", async () => {
|
||||
// we import here to make sure that Sentry is mocked first
|
||||
const { Initializer } = await import("./initializer.tsx");
|
||||
describe("initBeforeReact()", () => {
|
||||
it("sets font family from URL param", async () => {
|
||||
window.location.hash = "#?font=DejaVu Sans";
|
||||
await Initializer.initBeforeReact();
|
||||
expect(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-family",
|
||||
),
|
||||
).toBe('"DejaVu Sans"');
|
||||
});
|
||||
|
||||
it("sets font scale from URL param", async () => {
|
||||
window.location.hash = "#?fontScale=1.2";
|
||||
await Initializer.initBeforeReact();
|
||||
expect(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-scale",
|
||||
),
|
||||
).toBe("1.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("init()", () => {
|
||||
describe("sentry setup", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
Initializer.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sentryInitSpy.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not call Sentry.init() without config value", async () => {
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", async () => {
|
||||
mockConfig({
|
||||
sentry: {
|
||||
DSN: "https://config.example.com.localhost",
|
||||
environment: "config",
|
||||
},
|
||||
});
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses sentryDsn param if set", async () => {
|
||||
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}`;
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: "https://dsn.example.com.localhost",
|
||||
environment: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses sentryDsn and sentryEnvironment params if set", async () => {
|
||||
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}&sentryEnvironment=fooEnvironment`;
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: "https://dsn.example.com.localhost",
|
||||
environment: "fooEnvironment",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
Initializer.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sentryInitSpy.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value or URL param", async () => {
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores URL params and does not create instance", async () => {
|
||||
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}&sentryEnvironment=fooEnvironment`;
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates instance with config value", async () => {
|
||||
mockConfig({
|
||||
sentry: {
|
||||
DSN: "https://dsn.example.com.localhost",
|
||||
environment: "fooEnvironment",
|
||||
},
|
||||
});
|
||||
await Initializer.init();
|
||||
expect(sentryInitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: "https://dsn.example.com.localhost",
|
||||
environment: "fooEnvironment",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -108,11 +109,11 @@ class DependencyLoadStates {
|
||||
}
|
||||
|
||||
export class Initializer {
|
||||
private static internalInstance: Initializer;
|
||||
private static internalInstance: Initializer | undefined;
|
||||
private isInitialized = false;
|
||||
|
||||
public static isInitialized(): boolean {
|
||||
return Initializer.internalInstance?.isInitialized;
|
||||
return !!Initializer.internalInstance?.isInitialized;
|
||||
}
|
||||
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
@@ -192,11 +193,19 @@ export class Initializer {
|
||||
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
|
||||
// initStep calls itself recursively until everything is initialized in the correct order.
|
||||
// Then the promise gets resolved.
|
||||
Initializer.internalInstance.initStep(resolve);
|
||||
Initializer.internalInstance?.initStep(resolve);
|
||||
});
|
||||
return Initializer.internalInstance.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the initializer. This is used in tests to ensure that the initializer
|
||||
* is re-initialized for each test.
|
||||
*/
|
||||
public static reset(): void {
|
||||
Initializer.internalInstance = undefined;
|
||||
}
|
||||
|
||||
private loadStates = new DependencyLoadStates();
|
||||
|
||||
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
|
||||
@@ -219,10 +228,22 @@ export class Initializer {
|
||||
this.loadStates.sentry === LoadState.None &&
|
||||
this.loadStates.config === LoadState.Loaded
|
||||
) {
|
||||
if (Config.get().sentry?.DSN && Config.get().sentry?.environment) {
|
||||
let dsn: string | undefined;
|
||||
let environment: string | undefined;
|
||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
||||
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
|
||||
dsn = getUrlParams().sentryDsn ?? undefined;
|
||||
environment = getUrlParams().sentryEnvironment ?? undefined;
|
||||
}
|
||||
if (import.meta.env.VITE_PACKAGE === "full") {
|
||||
// in full package it is the server responsible for the analytics
|
||||
dsn = Config.get().sentry?.DSN;
|
||||
environment = Config.get().sentry?.environment;
|
||||
}
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn: Config.get().sentry?.DSN,
|
||||
environment: Config.get().sentry?.environment,
|
||||
dsn,
|
||||
environment,
|
||||
integrations: [
|
||||
Sentry.reactRouterV7BrowserTracingIntegration({
|
||||
useEffect: React.useEffect,
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useEffect, useState } from "react";
|
||||
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
||||
import { useErrorBoundary } from "../useErrorBoundary";
|
||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||
|
||||
export interface SFUConfig {
|
||||
url: string;
|
||||
@@ -38,6 +41,7 @@ export function useOpenIDSFU(
|
||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||
|
||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||
const { showErrorBoundary } = useErrorBoundary();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFocus) {
|
||||
@@ -46,13 +50,14 @@ export function useOpenIDSFU(
|
||||
setSFUConfig(sfuConfig);
|
||||
},
|
||||
(e) => {
|
||||
showErrorBoundary(new FailToGetOpenIdToken(e));
|
||||
logger.error("Failed to get SFU config", e);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setSFUConfig(undefined);
|
||||
}
|
||||
}, [client, activeFocus]);
|
||||
}, [client, activeFocus, showErrorBoundary]);
|
||||
|
||||
return sfuConfig;
|
||||
}
|
||||
@@ -61,7 +66,16 @@ export async function getSFUConfigWithOpenID(
|
||||
client: OpenIDClientParts,
|
||||
activeFocus: LivekitFocus,
|
||||
): Promise<SFUConfig | undefined> {
|
||||
const openIdToken = await client.getOpenIdToken();
|
||||
let openIdToken: IOpenIDToken;
|
||||
try {
|
||||
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||
client.getOpenIdToken(),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new FailToGetOpenIdToken(
|
||||
error instanceof Error ? error : new Error("Unknown error"),
|
||||
);
|
||||
}
|
||||
logger.debug("Got openID token", openIdToken);
|
||||
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
@@ -14,20 +14,23 @@ import {
|
||||
} from "livekit-client";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ErrorBoundary } from "@sentry/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { ErrorPage } from "../FullScreenView";
|
||||
import { useECConnectionState } from "./useECConnectionState";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
|
||||
|
||||
test.each<[string, ConnectionError]>([
|
||||
[
|
||||
"LiveKit",
|
||||
"LiveKit hits track limit",
|
||||
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
|
||||
],
|
||||
[
|
||||
"LiveKit Cloud",
|
||||
"LiveKit hits room participant limit",
|
||||
new ConnectionError("", ConnectionErrorReason.ServerUnreachable, 200),
|
||||
],
|
||||
[
|
||||
"LiveKit Cloud hits connection limit",
|
||||
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
|
||||
],
|
||||
])(
|
||||
@@ -61,9 +64,9 @@ test.each<[string, ConnectionError]>([
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ErrorBoundary fallback={ErrorPage}>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()} widget={null}>
|
||||
<TestComponent />
|
||||
</ErrorBoundary>
|
||||
</GroupCallErrorBoundary>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
@@ -20,7 +20,11 @@ import * as Sentry from "@sentry/react";
|
||||
|
||||
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { InsufficientCapacityError, RichError } from "../RichError";
|
||||
import {
|
||||
ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -140,11 +144,16 @@ async function connectAndPublish(
|
||||
websocketTimeout: window.websocketTimeout ?? 45000,
|
||||
});
|
||||
} catch (e) {
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits
|
||||
// or equivalently, 429 in LiveKit Cloud
|
||||
// For reference, the 503 response is generated at: https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
|
||||
if (e instanceof ConnectionError && (e.status === 503 || e.status === 429))
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
// participant limits.
|
||||
// LiveKit Cloud uses 429 for connection limits.
|
||||
// Either way, all these errors can be explained as "insufficient capacity".
|
||||
if (
|
||||
e instanceof ConnectionError &&
|
||||
(e.status === 503 || e.status === 200 || e.status === 429)
|
||||
)
|
||||
throw new InsufficientCapacityError();
|
||||
throw e;
|
||||
}
|
||||
@@ -188,7 +197,7 @@ export function useECConnectionState(
|
||||
|
||||
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
|
||||
const [isInDoConnect, setIsInDoConnect] = useState(false);
|
||||
const [error, setError] = useState<RichError | null>(null);
|
||||
const [error, setError] = useState<ElementCallError | null>(null);
|
||||
if (error !== null) throw error;
|
||||
|
||||
const onConnStateChanged = useCallback((state: ConnectionState) => {
|
||||
@@ -271,9 +280,11 @@ export function useECConnectionState(
|
||||
initialAudioOptions,
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e instanceof RichError)
|
||||
if (e instanceof ElementCallError) {
|
||||
setError(e); // Bubble up any error screens to React
|
||||
else logger.error("Failed to connect to SFU", e);
|
||||
} else if (e instanceof Error) {
|
||||
setError(new UnknownCallError(e));
|
||||
} else logger.error("Failed to connect to SFU", e);
|
||||
})
|
||||
.finally(() => setIsInDoConnect(false));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
79
src/otel/otel.test.ts
Normal file
79
src/otel/otel.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
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,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
describe("ElementCallOpenTelemetry", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
opentelemetry: {
|
||||
collector_url: "https://collector.example.com.localhost",
|
||||
},
|
||||
});
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
opentelemetry: {
|
||||
collector_url: "https://collector.example.com.localhost",
|
||||
},
|
||||
});
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { PosthogSpanProcessor } from "../analytics/PosthogSpanProcessor";
|
||||
import { Config } from "../config/Config";
|
||||
import { RageshakeSpanProcessor } from "../analytics/RageshakeSpanProcessor";
|
||||
import { getRageshakeSubmitUrl } from "../settings/submit-rageshake";
|
||||
|
||||
const SERVICE_NAME = "element-call";
|
||||
|
||||
@@ -28,20 +29,24 @@ export class ElementCallOpenTelemetry {
|
||||
public readonly rageshakeProcessor?: RageshakeSpanProcessor;
|
||||
|
||||
public static globalInit(): void {
|
||||
const config = Config.get();
|
||||
// this is only supported in the full package as the is currently no support for passing in the collector URL from the widget host
|
||||
const collectorUrl =
|
||||
import.meta.env.VITE_PACKAGE === "full"
|
||||
? Config.get().opentelemetry?.collector_url
|
||||
: undefined;
|
||||
// we always enable opentelemetry in general. We only enable the OTLP
|
||||
// collector if a URL is defined (and in future if another setting is defined)
|
||||
// Posthog reporting is enabled or disabled
|
||||
// within the posthog code.
|
||||
const shouldEnableOtlp = Boolean(config.opentelemetry?.collector_url);
|
||||
const shouldEnableOtlp = Boolean(collectorUrl);
|
||||
|
||||
if (!sharedInstance || sharedInstance.isOtlpEnabled !== shouldEnableOtlp) {
|
||||
logger.info("(Re)starting OpenTelemetry debug reporting");
|
||||
sharedInstance?.dispose();
|
||||
|
||||
sharedInstance = new ElementCallOpenTelemetry(
|
||||
config.opentelemetry?.collector_url,
|
||||
config.rageshake?.submit_url,
|
||||
collectorUrl,
|
||||
getRageshakeSubmitUrl(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
253
src/room/GroupCallErrorBoundary.test.tsx
Normal file
253
src/room/GroupCallErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
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 { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
type FC,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
type CallErrorRecoveryAction,
|
||||
GroupCallErrorBoundary,
|
||||
} from "./GroupCallErrorBoundary.tsx";
|
||||
import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
type ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
MatrixRTCFocusMissingError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
||||
|
||||
test.each([
|
||||
{
|
||||
error: new MatrixRTCFocusMissingError("example.com"),
|
||||
expectedTitle: "Call is not supported",
|
||||
},
|
||||
{
|
||||
error: new ConnectionLostError(),
|
||||
expectedTitle: "Connection lost",
|
||||
expectedDescription: "You were disconnected from the call.",
|
||||
},
|
||||
{
|
||||
error: new E2EENotSupportedError(),
|
||||
expectedTitle: "Incompatible browser",
|
||||
expectedDescription:
|
||||
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
||||
},
|
||||
{
|
||||
error: new InsufficientCapacityError(),
|
||||
expectedTitle: "Insufficient capacity",
|
||||
expectedDescription:
|
||||
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
},
|
||||
])(
|
||||
"should report correct error for $expectedTitle",
|
||||
async ({ error, expectedTitle, expectedDescription }) => {
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText(expectedTitle);
|
||||
if (expectedDescription) {
|
||||
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
|
||||
}
|
||||
expect(onErrorMock).toHaveBeenCalledWith(error);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
test("should render the error page with link back to home", async () => {
|
||||
const error = new MatrixRTCFocusMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<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(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await screen.findByRole("button", { name: "Return to home screen" });
|
||||
|
||||
expect(onErrorMock).toHaveBeenCalledOnce();
|
||||
expect(onErrorMock).toHaveBeenCalledWith(error);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("ConnectionLostError: Action handling should reset error state", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
|
||||
if (fail) {
|
||||
throw new ConnectionLostError();
|
||||
}
|
||||
return <div>HELLO</div>;
|
||||
};
|
||||
|
||||
const reconnectCallbackSpy = vi.fn();
|
||||
|
||||
const WrapComponent = (): ReactNode => {
|
||||
const [failState, setFailState] = useState(true);
|
||||
const reconnectCallback = useCallback(
|
||||
(action: CallErrorRecoveryAction) => {
|
||||
reconnectCallbackSpy(action);
|
||||
setFailState(false);
|
||||
},
|
||||
[setFailState],
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={reconnectCallback}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent fail={failState} />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
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", () => {
|
||||
function setupTest(testError: ElementCallError): void {
|
||||
mockConfig({
|
||||
rageshake: {
|
||||
submit_url: "https://rageshake.example.com.localhost",
|
||||
},
|
||||
});
|
||||
|
||||
const TestComponent = (): ReactElement => {
|
||||
throw testError;
|
||||
};
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
test("should show send rageshake button for unknown errors", () => {
|
||||
setupTest(new UnknownCallError(new Error("FOO")));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Send debug logs" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should not show send rageshake button for call errors", () => {
|
||||
setupTest(new E2EENotSupportedError());
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Send debug logs" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should have a close button in widget mode", async () => {
|
||||
const error = new MatrixRTCFocusMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
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();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
widget={mockWidget}
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
146
src/room/GroupCallErrorBoundary.tsx
Normal file
146
src/room/GroupCallErrorBoundary.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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 { ErrorBoundary, type FallbackRender } from "@sentry/react";
|
||||
import {
|
||||
type ComponentType,
|
||||
type FC,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type SVGAttributes,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
ErrorIcon,
|
||||
HostIcon,
|
||||
OfflineIcon,
|
||||
WebBrowserIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import {
|
||||
ConnectionLostError,
|
||||
ElementCallError,
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
UnknownCallError,
|
||||
} 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" ;
|
||||
|
||||
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: ElementCallError;
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
resetError: () => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
const ErrorPage: FC<ErrorPageProps> = ({
|
||||
error,
|
||||
recoveryActionHandler,
|
||||
widget,
|
||||
}: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
switch (error.category) {
|
||||
case ErrorCategory.CONFIGURATION_ISSUE:
|
||||
icon = HostIcon;
|
||||
break;
|
||||
case ErrorCategory.NETWORK_CONNECTIVITY:
|
||||
icon = OfflineIcon;
|
||||
break;
|
||||
case ErrorCategory.CLIENT_CONFIGURATION:
|
||||
icon = WebBrowserIcon;
|
||||
break;
|
||||
default:
|
||||
icon = ErrorIcon;
|
||||
}
|
||||
|
||||
const actions: { label: string; onClick: () => void }[] = [];
|
||||
if (error instanceof ConnectionLostError) {
|
||||
actions.push({
|
||||
label: t("call_ended_view.reconnect_button"),
|
||||
onClick: () => recoveryActionHandler("reconnect"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
<ErrorView
|
||||
Icon={icon}
|
||||
title={error.localisedTitle}
|
||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||
widget={widget}
|
||||
>
|
||||
<p>
|
||||
{error.localisedMessage ?? (
|
||||
<Trans
|
||||
i18nKey="error.unexpected_ec_error"
|
||||
components={[<b />, <code />]}
|
||||
values={{ errorCode: error.code }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
{actions &&
|
||||
actions.map((action, index) => (
|
||||
<button onClick={action.onClick} key={`action${index}`}>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</ErrorView>
|
||||
</FullScreenView>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoundaryProps {
|
||||
children: ReactNode | (() => ReactNode);
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
onError?: (error: unknown) => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallErrorBoundary = ({
|
||||
recoveryActionHandler,
|
||||
onError,
|
||||
children,
|
||||
widget,
|
||||
}: BoundaryProps): ReactElement => {
|
||||
const fallbackRenderer: FallbackRender = useCallback(
|
||||
({ error, resetError }): ReactElement => {
|
||||
const callError =
|
||||
error instanceof ElementCallError
|
||||
? error
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
error={callError}
|
||||
resetError={resetError}
|
||||
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
|
||||
resetError();
|
||||
recoveryActionHandler(action);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[recoveryActionHandler, widget],
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={fallbackRenderer}
|
||||
onError={(error) => onError?.(error)}
|
||||
children={children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
|
||||
import {
|
||||
beforeEach,
|
||||
expect,
|
||||
type MockedFunction,
|
||||
onTestFinished,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
||||
@@ -15,6 +22,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
@@ -28,20 +36,33 @@ import {
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCFocusMissingError } from "../utils/errors";
|
||||
|
||||
vitest.mock("../soundUtils");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("./InCallView");
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
vi.mock("./InCallView");
|
||||
vi.mock("react-use-measure", () => ({
|
||||
default: (): [() => void, object] => [(): void => {}, {}],
|
||||
}));
|
||||
|
||||
vitest.mock("../rtcSessionHelpers", async (importOriginal) => {
|
||||
const enterRTCSession = vi.hoisted(() => vi.fn(async () => Promise.resolve()));
|
||||
const leaveRTCSession = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
async (
|
||||
rtcSession: unknown,
|
||||
cause: unknown,
|
||||
promiseBeforeHangup = Promise.resolve(),
|
||||
) => await promiseBeforeHangup,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../rtcSessionHelpers", async (importOriginal) => {
|
||||
// TODO: perhaps there is a more elegant way to manage the type import here?
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
|
||||
vitest.spyOn(orig, "leaveRTCSession");
|
||||
return orig;
|
||||
return { ...orig, enterRTCSession, leaveRTCSession };
|
||||
});
|
||||
|
||||
let playSound: MockedFunction<
|
||||
@@ -55,11 +76,11 @@ const roomMembers = new Map([carol].map((p) => [p.userId, p]));
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||
sound: new ArrayBuffer(0),
|
||||
});
|
||||
playSound = vitest.fn();
|
||||
playSound = vi.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
});
|
||||
@@ -75,7 +96,10 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
function createGroupCallView(widget: WidgetHelpers | null): {
|
||||
function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
joined = true,
|
||||
): {
|
||||
rtcSession: MockRTCSession;
|
||||
getByText: ReturnType<typeof render>["getByText"];
|
||||
} {
|
||||
@@ -88,7 +112,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
|
||||
const room = mockMatrixRoom({
|
||||
relations: {
|
||||
getChildEventsForEvent: () =>
|
||||
vitest.mocked({
|
||||
vi.mocked({
|
||||
getRelations: () => [],
|
||||
}),
|
||||
} as unknown as RelationsContainer,
|
||||
@@ -106,24 +130,27 @@ function createGroupCallView(widget: WidgetHelpers | null): {
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(of([]));
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
video: { enabled: false },
|
||||
} as MuteStates;
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
hideHeader={true}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
isJoined
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
hideHeader={true}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
isJoined={joined}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
@@ -132,7 +159,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
|
||||
};
|
||||
}
|
||||
|
||||
test("will play a leave sound asynchronously in SPA mode", async () => {
|
||||
test("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText, rtcSession } = createGroupCallView(null);
|
||||
const leaveButton = getByText("Leave");
|
||||
@@ -143,13 +170,13 @@ test("will play a leave sound asynchronously in SPA mode", async () => {
|
||||
"user",
|
||||
expect.any(Promise),
|
||||
);
|
||||
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
// Ensure that the playSound promise resolves within this test to avoid
|
||||
// impacting the results of other tests
|
||||
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
||||
});
|
||||
|
||||
test("will play a leave sound synchronously in widget mode", async () => {
|
||||
test("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widget = {
|
||||
api: {
|
||||
@@ -158,7 +185,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
let resolvePlaySound: () => void;
|
||||
playSound = vitest
|
||||
playSound = vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
new Promise<void>((resolve) => (resolvePlaySound = resolve)),
|
||||
@@ -183,7 +210,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
|
||||
"user",
|
||||
expect.any(Promise),
|
||||
);
|
||||
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("GroupCallView leaves the session when an error occurs", async () => {
|
||||
@@ -205,8 +232,15 @@ test("GroupCallView leaves the session when an error occurs", async () => {
|
||||
"error",
|
||||
expect.any(Promise),
|
||||
);
|
||||
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
||||
// Ensure that the playSound promise resolves within this test to avoid
|
||||
// impacting the results of other tests
|
||||
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
||||
});
|
||||
|
||||
test("GroupCallView shows errors that occur during joining", async () => {
|
||||
const user = userEvent.setup();
|
||||
enterRTCSession.mockRejectedValue(new MatrixRTCFocusMissingError(""));
|
||||
onTestFinished(() => {
|
||||
enterRTCSession.mockReset();
|
||||
});
|
||||
createGroupCallView(null, false);
|
||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -16,20 +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 {
|
||||
OfflineIcon,
|
||||
WebBrowserIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { ErrorBoundary } from "@sentry/react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
@@ -37,7 +32,6 @@ import {
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "../widget";
|
||||
import { ErrorPage, FullScreenView } from "../FullScreenView";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
@@ -60,14 +54,19 @@ import { useAudioContext } from "../useAudioContext";
|
||||
import { callEventAudioSounds } from "./CallEventAudioRenderer";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { ErrorView } from "../ErrorView";
|
||||
import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
ElementCallError,
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
RTCSessionError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { ElementCallRichError } from "../RichError.tsx";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||
import {
|
||||
useNewMembershipManagerSetting as useNewMembershipManagerSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -75,11 +74,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface GroupCallErrorPageProps {
|
||||
error: Error | unknown;
|
||||
resetError: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
@@ -105,6 +99,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
muteStates,
|
||||
widget,
|
||||
}) => {
|
||||
// Used to thread through any errors that occur outside the error boundary
|
||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const leaveSoundContext = useLatest(
|
||||
useAudioContext({
|
||||
@@ -126,6 +125,18 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
}, [rtcSession]);
|
||||
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
(error) => {
|
||||
setExternalError(
|
||||
new RTCSessionError(
|
||||
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
|
||||
error.message ?? error,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
// Sanity check the room object
|
||||
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
|
||||
@@ -134,11 +145,14 @@ export const GroupCallView: 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 => {
|
||||
@@ -146,21 +160,13 @@ export const GroupCallView: 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(
|
||||
@@ -172,27 +178,32 @@ export const GroupCallView: FC<Props> = ({
|
||||
const latestDevices = useLatest(deviceContext);
|
||||
const latestMuteStates = useLatest(muteStates);
|
||||
|
||||
const enterRTCSessionOrError = async (
|
||||
rtcSession: MatrixRTCSession,
|
||||
perParticipantE2EE: boolean,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
} catch (e) {
|
||||
if (e instanceof ElementCallError) {
|
||||
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
|
||||
setEnterRTCError(e);
|
||||
} else {
|
||||
logger.error(`Unknown Error while entering RTC session`, e);
|
||||
const error = new ElementCallError(
|
||||
e instanceof Error ? e.message : "Unknown error",
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
const enterRTCSessionOrError = useCallback(
|
||||
async (
|
||||
rtcSession: MatrixRTCSession,
|
||||
perParticipantE2EE: boolean,
|
||||
newMembershipManager: boolean,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await enterRTCSession(
|
||||
rtcSession,
|
||||
perParticipantE2EE,
|
||||
newMembershipManager,
|
||||
);
|
||||
setEnterRTCError(error);
|
||||
} catch (e) {
|
||||
if (e instanceof ElementCallError) {
|
||||
setExternalError(e);
|
||||
} else {
|
||||
logger.error(`Unknown Error while entering RTC session`, e);
|
||||
const error = new UnknownCallError(
|
||||
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
|
||||
);
|
||||
setExternalError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[setExternalError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultDeviceSetup = async ({
|
||||
@@ -203,7 +214,7 @@ export const GroupCallView: 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);
|
||||
@@ -243,7 +254,11 @@ export const GroupCallView: 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);
|
||||
@@ -256,13 +271,21 @@ export const GroupCallView: 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -273,12 +296,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
perParticipantE2EE,
|
||||
latestDevices,
|
||||
latestMuteStates,
|
||||
enterRTCSessionOrError,
|
||||
useNewMembershipManager,
|
||||
]);
|
||||
|
||||
const [left, setLeft] = useState(false);
|
||||
const [enterRTCError, setEnterRTCError] = useState<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLeave = useCallback(
|
||||
@@ -292,7 +315,7 @@ export const GroupCallView: 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,
|
||||
@@ -321,11 +344,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
});
|
||||
},
|
||||
[
|
||||
leaveSoundContext,
|
||||
widget,
|
||||
rtcSession,
|
||||
room.roomId,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
leaveSoundContext,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
@@ -351,7 +375,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
}
|
||||
}, [widget, isJoined, rtcSession]);
|
||||
|
||||
const joinRule = useJoinRule(rtcSession.room);
|
||||
const joinRule = useJoinRule(room);
|
||||
|
||||
const [shareModalOpen, setInviteModalOpen] = useState(false);
|
||||
const onDismissInviteModal = useCallback(
|
||||
@@ -365,59 +389,14 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorPage = useMemo(() => {
|
||||
function GroupCallErrorPage({
|
||||
error,
|
||||
resetError,
|
||||
}: GroupCallErrorPageProps): ReactElement {
|
||||
useEffect(() => {
|
||||
if (rtcSession.isJoined()) onLeave("error");
|
||||
}, [error]);
|
||||
|
||||
const onReconnect = useCallback(() => {
|
||||
setLeft(false);
|
||||
resetError();
|
||||
enterRTCSessionOrError(rtcSession, perParticipantE2EE).catch((e) => {
|
||||
logger.error("Error re-entering RTC session", e);
|
||||
});
|
||||
}, [resetError]);
|
||||
|
||||
return error instanceof ConnectionLostError ? (
|
||||
<FullScreenView>
|
||||
<ErrorView
|
||||
Icon={OfflineIcon}
|
||||
title={t("error.connection_lost")}
|
||||
rageshake
|
||||
>
|
||||
<p>{t("error.connection_lost_description")}</p>
|
||||
<Button onClick={onReconnect}>
|
||||
{t("call_ended_view.reconnect_button")}
|
||||
</Button>
|
||||
</ErrorView>
|
||||
</FullScreenView>
|
||||
) : (
|
||||
<ErrorPage error={error} />
|
||||
);
|
||||
}
|
||||
return GroupCallErrorPage;
|
||||
}, [onLeave, rtcSession, perParticipantE2EE, t]);
|
||||
|
||||
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
|
||||
// If we have a encryption system but the browser does not support it.
|
||||
return (
|
||||
<FullScreenView>
|
||||
<ErrorView Icon={WebBrowserIcon} title={t("error.e2ee_unsupported")}>
|
||||
<p>{t("error.e2ee_unsupported_description")}</p>
|
||||
</ErrorView>
|
||||
</FullScreenView>
|
||||
);
|
||||
throw new E2EENotSupportedError();
|
||||
}
|
||||
|
||||
const shareModal = (
|
||||
<InviteModal
|
||||
room={rtcSession.room}
|
||||
room={room}
|
||||
open={shareModalOpen}
|
||||
onDismiss={onDismissInviteModal}
|
||||
/>
|
||||
@@ -430,7 +409,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() =>
|
||||
void enterRTCSessionOrError(rtcSession, perParticipantE2EE)
|
||||
void enterRTCSessionOrError(
|
||||
rtcSession,
|
||||
perParticipantE2EE,
|
||||
useNewMembershipManager,
|
||||
)
|
||||
}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
@@ -441,11 +424,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
|
||||
let body: ReactNode;
|
||||
if (enterRTCError) {
|
||||
// If an ElementCallError was recorded, then create a component that will fail to render and throw
|
||||
// an ElementCallRichError error. This will then be handled by the ErrorBoundary component.
|
||||
if (externalError) {
|
||||
// If an error was recorded within this component but outside
|
||||
// GroupCallErrorBoundary, create a component that rethrows the error from
|
||||
// within the error boundary, so it can be handled uniformly
|
||||
const ErrorComponent = (): ReactNode => {
|
||||
throw new ElementCallRichError(enterRTCError);
|
||||
throw externalError;
|
||||
};
|
||||
body = <ErrorComponent />;
|
||||
} else if (isJoined) {
|
||||
@@ -495,14 +479,35 @@ export const GroupCallView: 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 {
|
||||
body = lobbyView;
|
||||
}
|
||||
|
||||
return <ErrorBoundary fallback={errorPage}>{body}</ErrorBoundary>;
|
||||
return (
|
||||
<GroupCallErrorBoundary
|
||||
widget={widget}
|
||||
recoveryActionHandler={(action) => {
|
||||
if (action == "reconnect") {
|
||||
setLeft(false);
|
||||
enterRTCSessionOrError(
|
||||
rtcSession,
|
||||
perParticipantE2EE,
|
||||
useNewMembershipManager,
|
||||
).catch((e) => {
|
||||
logger.error("Error re-entering RTC session", e);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={
|
||||
(/**error*/) => {
|
||||
if (rtcSession.isJoined()) onLeave("error");
|
||||
}
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</GroupCallErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1051
src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
Normal file
1051
src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -23,7 +23,8 @@ interface Props {
|
||||
|
||||
export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||
const { submitRageshake, sending, sent, error, available } =
|
||||
useSubmitRageshake();
|
||||
const sendRageshakeRequest = useRageshakeRequest();
|
||||
|
||||
const onSubmitFeedback = useCallback(
|
||||
@@ -66,20 +67,27 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{t("common.analytics")}</h4>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="optInAnalytics"
|
||||
type="checkbox"
|
||||
checked={optInAnalytics ?? undefined}
|
||||
description={optInDescription}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setOptInAnalytics?.(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
</FieldRow>
|
||||
// in the embedded package the widget host is responsible for analytics consent
|
||||
const analyticsConsentBlock =
|
||||
import.meta.env.VITE_PACKAGE === "embedded" ? null : (
|
||||
<>
|
||||
<h4>{t("common.analytics")}</h4>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="optInAnalytics"
|
||||
type="checkbox"
|
||||
checked={optInAnalytics ?? undefined}
|
||||
description={optInDescription}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setOptInAnalytics?.(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
);
|
||||
|
||||
const feedbackBlock = available ? (
|
||||
<>
|
||||
<h4>{t("settings.feedback_tab_h4")}</h4>
|
||||
<Text>{t("settings.feedback_tab_body")}</Text>
|
||||
<form onSubmit={onSubmitFeedback}>
|
||||
@@ -113,6 +121,13 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
|
||||
{sent && <Text>{t("settings.feedback_tab_thank_you")}</Text>}
|
||||
</FieldRow>
|
||||
</form>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{analyticsConsentBlock}
|
||||
{feedbackBlock}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||
import { Slider } from "../Slider";
|
||||
import { DeviceSelection } from "./DeviceSelection";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import { isRageshakeAvailable } from "./submit-rageshake";
|
||||
|
||||
type SettingsTab =
|
||||
| "audio"
|
||||
@@ -146,7 +147,12 @@ export const SettingsModal: FC<Props> = ({
|
||||
|
||||
const tabs = [audioTab, videoTab];
|
||||
if (widget === null) tabs.push(profileTab);
|
||||
tabs.push(preferencesTab, feedbackTab);
|
||||
tabs.push(preferencesTab);
|
||||
if (isRageshakeAvailable() || import.meta.env.VITE_PACKAGE === "full") {
|
||||
// for full package we want to show the analytics consent checkbox
|
||||
// even if rageshake is not available
|
||||
tabs.push(feedbackTab);
|
||||
}
|
||||
if (showDeveloperSettingsTab) tabs.push(developerTab);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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);
|
||||
|
||||
153
src/settings/submit-rageshake.test.ts
Normal file
153
src/settings/submit-rageshake.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
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,
|
||||
afterEach,
|
||||
vi,
|
||||
type Mock,
|
||||
beforeEach,
|
||||
} from "vitest";
|
||||
|
||||
import {
|
||||
getRageshakeSubmitUrl,
|
||||
isRageshakeAvailable,
|
||||
} from "./submit-rageshake";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
|
||||
|
||||
describe("isRageshakeAvailable", () => {
|
||||
beforeEach(() => {
|
||||
(getUrlParams as Mock).mockReturnValue({});
|
||||
mockConfig({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("embedded package", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
it("returns false with no rageshakeSubmitUrl URL param", () => {
|
||||
expect(isRageshakeAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and returns false with no rageshakeSubmitUrl URL param", () => {
|
||||
mockConfig({
|
||||
rageshake: {
|
||||
submit_url: "https://config.example.com.localhost",
|
||||
},
|
||||
});
|
||||
expect(isRageshakeAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true with rageshakeSubmitUrl URL param", () => {
|
||||
(getUrlParams as Mock).mockReturnValue({
|
||||
rageshakeSubmitUrl: "https://url.example.com.localhost",
|
||||
});
|
||||
expect(isRageshakeAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
it("returns false with no config value", () => {
|
||||
expect(isRageshakeAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores rageshakeSubmitUrl URL param and returns false with no config value", () => {
|
||||
(getUrlParams as Mock).mockReturnValue({
|
||||
rageshakeSubmitUrl: "https://url.example.com.localhost",
|
||||
});
|
||||
expect(isRageshakeAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true with config value", () => {
|
||||
mockConfig({
|
||||
rageshake: {
|
||||
submit_url: "https://config.example.com.localhost",
|
||||
},
|
||||
});
|
||||
expect(isRageshakeAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRageshakeSubmitUrl", () => {
|
||||
beforeEach(() => {
|
||||
(getUrlParams as Mock).mockReturnValue({});
|
||||
mockConfig({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("embedded package", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
it("returns undefined no rageshakeSubmitUrl URL param", () => {
|
||||
expect(getRageshakeSubmitUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns rageshakeSubmitUrl URL param when set", () => {
|
||||
(getUrlParams as Mock).mockReturnValue({
|
||||
rageshakeSubmitUrl: "https://url.example.com.localhost",
|
||||
});
|
||||
expect(getRageshakeSubmitUrl()).toBe("https://url.example.com.localhost");
|
||||
});
|
||||
|
||||
it("ignores config param and returns undefined", () => {
|
||||
mockConfig({
|
||||
rageshake: {
|
||||
submit_url: "https://config.example.com.localhost",
|
||||
},
|
||||
});
|
||||
expect(getRageshakeSubmitUrl()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
it("returns undefined with no config value", () => {
|
||||
expect(getRageshakeSubmitUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores rageshakeSubmitUrl URL param and returns undefined", () => {
|
||||
(getUrlParams as Mock).mockReturnValue({
|
||||
rageshakeSubmitUrl: "https://url.example.com.localhost",
|
||||
});
|
||||
expect(getRageshakeSubmitUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns config value when set", () => {
|
||||
mockConfig({
|
||||
rageshake: {
|
||||
submit_url: "https://config.example.com.localhost",
|
||||
},
|
||||
});
|
||||
expect(getRageshakeSubmitUrl()).toBe(
|
||||
"https://config.example.com.localhost",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,16 +9,17 @@ 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";
|
||||
import { Config } from "../config/Config";
|
||||
import { ElementCallOpenTelemetry } from "../otel/otel";
|
||||
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
|
||||
const gzip = async (text: string): Promise<Blob> => {
|
||||
// pako is relatively large (200KB), so we only import it when needed
|
||||
@@ -34,7 +35,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 +83,7 @@ async function collectCryptoInfo(
|
||||
*/
|
||||
async function collectRecoveryInfo(
|
||||
client: MatrixClient,
|
||||
cryptoApi: Crypto.CryptoApi,
|
||||
cryptoApi: CryptoApi,
|
||||
body: FormData,
|
||||
): Promise<void> {
|
||||
const secretStorage = client.secretStorage;
|
||||
@@ -116,11 +117,30 @@ interface RageShakeSubmitOptions {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function getRageshakeSubmitUrl(): string | undefined {
|
||||
if (import.meta.env.VITE_PACKAGE === "full") {
|
||||
// in full package we always use the one configured on the server
|
||||
return Config.get().rageshake?.submit_url;
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
||||
// in embedded package we always use the one provided by the widget host
|
||||
return getUrlParams().rageshakeSubmitUrl ?? undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isRageshakeAvailable(): boolean {
|
||||
return !!getRageshakeSubmitUrl();
|
||||
}
|
||||
|
||||
export function useSubmitRageshake(): {
|
||||
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
|
||||
sending: boolean;
|
||||
sent: boolean;
|
||||
error?: Error;
|
||||
available: boolean;
|
||||
} {
|
||||
const { client } = useClient();
|
||||
|
||||
@@ -138,7 +158,7 @@ export function useSubmitRageshake(): {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
async (opts) => {
|
||||
if (!Config.get().rageshake?.submit_url) {
|
||||
if (!getRageshakeSubmitUrl()) {
|
||||
throw new Error("No rageshake URL is configured");
|
||||
}
|
||||
|
||||
@@ -297,6 +317,7 @@ export function useSubmitRageshake(): {
|
||||
sending,
|
||||
sent,
|
||||
error,
|
||||
available: isRageshakeAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
51
src/useErrorBoundary.test.tsx
Normal file
51
src/useErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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 { it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type ReactElement, useCallback } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { GroupCallErrorBoundary } from "./room/GroupCallErrorBoundary";
|
||||
import { useErrorBoundary } from "./useErrorBoundary";
|
||||
import { ConnectionLostError } from "./utils/errors";
|
||||
|
||||
it("should show async error", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const TestComponent = (): ReactElement => {
|
||||
const { showErrorBoundary } = useErrorBoundary();
|
||||
|
||||
const onClick = useCallback((): void => {
|
||||
showErrorBoundary(new ConnectionLostError());
|
||||
}, [showErrorBoundary]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>HELLO</h1>
|
||||
<button onClick={onClick}>Click me</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary widget={null} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Click me" }));
|
||||
|
||||
await screen.findByText("Connection lost");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Reconnect" }));
|
||||
|
||||
await screen.findByText("HELLO");
|
||||
});
|
||||
29
src/useErrorBoundary.ts
Normal file
29
src/useErrorBoundary.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2023, 2024 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 { useMemo, useState } from "react";
|
||||
|
||||
export type UseErrorBoundaryApi = {
|
||||
showErrorBoundary: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function useErrorBoundary(): UseErrorBoundaryApi {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const memoized: UseErrorBoundaryApi = useMemo(
|
||||
() => ({
|
||||
showErrorBoundary: (error: Error) => setError(error),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return memoized;
|
||||
}
|
||||
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,11 @@ 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",
|
||||
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
}
|
||||
|
||||
@@ -20,6 +25,8 @@ 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 ..
|
||||
}
|
||||
@@ -31,14 +38,17 @@ export class ElementCallError extends Error {
|
||||
public code: ErrorCode;
|
||||
public category: ErrorCategory;
|
||||
public localisedMessage?: string;
|
||||
public localisedTitle: string;
|
||||
|
||||
public constructor(
|
||||
name: string,
|
||||
protected constructor(
|
||||
localisedTitle: string,
|
||||
code: ErrorCode,
|
||||
category: ErrorCategory,
|
||||
localisedMessage?: string,
|
||||
cause?: Error,
|
||||
) {
|
||||
super(name);
|
||||
super(localisedTitle, { cause });
|
||||
this.localisedTitle = localisedTitle;
|
||||
this.localisedMessage = localisedMessage;
|
||||
this.category = category;
|
||||
this.code = code;
|
||||
@@ -50,7 +60,7 @@ export class MatrixRTCFocusMissingError extends ElementCallError {
|
||||
|
||||
public constructor(domain: string) {
|
||||
super(
|
||||
"MatrixRTCFocusMissingError",
|
||||
t("error.call_is_not_supported"),
|
||||
ErrorCode.MISSING_MATRIX_RTC_FOCUS,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
t("error.matrix_rtc_focus_missing", {
|
||||
@@ -66,9 +76,63 @@ export class MatrixRTCFocusMissingError extends ElementCallError {
|
||||
export class ConnectionLostError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
"Connection lost",
|
||||
t("error.connection_lost"),
|
||||
ErrorCode.CONNECTION_LOST_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
t("error.connection_lost_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
t("error.e2ee_unsupported"),
|
||||
ErrorCode.E2EE_NOT_SUPPORTED,
|
||||
ErrorCategory.CLIENT_CONFIGURATION,
|
||||
t("error.e2ee_unsupported_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownCallError extends ElementCallError {
|
||||
public constructor(error: Error) {
|
||||
super(
|
||||
t("error.generic"),
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
error.message,
|
||||
// Properly set it as a cause for a better reporting on sentry
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FailToGetOpenIdToken extends ElementCallError {
|
||||
public constructor(error: Error) {
|
||||
super(
|
||||
t("error.generic"),
|
||||
ErrorCode.OPEN_ID_ERROR,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
undefined,
|
||||
// Properly set it as a cause for a better reporting on sentry
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientCapacityError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.insufficient_capacity"),
|
||||
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
t("error.insufficient_capacity_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
|
||||
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
||||
import {
|
||||
calculateRetryBackoff,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
Preset,
|
||||
@@ -17,6 +18,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
@@ -69,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
|
||||
*/
|
||||
@@ -160,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.
|
||||
@@ -336,3 +337,30 @@ export function getRelativeRoomUrl(
|
||||
: "";
|
||||
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perfom a network operation with retries on ConnectionError.
|
||||
* If the error is not retryable, or the max number of retries is reached, the error is rethrown.
|
||||
* Supports handling of matrix quotas.
|
||||
*/
|
||||
export async function doNetworkOperationWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
let currentRetryCount = 0;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (e) {
|
||||
currentRetryCount++;
|
||||
const backoff = calculateRetryBackoff(e, currentRetryCount, true);
|
||||
if (backoff < 0) {
|
||||
// Max number of retries reached, or error is not retryable. rethrow the error
|
||||
throw e;
|
||||
}
|
||||
// wait for the specified time and then retry the request
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,8 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
});
|
||||
// simulate loading the config
|
||||
vi.spyOn(Config, "init").mockResolvedValue(void 0);
|
||||
}
|
||||
|
||||
export class MockRTCSession extends TypedEventEmitter<
|
||||
@@ -284,8 +286,9 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
super();
|
||||
}
|
||||
|
||||
public isJoined(): true {
|
||||
return true;
|
||||
public joined = true;
|
||||
public isJoined(): boolean {
|
||||
return this.joined;
|
||||
}
|
||||
|
||||
public withMemberships(
|
||||
|
||||
Reference in New Issue
Block a user