mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-03 19:28:05 +00:00
LiveKit's livekit-ice logger records each local ICE candidate, ICE candidate errors and ICE connection state changes, but only at debug, which we cap to info unless extended LiveKit logs are switched on. That means a 'could not establish pc connection' rageshake carries no way to tell 'no relay candidates at all' from 'relay candidates that never paired'. Keep that one logger at debug regardless of the toggle.
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
/*
|
|
Copyright 2022-2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import React from "react";
|
|
import {
|
|
type BackendModule,
|
|
type ReadCallback,
|
|
type ResourceKey,
|
|
} from "i18next";
|
|
import LanguageDetector from "i18next-browser-languagedetector";
|
|
import * as Sentry from "@sentry/react";
|
|
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.js";
|
|
import {
|
|
useLocation,
|
|
useNavigationType,
|
|
createRoutesFromChildren,
|
|
matchRoutes,
|
|
} from "react-router-dom";
|
|
import {
|
|
setLogExtension as setLKLogExtension,
|
|
setLogLevel as setLKLogLevel,
|
|
LoggerNames as LKLoggerNames,
|
|
} from "livekit-client";
|
|
|
|
import { getUrlParams } from "./UrlParams";
|
|
import { Config } from "./config/Config";
|
|
import { seedSettingsFromConfig } from "./settings/settings";
|
|
import { isFailure } from "./utils/fetch";
|
|
import { initializeWidget, type WidgetHelpers } from "./widget";
|
|
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
|
import {
|
|
type AnalyticsConfig,
|
|
PosthogAnalytics,
|
|
} from "./analytics/PosthogAnalytics.ts";
|
|
import { i18n, languageOfLocalePath } from "./utils/i18n.ts";
|
|
|
|
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
|
// {
|
|
// "../locales/en/app.json": "/whatever/assets/root/locales/en-aabbcc.json",
|
|
// ...
|
|
// }
|
|
const locales = import.meta.glob<string>("../locales/*/*.json", {
|
|
query: "?url",
|
|
import: "default",
|
|
eager: true,
|
|
});
|
|
|
|
const getLocaleUrl = (
|
|
language: string,
|
|
namespace: string,
|
|
): string | undefined => locales[`../locales/${language}/${namespace}.json`];
|
|
|
|
const supportedLngs = [
|
|
...new Set(Object.keys(locales).map(languageOfLocalePath)),
|
|
];
|
|
|
|
// A backend that fetches the locale files from the URLs generated by the glob above
|
|
const Backend = {
|
|
type: "backend",
|
|
init(): void {},
|
|
read(language: string, namespace: string, callback: ReadCallback): void {
|
|
(async (): Promise<ResourceKey> => {
|
|
const url = getLocaleUrl(language, namespace);
|
|
if (!url) {
|
|
throw new Error(
|
|
`Namespace ${namespace} for locale ${language} not found`,
|
|
);
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
credentials: "omit",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
|
|
if (isFailure(response)) {
|
|
throw Error(`Failed to fetch ${url}`);
|
|
}
|
|
|
|
return await response.json();
|
|
})().then(
|
|
(data) => callback(null, data),
|
|
(error) => callback(error, null),
|
|
);
|
|
},
|
|
} satisfies BackendModule;
|
|
|
|
/**
|
|
* Where analytics reporting is configured from.
|
|
*
|
|
* Note the two halves are decided differently, and deliberately so. *Where the
|
|
* PostHog credentials come from* depends on the package: an embedder passes
|
|
* them in through the URL because it is responsible for its own users'
|
|
* telemetry, whereas a standalone deployment is configured by whoever operates
|
|
* it. *Who owns the user's analytics identity*, on the other hand, depends on
|
|
* how Element Call is actually running right now — the full package can be used
|
|
* as a widget too.
|
|
*/
|
|
// Exported for testing
|
|
export function analyticsConfigFromEnvironment(): AnalyticsConfig {
|
|
const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } =
|
|
getUrlParams();
|
|
return {
|
|
matrixBackend: isWidget ? "embedded" : "jssdk",
|
|
hostAnalyticsId: posthogUserId,
|
|
...(import.meta.env.VITE_PACKAGE === "embedded"
|
|
? {
|
|
apiKey: posthogApiKey ?? undefined,
|
|
apiHost: posthogApiHost ?? undefined,
|
|
}
|
|
: {
|
|
apiKey: Config.get().posthog?.api_key,
|
|
apiHost: Config.get().posthog?.api_host,
|
|
}),
|
|
};
|
|
}
|
|
|
|
enum LoadState {
|
|
None,
|
|
Loading,
|
|
Loaded,
|
|
}
|
|
|
|
class DependencyLoadStates {
|
|
public config: LoadState = LoadState.None;
|
|
public sentry: LoadState = LoadState.None;
|
|
|
|
public allDepsAreLoaded(): boolean {
|
|
return !Object.values(this).some((s) => s !== LoadState.Loaded);
|
|
}
|
|
}
|
|
|
|
export class Initializer {
|
|
private static internalInstance: Initializer | undefined;
|
|
private isInitialized = false;
|
|
|
|
public static isInitialized(): boolean {
|
|
return !!Initializer.internalInstance?.isInitialized;
|
|
}
|
|
|
|
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
|
|
const widget = initializeWidget();
|
|
|
|
const polyfills: Promise<unknown>[] = [];
|
|
if (shouldPolyfillSegmenter()) {
|
|
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
|
|
}
|
|
|
|
if (shouldPolyfillDurationFormat()) {
|
|
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
|
|
}
|
|
|
|
await Promise.all(polyfills);
|
|
|
|
//i18n
|
|
const languageDetector = new LanguageDetector();
|
|
languageDetector.addDetector({
|
|
name: "urlFragment",
|
|
// Look for a language code in the URL's fragment
|
|
lookup: () => getUrlParams().lang ?? undefined,
|
|
});
|
|
|
|
// Synchronise the HTML lang attribute with the i18next language
|
|
i18n.on("languageChanged", (lng) => {
|
|
document.documentElement.lang = lng;
|
|
});
|
|
|
|
// Note: deliberately no `.use(initReactI18next)` — that would register this
|
|
// instance as react-i18next's global default, which is the very global we
|
|
// are avoiding. Components receive it through `<I18nextProvider>` instead.
|
|
await i18n
|
|
.use(Backend)
|
|
.use(languageDetector)
|
|
.init({
|
|
fallbackLng: "en",
|
|
defaultNS: "app",
|
|
keySeparator: ".",
|
|
nsSeparator: false,
|
|
pluralSeparator: "_",
|
|
contextSeparator: "|",
|
|
supportedLngs,
|
|
interpolation: {
|
|
escapeValue: false, // React has built-in XSS protections
|
|
},
|
|
detection: {
|
|
// No localStorage detectors or caching here, since we don't have any way
|
|
// of letting the user manually select a language
|
|
order: ["urlFragment", "navigator"],
|
|
caches: [],
|
|
},
|
|
});
|
|
|
|
// Custom Themeing
|
|
if (import.meta.env.VITE_CUSTOM_CSS) {
|
|
const style = document.createElement("style");
|
|
style.textContent = import.meta.env.VITE_CUSTOM_CSS;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// Custom fonts
|
|
const { fonts, fontScale } = getUrlParams();
|
|
if (fontScale !== null) {
|
|
document.documentElement.style.setProperty(
|
|
"--font-scale",
|
|
fontScale.toString(),
|
|
);
|
|
}
|
|
if (fonts.length > 0) {
|
|
document.documentElement.style.setProperty(
|
|
"--font-family",
|
|
fonts.map((f) => `"${f}"`).join(", "),
|
|
);
|
|
}
|
|
|
|
// livekit logging configuration
|
|
setLKLogExtension((level, msg, context) => {
|
|
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
|
global.mx_rage_logger.log(level, "livekit", msg, context);
|
|
});
|
|
|
|
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
|
|
setLKLogLevel(enabled ? "trace" : "info");
|
|
// ICE candidate types and connection states are what you need to diagnose
|
|
// "could not establish pc connection", so always keep those in the rageshake
|
|
setLKLogLevel("debug", LKLoggerNames.ICE);
|
|
});
|
|
|
|
window.setLKLogLevel = setLKLogLevel;
|
|
|
|
return widget;
|
|
}
|
|
|
|
public static init(): Promise<void> | null {
|
|
if (Initializer?.internalInstance?.initPromise) {
|
|
return null;
|
|
}
|
|
Initializer.internalInstance = new 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);
|
|
});
|
|
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 {
|
|
// config
|
|
if (this.loadStates.config === LoadState.None) {
|
|
this.loadStates.config = LoadState.Loading;
|
|
Config.init().then(
|
|
() => {
|
|
seedSettingsFromConfig(Config.get().media_quality);
|
|
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
|
|
this.loadStates.config = LoadState.Loaded;
|
|
this.initStep(resolve);
|
|
},
|
|
(e) => {
|
|
logger.error("Failed to load config", e);
|
|
},
|
|
);
|
|
}
|
|
|
|
//sentry (only initialize after the config is ready)
|
|
if (
|
|
this.loadStates.sentry === LoadState.None &&
|
|
this.loadStates.config === LoadState.Loaded
|
|
) {
|
|
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,
|
|
environment,
|
|
integrations: [
|
|
Sentry.reactRouterV7BrowserTracingIntegration({
|
|
useEffect: React.useEffect,
|
|
useLocation,
|
|
useNavigationType,
|
|
createRoutesFromChildren,
|
|
matchRoutes,
|
|
}),
|
|
],
|
|
tracesSampleRate: 1.0,
|
|
});
|
|
}
|
|
// Sentry is now 'loadeed' (even if we actually skipped starting
|
|
// it due to to not being configured)
|
|
this.loadStates.sentry = LoadState.Loaded;
|
|
}
|
|
|
|
if (this.loadStates.allDepsAreLoaded()) {
|
|
// resolve if there is no dependency that is not loaded
|
|
resolve();
|
|
this.isInitialized = true;
|
|
}
|
|
}
|
|
|
|
private initPromise?: Promise<void>;
|
|
}
|