Merge branch 'livekit' into defaut-server-config

This commit is contained in:
Timo
2025-04-04 23:27:45 +02:00
committed by GitHub
212 changed files with 20765 additions and 9913 deletions

View File

@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
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";

View File

@@ -11,7 +11,7 @@ import {
} from "../reactions";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" {
declare module "matrix-js-sdk/lib/types" {
export interface TimelineEvents {
[ElementCallReactionEventType]: ECallReactionEventContent;
}

View File

@@ -9,7 +9,7 @@ import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
@@ -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 />} />

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC, type PropsWithChildren } from "react";
import { ClientContextProvider } from "./ClientContext";

View File

@@ -13,7 +13,7 @@ import {
useEffect,
} from "react";
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import { useClientState } from "./ClientContext";

View File

@@ -17,9 +17,9 @@ import {
type JSX,
} from "react";
import { useNavigate } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import type { WidgetApi } from "matrix-widget-api";
import { ErrorPage } from "./FullScreenView";
@@ -50,7 +50,7 @@ export type ValidClientState = {
reactions: boolean;
thumbnails: boolean;
};
setClient: (params?: SetClientParams) => void;
setClient: (client: MatrixClient, session: Session) => void;
};
export type AuthenticatedClient = {
@@ -65,11 +65,6 @@ export type ErrorState = {
error: Error;
};
export type SetClientParams = {
client: MatrixClient;
session: Session;
};
const ClientContext = createContext<ClientState | undefined>(undefined);
export const ClientContextProvider = ClientContext.Provider;
@@ -79,7 +74,7 @@ export const useClientState = (): ClientState | undefined =>
export function useClient(): {
client?: MatrixClient;
setClient?: (params?: SetClientParams) => void;
setClient?: (client: MatrixClient, session: Session) => void;
} {
let client;
let setClient;
@@ -96,7 +91,7 @@ export function useClient(): {
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
export function useClientLegacy(): {
client?: MatrixClient;
setClient?: (params?: SetClientParams) => void;
setClient?: (client: MatrixClient, session: Session) => void;
passwordlessUser: boolean;
loading: boolean;
authenticated: boolean;
@@ -160,7 +155,11 @@ export const ClientProvider: FC<Props> = ({ children }) => {
initializing.current = true;
loadClient()
.then(setInitClientState)
.then((initResult) => {
setInitClientState(initResult);
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, []);
@@ -196,24 +195,20 @@ export const ClientProvider: FC<Props> = ({ children }) => {
);
const setClient = useCallback(
(clientParams?: SetClientParams) => {
(client: MatrixClient, session: Session) => {
const oldClient = initClientState?.client;
const newClient = clientParams?.client;
if (oldClient && oldClient !== newClient) {
if (oldClient && oldClient !== client) {
oldClient.stopClient();
}
if (clientParams) {
saveSession(clientParams.session);
setInitClientState({
widgetApi: null,
client: clientParams.client,
passwordlessUser: clientParams.session.passwordlessUser,
});
} else {
clearSession();
setInitClientState(null);
}
saveSession(session);
setInitClientState({
widgetApi: null,
client,
passwordlessUser: session.passwordlessUser,
});
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
},
[initClientState?.client],
);
@@ -229,6 +224,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
clearSession();
setInitClientState(null);
await navigate("/");
PosthogAnalytics.instance.logout();
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
}, [navigate, initClientState?.client]);
@@ -351,7 +347,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}, [initClientState, onSync]);
if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />;
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
}
return (

View File

@@ -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/lib/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>
);
};

View File

@@ -9,7 +9,7 @@ import { type FC, type ReactElement, type ReactNode, useEffect } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
@@ -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>
)}

View File

@@ -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>

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker";
import { IndexedDBStoreWorker } from "matrix-js-sdk/lib/indexeddb-worker";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const remoteWorker = new IndexedDBStoreWorker((self as any).postMessage);

View File

@@ -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;
}
}

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
@@ -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,7 +279,6 @@ 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(
@@ -271,6 +292,13 @@ export const getUrlParams = (
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"),
};
};

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, useCallback, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useClientLegacy } from "./ClientContext";
import { useProfile } from "./profile/useProfile";

View 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);
});
});
});

View File

@@ -10,9 +10,9 @@ import posthog, {
type PostHog,
type Properties,
} from "posthog-js";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { Buffer } from "buffer";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
import { type Subscription } from "rxjs";
import { widget } from "../widget";
import {
@@ -71,11 +71,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
@@ -101,6 +96,7 @@ export class PosthogAnalytics {
private anonymity = Anonymity.Disabled;
private platformSuperProperties = {};
private registrationType: RegistrationType = RegistrationType.Guest;
private optInListener: Subscription | null = null;
public static hasInstance(): boolean {
return Boolean(this.internalInstance);
@@ -113,24 +109,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,
@@ -146,7 +145,6 @@ export class PosthogAnalytics {
);
this.enabled = false;
}
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
}
private sanitizeProperties = (
@@ -274,7 +272,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,
@@ -297,7 +295,7 @@ export class PosthogAnalytics {
const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId();
const bufferForPosthogId = await crypto.subtle.digest(
"sha-256",
Buffer.from(posthogIdMaterial, "utf-8"),
new TextEncoder().encode(posthogIdMaterial),
);
const view = new Int32Array(bufferForPosthogId);
return Array.from(view)
@@ -328,6 +326,8 @@ export class PosthogAnalytics {
if (this.enabled) {
this.posthog.reset();
}
this.optInListener?.unsubscribe();
this.optInListener = null;
this.setAnonymity(Anonymity.Disabled);
}
@@ -406,7 +406,7 @@ export class PosthogAnalytics {
}
}
private startListeningToSettingsChanges(): void {
public startListeningToSettingsChanges(): void {
// Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called -
// * On page load, when the account data is first received by sync
@@ -415,7 +415,7 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
optInAnalytics.value$.subscribe((optIn) => {
this.optInListener ??= optInAnalytics.value$.subscribe((optIn) => {
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser().catch(() =>
logger.log("Could not identify user"),

View File

@@ -6,8 +6,8 @@ Please see LICENSE in the repository root for full details.
*/
import { type DisconnectReason } from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import {
type IPosthogEvent,

View File

@@ -11,7 +11,7 @@ import {
type Span,
} from "@opentelemetry/sdk-trace-base";
import { hrTimeToMilliseconds } from "@opentelemetry/core";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { PosthogAnalytics } from "./PosthogAnalytics";

View File

@@ -53,7 +53,7 @@ export const LoginPage: FC = () => {
return;
}
setClient({ client, session });
setClient(client, session);
const locationState = location.state;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@@ -16,9 +16,9 @@ import {
} from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils";
import { sleep } from "matrix-js-sdk/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Button, Text } from "@vector-im/compound-web";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@@ -95,7 +95,7 @@ export const RegisterPage: FC = () => {
}
}
setClient?.({ client: newClient, session });
setClient?.(newClient, session);
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
};
@@ -204,7 +204,7 @@ export const RegisterPage: FC = () => {
/>
</FieldRow>
<Text size="sm">
<Trans i18nKey="recaptcha_caption">
<Trans i18nKey="recaptcha_ssla_caption">
This site is protected by ReCAPTCHA and the Google{" "}
<ExternalLink href="https://www.google.com/policies/privacy/">
Privacy Policy
@@ -216,8 +216,8 @@ export const RegisterPage: FC = () => {
apply.
<br />
By clicking "Register", you agree to our{" "}
<ExternalLink href={Config.get().eula}>
End User Licensing Agreement (EULA)
<ExternalLink href={Config.get().ssla}>
Software and Services License Agreement (SSLA)
</ExternalLink>
</Trans>
</Text>

View File

@@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import { InteractiveAuth } from "matrix-js-sdk";
import {
createClient,
type LoginResponse,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
} from "matrix-js-sdk";
import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext";

View File

@@ -6,13 +6,13 @@ Please see LICENSE in the repository root for full details.
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import { InteractiveAuth } from "matrix-js-sdk";
import {
createClient,
type MatrixClient,
type RegisterResponse,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext";

View File

@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
*/
import { useEffect, useCallback, useRef, useState } from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { translatedError } from "../TranslatedError";
declare global {

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
@@ -47,7 +47,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
recaptchaResponse,
true,
);
setClient({ client, session });
setClient(client, session);
} catch (e) {
reset();
throw e;

View File

@@ -10,7 +10,7 @@ import { expect, test } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { type ReactNode } from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions";

View File

@@ -22,7 +22,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import classNames from "classnames";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";

View File

@@ -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();
}

View File

@@ -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;
@@ -74,9 +77,9 @@ export interface ConfigOptions {
};
/**
* A link to the end-user license agreement (EULA)
* A link to the software and services license agreement (SSLA)
*/
eula: string;
ssla?: string;
media_devices?: {
/**
@@ -125,6 +128,7 @@ export interface ConfigOptions {
// Overrides members from ConfigOptions that are always provided by the
// default config and are therefore non-optional.
export interface ResolvedConfigOptions extends ConfigOptions {
ssla: string;
media_devices: {
enable_audio: boolean;
enable_video: boolean;
@@ -136,7 +140,7 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: {
feature_use_device_session_member_events: true,
},
eula: "https://static.element.io/legal/online-EULA.pdf",
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
media_devices: {
enable_audio: true,
enable_video: true,

View File

@@ -9,7 +9,7 @@ import { describe, expect, test, vi } from "vitest";
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
} from "matrix-js-sdk/lib/matrixrtc";
import { KeyProviderEvent } from "livekit-client";
import { MatrixKeyProvider } from "./matrixKeyProvider";

View File

@@ -6,11 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { BaseKeyProvider, createKeyMaterialFromBuffer } from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
} from "matrix-js-sdk/lib/matrixrtc";
export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession;

View File

@@ -32,7 +32,7 @@ import {
} from "react";
import useMeasure from "react-use-measure";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { fromEvent, map, startWith } from "rxjs";

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { render, type RenderResult } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";

View File

@@ -6,9 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { Link } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type RoomMember } from "matrix-js-sdk/src/models/room-member";
import { type Room } from "matrix-js-sdk/src/models/room";
import { type RoomMember, type Room, type MatrixClient } from "matrix-js-sdk";
import { type FC, useCallback, type MouseEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconButton, Text } from "@vector-im/compound-web";

View File

@@ -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} />

View File

@@ -12,10 +12,10 @@ import {
type FormEventHandler,
type FC,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Button } from "@vector-im/compound-web";
import { useNavigate } from "react-router-dom";

View File

@@ -6,10 +6,10 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useState, type FormEventHandler } from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useNavigate } from "react-router-dom";
import { useClient } from "../ClientContext";
@@ -89,7 +89,7 @@ export const UnauthenticatedView: FC = () => {
// @ts-ignore
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => {
setClient({ client, session });
setClient(client, session);
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
navigate(`/${aliasLocalpart}`)?.catch((error) => {
logger.error("Failed to navigate to alias localpart", error);
@@ -111,7 +111,7 @@ export const UnauthenticatedView: FC = () => {
if (!createRoomResult.password)
throw new Error("Failed to create room with shared secret");
setClient({ client, session });
setClient(client, session);
await navigate(
getRelativeRoomUrl(
createRoomResult.roomId,
@@ -185,10 +185,10 @@ export const UnauthenticatedView: FC = () => {
</Text>
)}
<Text size="sm" className={styles.notice}>
<Trans i18nKey="unauthenticated_view_eula_caption">
<Trans i18nKey="unauthenticated_view_ssla_caption">
By clicking "Go", you agree to our{" "}
<ExternalLink href={Config.get().eula}>
End User Licensing Agreement (EULA)
<ExternalLink href={Config.get().ssla}>
Software and Services License Agreement (SSLA)
</ExternalLink>
</Trans>
</Text>

View File

@@ -5,14 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { type RoomMember } from "matrix-js-sdk/src/models/room-member";
import {
type MatrixClient,
type RoomMember,
type Room,
RoomEvent,
EventTimeline,
EventType,
JoinRule,
KnownMembership,
} from "matrix-js-sdk";
import { useState, useEffect } from "react";
import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk/src/matrix";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
import { KnownMembership } from "matrix-js-sdk/src/types";
import {
MatrixRTCSessionManagerEvents,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { getKeyForRoom } from "../e2ee/sharedKeyManagement";

View File

@@ -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",
}),
);
});
});
});
});
});

View File

@@ -14,7 +14,7 @@ import i18n, {
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill";
import {
@@ -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,

View File

@@ -19,7 +19,7 @@ import {
import { createMediaDeviceObserver } from "@livekit/components-core";
import { map, startWith } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
useSetting,

View File

@@ -5,13 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useEffect, useState } from "react";
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
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 {

View File

@@ -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" }));

View File

@@ -15,12 +15,16 @@ import {
Track,
} from "livekit-client";
import { useCallback, useEffect, useRef, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
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));
}

View File

@@ -15,8 +15,8 @@ import {
} from "livekit-client";
import { useEffect, useMemo, useRef } from "react";
import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU";

View File

@@ -9,12 +9,12 @@ Please see LICENSE in the repository root for full details.
// function gets set. It needs to be not in the same file as we use
// createClient, or the typescript transpiler gets confused about
// dependency references.
import "matrix-js-sdk/src/browser-index";
import "matrix-js-sdk/lib/browser-index";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,

View File

@@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { type Span } from "@opentelemetry/api";
import { type MatrixCall } from "matrix-js-sdk/src/matrix";
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import { type MatrixCall } from "matrix-js-sdk";
import { CallEvent } from "matrix-js-sdk/lib/webrtc/call";
import {
type TransceiverStats,
type CallFeedStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import { ObjectFlattener } from "./ObjectFlattener";
import { ElementCallOpenTelemetry } from "./otel";

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import opentelemetry, { type Span } from "@opentelemetry/api";
import { type TrackStats } from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { type TrackStats } from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import { type ElementCallOpenTelemetry } from "./otel";
import { OTelCallMediaStreamTrackSpan } from "./OTelCallMediaStreamTrackSpan";

View File

@@ -9,7 +9,7 @@ import { type Span } from "@opentelemetry/api";
import {
type CallFeedStats,
type TrackStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import { type ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type TrackStats } from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { type TrackStats } from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import opentelemetry, { type Span } from "@opentelemetry/api";
import { type ElementCallOpenTelemetry } from "./otel";

View File

@@ -9,7 +9,7 @@ import { type Span } from "@opentelemetry/api";
import {
type TrackStats,
type TransceiverStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import { type ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";

View File

@@ -15,26 +15,26 @@ import {
type MatrixClient,
type MatrixEvent,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type CallError,
type CallState,
type MatrixCall,
type VoipEvent,
} from "matrix-js-sdk/src/webrtc/call";
} from "matrix-js-sdk/lib/webrtc/call";
import {
type CallsByUserAndDevice,
type GroupCallError,
GroupCallEvent,
type GroupCallStatsReport,
} from "matrix-js-sdk/src/webrtc/groupCall";
} from "matrix-js-sdk/lib/webrtc/groupCall";
import {
type ConnectionStatsReport,
type ByteSentStatsReport,
type SummaryStatsReport,
type CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { ObjectFlattener } from "./ObjectFlattener";

View File

@@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall";
import { type GroupCallStatsReport } from "matrix-js-sdk/lib/webrtc/groupCall";
import {
type AudioConcealment,
type ByteSentStatsReport,
type ConnectionStatsReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
import { describe, expect, it } from "vitest";
import { ObjectFlattener } from "../../src/otel/ObjectFlattener";

View File

@@ -5,13 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Attributes } from "@opentelemetry/api";
import { type VoipEvent } from "matrix-js-sdk/src/webrtc/call";
import { type GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall";
import { type VoipEvent } from "matrix-js-sdk/lib/webrtc/call";
import { type GroupCallStatsReport } from "matrix-js-sdk/lib/webrtc/groupCall";
import {
type ByteSentStatsReport,
type ConnectionStatsReport,
type SummaryStatsReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
} from "matrix-js-sdk/lib/webrtc/stats/statsReport";
export class ObjectFlattener {
public static flattenReportObject(

79
src/otel/otel.test.ts Normal file
View 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);
});
});
});

View File

@@ -11,11 +11,12 @@ import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import opentelemetry, { type Tracer } from "@opentelemetry/api";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/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(),
);
}
}

View File

@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixEvent } from "matrix-js-sdk/src/models/event";
import { type User, UserEvent } from "matrix-js-sdk/src/models/user";
import { type FileType } from "matrix-js-sdk/src/http-api";
import {
type MatrixEvent,
type User,
type MatrixClient,
UserEvent,
type FileType,
} from "matrix-js-sdk";
import { useState, useCallback, useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
interface ProfileLoadState {
success: boolean;

View File

@@ -7,14 +7,14 @@ Please see LICENSE in the repository root for full details.
import { renderHook } from "@testing-library/react";
import { afterEach, test, vitest } from "vitest";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import {
RoomEvent as MatrixRoomEvent,
MatrixEvent,
type IRoomTimelineData,
EventType,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
} from "matrix-js-sdk";
import { ReactionsReader, REACTION_ACTIVE_TIME_MS } from "./ReactionsReader";
import {

View File

@@ -9,15 +9,15 @@ import {
type CallMembership,
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type ReactionEventContent } from "matrix-js-sdk/src/types";
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
import { type ReactionEventContent } from "matrix-js-sdk/lib/types";
import {
RelationType,
EventType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk/src/matrix";
} from "matrix-js-sdk";
import { BehaviorSubject, delay, type Subscription } from "rxjs";
import {

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type RelationType } from "matrix-js-sdk/src/types";
import { type RelationType } from "matrix-js-sdk";
import catSoundOgg from "../sound/reactions/cat.ogg?url";
import catSoundMp3 from "../sound/reactions/cat.mp3?url";

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { EventType, RelationType } from "matrix-js-sdk";
import {
createContext,
useContext,
@@ -14,8 +14,8 @@ import {
useMemo,
type JSX,
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";

View File

@@ -15,7 +15,7 @@ import {
import { useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Modal } from "../Modal";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";

View File

@@ -6,11 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, type FormEventHandler, useCallback, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web";
import { useNavigate } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import styles from "./CallEndedView.module.css";
import feedbackStyle from "../input/FeedbackInput.module.css";

View File

@@ -16,7 +16,7 @@ import {
afterEach,
} from "vitest";
import { act } from "react";
import { type CallMembership } from "matrix-js-sdk/src/matrixrtc";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { mockRtcMembership } from "../utils/test";
import {

View 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();
});

View 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}
/>
);
};

View File

@@ -5,16 +5,23 @@ 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";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { of } from "rxjs";
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
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 { type RelationsContainer } from "matrix-js-sdk/lib/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 +35,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 +75,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 +95,10 @@ beforeEach(() => {
);
});
function createGroupCallView(widget: WidgetHelpers | null): {
function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
): {
rtcSession: MockRTCSession;
getByText: ReturnType<typeof render>["getByText"];
} {
@@ -88,7 +111,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vitest.mocked({
vi.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
@@ -106,24 +129,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 +158,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 +169,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 +184,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 +209,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 +231,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");
});

View File

@@ -7,32 +7,23 @@ Please see LICENSE in the repository root for full details.
import {
type FC,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
import {
Room as LivekitRoom,
isE2EESupported as isE2EESupportedBrowser,
} from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import {
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
} from "matrix-js-sdk/lib/matrixrtc";
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 {
@@ -40,7 +31,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";
@@ -63,15 +53,14 @@ 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,
@@ -84,11 +73,6 @@ declare global {
}
}
interface GroupCallErrorPageProps {
error: Error | unknown;
resetError: () => void;
}
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
@@ -114,6 +98,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({
@@ -139,7 +128,7 @@ export const GroupCallView: FC<Props> = ({
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
setError(
setExternalError(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
@@ -188,32 +177,32 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
setError(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,
);
setError(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 ({
@@ -306,11 +295,12 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,
]);
const [left, setLeft] = useState(false);
const [error, setError] = useState<ElementCallError | null>(null);
const navigate = useNavigate();
const onLeave = useCallback(
@@ -398,58 +388,9 @@ 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,
useNewMembershipManager,
).catch((e) => {
logger.error("Error re-entering RTC session on reconnect", 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;
}, [t, rtcSession, onLeave, perParticipantE2EE, useNewMembershipManager]);
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 = (
@@ -482,11 +423,12 @@ export const GroupCallView: FC<Props> = ({
);
let body: ReactNode;
if (error) {
// 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(error);
throw externalError;
};
body = <ErrorComponent />;
} else if (isJoined) {
@@ -543,5 +485,28 @@ export const GroupCallView: FC<Props> = ({
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>
);
};

View File

@@ -11,7 +11,7 @@ import {
useLocalParticipant,
} from "@livekit/components-react";
import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent,
@@ -26,11 +26,11 @@ import {
type JSX,
} from "react";
import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type Room } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";

View File

@@ -13,7 +13,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type Room } from "matrix-js-sdk";
import { Button, Text } from "@vector-im/compound-web";
import {
LinkIcon,

View File

@@ -7,10 +7,10 @@ Please see LICENSE in the repository root for full details.
import { type FC, useCallback, useMemo, useState, type JSX } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk";
import { Button } from "@vector-im/compound-web";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { usePreviewTracks } from "@livekit/components-react";
import { type LocalVideoTrack, Track } from "livekit-client";
import { useObservable } from "observable-hooks";

View File

@@ -13,7 +13,7 @@ import {
useMemo,
} from "react";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MediaDevice,

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, useCallback, useState } from "react";
import { useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Button, Heading, Text } from "@vector-im/compound-web";
import styles from "./RoomAuthView.module.css";
@@ -80,10 +80,10 @@ export const RoomAuthView: FC = () => {
/>
</FieldRow>
<Text size="sm">
<Trans i18nKey="room_auth_view_eula_caption">
<Trans i18nKey="room_auth_view_ssla_caption">
By clicking "Join call now", you agree to our{" "}
<ExternalLink href={Config.get().eula}>
End User Licensing Agreement (EULA)
<ExternalLink href={Config.get().ssla}>
Software and Services License Agreement (SSLA)
</ExternalLink>
</Trans>
</Text>

View File

@@ -13,13 +13,13 @@ import {
useRef,
type JSX,
} from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Trans, useTranslation } from "react-i18next";
import {
CheckIcon,
UnknownSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MatrixError } from "matrix-js-sdk/src/http-api";
import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
@@ -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) {

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { vi, type Mocked, test, expect } from "vitest";
import { type RoomState } from "matrix-js-sdk/src/models/room-state";
import { type RoomState } from "matrix-js-sdk";
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";

View File

@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType } from "matrix-js-sdk/src/@types/event";
import { type RoomState } from "matrix-js-sdk/src/models/room-state";
import { EventType, type RoomState } from "matrix-js-sdk";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";

View File

@@ -8,14 +8,11 @@ Please see LICENSE in the repository root for full details.
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
} from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useState } from "react";
import { deepCompare } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import {
type LivekitFocus,
isLivekitFocus,
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
/**
* Gets the currently active (livekit) focus for a MatrixRTC session

View File

@@ -6,9 +6,8 @@ Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { type JoinRule } from "matrix-js-sdk/src/matrix";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { JoinRule, Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";
export function useJoinRule(room: Room): JoinRule {

View File

@@ -13,18 +13,20 @@ import {
type ComponentType,
type SVGAttributes,
} from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
JoinRule,
EventType,
SyncState,
MatrixError,
KnownMembership,
ClientEvent,
type MatrixClient,
type RoomSummary,
} from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { RoomEvent, type Room } from "matrix-js-sdk/src/models/room";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { JoinRule, MatrixError } from "matrix-js-sdk/src/matrix";
RoomEvent,
type Room,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useTranslation } from "react-i18next";
import {
AdminIcon,

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { type Room } from "matrix-js-sdk/src/models/room";
import { type Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { type Room, RoomEvent } from "matrix-js-sdk";
import { useState } from "react";
import { useTypedEventEmitter } from "../useEvents";

View File

@@ -5,13 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk/src/models/room-state";
import { useCallback, useMemo, useState } from "react";
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
import type { Room } from "matrix-js-sdk/src/models/room";
import { useTypedEventEmitter } from "../useEvents";
/**

View File

@@ -20,7 +20,7 @@ import {
TrackEvent,
} from "livekit-client";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { platform } from "../Platform";

View File

@@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { expect, onTestFinished, test, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import EventEmitter from "events";
import { enterRTCSession, leaveRTCSession } from "../src/rtcSessionHelpers";

View File

@@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import {
isLivekitFocus,
isLivekitFocusConfig,
type LivekitFocus,
type LivekitFocusActive,
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
} from "matrix-js-sdk/lib/matrixrtc";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { Config } from "./config/Config";
@@ -166,7 +166,6 @@ const widgetPostHangupProcedure = async (
logger.error("Failed to send close action", e);
}
widget.api.transport.stop();
PosthogAnalytics.instance.logout();
}
};

View File

@@ -17,9 +17,10 @@ import {
showConnectionStats as showConnectionStatsSetting,
useNewMembershipManagerSetting,
} from "./settings";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { MatrixClient } from "matrix-js-sdk";
import type { Room as LivekitRoom } from "livekit-client";
import styles from "./DeveloperSettingsTab.module.css";
import { useUrlParams } from "../UrlParams";
interface Props {
client: MatrixClient;
livekitRoom?: LivekitRoom;
@@ -43,6 +44,8 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
useNewMembershipManagerSetting,
);
const urlParams = useUrlParams();
const sfuUrl = useMemo((): URL | null => {
if (livekitRoom?.engine.client.ws?.url) {
// strip the URL params
@@ -169,6 +172,10 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
</pre>
</>
) : null}
<p>{t("developer_mode.environment_variables")}</p>
<pre>{JSON.stringify(import.meta.env, null, 2)}</pre>
<p>{t("developer_mode.url_params")}</p>
<pre>{JSON.stringify(urlParams, null, 2)}</pre>
</>
);
};

View File

@@ -6,10 +6,10 @@ Please see LICENSE in the repository root for full details.
*/
import { type ChangeEvent, type FC, useCallback } from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
@@ -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>
);
};

View File

@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useEffect, useMemo, useRef } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useProfile } from "../profile/useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { useTranslation } from "react-i18next";
import { type FC, useCallback, type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config } from "../config/Config";
import styles from "./RageshakeButton.module.css";

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk";
import { Root as Form } from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client";
@@ -30,6 +30,7 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { useSubmitRageshake } from "./submit-rageshake";
type SettingsTab =
| "audio"
@@ -70,6 +71,8 @@ export const SettingsModal: FC<Props> = ({
const [showDeveloperSettingsTab] = useSetting(developerMode);
const { available: isRageshakeAvailable } = useSubmitRageshake();
const audioTab: Tab<SettingsTab> = {
key: "audio",
name: t("common.audio"),
@@ -146,7 +149,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 (

View File

@@ -29,8 +29,8 @@ Please see LICENSE in the repository root for full details.
import EventEmitter from "events";
import { throttle } from "lodash-es";
import { type Logger, logger } from "matrix-js-sdk/src/logger";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { type Logger, logger } from "matrix-js-sdk/lib/logger";
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
import { type LoggingMethod } from "loglevel";
import type loglevel from "loglevel";

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable } from "rxjs";
import { useObservableEagerState } from "observable-hooks";

View File

@@ -0,0 +1,87 @@
/*
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 } from "./submit-rageshake";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
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",
);
});
});
});

View File

@@ -6,19 +6,20 @@ Please see LICENSE in the repository root for full details.
*/
import { type ComponentProps, useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ClientEvent,
type MatrixClient,
type MatrixEvent,
} from "matrix-js-sdk/src/matrix";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
} from "matrix-js-sdk";
import { type CryptoApi } from "matrix-js-sdk/lib/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
@@ -116,11 +117,28 @@ interface RageShakeSubmitOptions {
label?: string;
}
export function useSubmitRageshake(): {
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 useSubmitRageshake(
injectedGetRageshakeSubmitUrl = getRageshakeSubmitUrl,
): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error?: Error;
available: boolean;
} {
const { client } = useClient();
@@ -138,7 +156,8 @@ export function useSubmitRageshake(): {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
async (opts) => {
if (!Config.get().rageshake?.submit_url) {
const submitUrl = injectedGetRageshakeSubmitUrl();
if (!submitUrl) {
throw new Error("No rageshake URL is configured");
}
@@ -272,7 +291,7 @@ export function useSubmitRageshake(): {
);
}
const res = await fetch(Config.get().rageshake!.submit_url, {
const res = await fetch(submitUrl, {
method: "POST",
body,
});
@@ -289,7 +308,7 @@ export function useSubmitRageshake(): {
logger.error(error);
}
},
[client, sending],
[client, sending, injectedGetRageshakeSubmitUrl],
);
return {
@@ -297,6 +316,7 @@ export function useSubmitRageshake(): {
sending,
sent,
error,
available: !!injectedGetRageshakeSubmitUrl(),
};
}

View File

@@ -0,0 +1,222 @@
/*
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,
afterEach,
type Mock,
} from "vitest";
import { useState, type ReactElement } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/lib/client";
import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
import { ClientContextProvider } from "../ClientContext";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
const TestComponent = ({
sendLogs,
getRageshakeSubmitUrl,
}: {
sendLogs: boolean;
getRageshakeSubmitUrl: () => string | undefined;
}): ReactElement => {
const [clickError, setClickError] = useState<Error | null>(null);
const { available, sending, sent, submitRageshake, error } =
useSubmitRageshake(getRageshakeSubmitUrl);
const onClick = (): void => {
submitRageshake({
sendLogs,
}).catch((e) => {
setClickError(e);
});
};
return (
<div>
<p data-testid="available">{available ? "true" : "false"}</p>
<p data-testid="sending">{sending ? "true" : "false"}</p>
<p data-testid="sent">{sent ? "true" : "false"}</p>
<p data-testid="error">{error?.message}</p>
<p data-testid="clickError">{clickError?.message}</p>
<button onClick={onClick} data-testid="submit">
submit
</button>
</div>
);
};
function renderWithMockClient(
getRageshakeSubmitUrl: () => string | undefined,
sendLogs: boolean,
): void {
const client = vi.mocked<MatrixClient>({
getUserId: vi.fn().mockReturnValue("@user:localhost"),
getUser: vi.fn().mockReturnValue(null),
credentials: {
userId: "@user:localhost",
},
getCrypto: vi.fn().mockReturnValue(undefined),
} as unknown as MatrixClient);
render(
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
thumbnails: true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
<TestComponent
sendLogs={sendLogs}
getRageshakeSubmitUrl={getRageshakeSubmitUrl}
/>
</ClientContextProvider>,
);
}
describe("useSubmitRageshake", () => {
describe("available", () => {
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", () => {
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("ignores config value and returns false with no rageshakeSubmitUrl URL param", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("returns true with rageshakeSubmitUrl URL param", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("true");
});
});
describe("full package", () => {
beforeEach(() => {
mockConfig({});
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns false with no config value", () => {
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("ignores rageshakeSubmitUrl URL param and returns false with no config value", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("false");
});
it("returns true with config value", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
renderWithMockClient(getRageshakeSubmitUrl, false);
expect(screen.getByTestId("available").textContent).toBe("true");
});
});
});
describe("when rageshake is available", () => {
beforeEach(() => {
mockConfig({});
vi.unstubAllGlobals();
});
it("starts unsent", () => {
renderWithMockClient(() => "https://rageshake.localhost/foo", false);
expect(screen.getByTestId("sending").textContent).toBe("false");
expect(screen.getByTestId("sent").textContent).toBe("false");
});
it("submitRageshake fetches expected URL", async () => {
const fetchFn = vi.fn().mockResolvedValue({
status: 200,
});
vi.stubGlobal("fetch", fetchFn);
renderWithMockClient(() => "https://rageshake.localhost/foo", false);
screen.getByTestId("submit").click();
await waitFor(() => {
expect(screen.getByTestId("sent").textContent).toBe("true");
});
expect(fetchFn).toHaveBeenCalledExactlyOnceWith(
"https://rageshake.localhost/foo",
expect.objectContaining({
method: "POST",
}),
);
expect(screen.getByTestId("clickError").textContent).toBe("");
expect(screen.getByTestId("error").textContent).toBe("");
});
});
describe("when rageshake is not available", () => {
it("starts unsent", () => {
renderWithMockClient(() => undefined, false);
expect(screen.getByTestId("sending").textContent).toBe("false");
expect(screen.getByTestId("sent").textContent).toBe("false");
});
it("submitRageshake throws error", async () => {
renderWithMockClient(() => undefined, false);
screen.getByTestId("submit").click();
await waitFor(() => {
expect(screen.getByTestId("clickError").textContent).toBe(
"No rageshake URL is configured",
);
});
});
});
});

View File

@@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { isFailure } from "./utils/fetch";
type SoundDefinition = { mp3?: string; ogg: string };
@@ -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`);

View File

@@ -17,7 +17,7 @@ import {
skip,
switchMap,
} from "rxjs";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk";
import {
ConnectionState,
type LocalParticipant,
@@ -30,7 +30,7 @@ import { isEqual } from "lodash-es";
import {
type CallMembership,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc";
} from "matrix-js-sdk/lib/matrixrtc";
import { CallViewModel, type Layout } from "./CallViewModel";
import {

View File

@@ -18,11 +18,7 @@ import {
type RemoteParticipant,
Track,
} from "livekit-client";
import {
RoomStateEvent,
type Room,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
import {
BehaviorSubject,
EMPTY,
@@ -49,12 +45,12 @@ import {
timer,
withLatestFrom,
} from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type CallMembership,
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
} from "matrix-js-sdk/lib/matrixrtc";
import { ViewModel } from "./ViewModel";
import {

View File

@@ -26,7 +26,7 @@ import {
RoomEvent as LivekitRoomEvent,
RemoteTrack,
} from "livekit-client";
import { type RoomMember } from "matrix-js-sdk/src/matrix";
import { type RoomMember } from "matrix-js-sdk";
import {
BehaviorSubject,
type Observable,

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";

Some files were not shown because too many files have changed in this diff Show More