diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index f447158ccc..7da2ab3121 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -16,19 +16,16 @@ limitations under the License. */ import React from 'react'; -import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../../../languageHandler"; -import SdkConfig from "../../../../../SdkConfig"; import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../MediaDeviceHandler"; import Field from "../../../elements/Field"; import AccessibleButton from "../../../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import Modal from "../../../../../Modal"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import SettingsFlag from '../../../elements/SettingsFlag'; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; -import ErrorDialog from '../../../dialogs/ErrorDialog'; +import { requestMediaPermissions } from '../../../../../utils/media/requestMediaPermissions'; const getDefaultDevice = (devices: Array>) => { // Note we're looking for a device with deviceId 'default' but adding a device @@ -90,37 +87,8 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> { }; private requestMediaPermissions = async (): Promise => { - let constraints; - let stream; - let error; - try { - constraints = { video: true, audio: true }; - stream = await navigator.mediaDevices.getUserMedia(constraints); - } catch (err) { - // user likely doesn't have a webcam, - // we should still allow to select a microphone - if (err.name === "NotFoundError") { - constraints = { audio: true }; - try { - stream = await navigator.mediaDevices.getUserMedia(constraints); - } catch (err) { - error = err; - } - } else { - error = err; - } - } - if (error) { - logger.log("Failed to list userMedia devices", error); - const brand = SdkConfig.get().brand; - Modal.createDialog(ErrorDialog, { - title: _t('No media permissions'), - description: _t( - 'You may need to manually permit %(brand)s to access your microphone/webcam', - { brand }, - ), - }); - } else { + const stream = await requestMediaPermissions(); + if (stream) { this.refreshMediaDevices(stream); } }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3957ab7f7e..9af1b476f1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -754,6 +754,8 @@ "Invite to %(spaceName)s": "Invite to %(spaceName)s", "Share your public space": "Share your public space", "Unknown App": "Unknown App", + "No media permissions": "No media permissions", + "You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam", "This homeserver is not configured to display maps.": "This homeserver is not configured to display maps.", "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.", "Toggle attribution": "Toggle attribution", @@ -1618,8 +1620,6 @@ "Rooms outside of a space": "Rooms outside of a space", "Group all your rooms that aren't part of a space in one place.": "Group all your rooms that aren't part of a space in one place.", "Default Device": "Default Device", - "No media permissions": "No media permissions", - "You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam", "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", "Request media permissions": "Request media permissions", "Audio Output": "Audio Output", diff --git a/src/utils/media/requestMediaPermissions.tsx b/src/utils/media/requestMediaPermissions.tsx new file mode 100644 index 0000000000..7740fb8da4 --- /dev/null +++ b/src/utils/media/requestMediaPermissions.tsx @@ -0,0 +1,59 @@ +/* +Copyright 2022 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 ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { _t } from "../../languageHandler"; +import Modal from "../../Modal"; +import SdkConfig from "../../SdkConfig"; + +export const requestMediaPermissions = async (video = true): Promise => { + let stream: MediaStream | undefined; + let error: any; + + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video, + }); + } catch (err: any) { + // user likely doesn't have a webcam, + // we should still allow to select a microphone + if (video && err.name === "NotFoundError") { + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (err) { + error = err; + } + } else { + error = err; + } + } + if (error) { + logger.log("Failed to list userMedia devices", error); + const brand = SdkConfig.get().brand; + Modal.createDialog(ErrorDialog, { + title: _t('No media permissions'), + description: _t( + 'You may need to manually permit %(brand)s to access your microphone/webcam', + { brand }, + ), + }); + } + + return stream; +}; diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx index 31e638dca4..04ca0a4bf7 100644 --- a/test/components/views/messages/CallEvent-test.tsx +++ b/test/components/views/messages/CallEvent-test.tsx @@ -44,7 +44,6 @@ const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent); describe("CallEvent", () => { useMockedCalls(); - Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); let client: Mocked; diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index dfaa4405c8..7ee9cf11df 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -43,9 +43,6 @@ describe("RoomTile", () => { jest.spyOn(PlatformPeg, "get") .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); useMockedCalls(); - Object.defineProperty(navigator, "mediaDevices", { - value: { enumerateDevices: async () => [] }, - }); let client: Mocked; diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index d87a6591f8..0be81c9040 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -44,12 +44,6 @@ const CallView = wrapInMatrixClientContext(_CallView); describe("CallLobby", () => { useMockedCalls(); - Object.defineProperty(navigator, "mediaDevices", { - value: { - enumerateDevices: jest.fn(), - getUserMedia: () => null, - }, - }); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); let client: Mocked; diff --git a/test/components/views/voip/PipView-test.tsx b/test/components/views/voip/PipView-test.tsx index 370dfbe242..2f3699df14 100644 --- a/test/components/views/voip/PipView-test.tsx +++ b/test/components/views/voip/PipView-test.tsx @@ -55,7 +55,6 @@ import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/t describe("PipView", () => { useMockedCalls(); - Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); let sdkContext: TestSdkContext; diff --git a/test/setup/setupManualMocks.ts b/test/setup/setupManualMocks.ts index 3510ee1e8c..31afddb205 100644 --- a/test/setup/setupManualMocks.ts +++ b/test/setup/setupManualMocks.ts @@ -86,3 +86,11 @@ fetchMock.catch(""); fetchMock.get("/image-file-stub", "image file stub"); // @ts-ignore window.fetch = fetchMock.sandbox(); + +// set up mediaDevices mock +Object.defineProperty(navigator, "mediaDevices", { + value: { + enumerateDevices: jest.fn().mockResolvedValue([]), + getUserMedia: jest.fn(), + }, +}); diff --git a/test/stores/room-list/algorithms/Algorithm-test.ts b/test/stores/room-list/algorithms/Algorithm-test.ts index c270715926..ec45bed553 100644 --- a/test/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/stores/room-list/algorithms/Algorithm-test.ts @@ -89,10 +89,6 @@ describe("Algorithm", () => { stop: () => {}, } as unknown as ClientWidgetApi); - Object.defineProperty(navigator, "mediaDevices", { - value: { enumerateDevices: async () => [] }, - }); - // End of setup expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 42a279dac7..51ae8cd48e 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -42,7 +42,6 @@ import { getIncomingCallToastKey, IncomingCallToast } from "../../src/toasts/Inc describe("IncomingCallEvent", () => { useMockedCalls(); - Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => { }); let client: Mocked; diff --git a/test/utils/media/requestMediaPermissions-test.tsx b/test/utils/media/requestMediaPermissions-test.tsx new file mode 100644 index 0000000000..732a9d8723 --- /dev/null +++ b/test/utils/media/requestMediaPermissions-test.tsx @@ -0,0 +1,124 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { logger } from "matrix-js-sdk/src/logger"; +import { screen } from "@testing-library/react"; + +import { requestMediaPermissions } from "../../../src/utils/media/requestMediaPermissions"; +import { flushPromises } from "../../test-utils"; + +describe("requestMediaPermissions", () => { + let error: Error; + const audioVideoStream = {} as MediaStream; + const audioStream = {} as MediaStream; + + const itShouldLogTheErrorAndShowTheNoMediaPermissionsModal = () => { + it("should log the error and show the »No media permissions« modal", () => { + expect(logger.log).toHaveBeenCalledWith( + "Failed to list userMedia devices", + error, + ); + screen.getByText("No media permissions"); + }); + }; + + beforeEach(() => { + error = new Error(); + jest.spyOn(logger, "log"); + }); + + describe("when an audio and video device is available", () => { + beforeEach(() => { + mocked(navigator.mediaDevices.getUserMedia).mockImplementation( + async ({ audio, video }): Promise => { + if (audio && video) return audioVideoStream; + return audioStream; + }, + ); + }); + + it("should return the audio/video stream", async () => { + expect(await requestMediaPermissions()).toBe(audioVideoStream); + }); + }); + + describe("when calling with video = false and an audio device is available", () => { + beforeEach(() => { + mocked(navigator.mediaDevices.getUserMedia).mockImplementation( + async ({ audio, video }): Promise => { + if (audio && !video) return audioStream; + return audioVideoStream; + }, + ); + }); + + it("should return the audio stream", async () => { + expect(await requestMediaPermissions(false)).toBe(audioStream); + }); + }); + + describe("when only an audio stream is available", () => { + beforeEach(() => { + error.name = "NotFoundError"; + mocked(navigator.mediaDevices.getUserMedia).mockImplementation( + async ({ audio, video }): Promise => { + if (audio && video) throw error; + if (audio) return audioStream; + return audioVideoStream; + }, + ); + }); + + it("should return the audio stream", async () => { + expect(await requestMediaPermissions()).toBe(audioStream); + }); + }); + + describe("when no device is available", () => { + beforeEach(async () => { + error.name = "NotFoundError"; + mocked(navigator.mediaDevices.getUserMedia).mockImplementation( + async (): Promise => { + throw error; + }, + ); + await requestMediaPermissions(); + // required for the modal to settle + await flushPromises(); + await flushPromises(); + }); + + itShouldLogTheErrorAndShowTheNoMediaPermissionsModal(); + }); + + describe("when an Error is raised", () => { + beforeEach(async () => { + mocked(navigator.mediaDevices.getUserMedia).mockImplementation( + async ({ audio, video }): Promise => { + if (audio && video) throw error; + return audioVideoStream; + }, + ); + await requestMediaPermissions(); + // required for the modal to settle + await flushPromises(); + await flushPromises(); + }); + + itShouldLogTheErrorAndShowTheNoMediaPermissionsModal(); + }); +});