mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Merge branch 'livekit' into defaut-server-config
This commit is contained in:
1
src/@types/global.d.ts
vendored
1
src/@types/global.d.ts
vendored
@@ -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";
|
||||
|
||||
2
src/@types/matrix-js-sdk.d.ts
vendored
2
src/@types/matrix-js-sdk.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -161,7 +161,12 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
height={20}
|
||||
aria-label={t("header_participants_label")}
|
||||
/>
|
||||
<Text as="span" size="sm" weight="medium">
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
data-testid="roomHeader_participants_count"
|
||||
>
|
||||
{t("participant_count", { count: participantCount ?? 0 })}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -5,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
91
src/analytics/PosthogAnalytics.test.ts
Normal file
91
src/analytics/PosthogAnalytics.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
expect,
|
||||
describe,
|
||||
it,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value or URL params", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses URL params if both set", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores URL params and does not create instance", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ConfigOptions,
|
||||
type ResolvedConfigOptions,
|
||||
} from "./ConfigOptions";
|
||||
import { isFailure } from "../utils/fetch";
|
||||
|
||||
export class Config {
|
||||
private static internalInstance: Config | undefined;
|
||||
@@ -28,7 +29,20 @@ export class Config {
|
||||
const internalInstance = new Config();
|
||||
Config.internalInstance = internalInstance;
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig("/config.json").then(
|
||||
let fetchTarget: string;
|
||||
|
||||
if (
|
||||
window.location.pathname.endsWith("/room/") ||
|
||||
window.location.pathname.endsWith("/room")
|
||||
) {
|
||||
// it looks like we are running in standalone mode so use the config at the root
|
||||
fetchTarget = new URL("/config.json", window.location.href).href;
|
||||
} else {
|
||||
// otherwise we are probably running as a widget so use the config in the same directory
|
||||
fetchTarget = "config.json";
|
||||
}
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||
(config) => {
|
||||
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
|
||||
},
|
||||
@@ -70,18 +84,15 @@ export class Config {
|
||||
private initPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
async function downloadConfig(
|
||||
configJsonFilename: string,
|
||||
): Promise<ConfigOptions> {
|
||||
const url = new URL(configJsonFilename, window.location.href);
|
||||
const res = await fetch(url);
|
||||
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
const response = await fetch(fetchTarget);
|
||||
|
||||
if (!res.ok || res.status === 404 || res.status === 0) {
|
||||
if (isFailure(response)) {
|
||||
// Lack of a config isn't an error, we should just use the defaults.
|
||||
// Also treat a blank config as no config, assuming the status code is 0, because we don't get 404s from file:
|
||||
// URIs so this is the only way we can not fail if the file doesn't exist when loading from a file:// URI.
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
export interface ConfigOptions {
|
||||
/**
|
||||
* The Posthog endpoint to which analytics data will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
*/
|
||||
posthog?: {
|
||||
api_key: string;
|
||||
@@ -15,6 +16,7 @@ export interface ConfigOptions {
|
||||
};
|
||||
/**
|
||||
* The Sentry endpoint to which crash data will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
*/
|
||||
sentry?: {
|
||||
DSN: string;
|
||||
@@ -22,6 +24,7 @@ export interface ConfigOptions {
|
||||
};
|
||||
/**
|
||||
* The rageshake server to which feedback and debug logs will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
*/
|
||||
rageshake?: {
|
||||
submit_url: string;
|
||||
@@ -29,7 +32,7 @@ export interface ConfigOptions {
|
||||
|
||||
/**
|
||||
* Sets the URL to send opentelemetry data to. If unset, opentelemetry will
|
||||
* be disabled.
|
||||
* be disabled. This is only used in the full package of Element Call.
|
||||
*/
|
||||
opentelemetry?: {
|
||||
collector_url: string;
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
79
src/otel/otel.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
expect,
|
||||
describe,
|
||||
it,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
describe("ElementCallOpenTelemetry", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
opentelemetry: {
|
||||
collector_url: "https://collector.example.com.localhost",
|
||||
},
|
||||
});
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
opentelemetry: {
|
||||
collector_url: "https://collector.example.com.localhost",
|
||||
},
|
||||
});
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
253
src/room/GroupCallErrorBoundary.test.tsx
Normal file
253
src/room/GroupCallErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
type FC,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
type CallErrorRecoveryAction,
|
||||
GroupCallErrorBoundary,
|
||||
} from "./GroupCallErrorBoundary.tsx";
|
||||
import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
type ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
MatrixRTCFocusMissingError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
||||
|
||||
test.each([
|
||||
{
|
||||
error: new MatrixRTCFocusMissingError("example.com"),
|
||||
expectedTitle: "Call is not supported",
|
||||
},
|
||||
{
|
||||
error: new ConnectionLostError(),
|
||||
expectedTitle: "Connection lost",
|
||||
expectedDescription: "You were disconnected from the call.",
|
||||
},
|
||||
{
|
||||
error: new E2EENotSupportedError(),
|
||||
expectedTitle: "Incompatible browser",
|
||||
expectedDescription:
|
||||
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
||||
},
|
||||
{
|
||||
error: new InsufficientCapacityError(),
|
||||
expectedTitle: "Insufficient capacity",
|
||||
expectedDescription:
|
||||
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
},
|
||||
])(
|
||||
"should report correct error for $expectedTitle",
|
||||
async ({ error, expectedTitle, expectedDescription }) => {
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText(expectedTitle);
|
||||
if (expectedDescription) {
|
||||
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
|
||||
}
|
||||
expect(onErrorMock).toHaveBeenCalledWith(error);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
test("should render the error page with link back to home", async () => {
|
||||
const error = new MatrixRTCFocusMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("Call is not supported");
|
||||
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await screen.findByRole("button", { name: "Return to home screen" });
|
||||
|
||||
expect(onErrorMock).toHaveBeenCalledOnce();
|
||||
expect(onErrorMock).toHaveBeenCalledWith(error);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("ConnectionLostError: Action handling should reset error state", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
|
||||
if (fail) {
|
||||
throw new ConnectionLostError();
|
||||
}
|
||||
return <div>HELLO</div>;
|
||||
};
|
||||
|
||||
const reconnectCallbackSpy = vi.fn();
|
||||
|
||||
const WrapComponent = (): ReactNode => {
|
||||
const [failState, setFailState] = useState(true);
|
||||
const reconnectCallback = useCallback(
|
||||
(action: CallErrorRecoveryAction) => {
|
||||
reconnectCallbackSpy(action);
|
||||
setFailState(false);
|
||||
},
|
||||
[setFailState],
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={reconnectCallback}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent fail={failState} />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const { asFragment } = render(<WrapComponent />);
|
||||
|
||||
// Should fail first
|
||||
await screen.findByText("Connection lost");
|
||||
await screen.findByRole("button", { name: "Reconnect" });
|
||||
await screen.findByRole("button", { name: "Return to home screen" });
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Reconnect" }));
|
||||
|
||||
// reconnect should have reset the error, thus rendering should be ok
|
||||
await screen.findByText("HELLO");
|
||||
|
||||
expect(reconnectCallbackSpy).toHaveBeenCalledOnce();
|
||||
expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect");
|
||||
});
|
||||
|
||||
describe("Rageshake button", () => {
|
||||
function setupTest(testError: ElementCallError): void {
|
||||
mockConfig({
|
||||
rageshake: {
|
||||
submit_url: "https://rageshake.example.com.localhost",
|
||||
},
|
||||
});
|
||||
|
||||
const TestComponent = (): ReactElement => {
|
||||
throw testError;
|
||||
};
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
test("should show send rageshake button for unknown errors", () => {
|
||||
setupTest(new UnknownCallError(new Error("FOO")));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Send debug logs" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should not show send rageshake button for call errors", () => {
|
||||
setupTest(new E2EENotSupportedError());
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Send debug logs" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should have a close button in widget mode", async () => {
|
||||
const error = new MatrixRTCFocusMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const mockWidget = {
|
||||
api: {
|
||||
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
|
||||
},
|
||||
} as unknown as WidgetHelpers;
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
widget={mockWidget}
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("Call is not supported");
|
||||
|
||||
await screen.findByRole("button", { name: "Close" });
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
||||
});
|
||||
146
src/room/GroupCallErrorBoundary.tsx
Normal file
146
src/room/GroupCallErrorBoundary.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ErrorBoundary, type FallbackRender } from "@sentry/react";
|
||||
import {
|
||||
type ComponentType,
|
||||
type FC,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type SVGAttributes,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
ErrorIcon,
|
||||
HostIcon,
|
||||
OfflineIcon,
|
||||
WebBrowserIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import {
|
||||
ConnectionLostError,
|
||||
ElementCallError,
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { FullScreenView } from "../FullScreenView.tsx";
|
||||
import { ErrorView } from "../ErrorView.tsx";
|
||||
import { type WidgetHelpers } from "../widget.ts";
|
||||
|
||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||
|
||||
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: ElementCallError;
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
resetError: () => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
const ErrorPage: FC<ErrorPageProps> = ({
|
||||
error,
|
||||
recoveryActionHandler,
|
||||
widget,
|
||||
}: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
switch (error.category) {
|
||||
case ErrorCategory.CONFIGURATION_ISSUE:
|
||||
icon = HostIcon;
|
||||
break;
|
||||
case ErrorCategory.NETWORK_CONNECTIVITY:
|
||||
icon = OfflineIcon;
|
||||
break;
|
||||
case ErrorCategory.CLIENT_CONFIGURATION:
|
||||
icon = WebBrowserIcon;
|
||||
break;
|
||||
default:
|
||||
icon = ErrorIcon;
|
||||
}
|
||||
|
||||
const actions: { label: string; onClick: () => void }[] = [];
|
||||
if (error instanceof ConnectionLostError) {
|
||||
actions.push({
|
||||
label: t("call_ended_view.reconnect_button"),
|
||||
onClick: () => recoveryActionHandler("reconnect"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
<ErrorView
|
||||
Icon={icon}
|
||||
title={error.localisedTitle}
|
||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||
widget={widget}
|
||||
>
|
||||
<p>
|
||||
{error.localisedMessage ?? (
|
||||
<Trans
|
||||
i18nKey="error.unexpected_ec_error"
|
||||
components={[<b />, <code />]}
|
||||
values={{ errorCode: error.code }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
{actions &&
|
||||
actions.map((action, index) => (
|
||||
<button onClick={action.onClick} key={`action${index}`}>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</ErrorView>
|
||||
</FullScreenView>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoundaryProps {
|
||||
children: ReactNode | (() => ReactNode);
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
onError?: (error: unknown) => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallErrorBoundary = ({
|
||||
recoveryActionHandler,
|
||||
onError,
|
||||
children,
|
||||
widget,
|
||||
}: BoundaryProps): ReactElement => {
|
||||
const fallbackRenderer: FallbackRender = useCallback(
|
||||
({ error, resetError }): ReactElement => {
|
||||
const callError =
|
||||
error instanceof ElementCallError
|
||||
? error
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
error={callError}
|
||||
resetError={resetError}
|
||||
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
|
||||
resetError();
|
||||
recoveryActionHandler(action);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[recoveryActionHandler, widget],
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={fallbackRenderer}
|
||||
onError={(error) => onError?.(error)}
|
||||
children={children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1051
src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
Normal file
1051
src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { 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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
87
src/settings/submit-rageshake.test.ts
Normal file
87
src/settings/submit-rageshake.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
222
src/settings/useSubmitRageshake.test.tsx
Normal file
222
src/settings/useSubmitRageshake.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user