Prevent Element appearing in system media controls (#10995)

* Use WebAudio API to play notification sound

So that it won't appear in system media control.

* Run prettier

* Chosse from mp3 and ogg

* Run prettier

* Use WebAudioAPI everywhere

There's still one remoteAudio. I'm not sure what it does. It seems it's
only used in tests...

* Run prettier

* Eliminate a stupid error

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update setupManualMocks.ts

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* mocks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* mocks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Simplify

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* covg

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
SuperKenVery 2024-07-05 02:08:06 +08:00 committed by GitHub
parent c61eca8c24
commit e288f61f0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 255 additions and 164 deletions

View file

@ -22,7 +22,7 @@ const config: Config = {
testEnvironment: "jsdom", testEnvironment: "jsdom",
testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"], testMatch: ["<rootDir>/test/**/*-test.[jt]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts", globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock"], setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
moduleNameMapper: { moduleNameMapper: {
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js", "\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",

View file

@ -227,7 +227,8 @@
"stylelint-config-standard": "^36.0.0", "stylelint-config-standard": "^36.0.0",
"stylelint-scss": "^6.0.0", "stylelint-scss": "^6.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.5.2" "typescript": "5.5.2",
"web-streams-polyfill": "^4.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"postcss": "^8.4.19", "postcss": "^8.4.19",

View file

@ -66,6 +66,7 @@ import { localNotificationsAreSilenced } from "./utils/notifications";
import { SdkContextClass } from "./contexts/SDKContext"; import { SdkContextClass } from "./contexts/SDKContext";
import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog"; import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog";
import { isNotNull } from "./Typeguards"; import { isNotNull } from "./Typeguards";
import { BackgroundAudio } from "./audio/BackgroundAudio";
export const PROTOCOL_PSTN = "m.protocol.pstn"; export const PROTOCOL_PSTN = "m.protocol.pstn";
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn"; export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
@ -157,8 +158,6 @@ export default class LegacyCallHandler extends EventEmitter {
// Calls started as an attended transfer, ie. with the intention of transferring another // Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one. // call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee) private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>();
private audioElementsWithListeners = new Map<HTMLMediaElement, boolean>();
private supportsPstnProtocol: boolean | null = null; private supportsPstnProtocol: boolean | null = null;
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
@ -170,6 +169,9 @@ export default class LegacyCallHandler extends EventEmitter {
private silencedCalls = new Set<string>(); // callIds private silencedCalls = new Set<string>(); // callIds
private backgroundAudio = new BackgroundAudio();
private playingSources: Record<string, AudioBufferSourceNode> = {}; // Record them for stopping
public static get instance(): LegacyCallHandler { public static get instance(): LegacyCallHandler {
if (!window.mxLegacyCallHandler) { if (!window.mxLegacyCallHandler) {
window.mxLegacyCallHandler = new LegacyCallHandler(); window.mxLegacyCallHandler = new LegacyCallHandler();
@ -199,33 +201,11 @@ export default class LegacyCallHandler extends EventEmitter {
} }
public start(): void { public start(): void {
// add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc
// audio clips in to play.
if (navigator.mediaSession) {
navigator.mediaSession.setActionHandler("play", function () {});
navigator.mediaSession.setActionHandler("pause", function () {});
navigator.mediaSession.setActionHandler("seekbackward", function () {});
navigator.mediaSession.setActionHandler("seekforward", function () {});
navigator.mediaSession.setActionHandler("previoustrack", function () {});
navigator.mediaSession.setActionHandler("nexttrack", function () {});
}
if (SettingsStore.getValue(UIFeature.Voip)) { if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming); MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming);
} }
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS); this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
// Add event listeners for the <audio> elements
Object.values(AudioID).forEach((audioId) => {
const audioElement = document.getElementById(audioId) as HTMLMediaElement;
if (audioElement) {
this.addEventListenersForAudioElement(audioElement);
} else {
logger.warn(`LegacyCallHandler: missing <audio id="${audioId}"> from page`);
}
});
} }
public stop(): void { public stop(): void {
@ -233,27 +213,6 @@ export default class LegacyCallHandler extends EventEmitter {
if (cli) { if (cli) {
cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming); cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming);
} }
// Remove event listeners for the <audio> elements
Array.from(this.audioElementsWithListeners.keys()).forEach((audioElement) => {
this.removeEventListenersForAudioElement(audioElement);
});
}
private addEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
// Only need to setup the listeners once
if (!this.audioElementsWithListeners.get(audioElement)) {
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
audioElement.addEventListener(errorEventType, this);
this.audioElementsWithListeners.set(audioElement, true);
});
}
}
private removeEventListenersForAudioElement(audioElement: HTMLMediaElement): void {
MEDIA_EVENT_TYPES.forEach((errorEventType) => {
audioElement.removeEventListener(errorEventType, this);
});
} }
/* istanbul ignore next (remove if we start using this function for things other than debug logging) */ /* istanbul ignore next (remove if we start using this function for things other than debug logging) */
@ -465,74 +424,37 @@ export default class LegacyCallHandler extends EventEmitter {
return this.transferees.get(callId); return this.transferees.get(callId);
} }
public play(audioId: AudioID): void { public async play(audioId: AudioID): Promise<void> {
const logPrefix = `LegacyCallHandler.play(${audioId}):`; const logPrefix = `LegacyCallHandler.play(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`); logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
if (audio) {
this.addEventListenersForAudioElement(audio);
const playAudio = async (): Promise<void> => {
try {
if (audio.muted) {
logger.error(
`${logPrefix} <audio> element was unexpectedly muted but we recovered ` +
`gracefully by unmuting it`,
);
// Recover gracefully
audio.muted = false;
}
// This still causes the chrome debugger to break on promise rejection if const audioInfo: Record<AudioID, [prefix: string, loop: boolean]> = {
// the promise is rejected, even though we're catching the exception. [AudioID.Ring]: [`./media/ring`, true],
logger.debug(`${logPrefix} attempting to play audio at volume=${audio.volume}`); [AudioID.Ringback]: [`./media/ringback`, true],
await audio.play(); [AudioID.CallEnd]: [`./media/callend`, false],
logger.debug(`${logPrefix} playing audio successfully`); [AudioID.Busy]: [`./media/busy`, false],
} catch (e) { };
// This is usually because the user hasn't interacted with the document,
// or chrome doesn't think so and is denying the request. Not sure what const [urlPrefix, loop] = audioInfo[audioId];
// we can really do here... const source = await this.backgroundAudio.pickFormatAndPlay(urlPrefix, ["mp3", "ogg"], loop);
// https://github.com/vector-im/element-web/issues/7657 this.playingSources[audioId] = source;
logger.warn(`${logPrefix} unable to play audio clip`, e); logger.debug(`${logPrefix} playing audio successfully`);
}
};
if (this.audioPromises.has(audioId)) {
this.audioPromises.set(
audioId,
this.audioPromises.get(audioId)!.then(() => {
audio.load();
return playAudio();
}),
);
} else {
this.audioPromises.set(audioId, playAudio());
}
} else {
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`);
}
} }
public pause(audioId: AudioID): void { public pause(audioId: AudioID): void {
const logPrefix = `LegacyCallHandler.pause(${audioId}):`; const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`); logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens? const source = this.playingSources[audioId];
const audio = document.getElementById(audioId) as HTMLMediaElement; if (!source) {
const pauseAudio = (): void => { logger.debug(`${logPrefix} audio not playing`);
logger.debug(`${logPrefix} pausing audio`); return;
// pause doesn't return a promise, so just do it
audio.pause();
};
if (audio) {
if (this.audioPromises.has(audioId)) {
this.audioPromises.set(audioId, this.audioPromises.get(audioId)!.then(pauseAudio));
} else {
pauseAudio();
}
} else {
logger.warn(`${logPrefix} unable to find <audio> element for ${audioId}`);
} }
source.stop();
delete this.playingSources[audioId];
logger.debug(`${logPrefix} paused audio`);
} }
private matchesCallForThisRoom(call: MatrixCall): boolean { private matchesCallForThisRoom(call: MatrixCall): boolean {

View file

@ -58,6 +58,7 @@ import ToastStore from "./stores/ToastStore";
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast"; import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast";
import { getSenderName } from "./utils/event/getSenderName"; import { getSenderName } from "./utils/event/getSenderName";
import { stripPlainReply } from "./utils/Reply"; import { stripPlainReply } from "./utils/Reply";
import { BackgroundAudio } from "./audio/BackgroundAudio";
/* /*
* Dispatches: * Dispatches:
@ -112,6 +113,8 @@ class NotifierClass {
private toolbarHidden?: boolean; private toolbarHidden?: boolean;
private isSyncing?: boolean; private isSyncing?: boolean;
private backgroundAudio = new BackgroundAudio();
public notificationMessageForEvent(ev: MatrixEvent): string | null { public notificationMessageForEvent(ev: MatrixEvent): string | null {
const msgType = ev.getContent().msgtype; const msgType = ev.getContent().msgtype;
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) { if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
@ -226,28 +229,14 @@ class NotifierClass {
return; return;
} }
// Play notification sound here
const sound = this.getSoundForRoom(room.roomId); const sound = this.getSoundForRoom(room.roomId);
logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`); logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`);
try { if (sound) {
const selector = document.querySelector<HTMLAudioElement>( await this.backgroundAudio.play(sound.url);
sound ? `audio[src='${sound.url}']` : "#messageAudio", } else {
); await this.backgroundAudio.pickFormatAndPlay("media/message", ["mp3", "ogg"]);
let audioElement = selector;
if (!audioElement) {
if (!sound) {
logger.error("No audio element or sound to play for notification");
return;
}
audioElement = new Audio(sound.url);
if (sound.type) {
audioElement.type = sound.type;
}
document.body.appendChild(audioElement);
}
await audioElement.play();
} catch (ex) {
logger.warn("Caught error when trying to fetch room notification sound:", ex);
} }
} }

View file

@ -0,0 +1,74 @@
/*
Copyright 2024 The 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 { logger } from "matrix-js-sdk/src/logger";
import { createAudioContext } from "./compat";
const formatMap = {
mp3: "audio/mpeg",
ogg: "audio/ogg",
};
export class BackgroundAudio {
private audioContext = createAudioContext();
private sounds: Record<string, AudioBuffer> = {};
public async pickFormatAndPlay<F extends Array<keyof typeof formatMap>>(
urlPrefix: string,
formats: F,
loop = false,
): Promise<AudioBufferSourceNode> {
const format = this.pickFormat(...formats);
if (!format) {
console.log("Browser doesn't support any of the formats", formats);
// Will probably never happen. If happened, format="" and will fail to load audio. Who cares...
}
return this.play(`${urlPrefix}.${format}`, loop);
}
public async play(url: string, loop = false): Promise<AudioBufferSourceNode> {
if (!this.sounds.hasOwnProperty(url)) {
// No cache, fetch it
const response = await fetch(url);
if (response.status != 200) {
logger.warn("Failed to fetch error audio");
}
const buffer = await response.arrayBuffer();
const sound = await this.audioContext.decodeAudioData(buffer);
this.sounds[url] = sound;
}
const source = this.audioContext.createBufferSource();
source.buffer = this.sounds[url];
source.loop = loop;
source.connect(this.audioContext.destination);
source.start();
return source;
}
private pickFormat<F extends Array<keyof typeof formatMap>>(...formats: F): F[number] | null {
// Detect supported formats
const audioElement = document.createElement("audio");
for (const format of formats) {
if (audioElement.canPlayType(formatMap[format])) {
return format;
}
}
return null;
}
}

View file

@ -47,6 +47,7 @@ import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
import { createReconnectedListener } from "../../utils/connection"; import { createReconnectedListener } from "../../utils/connection";
import { localNotificationsAreSilenced } from "../../utils/notifications"; import { localNotificationsAreSilenced } from "../../utils/notifications";
import { BackgroundAudio } from "../../audio/BackgroundAudio";
export enum VoiceBroadcastRecordingEvent { export enum VoiceBroadcastRecordingEvent {
StateChanged = "liveness_changed", StateChanged = "liveness_changed",
@ -75,6 +76,7 @@ export class VoiceBroadcastRecording
private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync]; private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync];
private roomId: string; private roomId: string;
private infoEventId: string; private infoEventId: string;
private backgroundAudio = new BackgroundAudio();
/** /**
* Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing. * Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing.
@ -346,15 +348,7 @@ export class VoiceBroadcastRecording
return; return;
} }
// Audio files are added to the document in Element Web. await this.backgroundAudio.pickFormatAndPlay("./media/error", ["mp3", "ogg"]);
// See <audio> elements in https://github.com/vector-im/element-web/blob/develop/src/vector/index.html
const audioElement = document.querySelector<HTMLAudioElement>("audio#errorAudio");
try {
await audioElement?.play();
} catch (e) {
logger.warn("error playing 'errorAudio'", e);
}
} }
private async uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> { private async uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> {

View file

@ -28,16 +28,18 @@ import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/we
import EventEmitter from "events"; import EventEmitter from "events";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
import fetchMock from "fetch-mock-jest";
import { waitFor } from "@testing-library/react";
import LegacyCallHandler, { import LegacyCallHandler, {
LegacyCallHandlerEvent,
AudioID, AudioID,
LegacyCallHandlerEvent,
PROTOCOL_PSTN, PROTOCOL_PSTN,
PROTOCOL_PSTN_PREFIXED, PROTOCOL_PSTN_PREFIXED,
PROTOCOL_SIP_NATIVE, PROTOCOL_SIP_NATIVE,
PROTOCOL_SIP_VIRTUAL, PROTOCOL_SIP_VIRTUAL,
} from "../src/LegacyCallHandler"; } from "../src/LegacyCallHandler";
import { stubClient, mkStubRoom, untilDispatch } from "./test-utils"; import { mkStubRoom, stubClient, untilDispatch } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg"; import { MatrixClientPeg } from "../src/MatrixClientPeg";
import DMRoomMap from "../src/utils/DMRoomMap"; import DMRoomMap from "../src/utils/DMRoomMap";
import SdkConfig from "../src/SdkConfig"; import SdkConfig from "../src/SdkConfig";
@ -49,6 +51,7 @@ import { VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastRecordin
import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils";
import { SdkContextClass } from "../src/contexts/SDKContext"; import { SdkContextClass } from "../src/contexts/SDKContext";
import Modal from "../src/Modal"; import Modal from "../src/Modal";
import { createAudioContext } from "../src/audio/compat";
jest.mock("../src/Modal"); jest.mock("../src/Modal");
@ -72,6 +75,11 @@ jest.mock("../src/utils/room/getFunctionalMembers", () => ({
getFunctionalMembers: jest.fn(), getFunctionalMembers: jest.fn(),
})); }));
jest.mock("../src/audio/compat", () => ({
...jest.requireActual("../src/audio/compat"),
createAudioContext: jest.fn(),
}));
// The Matrix IDs that the user sees when talking to Alice & Bob // The Matrix IDs that the user sees when talking to Alice & Bob
const NATIVE_ALICE = "@alice:example.org"; const NATIVE_ALICE = "@alice:example.org";
const NATIVE_BOB = "@bob:example.org"; const NATIVE_BOB = "@bob:example.org";
@ -449,6 +457,20 @@ describe("LegacyCallHandler without third party protocols", () => {
let audioElement: HTMLAudioElement; let audioElement: HTMLAudioElement;
let fakeCall: MatrixCall | null; let fakeCall: MatrixCall | null;
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn().mockResolvedValue({}),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
fakeCall = null; fakeCall = null;
@ -464,6 +486,7 @@ describe("LegacyCallHandler without third party protocols", () => {
throw new Error("Endpoint unsupported."); throw new Error("Endpoint unsupported.");
}; };
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
callHandler = new LegacyCallHandler(); callHandler = new LegacyCallHandler();
callHandler.start(); callHandler.start();
@ -506,6 +529,12 @@ describe("LegacyCallHandler without third party protocols", () => {
SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent(); SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent();
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent(); SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
fetchMock.get(
"/media/ring.mp3",
{ body: new Blob(["1", "2", "3", "4"], { type: "audio/mpeg" }) },
{ sendAsJson: false },
);
}); });
afterEach(() => { afterEach(() => {
@ -520,6 +549,20 @@ describe("LegacyCallHandler without third party protocols", () => {
SdkConfig.reset(); SdkConfig.reset();
}); });
it("should cache sounds between playbacks", async () => {
await callHandler.play(AudioID.Ring);
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
await callHandler.play(AudioID.Ring);
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
});
it("should allow silencing an incoming call ring", async () => {
await callHandler.play(AudioID.Ring);
await callHandler.silenceCall("call123");
expect(mockAudioBufferSourceNode.stop).toHaveBeenCalled();
});
it("should still start a native call", async () => { it("should still start a native call", async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
@ -537,13 +580,6 @@ describe("LegacyCallHandler without third party protocols", () => {
describe("incoming calls", () => { describe("incoming calls", () => {
const roomId = "test-room-id"; const roomId = "test-room-id";
const mockAudioElement = {
play: jest.fn(),
pause: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
muted: false,
} as unknown as HTMLMediaElement;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === UIFeature.Voip); jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === UIFeature.Voip);
@ -571,8 +607,6 @@ describe("LegacyCallHandler without third party protocols", () => {
}, },
}; };
jest.spyOn(document, "getElementById").mockReturnValue(mockAudioElement);
// silence local notifications by default // silence local notifications by default
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockImplementation((eventType) => { jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockImplementation((eventType) => {
if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
@ -586,19 +620,6 @@ describe("LegacyCallHandler without third party protocols", () => {
}); });
}); });
it("should unmute <audio> before playing", () => {
// Test setup: set the audio element as muted
mockAudioElement.muted = true;
expect(mockAudioElement.muted).toStrictEqual(true);
callHandler.play(AudioID.Ring);
// Ensure audio is no longer muted
expect(mockAudioElement.muted).toStrictEqual(false);
// Ensure the audio was played
expect(mockAudioElement.play).toHaveBeenCalled();
});
it("listens for incoming call events when voip is enabled", () => { it("listens for incoming call events when voip is enabled", () => {
const call = new MatrixCall({ const call = new MatrixCall({
client: MatrixClientPeg.safeGet(), client: MatrixClientPeg.safeGet(),
@ -612,7 +633,7 @@ describe("LegacyCallHandler without third party protocols", () => {
expect(callHandler.getCallForRoom(roomId)).toEqual(call); expect(callHandler.getCallForRoom(roomId)).toEqual(call);
}); });
it("rings when incoming call state is ringing and notifications set to ring", () => { it("rings when incoming call state is ringing and notifications set to ring", async () => {
// remove local notification silencing mock for this test // remove local notification silencing mock for this test
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined); jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined);
const call = new MatrixCall({ const call = new MatrixCall({
@ -627,8 +648,8 @@ describe("LegacyCallHandler without third party protocols", () => {
expect(callHandler.getCallForRoom(roomId)).toEqual(call); expect(callHandler.getCallForRoom(roomId)).toEqual(call);
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!); call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
// ringer audio element started // ringer audio started
expect(mockAudioElement.play).toHaveBeenCalled(); await waitFor(() => expect(mockAudioBufferSourceNode.start).toHaveBeenCalled());
}); });
it("does not ring when incoming call state is ringing but local notifications are silenced", () => { it("does not ring when incoming call state is ringing but local notifications are silenced", () => {
@ -645,7 +666,7 @@ describe("LegacyCallHandler without third party protocols", () => {
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!); call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
// ringer audio element started // ringer audio element started
expect(mockAudioElement.play).not.toHaveBeenCalled(); expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
expect(callHandler.isCallSilenced(call.callId)).toEqual(true); expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
}); });
@ -679,7 +700,7 @@ describe("LegacyCallHandler without third party protocols", () => {
// call still silenced // call still silenced
expect(callHandler.isCallSilenced(call.callId)).toEqual(true); expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
// ringer not played // ringer not played
expect(mockAudioElement.play).not.toHaveBeenCalled(); expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
}); });
}); });
}); });

View file

@ -60,6 +60,11 @@ jest.mock("../src/utils/notifications", () => ({
createLocalNotificationSettingsIfNeeded: jest.fn(), createLocalNotificationSettingsIfNeeded: jest.fn(),
})); }));
jest.mock("../src/audio/compat", () => ({
...jest.requireActual("../src/audio/compat"),
createAudioContext: jest.fn(),
}));
describe("Notifier", () => { describe("Notifier", () => {
const roomId = "!room1:server"; const roomId = "!room1:server";
const testEvent = mkEvent({ const testEvent = mkEvent({
@ -103,6 +108,19 @@ describe("Notifier", () => {
}); });
}; };
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => { beforeEach(() => {
accountDataStore = {}; accountDataStore = {};
mockClient = getMockClientWithEventEmitter({ mockClient = getMockClientWithEventEmitter({
@ -144,6 +162,9 @@ describe("Notifier", () => {
if (id) return new Room(id, mockClient, mockClient.getSafeUserId()); if (id) return new Room(id, mockClient, mockClient.getSafeUserId());
return null; return null;
}); });
// @ts-ignore
Notifier.backgroundAudio.audioContext = mockAudioContext;
}); });
describe("triggering notification from events", () => { describe("triggering notification from events", () => {

35
test/setup/mocks.ts Normal file
View file

@ -0,0 +1,35 @@
/*
Copyright 2024 The 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.
*/
export const mocks = {
AudioBufferSourceNode: {
connect: jest.fn(),
start: jest.fn(),
} as unknown as AudioBufferSourceNode,
AudioContext: {
close: jest.fn(),
createMediaElementSource: jest.fn(),
createMediaStreamDestination: jest.fn(),
createMediaStreamSource: jest.fn(),
createStreamTrackSource: jest.fn(),
createBufferSource: jest.fn((): AudioBufferSourceNode => ({ ...mocks.AudioBufferSourceNode })),
getOutputTimestamp: jest.fn(),
resume: jest.fn(),
setSinkId: jest.fn(),
suspend: jest.fn(),
decodeAudioData: jest.fn(),
},
};

View file

@ -18,6 +18,8 @@ import fetchMock from "fetch-mock-jest";
import { TextDecoder, TextEncoder } from "util"; import { TextDecoder, TextEncoder } from "util";
import { Response } from "node-fetch"; import { Response } from "node-fetch";
import { mocks } from "./mocks";
// Stub ResizeObserver // Stub ResizeObserver
// @ts-ignore - we know it's a duplicate (that's why we're stubbing it) // @ts-ignore - we know it's a duplicate (that's why we're stubbing it)
class ResizeObserver { class ResizeObserver {
@ -76,6 +78,7 @@ global.TextDecoder = TextDecoder;
// prevent errors whenever a component tries to manually scroll. // prevent errors whenever a component tries to manually scroll.
window.HTMLElement.prototype.scrollIntoView = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLAudioElement.prototype.canPlayType = jest.fn((format) => (format === "audio/mpeg" ? "probably" : ""));
// set up fetch API mock // set up fetch API mock
fetchMock.config.overwriteRoutes = false; fetchMock.config.overwriteRoutes = false;
@ -87,3 +90,6 @@ window.fetch = fetchMock.sandbox();
// @ts-ignore // @ts-ignore
window.Response = Response; window.Response = Response;
// set up AudioContext API mock
global.AudioContext = jest.fn().mockImplementation(() => ({ ...mocks.AudioContext }));

View file

@ -30,6 +30,7 @@ import {
SyncState, SyncState,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { EncryptedFile } from "matrix-js-sdk/src/types"; import { EncryptedFile } from "matrix-js-sdk/src/types";
import fetchMock from "fetch-mock-jest";
import { uploadFile } from "../../../src/ContentMessages"; import { uploadFile } from "../../../src/ContentMessages";
import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent"; import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent";
@ -49,6 +50,7 @@ import {
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher"; import dis from "../../../src/dispatcher/dispatcher";
import { VoiceRecording } from "../../../src/audio/VoiceRecording"; import { VoiceRecording } from "../../../src/audio/VoiceRecording";
import { createAudioContext } from "../../../src/audio/compat";
jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({ jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({
...(jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object), ...(jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object),
@ -79,6 +81,11 @@ jest.mock("../../../src/utils/createVoiceMessageContent", () => ({
createVoiceMessageContent: jest.fn(), createVoiceMessageContent: jest.fn(),
})); }));
jest.mock("../../../src/audio/compat", () => ({
...jest.requireActual("../../../src/audio/compat"),
createAudioContext: jest.fn(),
}));
describe("VoiceBroadcastRecording", () => { describe("VoiceBroadcastRecording", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
const uploadedUrl = "mxc://example.com/vb"; const uploadedUrl = "mxc://example.com/vb";
@ -198,6 +205,19 @@ describe("VoiceBroadcastRecording", () => {
}); });
}; };
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => { beforeEach(() => {
client = stubClient(); client = stubClient();
room = mkStubRoom(roomId, "Test Room", client); room = mkStubRoom(roomId, "Test Room", client);
@ -265,6 +285,8 @@ describe("VoiceBroadcastRecording", () => {
return null; return null;
}); });
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
}); });
afterEach(() => { afterEach(() => {
@ -546,12 +568,13 @@ describe("VoiceBroadcastRecording", () => {
beforeEach(() => { beforeEach(() => {
mocked(client.sendMessage).mockRejectedValue("Error"); mocked(client.sendMessage).mockRejectedValue("Error");
emitFirsChunkRecorded(); emitFirsChunkRecorded();
fetchMock.get("media/error.mp3", 200);
}); });
itShouldBeInState("connection_error"); itShouldBeInState("connection_error");
it("should play a notification", () => { it("should play a notification", () => {
expect(audioElement.play).toHaveBeenCalled(); expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
}); });
describe("and the connection is back", () => { describe("and the connection is back", () => {

View file

@ -9230,6 +9230,11 @@ walker@^1.0.8:
dependencies: dependencies:
makeerror "1.0.12" makeerror "1.0.12"
web-streams-polyfill@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0.tgz#74cedf168339ee6e709532f76c49313a8c7acdac"
integrity sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==
web-vitals@^4.0.1: web-vitals@^4.0.1:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.0.tgz#008949ab79717a68ccaaa3c4371cbc7bbbd78a92" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.0.tgz#008949ab79717a68ccaaa3c4371cbc7bbbd78a92"