From c84cf3c36ce31a0a1f88ea29a6d67ea76e21f48e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 13 May 2025 11:51:05 +0100 Subject: [PATCH] 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> --- src/@types/global.d.ts | 37 ---------------- src/audio/Playback.ts | 43 ++++++------------- src/audio/compat.ts | 16 +++---- .../views/elements/LanguageDropdown.tsx | 16 +------ .../elements/SpellCheckLanguagesDropdown.tsx | 14 +----- src/components/views/voip/LegacyCallView.tsx | 17 ++------ src/languageHandler.tsx | 3 +- test/unit-tests/audio/Playback-test.ts | 6 +-- test/unit-tests/audio/compat-test.ts | 15 +++++++ .../audio_messages/RecordingPlayback-test.tsx | 2 +- .../views/voip/LegacyCallView-test.tsx | 40 +++++++++++++++++ test/unit-tests/languageHandler-test.tsx | 24 +++++++++++ 12 files changed, 109 insertions(+), 124 deletions(-) create mode 100644 test/unit-tests/audio/compat-test.ts create mode 100644 test/unit-tests/components/views/voip/LegacyCallView-test.tsx diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 344059fee4..5a080e0d1c 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -82,19 +82,10 @@ declare global { mxMatrixClientPeg: IMatrixClientPeg; mxReactSdkConfig: DeepReadonly; - // 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; - msExitFullscreen(): Promise; - 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; - msRequestFullscreen(options?: FullscreenOptions): Promise; - // 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 */ diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index 5247e4836c..54d2c710d0 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -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 => { - 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..."); + try { + 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..."); - 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); - }, - ); - } catch (e) { - logger.error("Caught decoding error:", e); - reject(e); - } - }, - ); - }); + try { + // This error handler is largely for Safari, which doesn't support Opus/Ogg very well. + const wav = await decodeOgg(this.buf); + this.audioBuf = await this.context.decodeAudioData(wav); + } catch (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... diff --git a/src/audio/compat.ts b/src/audio/compat.ts index 0c972e9b73..d967596450 100644 --- a/src/audio/compat.ts +++ b/src/audio/compat.ts @@ -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); + 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"); } - // 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 { diff --git a/src/components/views/elements/LanguageDropdown.tsx b/src/components/views/elements/LanguageDropdown.tsx index c4a72c4f4f..7bf50f7806 100644 --- a/src/components/views/elements/LanguageDropdown.tsx +++ b/src/components/views/elements/LanguageDropdown.tsx @@ -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 { return
{language.labelInTargetLanguage}
; }) as NonEmptyArray; - // 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 ( { onOptionChange={this.props.onOptionChange} onSearchChange={this.onSearchChange} searchEnabled={true} - value={value} + value={this.props.value} label={_t("language_dropdown_label")} disabled={this.props.disabled} > diff --git a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx index a267999f5b..88a1ab1bd6 100644 --- a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx +++ b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx @@ -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
{language.label}
; }) as NonEmptyArray; - // 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 ( diff --git a/src/components/views/voip/LegacyCallView.tsx b/src/components/views/voip/LegacyCallView.tsx index c972cb21d4..255b1ab0f4 100644 --- a/src/components/views/voip/LegacyCallView.tsx +++ b/src/components/views/voip/LegacyCallView.tsx @@ -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 { diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 865430fb9e..e99bb3706f 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -529,8 +529,7 @@ export async function getAllLanguagesWithLabels(): Promise { 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 { diff --git a/test/unit-tests/audio/Playback-test.ts b/test/unit-tests/audio/Playback-test.ts index 0aeea5c832..7f14685f45 100644 --- a/test/unit-tests/audio/Playback-test.ts +++ b/test/unit-tests/audio/Playback-test.ts @@ -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); diff --git a/test/unit-tests/audio/compat-test.ts b/test/unit-tests/audio/compat-test.ts new file mode 100644 index 0000000000..da90d61711 --- /dev/null +++ b/test/unit-tests/audio/compat-test.ts @@ -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"); + }); +}); diff --git a/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx b/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx index a84ecb1e16..243fea38b2 100644 --- a/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx @@ -65,7 +65,7 @@ describe("", () => { 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); }); diff --git a/test/unit-tests/components/views/voip/LegacyCallView-test.tsx b/test/unit-tests/components/views/voip/LegacyCallView-test.tsx new file mode 100644 index 0000000000..ee1f4fe326 --- /dev/null +++ b/test/unit-tests/components/views/voip/LegacyCallView-test.tsx @@ -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(); + expect(document.exitFullscreen).not.toHaveBeenCalled(); + unmount(); + expect(document.exitFullscreen).toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/languageHandler-test.tsx b/test/unit-tests/languageHandler-test.tsx index 19107aaf27..c69eefd85c 100644 --- a/test/unit-tests/languageHandler-test.tsx +++ b/test/unit-tests/languageHandler-test.tsx @@ -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 () {