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:
Michael Telatynski 2025-05-13 11:51:05 +01:00 committed by GitHub
parent e235100dd0
commit c84cf3c36c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 109 additions and 124 deletions

View File

@ -82,19 +82,10 @@ declare global {
mxMatrixClientPeg: IMatrixClientPeg;
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
// 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
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;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
@ -156,31 +147,10 @@ declare global {
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 {
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
interface AudioWorkletProcessor {
readonly port: MessagePort;
@ -239,11 +209,4 @@ declare global {
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 */

View File

@ -163,36 +163,21 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
this.element.src = URL.createObjectURL(new Blob([this.buf]));
await deferred.promise; // make sure the audio element is ready for us
} else {
// Safari compat: promise API not supported on this function
this.audioBuf = await new Promise((resolve, reject) => {
this.context.decodeAudioData(
this.buf,
(b) => resolve(b),
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);
this.audioBuf = await this.context.decodeAudioData(this.buf);
} catch (e) {
logger.error("Error decoding recording:", e);
logger.warn("Trying to re-encode to WAV instead...");
try {
// This error handler is largely for Safari, which doesn't support Opus/Ogg very well.
const wav = await decodeOgg(this.buf);
// noinspection ES6MissingAwait - not needed when using callbacks
this.context.decodeAudioData(
wav,
(b) => resolve(b),
(e) => {
logger.error("Still failed to decode recording: ", e);
reject(e);
},
);
this.audioBuf = await this.context.decodeAudioData(wav);
} catch (e) {
logger.error("Caught decoding error:", e);
reject(e);
logger.error("Error decoding recording:", e);
throw e;
}
}
},
);
});
// 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...

View File

@ -15,21 +15,15 @@ import { logger } from "matrix-js-sdk/src/logger";
import { SAMPLE_RATE } from "./VoiceRecording";
export function createAudioContext(opts?: AudioContextOptions): AudioContext {
let ctx: AudioContext;
if (window.AudioContext) {
ctx = new AudioContext(opts);
} else if (window.webkitAudioContext) {
// While the linter is correct that "a constructor name should not start with
// a lowercase letter", it's also wrong to think that we have control over this.
// eslint-disable-next-line new-cap
ctx = new window.webkitAudioContext(opts);
} else {
throw new Error("Unsupported browser");
}
const ctx = new AudioContext(opts);
// 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;
} else {
throw new Error("Unsupported browser");
}
}
export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {

View File

@ -11,7 +11,6 @@ import React, { type ReactElement } from "react";
import classNames from "classnames";
import * as languageHandler from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
import Spinner from "./Spinner";
import Dropdown from "./Dropdown";
@ -29,7 +28,7 @@ function languageMatchesSearchQuery(query: string, language: Languages[0]): bool
interface IProps {
className?: string;
onOptionChange: (language: string) => void;
value?: string;
value: string;
disabled?: boolean;
}
@ -103,17 +102,6 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
return <div key={language.value}>{language.labelInTargetLanguage}</div>;
}) 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 (
<Dropdown
id="mx_LanguageDropdown"
@ -121,7 +109,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
onOptionChange={this.props.onOptionChange}
onSearchChange={this.onSearchChange}
searchEnabled={true}
value={value}
value={this.props.value}
label={_t("language_dropdown_label")}
disabled={this.props.disabled}
>

View File

@ -10,7 +10,6 @@ import React, { type ReactElement } from "react";
import Dropdown from "../../views/elements/Dropdown";
import PlatformPeg from "../../../PlatformPeg";
import SettingsStore from "../../../settings/SettingsStore";
import { _t, getUserLanguage } from "../../../languageHandler";
import Spinner from "./Spinner";
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>;
}) 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 (
<Dropdown
id="mx_LanguageDropdown"
@ -123,7 +111,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
onOptionChange={this.props.onOptionChange}
onSearchChange={this.onSearchChange}
searchEnabled={true}
value={value}
value={this.props.value}
label={_t("language_dropdown_label")}
placeholder={_t("settings|general|spell_check_locale_placeholder")}
>

View File

@ -66,26 +66,15 @@ interface IState {
}
function getFullScreenElement(): Element | null {
return (
document.fullscreenElement ||
// moz omitted because firefox supports this unprefixed now (webkit here for safari)
document.webkitFullscreenElement ||
document.msFullscreenElement
);
return document.fullscreenElement;
}
function requestFullscreen(element: Element): void {
const method =
element.requestFullscreen ||
// moz omitted since firefox supports unprefixed now
element.webkitRequestFullScreen ||
element.msRequestFullscreen;
if (method) method.call(element);
element.requestFullscreen();
}
function exitFullscreen(): void {
const exitMethod = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
if (exitMethod) exitMethod.call(document);
document.exitFullscreen();
}
export default class LegacyCallView extends React.Component<IProps, IState> {

View File

@ -529,8 +529,7 @@ export async function getAllLanguagesWithLabels(): Promise<Language[]> {
export function getLanguagesFromBrowser(): readonly string[] {
if (navigator.languages && navigator.languages.length) return navigator.languages;
if (navigator.language) return [navigator.language];
return [navigator.userLanguage || "en"];
return [navigator.language ?? "en"];
}
export function getLanguageFromBrowser(): string {

View File

@ -47,7 +47,7 @@ describe("Playback", () => {
beforeEach(() => {
jest.spyOn(logger, "error").mockRestore();
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
mockAudioContext.decodeAudioData.mockReset().mockImplementation((_b, callback) => callback(mockAudioBuffer));
mockAudioContext.decodeAudioData.mockReset().mockResolvedValue(mockAudioBuffer);
mockAudioContext.resume.mockClear().mockResolvedValue(undefined);
mockAudioContext.suspend.mockClear().mockResolvedValue(undefined);
mocked(decodeOgg).mockClear().mockResolvedValue(new ArrayBuffer(1));
@ -131,8 +131,8 @@ describe("Playback", () => {
const buffer = new ArrayBuffer(8);
const decodingError = new Error("test");
mockAudioContext.decodeAudioData
.mockImplementationOnce((_b, _callback, error) => error(decodingError))
.mockImplementationOnce((_b, callback) => callback(mockAudioBuffer));
.mockRejectedValueOnce(decodingError)
.mockResolvedValueOnce(mockAudioBuffer);
const playback = new Playback(buffer);

View 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");
});
});

View File

@ -65,7 +65,7 @@ describe("<RecordingPlayback />", () => {
beforeEach(() => {
jest.spyOn(logger, "error").mockRestore();
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);
});

View File

@ -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();
});
});

View File

@ -26,6 +26,7 @@ import {
type TranslationKey,
type IVariables,
type Tags,
getLanguagesFromBrowser,
} from "../../src/languageHandler";
import { stubClient } from "../test-utils";
import { setupLanguageMock } from "../setup/setupLanguage";
@ -198,6 +199,29 @@ describe("languageHandler", () => {
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 () {