diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index d03040e4..7359f781 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -24,7 +24,7 @@ jobs: run: "yarn install --frozen-lockfile" - name: Prune i18n - run: "rm -R public/locales" + run: "rm -R locales" - name: Download translation files uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0 @@ -32,7 +32,7 @@ jobs: groups: "-p includeSourceLang:true" - name: Fix the owner of the downloaded files - run: "sudo chown runner:docker -R public/locales" + run: "sudo chown runner:docker -R locales" - name: Prettier run: yarn prettier:format diff --git a/Dockerfile b/Dockerfile index 05354e3f..275ab153 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,3 @@ FROM nginxinc/nginx-unprivileged:alpine COPY ./dist /app COPY config/nginx.conf /etc/nginx/conf.d/default.conf - -USER root - -RUN rm -rf /usr/share/nginx/html - -USER 101 diff --git a/README.md b/README.md index 43a2dce0..a0af77fc 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ To add a new translation key you can do these steps: 1. Add the new key entry to the code where the new key is used: `t("some_new_key")` 1. Run `yarn i18n` to extract the new key and update the translation files. This - will add a skeleton entry to the `public/locales/en-GB/app.json` file: + will add a skeleton entry to the `locales/en-GB/app.json` file: ```jsonc { ... @@ -221,7 +221,7 @@ To add a new translation key you can do these steps: ... } ``` -1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with +1. Update the skeleton entry in the `locales/en-GB/app.json` file with the English translation: ```jsonc diff --git a/config/nginx.conf b/config/nginx.conf index ca4ac4c6..f3f8140e 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -3,23 +3,17 @@ server { server_name localhost; root /app; + gzip_static on; + gzip_vary on; location / { - # disable cache entriely by default (apart from Etag which is accurate enough) - add_header Cache-Control 'private no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; - if_modified_since off; - expires off; - # also turn off last-modified since they are just the timestamps of the file in the docker image - # and may or may not bear any resemblance to when the resource changed - add_header Last-Modified ""; - try_files $uri $uri/ /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; } # assets can be cached because they have hashed filenames location /assets { - expires 1w; - add_header Cache-Control "public, no-transform"; + add_header Cache-Control "public, immutable, max-age=31536000"; } location /apple-app-site-association { diff --git a/i18next-parser.config.ts b/i18next-parser.config.ts index f603c37e..7d71d727 100644 --- a/i18next-parser.config.ts +++ b/i18next-parser.config.ts @@ -22,7 +22,7 @@ export default { ], }, locales: ["en-GB"], - output: "public/locales/$LOCALE/$NAMESPACE.json", + output: "locales/$LOCALE/$NAMESPACE.json", input: ["src/**/*.{ts,tsx}"], sort: true, }; diff --git a/localazy.json b/localazy.json index 41cb7656..2b9f713c 100644 --- a/localazy.json +++ b/localazy.json @@ -7,13 +7,13 @@ "features": ["plural_postfix_us", "filter_untranslated"], "files": [ { - "pattern": "public/locales/en-GB/*.json", + "pattern": "locales/en-GB/*.json", "lang": "inherited" }, { "group": "existing", - "pattern": "public/locales/*/*.json", - "excludes": ["public/locales/en-GB/*.json"], + "pattern": "locales/*/*.json", + "excludes": ["locales/en-GB/*.json"], "lang": "${autodetectLang}" } ] @@ -22,7 +22,7 @@ "download": { "files": [ { - "output": "public/locales/${langLsrDash}/${file}" + "output": "locales/${langLsrDash}/${file}" } ], "includeSourceLang": "${includeSourceLang|false}", diff --git a/public/locales/bg/app.json b/locales/bg/app.json similarity index 100% rename from public/locales/bg/app.json rename to locales/bg/app.json diff --git a/public/locales/cs/app.json b/locales/cs/app.json similarity index 100% rename from public/locales/cs/app.json rename to locales/cs/app.json diff --git a/public/locales/de/app.json b/locales/de/app.json similarity index 100% rename from public/locales/de/app.json rename to locales/de/app.json diff --git a/public/locales/el/app.json b/locales/el/app.json similarity index 100% rename from public/locales/el/app.json rename to locales/el/app.json diff --git a/public/locales/en-GB/app.json b/locales/en-GB/app.json similarity index 100% rename from public/locales/en-GB/app.json rename to locales/en-GB/app.json diff --git a/public/locales/es/app.json b/locales/es/app.json similarity index 100% rename from public/locales/es/app.json rename to locales/es/app.json diff --git a/public/locales/et/app.json b/locales/et/app.json similarity index 100% rename from public/locales/et/app.json rename to locales/et/app.json diff --git a/public/locales/fa/app.json b/locales/fa/app.json similarity index 100% rename from public/locales/fa/app.json rename to locales/fa/app.json diff --git a/public/locales/fr/app.json b/locales/fr/app.json similarity index 100% rename from public/locales/fr/app.json rename to locales/fr/app.json diff --git a/public/locales/id/app.json b/locales/id/app.json similarity index 100% rename from public/locales/id/app.json rename to locales/id/app.json diff --git a/public/locales/it/app.json b/locales/it/app.json similarity index 100% rename from public/locales/it/app.json rename to locales/it/app.json diff --git a/public/locales/ja/app.json b/locales/ja/app.json similarity index 100% rename from public/locales/ja/app.json rename to locales/ja/app.json diff --git a/public/locales/lv/app.json b/locales/lv/app.json similarity index 100% rename from public/locales/lv/app.json rename to locales/lv/app.json diff --git a/public/locales/pl/app.json b/locales/pl/app.json similarity index 100% rename from public/locales/pl/app.json rename to locales/pl/app.json diff --git a/public/locales/ru/app.json b/locales/ru/app.json similarity index 100% rename from public/locales/ru/app.json rename to locales/ru/app.json diff --git a/public/locales/sk/app.json b/locales/sk/app.json similarity index 100% rename from public/locales/sk/app.json rename to locales/sk/app.json diff --git a/public/locales/sv/app.json b/locales/sv/app.json similarity index 100% rename from public/locales/sv/app.json rename to locales/sv/app.json diff --git a/public/locales/tr/app.json b/locales/tr/app.json similarity index 100% rename from public/locales/tr/app.json rename to locales/tr/app.json diff --git a/public/locales/uk/app.json b/locales/uk/app.json similarity index 100% rename from public/locales/uk/app.json rename to locales/uk/app.json diff --git a/public/locales/vi/app.json b/locales/vi/app.json similarity index 100% rename from public/locales/vi/app.json rename to locales/vi/app.json diff --git a/public/locales/zh-Hans/app.json b/locales/zh-Hans/app.json similarity index 100% rename from public/locales/zh-Hans/app.json rename to locales/zh-Hans/app.json diff --git a/public/locales/zh-Hant/app.json b/locales/zh-Hant/app.json similarity index 100% rename from public/locales/zh-Hant/app.json rename to locales/zh-Hant/app.json diff --git a/package.json b/package.json index 7c18352b..6238571c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/jsdom": "^21.1.7", "@types/lodash-es": "^4.17.12", "@types/node": "^20.0.0", + "@types/pako": "^2.0.3", "@types/qrcode": "^1.5.5", "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.3.3", @@ -83,7 +84,6 @@ "history": "^4.0.0", "i18next": "^23.0.0", "i18next-browser-languagedetector": "^8.0.0", - "i18next-http-backend": "^2.0.0", "i18next-parser": "^9.0.0", "jsdom": "^25.0.0", "knip": "^5.27.2", @@ -111,8 +111,9 @@ "typescript": "^5.1.6", "typescript-eslint-language-service": "^5.0.5", "unique-names-generator": "^4.6.0", - "vaul": "^0.9.0", + "vaul": "^1.0.0", "vite": "^5.0.0", + "vite-plugin-compression2": "^1.3.1", "vite-plugin-html-template": "^1.1.0", "vite-plugin-svgr": "^4.0.0", "vitest": "^2.0.0", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index de8bb788..94a4e379 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details. */ import "matrix-js-sdk/src/@types/global"; +import type { DurationFormat as PolyfillDurationFormat } from "@formatjs/intl-durationformat"; import { Controls } from "../controls"; declare global { @@ -23,4 +24,9 @@ declare global { // Safari only supports this prefixed, so tell the type system about it webkitRequestFullscreen: () => void; } + + namespace Intl { + // Add DurationFormat as part of the Intl namespace because we polyfill it + const DurationFormat: typeof PolyfillDurationFormat; + } } diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts index ad35f456..4a8830da 100644 --- a/src/@types/i18next.d.ts +++ b/src/@types/i18next.d.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import "i18next"; // import all namespaces (for the default language, only) -import app from "../../public/locales/en-GB/app.json"; +import app from "../../locales/en-GB/app.json"; declare module "i18next" { interface CustomTypeOptions { diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 5a531c2a..8b5589d5 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -16,19 +16,13 @@ import { useMemo, } from "react"; import { useHistory } from "react-router-dom"; -import { - ClientEvent, - ICreateClientOpts, - MatrixClient, -} from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { useTranslation } from "react-i18next"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; -import { MatrixError } from "matrix-js-sdk/src/matrix"; -import { WidgetApi } from "matrix-widget-api"; +import { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/client"; +import type { WidgetApi } from "matrix-widget-api"; import { ErrorView } from "./FullScreenView"; -import { fallbackICEServerAllowed, initClient } from "./utils/matrix"; import { widget } from "./widget"; import { PosthogAnalytics, @@ -36,7 +30,6 @@ import { } from "./analytics/PosthogAnalytics"; import { translatedError } from "./TranslatedError"; import { useEventTarget } from "./useEvents"; -import { Config } from "./config/Config"; declare global { interface Window { @@ -359,7 +352,7 @@ export const ClientProvider: FC = ({ children }) => { ); }; -type InitResult = { +export type InitResult = { widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; @@ -376,50 +369,8 @@ async function loadClient(): Promise { passwordlessUser: false, }; } else { - // We're running as a standalone application - try { - const session = loadSession(); - if (!session) { - logger.log("No session stored; continuing without a client"); - return null; - } - - logger.log("Using a standalone client"); - - /* eslint-disable camelcase */ - const { user_id, device_id, access_token, passwordlessUser } = session; - const initClientParams: ICreateClientOpts = { - baseUrl: Config.defaultHomeserverUrl()!, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - fallbackICEServerAllowed: fallbackICEServerAllowed, - livekitServiceURL: Config.get().livekit?.livekit_service_url, - }; - - try { - const client = await initClient(initClientParams, true); - return { - widgetApi: null, - client, - passwordlessUser, - }; - } catch (err) { - if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") { - // We can't use this session anymore, so let's log it out - logger.log( - "The session from local store is invalid; continuing without a client", - ); - clearSession(); - // returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line) - return null; - } - throw err; - } - } catch (err) { - clearSession(); - throw err; - } + const { initSPA } = await import("./utils/spa"); + return initSPA(loadSession, clearSession); } } diff --git a/src/__snapshots__/Modal.test.tsx.snap b/src/__snapshots__/Modal.test.tsx.snap index 6b7091e5..8772c543 100644 --- a/src/__snapshots__/Modal.test.tsx.snap +++ b/src/__snapshots__/Modal.test.tsx.snap @@ -39,13 +39,16 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = ` aria-labelledby="radix-:ra:" class="overlay modal drawer" data-state="open" + data-vaul-animate="true" + data-vaul-custom-container="false" + data-vaul-delayed-snap-points="false" + data-vaul-drawer="" + data-vaul-drawer-direction="bottom" + data-vaul-snap-points="false" id="radix-:r9:" role="dialog" style="pointer-events: auto;" tabindex="-1" - vaul-drawer="" - vaul-drawer-direction="bottom" - vaul-drawer-visible="true" >
{ +test("initBeforeReact sets font family from URL param", async () => { window.location.hash = "#?font=DejaVu Sans"; - Initializer.initBeforeReact(); + await Initializer.initBeforeReact(); expect( getComputedStyle(document.documentElement).getPropertyValue( "--font-family", @@ -19,9 +19,9 @@ test("initBeforeReact sets font family from URL param", () => { ).toBe('"DejaVu Sans"'); }); -test("initBeforeReact sets font scale from URL param", () => { +test("initBeforeReact sets font scale from URL param", async () => { window.location.hash = "#?fontScale=1.2"; - Initializer.initBeforeReact(); + await Initializer.initBeforeReact(); expect( getComputedStyle(document.documentElement).getPropertyValue("--font-scale"), ).toBe("1.2"); diff --git a/src/initializer.tsx b/src/initializer.tsx index 4bc1dc9f..47634078 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -5,18 +5,85 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import i18n from "i18next"; +import i18n, { + type BackendModule, + type ReadCallback, + type ResourceKey, +} 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 { logger } from "matrix-js-sdk/src/logger"; +import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; +import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill"; import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { ElementCallOpenTelemetry } from "./otel/otel"; import { platform } from "./Platform"; +// This generates a map of locale names to their URL (based on import.meta.url), which looks like this: +// { +// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json", +// ... +// } +const locales = import.meta.glob("../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((url) => { + // The URLs are of the form ../locales/en-GB/app.json + // This extracts the language code from the URL + const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; + if (!lang) { + throw new Error(`Could not parse locale URL ${url}`); + } + return lang; + }), + ), +]; + +// 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 => { + 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 (!response.ok) { + throw Error(`Failed to fetch ${url}`); + } + + return await response.json(); + })().then( + (data) => callback(null, data), + (error) => callback(error, null), + ); + }, +} satisfies BackendModule; + enum LoadState { None, Loading, @@ -41,10 +108,17 @@ export class Initializer { return Initializer.internalInstance?.isInitialized; } - public static initBeforeReact(): void { - // this maybe also needs to return a promise in the future, - // if we have to do async inits before showing the loading screen - // but this should be avoided if possible + public static async initBeforeReact(): Promise { + const polyfills: Promise[] = []; + if (shouldPolyfillSegmenter()) { + polyfills.push(import("@formatjs/intl-segmenter/polyfill-force")); + } + + if (shouldPolyfillDurationFormat()) { + polyfills.push(import("@formatjs/intl-durationformat/polyfill-force")); + } + + await Promise.all(polyfills); //i18n const languageDetector = new LanguageDetector(); @@ -54,7 +128,7 @@ export class Initializer { lookup: () => getUrlParams().lang ?? undefined, }); - i18n + await i18n .use(Backend) .use(languageDetector) .use(initReactI18next) @@ -65,6 +139,7 @@ export class Initializer { nsSeparator: false, pluralSeparator: "_", contextSeparator: "|", + supportedLngs, interpolation: { escapeValue: false, // React has built-in XSS protections }, @@ -74,9 +149,6 @@ export class Initializer { order: ["urlFragment", "navigator"], caches: [], }, - }) - .catch((e) => { - logger.error("Failed to initialize i18n", e); }); // Custom Themeing diff --git a/src/main.tsx b/src/main.tsx index d4a4539b..ac0440b7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,8 +20,6 @@ import { setLogExtension as setLKLogExtension, setLogLevel as setLKLogLevel, } from "livekit-client"; -import "@formatjs/intl-segmenter/polyfill"; -import "@formatjs/intl-durationformat/polyfill"; import { App } from "./App"; import { init as initRageshake } from "./settings/rageshake"; @@ -57,12 +55,17 @@ if (fatalError !== null) { throw fatalError; // Stop the app early } -Initializer.initBeforeReact(); +Initializer.initBeforeReact() + .then(() => { + const history = createBrowserHistory(); -const history = createBrowserHistory(); - -root.render( - - - , -); + root.render( + + + , + ); + }) + .catch((e) => { + logger.error("Failed to initialize app", e); + root.render(e.message); + }); diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx index cfc83ab8..8c4747b3 100644 --- a/src/reactions/RaisedHandIndicator.tsx +++ b/src/reactions/RaisedHandIndicator.tsx @@ -11,19 +11,12 @@ import { useCallback, useEffect, useState, + useMemo, } from "react"; -import { DurationFormat } from "@formatjs/intl-durationformat"; import { useTranslation } from "react-i18next"; import { ReactionIndicator } from "./ReactionIndicator"; -const durationFormatter = new DurationFormat(undefined, { - minutesDisplay: "always", - secondsDisplay: "always", - hoursDisplay: "auto", - style: "digital", -}); - export function RaisedHandIndicator({ raisedHandTime, miniature, @@ -38,6 +31,17 @@ export function RaisedHandIndicator({ const { t } = useTranslation(); const [raisedHandDuration, setRaisedHandDuration] = useState(""); + const durationFormatter = useMemo( + () => + new Intl.DurationFormat(undefined, { + minutesDisplay: "always", + secondsDisplay: "always", + hoursDisplay: "auto", + style: "digital", + }), + [], + ); + const clickCallback = useCallback>( (event) => { if (!onClick) { @@ -69,7 +73,7 @@ export function RaisedHandIndicator({ calculateTime(); const to = setInterval(calculateTime, 1000); return (): void => clearInterval(to); - }, [setRaisedHandDuration, raisedHandTime, showTimer]); + }, [setRaisedHandDuration, raisedHandTime, showTimer, durationFormatter]); if (!raisedHandTime) { return; diff --git a/src/settings/submit-rageshake.ts b/src/settings/submit-rageshake.ts index 1ab90667..ae320493 100644 --- a/src/settings/submit-rageshake.ts +++ b/src/settings/submit-rageshake.ts @@ -6,9 +6,6 @@ Please see LICENSE in the repository root for full details. */ import { ComponentProps, useCallback, useEffect, useState } from "react"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import pako from "pako"; import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent, @@ -23,11 +20,14 @@ import { Config } from "../config/Config"; import { ElementCallOpenTelemetry } from "../otel/otel"; import { RageshakeRequestModal } from "../room/RageshakeRequestModal"; -const gzip = (text: string): Blob => { +const gzip = async (text: string): Promise => { + // pako is relatively large (200KB), so we only import it when needed + const { gzip: pakoGzip } = await import("pako"); + // encode as UTF-8 const buf = new TextEncoder().encode(text); // compress - return new Blob([pako.gzip(buf)]); + return new Blob([pakoGzip(buf)]); }; /** @@ -253,12 +253,14 @@ export function useSubmitRageshake(): { const logs = await getLogsForReport(); for (const entry of logs) { - body.append("compressed-log", gzip(entry.lines), entry.id); + body.append("compressed-log", await gzip(entry.lines), entry.id); } body.append( "file", - gzip(ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump()), + await gzip( + ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump(), + ), "traces.json.gz", ); } diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index b344716e..423a0822 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -672,7 +672,7 @@ export class CallViewModel extends ViewModel { this.gridModeUserSelection.next(value); } - private readonly gridLayout: Observable = combineLatest( + private readonly gridLayoutMedia: Observable = combineLatest( [this.grid, this.spotlight], (grid, spotlight) => ({ type: "grid", @@ -683,28 +683,28 @@ export class CallViewModel extends ViewModel { }), ); - private readonly spotlightLandscapeLayout: Observable = + private readonly spotlightLandscapeLayoutMedia: Observable = combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ type: "spotlight-landscape", spotlight, grid, })); - private readonly spotlightPortraitLayout: Observable = + private readonly spotlightPortraitLayoutMedia: Observable = combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ type: "spotlight-portrait", spotlight, grid, })); - private readonly spotlightExpandedLayout: Observable = + private readonly spotlightExpandedLayoutMedia: Observable = combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({ type: "spotlight-expanded", spotlight, pip: pip ?? undefined, })); - private readonly oneOnOneLayout: Observable = + private readonly oneOnOneLayoutMedia: Observable = this.mediaItems.pipe( map((mediaItems) => { if (mediaItems.length !== 2) return null; @@ -722,9 +722,8 @@ export class CallViewModel extends ViewModel { }), ); - private readonly pipLayout: Observable = this.spotlight.pipe( - map((spotlight) => ({ type: "pip", spotlight })), - ); + private readonly pipLayoutMedia: Observable = + this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight }))); /** * The media to be used to produce a layout. @@ -737,24 +736,24 @@ export class CallViewModel extends ViewModel { switchMap((gridMode) => { switch (gridMode) { case "grid": - return this.oneOnOneLayout.pipe( + return this.oneOnOneLayoutMedia.pipe( switchMap((oneOnOne) => - oneOnOne === null ? this.gridLayout : of(oneOnOne), + oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne), ), ); case "spotlight": return this.spotlightExpanded.pipe( switchMap((expanded) => expanded - ? this.spotlightExpandedLayout - : this.spotlightLandscapeLayout, + ? this.spotlightExpandedLayoutMedia + : this.spotlightLandscapeLayoutMedia, ), ); } }), ); case "narrow": - return this.oneOnOneLayout.pipe( + return this.oneOnOneLayoutMedia.pipe( switchMap((oneOnOne) => oneOnOne === null ? combineLatest( @@ -762,12 +761,12 @@ export class CallViewModel extends ViewModel { (grid, spotlight) => grid.length > smallMobileCallThreshold || spotlight.some((vm) => vm instanceof ScreenShareViewModel) - ? this.spotlightPortraitLayout - : this.gridLayout, + ? this.spotlightPortraitLayoutMedia + : this.gridLayoutMedia, ).pipe(switchAll()) : // The expanded spotlight layout makes for a better one-on-one // experience in narrow windows - this.spotlightExpandedLayout, + this.spotlightExpandedLayoutMedia, ), ); case "flat": @@ -777,14 +776,14 @@ export class CallViewModel extends ViewModel { case "grid": // Yes, grid mode actually gets you a "spotlight" layout in // this window mode. - return this.spotlightLandscapeLayout; + return this.spotlightLandscapeLayoutMedia; case "spotlight": - return this.spotlightExpandedLayout; + return this.spotlightExpandedLayoutMedia; } }), ); case "pip": - return this.pipLayout; + return this.pipLayoutMedia; } }), this.scope.state(), diff --git a/src/utils/spa.ts b/src/utils/spa.ts new file mode 100644 index 00000000..37835259 --- /dev/null +++ b/src/utils/spa.ts @@ -0,0 +1,64 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { ICreateClientOpts } from "matrix-js-sdk/src/client"; +import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { Config } from "../config/Config"; +import { fallbackICEServerAllowed, initClient } from "./matrix"; +import type { InitResult, Session } from "../ClientContext"; + +export async function initSPA( + loadSession: () => Session | undefined, + clearSession: () => void, +): Promise { + // We're running as a standalone application + try { + const session = loadSession(); + if (!session) { + logger.log("No session stored; continuing without a client"); + return null; + } + + logger.log("Using a standalone client"); + + /* eslint-disable camelcase */ + const { user_id, device_id, access_token, passwordlessUser } = session; + const initClientParams: ICreateClientOpts = { + baseUrl: Config.defaultHomeserverUrl()!, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + fallbackICEServerAllowed, + livekitServiceURL: Config.get().livekit?.livekit_service_url, + }; + + try { + const client = await initClient(initClientParams, true); + return { + widgetApi: null, + client, + passwordlessUser, + }; + } catch (err) { + if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") { + // We can't use this session anymore, so let's log it out + logger.log( + "The session from local store is invalid; continuing without a client", + ); + clearSession(); + // returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line) + return null; + } + throw err; + } + } catch (err) { + clearSession(); + throw err; + } +} diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 596453ed..421ec663 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details. */ import "global-jsdom/register"; +import "@formatjs/intl-durationformat/polyfill"; +import "@formatjs/intl-segmenter/polyfill"; import i18n from "i18next"; import posthog from "posthog-js"; import { initReactI18next } from "react-i18next"; @@ -14,6 +16,7 @@ import { cleanup } from "@testing-library/react"; import "vitest-axe/extend-expect"; import { logger } from "matrix-js-sdk/src/logger"; +import EN_GB from "../locales/en-GB/app.json"; import { Config } from "./config/Config"; // Bare-minimum i18n config @@ -22,6 +25,13 @@ i18n .init({ lng: "en-GB", fallbackLng: "en-GB", + supportedLngs: ["en-GB"], + // We embed the translations, so that it never needs to fetch + resources: { + "en-GB": { + app: EN_GB, + }, + }, interpolation: { escapeValue: false, // React has built-in XSS protections }, diff --git a/vite.config.js b/vite.config.js index b92f717b..b8072577 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { defineConfig, loadEnv } from "vite"; +import { compression } from "vite-plugin-compression2"; import svgrPlugin from "vite-plugin-svgr"; import htmlTemplate from "vite-plugin-html-template"; import { codecovVitePlugin } from "@codecov/vite-plugin"; @@ -38,6 +39,10 @@ export default defineConfig(({ mode }) => { bundleName: "element-call", uploadToken: process.env.CODECOV_TOKEN, }), + + compression({ + exclude: [/config.json/], + }), ]; if ( @@ -60,6 +65,25 @@ export default defineConfig(({ mode }) => { }, build: { sourcemap: true, + rollupOptions: { + output: { + assetFileNames: ({ originalFileNames }) => { + if (originalFileNames) { + for (const name of originalFileNames) { + // Custom asset name for locales to include the locale code in the filename + const match = name.match(/locales\/([^/]+)\/(.+)\.json$/); + if (match) { + const [, locale, filename] = match; + return `assets/${locale}-${filename}-[hash].json`; + } + } + } + + // Default naming fallback + return "assets/[name]-[hash][extname]"; + }, + }, + }, }, plugins, resolve: { diff --git a/yarn.lock b/yarn.lock index 504bc0eb..4ad34928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2542,7 +2542,7 @@ "@react-spring/shared" "~9.7.5" "@react-spring/types" "~9.7.5" -"@rollup/pluginutils@^5.1.3": +"@rollup/pluginutils@^5.1.0", "@rollup/pluginutils@^5.1.3": version "5.1.3" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.3.tgz#3001bf1a03f3ad24457591f2c259c8e514e0dbdf" integrity sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A== @@ -3060,6 +3060,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/pako@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/pako/-/pako-2.0.3.tgz#b6993334f3af27c158f3fe0dfeeba987c578afb1" + integrity sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q== + "@types/prop-types@*": version "15.7.13" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.13.tgz#2af91918ee12d9d32914feb13f5326658461b451" @@ -4133,13 +4138,6 @@ cosmiconfig@^8.1.3: parse-json "^5.2.0" path-type "^4.0.0" -cross-fetch@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" - integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== - dependencies: - node-fetch "^2.6.12" - cross-spawn@^7.0.0, cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5464,13 +5462,6 @@ i18next-browser-languagedetector@^8.0.0: dependencies: "@babel/runtime" "^7.23.2" -i18next-http-backend@^2.0.0: - version "2.6.2" - resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.6.2.tgz#b25516446ae6f251ce8231e70e6ffbca833d46a5" - integrity sha512-Hp/kd8/VuoxIHmxsknJXjkTYYHzivAyAF15pzliKzk2TiXC25rZCEerb1pUFoxz4IVrG3fCvQSY51/Lu4ECV4A== - dependencies: - cross-fetch "4.0.0" - i18next-parser@^9.0.0: version "9.0.2" resolved "https://registry.yarnpkg.com/i18next-parser/-/i18next-parser-9.0.2.tgz#f9d627422d33c352967556c8724975d58f1f5a95" @@ -6326,7 +6317,7 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== -node-fetch@^2.6.12, node-fetch@^2.6.7: +node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -7922,6 +7913,11 @@ tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tar-mini@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/tar-mini/-/tar-mini-0.2.0.tgz#2b2cdc215f5b83b0ab8ce363dc9ded22de51849b" + integrity sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ== + teex@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/teex/-/teex-1.0.1.tgz#b8fa7245ef8e8effa8078281946c85ab780a0b12" @@ -8357,13 +8353,6 @@ value-or-function@^4.0.0: resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-4.0.0.tgz#70836b6a876a010dc3a2b884e7902e9db064378d" integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg== -vaul@^0.9.0: - version "0.9.1" - resolved "https://registry.yarnpkg.com/vaul/-/vaul-0.9.1.tgz#3640198e04636b209b1f907fcf3079bec6ecc66b" - integrity sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw== - dependencies: - "@radix-ui/react-dialog" "^1.0.4" - vaul@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.0.tgz#7da4bc965e0b184ada632f1208096b0f5575d920" @@ -8432,6 +8421,16 @@ vite-node@2.1.4: pathe "^1.1.2" vite "^5.0.0" +vite-plugin-compression2@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/vite-plugin-compression2/-/vite-plugin-compression2-1.3.1.tgz#ac2a512f8ca90a76687add6cf441000dd2c41485" + integrity sha512-UMr66CFu+RVPiD8E3iaX9BdZjCgO+lzzaAPAZvL5YgwH6FU4OR/MulJEyp9wq9EKoO6ErjUtPpaiDi3hvzv79Q== + dependencies: + "@rollup/pluginutils" "^5.1.0" + tar-mini "^0.2.0" + optionalDependencies: + vite "^5.3.4" + vite-plugin-html-template@^1.1.0: version "1.2.2" resolved "https://registry.yarnpkg.com/vite-plugin-html-template/-/vite-plugin-html-template-1.2.2.tgz#d263c18dcf5f5e54bc74894546fd0ed993191f2f" @@ -8448,7 +8447,7 @@ vite-plugin-svgr@^4.0.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -vite@^5.0.0: +vite@^5.0.0, vite@^5.3.4: version "5.4.11" resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==