diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7bf9faa..a3fdc01 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,9 +4,6 @@ on: branches: [main] env: VITE_DEFAULT_HOMESERVER: "https://call.ems.host" - VITE_SENTRY_DSN: https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41 - VITE_SENTRY_ENVIRONMENT: main-branch-cd - VITE_RAGESHAKE_SUBMIT_URL: https://element.io/bugreports/submit jobs: build: name: Build @@ -17,7 +14,7 @@ jobs: - name: Yarn cache uses: actions/setup-node@v3 with: - cache: 'yarn' + cache: "yarn" - name: Install dependencies run: "yarn install" - name: Build diff --git a/.gitignore b/.gitignore index 9bafa52..f46dc2b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist dist-ssr *.local .idea/ +config.json diff --git a/sample.config.json b/sample.config.json new file mode 100644 index 0000000..c00e539 --- /dev/null +++ b/sample.config.json @@ -0,0 +1,13 @@ +{ + "posthog": { + "api_key": "examplePosthogKey", + "api_host": "https://posthog.com" + }, + "sentry": { + "environment": "main-branch", + "DSN": "https://examplePublicKey@o0.ingest.sentry.io/0" + }, + "rageshake": { + "submit_url": "http://localhost:9110/api/submit" + } +} diff --git a/scripts/dockerbuild.sh b/scripts/dockerbuild.sh index 491fc02..7dce8f6 100755 --- a/scripts/dockerbuild.sh +++ b/scripts/dockerbuild.sh @@ -3,8 +3,6 @@ set -ex export VITE_DEFAULT_HOMESERVER=https://call.ems.host -export VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41 -export VITE_RAGESHAKE_SUBMIT_URL=https://element.io/bugreports/submit export VITE_PRODUCT_NAME="Element Call" git clone https://github.com/matrix-org/matrix-js-sdk.git diff --git a/src/App.tsx b/src/App.tsx index 0299734..5fc0414 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Suspense } from "react"; +import React, { Suspense, useEffect, useState } from "react"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { OverlayProvider } from "@react-aria/overlays"; @@ -28,7 +28,8 @@ import { ClientProvider } from "./ClientContext"; import { usePageFocusStyle } from "./usePageFocusStyle"; import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage"; import { InspectorContextProvider } from "./room/GroupCallInspector"; -import { CrashView } from "./FullScreenView"; +import { CrashView, LoadingView } from "./FullScreenView"; +import { Initializer } from "./initializer"; const SentryRoute = Sentry.withSentryRouting(Route); @@ -37,42 +38,54 @@ interface AppProps { } export default function App({ history }: AppProps) { + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + Initializer.init()?.then(() => { + setLoaded(true); + }); + }); + usePageFocusStyle(); const errorPage = ; return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {loaded ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + + )} ); } diff --git a/src/config/Config.ts b/src/config/Config.ts new file mode 100644 index 0000000..9b3e718 --- /dev/null +++ b/src/config/Config.ts @@ -0,0 +1,64 @@ +/* +Copyright 2021-2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { DEFAULT_CONFIG, IConfigOptions } from "./ConfigOptions"; + +export class Config { + private static internalInstance: Config; + public static get instance(): Config { + if (!this.internalInstance) + throw new Error("Config instance read before config got initialized"); + return this.internalInstance; + } + public static init(): Promise { + if (Config?.internalInstance?.initPromise) { + return Config.internalInstance.initPromise; + } + Config.internalInstance = new Config(); + Config.internalInstance.initPromise = new Promise((resolve) => { + downloadConfig("../config.json").then((config) => { + Config.internalInstance.config = { ...DEFAULT_CONFIG, ...config }; + resolve(); + }); + }); + return Config.internalInstance.initPromise; + } + + public config: IConfigOptions; + private initPromise: Promise; +} + +async function downloadConfig( + configJsonFilename: string +): Promise { + const url = new URL(configJsonFilename, window.location.href); + url.searchParams.set("cachebuster", Date.now().toString()); + const res = await fetch(url, { + cache: "no-cache", + method: "GET", + }); + + if (res.status === 404 || res.status === 0) { + // 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 {} as IConfigOptions; + } + + if (res.ok) { + return res.json(); + } +} diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts new file mode 100644 index 0000000..6e29a94 --- /dev/null +++ b/src/config/ConfigOptions.ts @@ -0,0 +1,19 @@ +export interface IConfigOptions { + posthog?: { + api_key: string; + }; + sentry?: { + DSN: string; + environment: string; + }; + rageshake?: { + submit_url: string; + }; +} + +export const DEFAULT_CONFIG: IConfigOptions = { + sentry: { DSN: "", environment: "production" }, + rageshake: { + submit_url: "https://element.io/bugreports/submit", + }, +}; diff --git a/src/initializer.tsx b/src/initializer.tsx new file mode 100644 index 0000000..1af0574 --- /dev/null +++ b/src/initializer.tsx @@ -0,0 +1,146 @@ +/* +Copyright 2022 Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Integrations } from "@sentry/tracing"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import Backend from "i18next-http-backend"; +import * as Sentry from "@sentry/react"; + +import { getUrlParams } from "./UrlParams"; +import { Config } from "./config/Config"; +import { DEFAULT_CONFIG } from "./config/ConfigOptions"; + +enum LoadState { + None, + Loading, + Loaded, +} + +class DependencyLoadStates { + // TODO: decide where olm should be initialized (see TODO comment below) + // olm: LoadState = LoadState.None; + config: LoadState = LoadState.None; + sentry: LoadState = LoadState.None; + i18n: LoadState = LoadState.None; + + allDepsAreLoaded() { + return !Object.values(this).some((s) => s !== LoadState.Loaded); + } +} + +export class Initializer { + private static internalInstance: Initializer; + + public static init(): Promise | null { + if (Initializer?.internalInstance?.initPromise) { + return null; + } + Initializer.internalInstance = new Initializer(); + Initializer.internalInstance.initPromise = new Promise((resolve) => { + // initStep calls itself recursivly until everything is initialized in the correct order. + // Then the promise gets resolved. + Initializer.internalInstance.initStep(resolve); + }); + return Initializer.internalInstance.initPromise; + } + + loadStates = new DependencyLoadStates(); + + initStep(resolve: (value: void | PromiseLike) => void) { + // TODO: Olm is initialized with the client currently (see `initClient()` and `olm.ts`) + // we need to decide if we want to init it here or keep it in initClient + // if (this.loadStates.olm === LoadState.None) { + // this.loadStates.olm = LoadState.Loading; + // // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10 + // window.OLM_OPTIONS = {}; + // Olm.init({ locateFile: () => olmWasmPath }).then(() => { + // this.loadStates.olm = LoadState.Loaded; + // this.initStep(resolve); + // }); + // } + + // config + if (this.loadStates.config === LoadState.None) { + this.loadStates.config = LoadState.Loading; + Config.init().then(() => { + this.loadStates.config = LoadState.Loaded; + this.initStep(resolve); + }); + } + + //sentry (only initialize after the config is ready) + if ( + this.loadStates.sentry === LoadState.None && + this.loadStates.config === LoadState.Loaded + ) { + Sentry.init({ + dsn: Config.instance.config.sentry?.DSN ?? DEFAULT_CONFIG.sentry.DSN, + environment: + Config.instance.config.sentry.environment ?? + DEFAULT_CONFIG.sentry.environment, + integrations: [ + new Integrations.BrowserTracing({ + routingInstrumentation: + Sentry.reactRouterV5Instrumentation(history), + }), + ], + tracesSampleRate: 1.0, + }); + this.loadStates.sentry = LoadState.Loaded; + } + + //i18n + if (this.loadStates.i18n === LoadState.None) { + const languageDetector = new LanguageDetector(); + languageDetector.addDetector({ + name: "urlFragment", + // Look for a language code in the URL's fragment + lookup: () => getUrlParams().lang ?? undefined, + }); + + i18n + .use(Backend) + .use(languageDetector) + .use(initReactI18next) + .init({ + fallbackLng: "en-GB", + defaultNS: "app", + keySeparator: false, + nsSeparator: false, + pluralSeparator: "|", + contextSeparator: "|", + 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: [], + }, + }); + this.loadStates.i18n = LoadState.Loaded; + } + + if (this.loadStates.allDepsAreLoaded()) { + // resolve if there is no dependency that is not loaded + resolve(); + } + } + private initPromise: Promise; +} diff --git a/src/main.tsx b/src/main.tsx index f7aa6ca..bf4ab5f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -23,17 +23,10 @@ import "matrix-js-sdk/src/browser-index"; import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserHistory } from "history"; -import * as Sentry from "@sentry/react"; -import { Integrations } from "@sentry/tracing"; -import i18n from "i18next"; -import { initReactI18next } from "react-i18next"; -import Backend from "i18next-http-backend"; -import LanguageDetector from "i18next-browser-languagedetector"; import "./index.css"; import App from "./App"; import { init as initRageshake } from "./settings/rageshake"; -import { getUrlParams } from "./UrlParams"; initRageshake(); @@ -108,47 +101,6 @@ if (import.meta.env.VITE_CUSTOM_THEME) { const history = createBrowserHistory(); -Sentry.init({ - dsn: import.meta.env.VITE_SENTRY_DSN as string, - environment: - (import.meta.env.VITE_SENTRY_ENVIRONMENT as string) ?? "production", - integrations: [ - new Integrations.BrowserTracing({ - routingInstrumentation: Sentry.reactRouterV5Instrumentation(history), - }), - ], - tracesSampleRate: 1.0, -}); - -const languageDetector = new LanguageDetector(); -languageDetector.addDetector({ - name: "urlFragment", - // Look for a language code in the URL's fragment - lookup: () => getUrlParams().lang ?? undefined, -}); - -i18n - .use(Backend) - .use(languageDetector) - .use(initReactI18next) - .init({ - fallbackLng: "en-GB", - defaultNS: "app", - keySeparator: false, - nsSeparator: false, - pluralSeparator: "|", - contextSeparator: "|", - 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: [], - }, - }); - root.render( diff --git a/src/settings/submit-rageshake.ts b/src/settings/submit-rageshake.ts index 5128030..dfff6f0 100644 --- a/src/settings/submit-rageshake.ts +++ b/src/settings/submit-rageshake.ts @@ -24,6 +24,8 @@ import { getLogsForReport } from "./rageshake"; import { useClient } from "../ClientContext"; import { InspectorContext } from "../room/GroupCallInspector"; import { useModalTriggerState } from "../Modal"; +import { Config } from "../config/Config"; +import { DEFAULT_CONFIG } from "../config/ConfigOptions"; interface RageShakeSubmitOptions { sendLogs: boolean; @@ -252,8 +254,8 @@ export function useSubmitRageshake(): { } await fetch( - (import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) || - "https://element.io/bugreports/submit", + Config.instance.config.rageshake?.submit_url ?? + DEFAULT_CONFIG.rageshake.submit_url, { method: "POST", body,