mirror of
https://github.com/vector-im/element-web.git
synced 2025-08-07 06:47:06 +02:00
Remove legacy Safari/Firefox/IE compatibility aids (#29010)
* Remove legacy Safari prefix compatibility for AudioContext Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove more legacy webkit/ms/moz support Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage, cull dead code Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Simplify Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
e235100dd0
commit
c84cf3c36c
37
src/@types/global.d.ts
vendored
37
src/@types/global.d.ts
vendored
@ -82,19 +82,10 @@ declare global {
|
|||||||
mxMatrixClientPeg: IMatrixClientPeg;
|
mxMatrixClientPeg: IMatrixClientPeg;
|
||||||
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
|
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
|
||||||
|
|
||||||
// Needed for Safari, unknown to TypeScript
|
|
||||||
webkitAudioContext: typeof AudioContext;
|
|
||||||
|
|
||||||
// https://docs.microsoft.com/en-us/previous-versions/hh772328(v=vs.85)
|
// https://docs.microsoft.com/en-us/previous-versions/hh772328(v=vs.85)
|
||||||
// we only ever check for its existence, so we can ignore its actual type
|
// we only ever check for its existence, so we can ignore its actual type
|
||||||
MSStream?: unknown;
|
MSStream?: unknown;
|
||||||
|
|
||||||
// https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029#issuecomment-869224737
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
|
|
||||||
OffscreenCanvas?: {
|
|
||||||
new (width: number, height: number): OffscreenCanvas;
|
|
||||||
};
|
|
||||||
|
|
||||||
mxContentMessages: ContentMessages;
|
mxContentMessages: ContentMessages;
|
||||||
mxToastStore: ToastStore;
|
mxToastStore: ToastStore;
|
||||||
mxDeviceListener: DeviceListener;
|
mxDeviceListener: DeviceListener;
|
||||||
@ -156,31 +147,10 @@ declare global {
|
|||||||
fetchWindowIcons?: boolean;
|
fetchWindowIcons?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Document {
|
|
||||||
// Safari & IE11 only have this prefixed: we used prefixed versions
|
|
||||||
// previously so let's continue to support them for now
|
|
||||||
webkitExitFullscreen(): Promise<void>;
|
|
||||||
msExitFullscreen(): Promise<void>;
|
|
||||||
readonly webkitFullscreenElement: Element | null;
|
|
||||||
readonly msFullscreenElement: Element | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Navigator {
|
|
||||||
userLanguage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StorageEstimate {
|
interface StorageEstimate {
|
||||||
usageDetails?: { [key: string]: number };
|
usageDetails?: { [key: string]: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Element {
|
|
||||||
// Safari & IE11 only have this prefixed: we used prefixed versions
|
|
||||||
// previously so let's continue to support them for now
|
|
||||||
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
|
|
||||||
msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
|
|
||||||
// scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
|
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
|
||||||
interface AudioWorkletProcessor {
|
interface AudioWorkletProcessor {
|
||||||
readonly port: MessagePort;
|
readonly port: MessagePort;
|
||||||
@ -239,11 +209,4 @@ declare global {
|
|||||||
var mx_rage_store: IndexedDBLogStore;
|
var mx_rage_store: IndexedDBLogStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add method which is missing from the node typing
|
|
||||||
declare module "url" {
|
|
||||||
interface Url {
|
|
||||||
format(): string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
@ -163,36 +163,21 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
|
|||||||
this.element.src = URL.createObjectURL(new Blob([this.buf]));
|
this.element.src = URL.createObjectURL(new Blob([this.buf]));
|
||||||
await deferred.promise; // make sure the audio element is ready for us
|
await deferred.promise; // make sure the audio element is ready for us
|
||||||
} else {
|
} else {
|
||||||
// Safari compat: promise API not supported on this function
|
try {
|
||||||
this.audioBuf = await new Promise((resolve, reject) => {
|
this.audioBuf = await this.context.decodeAudioData(this.buf);
|
||||||
this.context.decodeAudioData(
|
} catch (e) {
|
||||||
this.buf,
|
logger.error("Error decoding recording:", e);
|
||||||
(b) => resolve(b),
|
logger.warn("Trying to re-encode to WAV instead...");
|
||||||
async (e): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg
|
|
||||||
// very well.
|
|
||||||
logger.error("Error decoding recording: ", e);
|
|
||||||
logger.warn("Trying to re-encode to WAV instead...");
|
|
||||||
|
|
||||||
const wav = await decodeOgg(this.buf);
|
try {
|
||||||
|
// This error handler is largely for Safari, which doesn't support Opus/Ogg very well.
|
||||||
// noinspection ES6MissingAwait - not needed when using callbacks
|
const wav = await decodeOgg(this.buf);
|
||||||
this.context.decodeAudioData(
|
this.audioBuf = await this.context.decodeAudioData(wav);
|
||||||
wav,
|
} catch (e) {
|
||||||
(b) => resolve(b),
|
logger.error("Error decoding recording:", e);
|
||||||
(e) => {
|
throw e;
|
||||||
logger.error("Still failed to decode recording: ", e);
|
}
|
||||||
reject(e);
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Caught decoding error:", e);
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the waveform to the real waveform once we have channel data to use. We don't
|
// Update the waveform to the real waveform once we have channel data to use. We don't
|
||||||
// exactly trust the user-provided waveform to be accurate...
|
// exactly trust the user-provided waveform to be accurate...
|
||||||
|
@ -15,21 +15,15 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||||||
import { SAMPLE_RATE } from "./VoiceRecording";
|
import { SAMPLE_RATE } from "./VoiceRecording";
|
||||||
|
|
||||||
export function createAudioContext(opts?: AudioContextOptions): AudioContext {
|
export function createAudioContext(opts?: AudioContextOptions): AudioContext {
|
||||||
let ctx: AudioContext;
|
|
||||||
if (window.AudioContext) {
|
if (window.AudioContext) {
|
||||||
ctx = new AudioContext(opts);
|
const ctx = new AudioContext(opts);
|
||||||
} else if (window.webkitAudioContext) {
|
// Initialize in suspended state, as Firefox starts using
|
||||||
// While the linter is correct that "a constructor name should not start with
|
// CPU/battery right away, even if we don't play any sound yet.
|
||||||
// a lowercase letter", it's also wrong to think that we have control over this.
|
void ctx.suspend();
|
||||||
// eslint-disable-next-line new-cap
|
return ctx;
|
||||||
ctx = new window.webkitAudioContext(opts);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unsupported browser");
|
throw new Error("Unsupported browser");
|
||||||
}
|
}
|
||||||
// Initialize in suspended state, as Firefox starts using
|
|
||||||
// CPU/battery right away, even if we don't play any sound yet.
|
|
||||||
void ctx.suspend();
|
|
||||||
return ctx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
||||||
|
@ -11,7 +11,6 @@ import React, { type ReactElement } from "react";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import * as languageHandler from "../../../languageHandler";
|
import * as languageHandler from "../../../languageHandler";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import Dropdown from "./Dropdown";
|
import Dropdown from "./Dropdown";
|
||||||
@ -29,7 +28,7 @@ function languageMatchesSearchQuery(query: string, language: Languages[0]): bool
|
|||||||
interface IProps {
|
interface IProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
onOptionChange: (language: string) => void;
|
onOptionChange: (language: string) => void;
|
||||||
value?: string;
|
value: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,17 +102,6 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
|
|||||||
return <div key={language.value}>{language.labelInTargetLanguage}</div>;
|
return <div key={language.value}>{language.labelInTargetLanguage}</div>;
|
||||||
}) as NonEmptyArray<ReactElement & { key: string }>;
|
}) as NonEmptyArray<ReactElement & { key: string }>;
|
||||||
|
|
||||||
// default value here too, otherwise we need to handle null / undefined
|
|
||||||
// values between mounting and the initial value propagating
|
|
||||||
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/ true);
|
|
||||||
let value: string | undefined;
|
|
||||||
if (language) {
|
|
||||||
value = this.props.value || language;
|
|
||||||
} else {
|
|
||||||
language = navigator.language || navigator.userLanguage;
|
|
||||||
value = this.props.value || language;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
id="mx_LanguageDropdown"
|
id="mx_LanguageDropdown"
|
||||||
@ -121,7 +109,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
|
|||||||
onOptionChange={this.props.onOptionChange}
|
onOptionChange={this.props.onOptionChange}
|
||||||
onSearchChange={this.onSearchChange}
|
onSearchChange={this.onSearchChange}
|
||||||
searchEnabled={true}
|
searchEnabled={true}
|
||||||
value={value}
|
value={this.props.value}
|
||||||
label={_t("language_dropdown_label")}
|
label={_t("language_dropdown_label")}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
>
|
>
|
||||||
|
@ -10,7 +10,6 @@ import React, { type ReactElement } from "react";
|
|||||||
|
|
||||||
import Dropdown from "../../views/elements/Dropdown";
|
import Dropdown from "../../views/elements/Dropdown";
|
||||||
import PlatformPeg from "../../../PlatformPeg";
|
import PlatformPeg from "../../../PlatformPeg";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
|
||||||
import { _t, getUserLanguage } from "../../../languageHandler";
|
import { _t, getUserLanguage } from "../../../languageHandler";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { type NonEmptyArray } from "../../../@types/common";
|
import { type NonEmptyArray } from "../../../@types/common";
|
||||||
@ -105,17 +104,6 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
|
|||||||
return <div key={language.value}>{language.label}</div>;
|
return <div key={language.value}>{language.label}</div>;
|
||||||
}) as NonEmptyArray<ReactElement & { key: string }>;
|
}) as NonEmptyArray<ReactElement & { key: string }>;
|
||||||
|
|
||||||
// default value here too, otherwise we need to handle null / undefined;
|
|
||||||
// values between mounting and the initial value propagating
|
|
||||||
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/ true);
|
|
||||||
let value: string | undefined;
|
|
||||||
if (language) {
|
|
||||||
value = this.props.value || language;
|
|
||||||
} else {
|
|
||||||
language = navigator.language || navigator.userLanguage;
|
|
||||||
value = this.props.value || language;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
id="mx_LanguageDropdown"
|
id="mx_LanguageDropdown"
|
||||||
@ -123,7 +111,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
|
|||||||
onOptionChange={this.props.onOptionChange}
|
onOptionChange={this.props.onOptionChange}
|
||||||
onSearchChange={this.onSearchChange}
|
onSearchChange={this.onSearchChange}
|
||||||
searchEnabled={true}
|
searchEnabled={true}
|
||||||
value={value}
|
value={this.props.value}
|
||||||
label={_t("language_dropdown_label")}
|
label={_t("language_dropdown_label")}
|
||||||
placeholder={_t("settings|general|spell_check_locale_placeholder")}
|
placeholder={_t("settings|general|spell_check_locale_placeholder")}
|
||||||
>
|
>
|
||||||
|
@ -66,26 +66,15 @@ interface IState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFullScreenElement(): Element | null {
|
function getFullScreenElement(): Element | null {
|
||||||
return (
|
return document.fullscreenElement;
|
||||||
document.fullscreenElement ||
|
|
||||||
// moz omitted because firefox supports this unprefixed now (webkit here for safari)
|
|
||||||
document.webkitFullscreenElement ||
|
|
||||||
document.msFullscreenElement
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestFullscreen(element: Element): void {
|
function requestFullscreen(element: Element): void {
|
||||||
const method =
|
element.requestFullscreen();
|
||||||
element.requestFullscreen ||
|
|
||||||
// moz omitted since firefox supports unprefixed now
|
|
||||||
element.webkitRequestFullScreen ||
|
|
||||||
element.msRequestFullscreen;
|
|
||||||
if (method) method.call(element);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitFullscreen(): void {
|
function exitFullscreen(): void {
|
||||||
const exitMethod = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
|
document.exitFullscreen();
|
||||||
if (exitMethod) exitMethod.call(document);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class LegacyCallView extends React.Component<IProps, IState> {
|
export default class LegacyCallView extends React.Component<IProps, IState> {
|
||||||
|
@ -529,8 +529,7 @@ export async function getAllLanguagesWithLabels(): Promise<Language[]> {
|
|||||||
|
|
||||||
export function getLanguagesFromBrowser(): readonly string[] {
|
export function getLanguagesFromBrowser(): readonly string[] {
|
||||||
if (navigator.languages && navigator.languages.length) return navigator.languages;
|
if (navigator.languages && navigator.languages.length) return navigator.languages;
|
||||||
if (navigator.language) return [navigator.language];
|
return [navigator.language ?? "en"];
|
||||||
return [navigator.userLanguage || "en"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLanguageFromBrowser(): string {
|
export function getLanguageFromBrowser(): string {
|
||||||
|
@ -47,7 +47,7 @@ describe("Playback", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(logger, "error").mockRestore();
|
jest.spyOn(logger, "error").mockRestore();
|
||||||
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
|
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
|
||||||
mockAudioContext.decodeAudioData.mockReset().mockImplementation((_b, callback) => callback(mockAudioBuffer));
|
mockAudioContext.decodeAudioData.mockReset().mockResolvedValue(mockAudioBuffer);
|
||||||
mockAudioContext.resume.mockClear().mockResolvedValue(undefined);
|
mockAudioContext.resume.mockClear().mockResolvedValue(undefined);
|
||||||
mockAudioContext.suspend.mockClear().mockResolvedValue(undefined);
|
mockAudioContext.suspend.mockClear().mockResolvedValue(undefined);
|
||||||
mocked(decodeOgg).mockClear().mockResolvedValue(new ArrayBuffer(1));
|
mocked(decodeOgg).mockClear().mockResolvedValue(new ArrayBuffer(1));
|
||||||
@ -131,8 +131,8 @@ describe("Playback", () => {
|
|||||||
const buffer = new ArrayBuffer(8);
|
const buffer = new ArrayBuffer(8);
|
||||||
const decodingError = new Error("test");
|
const decodingError = new Error("test");
|
||||||
mockAudioContext.decodeAudioData
|
mockAudioContext.decodeAudioData
|
||||||
.mockImplementationOnce((_b, _callback, error) => error(decodingError))
|
.mockRejectedValueOnce(decodingError)
|
||||||
.mockImplementationOnce((_b, callback) => callback(mockAudioBuffer));
|
.mockResolvedValueOnce(mockAudioBuffer);
|
||||||
|
|
||||||
const playback = new Playback(buffer);
|
const playback = new Playback(buffer);
|
||||||
|
|
||||||
|
15
test/unit-tests/audio/compat-test.ts
Normal file
15
test/unit-tests/audio/compat-test.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createAudioContext } from "../../../src/audio/compat";
|
||||||
|
|
||||||
|
describe("createAudioContext", () => {
|
||||||
|
it("should throw if AudioContext is not supported", () => {
|
||||||
|
window.AudioContext = undefined as any;
|
||||||
|
expect(createAudioContext).toThrow("Unsupported browser");
|
||||||
|
});
|
||||||
|
});
|
@ -65,7 +65,7 @@ describe("<RecordingPlayback />", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(logger, "error").mockRestore();
|
jest.spyOn(logger, "error").mockRestore();
|
||||||
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
|
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
|
||||||
mockAudioContext.decodeAudioData.mockReset().mockImplementation((_b, callback) => callback(mockAudioBuffer));
|
mockAudioContext.decodeAudioData.mockReset().mockResolvedValue(mockAudioBuffer);
|
||||||
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
|
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "jest-matrix-react";
|
||||||
|
import { type MatrixCall } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import LegacyCallView from "../../../../../src/components/views/voip/LegacyCallView";
|
||||||
|
import { stubClient } from "../../../../test-utils";
|
||||||
|
|
||||||
|
describe("LegacyCallView", () => {
|
||||||
|
it("should exit full screen on unmount", () => {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
// @ts-expect-error
|
||||||
|
document.fullscreenElement = element;
|
||||||
|
document.exitFullscreen = jest.fn();
|
||||||
|
|
||||||
|
stubClient();
|
||||||
|
|
||||||
|
const call = {
|
||||||
|
on: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
getFeeds: jest.fn().mockReturnValue([]),
|
||||||
|
isLocalOnHold: jest.fn().mockReturnValue(false),
|
||||||
|
isRemoteOnHold: jest.fn().mockReturnValue(false),
|
||||||
|
isMicrophoneMuted: jest.fn().mockReturnValue(false),
|
||||||
|
isLocalVideoMuted: jest.fn().mockReturnValue(false),
|
||||||
|
isScreensharing: jest.fn().mockReturnValue(false),
|
||||||
|
} as unknown as MatrixCall;
|
||||||
|
|
||||||
|
const { unmount } = render(<LegacyCallView call={call} />);
|
||||||
|
expect(document.exitFullscreen).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
expect(document.exitFullscreen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@ -26,6 +26,7 @@ import {
|
|||||||
type TranslationKey,
|
type TranslationKey,
|
||||||
type IVariables,
|
type IVariables,
|
||||||
type Tags,
|
type Tags,
|
||||||
|
getLanguagesFromBrowser,
|
||||||
} from "../../src/languageHandler";
|
} from "../../src/languageHandler";
|
||||||
import { stubClient } from "../test-utils";
|
import { stubClient } from "../test-utils";
|
||||||
import { setupLanguageMock } from "../setup/setupLanguage";
|
import { setupLanguageMock } from "../setup/setupLanguage";
|
||||||
@ -198,6 +199,29 @@ describe("languageHandler", () => {
|
|||||||
setupLanguageMock(); // restore language mock
|
setupLanguageMock(); // restore language mock
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getLanguagesFromBrowser", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return navigator.languages if available", () => {
|
||||||
|
jest.spyOn(window.navigator, "languages", "get").mockReturnValue(["en", "de"]);
|
||||||
|
expect(getLanguagesFromBrowser()).toEqual(["en", "de"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return navigator.language if available", () => {
|
||||||
|
jest.spyOn(window.navigator, "languages", "get").mockReturnValue([]);
|
||||||
|
jest.spyOn(window.navigator, "language", "get").mockReturnValue("de");
|
||||||
|
expect(getLanguagesFromBrowser()).toEqual(["de"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'en' otherwise", () => {
|
||||||
|
jest.spyOn(window.navigator, "languages", "get").mockReturnValue([]);
|
||||||
|
jest.spyOn(window.navigator, "language", "get").mockReturnValue(undefined as any);
|
||||||
|
expect(getLanguagesFromBrowser()).toEqual(["en"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("languageHandler JSX", function () {
|
describe("languageHandler JSX", function () {
|
||||||
|
Loading…
Reference in New Issue
Block a user