From daf097e123969440c45e95c1bd4e6463d5703d76 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 24 Oct 2022 09:03:05 -0400 Subject: [PATCH 1/5] Fix joining calls without audio or video inputs (#9486) The lobby view was requesting a stream with both video and audio, even if the system lacked video or audio devices. Requesting one of audio or video is enough to get all device labels. --- src/components/views/voip/CallView.tsx | 50 +++++++++++++------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index ea5d3c1e79..f003fdc6ca 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -33,7 +33,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; +import MediaDeviceHandler from "../../../MediaDeviceHandler"; import { CallStore } from "../../../stores/CallStore"; import IconizedContextMenu, { IconizedContextMenuOption, @@ -141,36 +141,38 @@ export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallBu }, [videoMuted, setVideoMuted]); const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => { - let previewStream: MediaStream; + let devices = await MediaDeviceHandler.getDevices(); + + // We get the preview stream before requesting devices: this is because + // we need (in some browsers) an active media stream in order to get + // non-blank labels for the devices. + let stream: MediaStream | null = null; try { - // We get the preview stream before requesting devices: this is because - // we need (in some browsers) an active media stream in order to get - // non-blank labels for the devices. According to the docs, we - // need a stream of each type (audio + video) if we want to enumerate - // audio & video devices, although this didn't seem to be the case - // in practice for me. We request both anyway. - // For similar reasons, we also request a stream even if video is muted, - // which could be a bit strange but allows us to get the device list - // reliably. One option could be to try & get devices without a stream, - // then try again with a stream if we get blank deviceids, but... ew. - previewStream = await navigator.mediaDevices.getUserMedia({ - video: { deviceId: videoInputId }, - audio: { deviceId: MediaDeviceHandler.getAudioInput() }, - }); + if (devices.audioinput.length > 0) { + // Holding just an audio stream will be enough to get us all device labels, so + // if video is muted, don't bother requesting video. + stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: !videoMuted && devices.videoinput.length > 0 && { deviceId: videoInputId }, + }); + } else if (devices.videoinput.length > 0) { + // We have to resort to a video stream, even if video is supposed to be muted. + stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } }); + } } catch (e) { logger.error(`Failed to get stream for device ${videoInputId}`, e); } - const devices = await MediaDeviceHandler.getDevices(); + // Refresh the devices now that we hold a stream + if (stream !== null) devices = await MediaDeviceHandler.getDevices(); - // If video is muted, we don't actually want the stream, so we can get rid of - // it now. + // If video is muted, we don't actually want the stream, so we can get rid of it now. if (videoMuted) { - previewStream.getTracks().forEach(t => t.stop()); - previewStream = undefined; + stream?.getTracks().forEach(t => t.stop()); + stream = null; } - return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]]; + return [stream, devices.audioinput, devices.videoinput]; }, [videoInputId, videoMuted], [null, [], []]); const setAudioInput = useCallback((device: MediaDeviceInfo) => { @@ -188,7 +190,7 @@ export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallBu videoElement.play(); return () => { - videoStream?.getTracks().forEach(track => track.stop()); + videoStream.getTracks().forEach(track => track.stop()); videoElement.srcObject = null; }; } @@ -358,7 +360,7 @@ const JoinCallView: FC = ({ room, resizing, call }) => { lobby = { facePile } From d702f4a291f0158c8fa5f9c6b0ed679cef4322ec Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 24 Oct 2022 16:06:58 +0200 Subject: [PATCH 2/5] When start listening to a broadcast, pause the others (#9489) --- .../models/VoiceBroadcastPlayback.ts | 17 ++- .../stores/VoiceBroadcastPlaybacksStore.ts | 56 ++++++++- .../models/VoiceBroadcastPlayback-test.ts | 67 +++++++---- .../VoiceBroadcastPlaybacksStore-test.ts | 107 +++++++++++++----- 4 files changed, 186 insertions(+), 61 deletions(-) diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index 641deb66ad..dcecaa7282 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -47,7 +47,10 @@ export enum VoiceBroadcastPlaybackEvent { interface EventMap { [VoiceBroadcastPlaybackEvent.LengthChanged]: (length: number) => void; - [VoiceBroadcastPlaybackEvent.StateChanged]: (state: VoiceBroadcastPlaybackState) => void; + [VoiceBroadcastPlaybackEvent.StateChanged]: ( + state: VoiceBroadcastPlaybackState, + playback: VoiceBroadcastPlayback + ) => void; [VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void; } @@ -217,14 +220,20 @@ export class VoiceBroadcastPlayback } public pause(): void { - if (!this.currentlyPlaying) return; + // stopped voice broadcasts cannot be paused + if (this.getState() === VoiceBroadcastPlaybackState.Stopped) return; this.setState(VoiceBroadcastPlaybackState.Paused); + if (!this.currentlyPlaying) return; this.currentlyPlaying.pause(); } public resume(): void { - if (!this.currentlyPlaying) return; + if (!this.currentlyPlaying) { + // no playback to resume, start from the beginning + this.start(); + return; + } this.setState(VoiceBroadcastPlaybackState.Playing); this.currentlyPlaying.play(); @@ -260,7 +269,7 @@ export class VoiceBroadcastPlayback } this.state = state; - this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state); + this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state, this); } public getInfoState(): VoiceBroadcastInfoState { diff --git a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts index 38d774e088..03378d9492 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts @@ -17,7 +17,8 @@ limitations under the License. import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; -import { VoiceBroadcastPlayback } from ".."; +import { VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState } from ".."; +import { IDestroyable } from "../../utils/IDestroyable"; export enum VoiceBroadcastPlaybacksStoreEvent { CurrentChanged = "current_changed", @@ -28,10 +29,16 @@ interface EventMap { } /** - * This store provides access to the current and specific Voice Broadcast playbacks. + * This store manages VoiceBroadcastPlaybacks: + * - access the currently playing voice broadcast + * - ensures that only once broadcast is playing at a time */ -export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter { +export class VoiceBroadcastPlaybacksStore + extends TypedEventEmitter + implements IDestroyable { private current: VoiceBroadcastPlayback | null; + + /** Playbacks indexed by their info event id. */ private playbacks = new Map(); public constructor() { @@ -42,7 +49,7 @@ export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter { + if ([ + VoiceBroadcastPlaybackState.Buffering, + VoiceBroadcastPlaybackState.Playing, + ].includes(state)) { + this.pauseExcept(playback); + } + }; + + private pauseExcept(playbackNotToPause: VoiceBroadcastPlayback): void { + for (const playback of this.playbacks.values()) { + if (playback !== playbackNotToPause) { + playback.pause(); + } + } + } + + public destroy(): void { + this.removeAllListeners(); + + for (const playback of this.playbacks.values()) { + playback.off(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged); + } + + this.playbacks = new Map(); + } + public static readonly _instance = new VoiceBroadcastPlaybacksStore(); /** diff --git a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts index 7e7722321c..ec28a95fd6 100644 --- a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts +++ b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts @@ -74,7 +74,25 @@ describe("VoiceBroadcastPlayback", () => { const itShouldEmitAStateChangedEvent = (state: VoiceBroadcastPlaybackState) => { it(`should emit a ${state} state changed event`, () => { - expect(mocked(onStateChanged)).toHaveBeenCalledWith(state); + expect(mocked(onStateChanged)).toHaveBeenCalledWith(state, playback); + }); + }; + + const startPlayback = () => { + beforeEach(async () => { + await playback.start(); + }); + }; + + const pausePlayback = () => { + beforeEach(() => { + playback.pause(); + }); + }; + + const stopPlayback = () => { + beforeEach(() => { + playback.stop(); }); }; @@ -180,14 +198,28 @@ describe("VoiceBroadcastPlayback", () => { }); describe("and calling start", () => { - beforeEach(async () => { - await playback.start(); - }); + startPlayback(); it("should be in buffering state", () => { expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Buffering); }); + describe("and calling stop", () => { + stopPlayback(); + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + + describe("and calling pause", () => { + pausePlayback(); + // stopped voice broadcasts cannot be paused + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + }); + }); + + describe("and calling pause", () => { + pausePlayback(); + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + }); + describe("and receiving the first chunk", () => { beforeEach(() => { // TODO Michael W: Use RelationsHelper @@ -212,9 +244,7 @@ describe("VoiceBroadcastPlayback", () => { }); describe("and calling start", () => { - beforeEach(async () => { - await playback.start(); - }); + startPlayback(); it("should play the last chunk", () => { // assert that the last chunk is played first @@ -258,10 +288,7 @@ describe("VoiceBroadcastPlayback", () => { }); describe("and calling start", () => { - beforeEach(async () => { - await playback.start(); - }); - + startPlayback(); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Buffering); }); }); @@ -278,9 +305,7 @@ describe("VoiceBroadcastPlayback", () => { itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); describe("and calling start", () => { - beforeEach(async () => { - await playback.start(); - }); + startPlayback(); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); @@ -303,13 +328,15 @@ describe("VoiceBroadcastPlayback", () => { }); describe("and calling pause", () => { - beforeEach(() => { - playback.pause(); - }); - + pausePlayback(); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); }); + + describe("and calling stop", () => { + stopPlayback(); + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + }); }); describe("and calling toggle for the first time", () => { @@ -337,9 +364,7 @@ describe("VoiceBroadcastPlayback", () => { }); describe("and calling stop", () => { - beforeEach(() => { - playback.stop(); - }); + stopPlayback(); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); diff --git a/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts b/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts index 8b994ef9c6..07c7e2fe63 100644 --- a/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts +++ b/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts @@ -22,24 +22,24 @@ import { } from "matrix-js-sdk/src/matrix"; import { - VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, VoiceBroadcastPlayback, + VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybacksStore, VoiceBroadcastPlaybacksStoreEvent, + VoiceBroadcastPlaybackState, } from "../../../src/voice-broadcast"; -import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; - -jest.mock("../../../src/voice-broadcast/models/VoiceBroadcastPlayback", () => ({ - ...jest.requireActual("../../../src/voice-broadcast/models/VoiceBroadcastPlayback") as object, - VoiceBroadcastPlayback: jest.fn().mockImplementation((infoEvent: MatrixEvent) => ({ infoEvent })), -})); +import { mkStubRoom, stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; describe("VoiceBroadcastPlaybacksStore", () => { const roomId = "!room:example.com"; let client: MatrixClient; let room: Room; - let infoEvent: MatrixEvent; - let playback: VoiceBroadcastPlayback; + let infoEvent1: MatrixEvent; + let infoEvent2: MatrixEvent; + let playback1: VoiceBroadcastPlayback; + let playback2: VoiceBroadcastPlayback; let playbacks: VoiceBroadcastPlaybacksStore; let onCurrentChanged: (playback: VoiceBroadcastPlayback) => void; @@ -51,17 +51,26 @@ describe("VoiceBroadcastPlaybacksStore", () => { return room; } }); - infoEvent = mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - user: client.getUserId(), - room: roomId, - content: {}, - }); - playback = { - infoEvent, - } as unknown as VoiceBroadcastPlayback; + + infoEvent1 = mkVoiceBroadcastInfoStateEvent( + roomId, + VoiceBroadcastInfoState.Started, + client.getUserId(), + client.getDeviceId(), + ); + infoEvent2 = mkVoiceBroadcastInfoStateEvent( + roomId, + VoiceBroadcastInfoState.Started, + client.getUserId(), + client.getDeviceId(), + ); + playback1 = new VoiceBroadcastPlayback(infoEvent1, client); + jest.spyOn(playback1, "off"); + playback2 = new VoiceBroadcastPlayback(infoEvent2, client); + jest.spyOn(playback2, "off"); + playbacks = new VoiceBroadcastPlaybacksStore(); + jest.spyOn(playbacks, "removeAllListeners"); onCurrentChanged = jest.fn(); playbacks.on(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, onCurrentChanged); }); @@ -72,31 +81,69 @@ describe("VoiceBroadcastPlaybacksStore", () => { describe("when setting a current Voice Broadcast playback", () => { beforeEach(() => { - playbacks.setCurrent(playback); + playbacks.setCurrent(playback1); }); it("should return it as current", () => { - expect(playbacks.getCurrent()).toBe(playback); + expect(playbacks.getCurrent()).toBe(playback1); }); it("should return it by id", () => { - expect(playbacks.getByInfoEvent(infoEvent, client)).toBe(playback); + expect(playbacks.getByInfoEvent(infoEvent1, client)).toBe(playback1); }); it("should emit a CurrentChanged event", () => { - expect(onCurrentChanged).toHaveBeenCalledWith(playback); + expect(onCurrentChanged).toHaveBeenCalledWith(playback1); }); describe("and setting the same again", () => { beforeEach(() => { mocked(onCurrentChanged).mockClear(); - playbacks.setCurrent(playback); + playbacks.setCurrent(playback1); }); it("should not emit a CurrentChanged event", () => { expect(onCurrentChanged).not.toHaveBeenCalled(); }); }); + + describe("and setting another playback and start both", () => { + beforeEach(() => { + playbacks.setCurrent(playback2); + playback1.start(); + playback2.start(); + }); + + it("should set playback1 to paused", () => { + expect(playback1.getState()).toBe(VoiceBroadcastPlaybackState.Paused); + }); + + it("should set playback2 to buffering", () => { + // buffering because there are no chunks, yet + expect(playback2.getState()).toBe(VoiceBroadcastPlaybackState.Buffering); + }); + + describe("and calling destroy", () => { + beforeEach(() => { + playbacks.destroy(); + }); + + it("should remove all listeners", () => { + expect(playbacks.removeAllListeners).toHaveBeenCalled(); + }); + + it("should deregister the listeners on the playbacks", () => { + expect(playback1.off).toHaveBeenCalledWith( + VoiceBroadcastPlaybackEvent.StateChanged, + expect.any(Function), + ); + expect(playback2.off).toHaveBeenCalledWith( + VoiceBroadcastPlaybackEvent.StateChanged, + expect.any(Function), + ); + }); + }); + }); }); describe("getByInfoEventId", () => { @@ -104,24 +151,22 @@ describe("VoiceBroadcastPlaybacksStore", () => { describe("when retrieving a known playback", () => { beforeEach(() => { - playbacks.setCurrent(playback); - returnedPlayback = playbacks.getByInfoEvent(infoEvent, client); + playbacks.setCurrent(playback1); + returnedPlayback = playbacks.getByInfoEvent(infoEvent1, client); }); it("should return the playback", () => { - expect(returnedPlayback).toBe(playback); + expect(returnedPlayback).toBe(playback1); }); }); describe("when retrieving an unknown playback", () => { beforeEach(() => { - returnedPlayback = playbacks.getByInfoEvent(infoEvent, client); + returnedPlayback = playbacks.getByInfoEvent(infoEvent1, client); }); it("should return the playback", () => { - expect(returnedPlayback).toEqual({ - infoEvent, - }); + expect(returnedPlayback.infoEvent).toBe(infoEvent1); }); }); }); From 1497089b92b1a9c919bed5ca6c6f6ae916c2d5e3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 24 Oct 2022 15:30:49 +0100 Subject: [PATCH 3/5] Send Content-Type: application/json header for integration manager /register API (#9490) --- src/ScalarAuthClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 5dacd07973..3ee1e7c15d 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -190,6 +190,9 @@ export default class ScalarAuthClient { const res = await fetch(scalarRestUrl, { method: "POST", body: JSON.stringify(openidTokenObject), + headers: { + "Content-Type": "application/json", + }, }); if (!res.ok) { From e4c44dc282d68b76b7261e4ad59bfd231c705260 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 24 Oct 2022 13:49:54 -0400 Subject: [PATCH 4/5] Auto-approve rageshake event capabilities for virtual Element Call widgets (#9492) --- src/stores/widgets/StopGapWidgetDriver.ts | 6 ++++++ test/stores/widgets/StopGapWidgetDriver-test.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index ff2619ad59..e1fb1d6729 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -113,6 +113,12 @@ export class StopGapWidgetDriver extends WidgetDriver { this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers); this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`); + this.allowedCapabilities.add( + WidgetEventCapability.forRoomEvent(EventDirection.Send, "org.matrix.rageshake_request").raw, + ); + this.allowedCapabilities.add( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, "org.matrix.rageshake_request").raw, + ); this.allowedCapabilities.add( WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw, ); diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index 0fd2f18be7..7adf38a853 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -66,6 +66,8 @@ describe("StopGapWidgetDriver", () => { "m.always_on_screen", "town.robin.msc3846.turn_servers", "org.matrix.msc2762.timeline:!1:example.org", + "org.matrix.msc2762.send.event:org.matrix.rageshake_request", + "org.matrix.msc2762.receive.event:org.matrix.rageshake_request", "org.matrix.msc2762.receive.state_event:m.room.member", "org.matrix.msc2762.send.state_event:org.matrix.msc3401.call", "org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call", From 37e613bb05d4e9bb9d83d9910bd1e46151e7341a Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 24 Oct 2022 18:54:24 +0100 Subject: [PATCH 5/5] Fix embedded Element Call screen sharing (#9485) * Fix embedded Element Call screen sharing Makes it a request in each direction rather than a request and reply since replies to requests time out and so can't wait for user interaction. * Fix tests --- src/models/Call.ts | 21 ++++++++----- src/stores/widgets/ElementWidgetActions.ts | 15 +++++++++- test/models/Call-test.ts | 34 +++++++++++++++------- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index ed9e227d24..c3ef2e6775 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -816,7 +816,7 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); - this.messaging!.on(`action:${ElementWidgetActions.Screenshare}`, this.onScreenshare); + this.messaging!.on(`action:${ElementWidgetActions.ScreenshareRequest}`, this.onScreenshareRequest); } protected async performDisconnection(): Promise { @@ -832,7 +832,7 @@ export class ElementCall extends Call { this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); - this.messaging!.off(`action:${ElementWidgetActions.Screenshare}`, this.onSpotlightLayout); + this.messaging!.off(`action:${ElementWidgetActions.ScreenshareRequest}`, this.onScreenshareRequest); super.setDisconnected(); } @@ -952,19 +952,24 @@ export class ElementCall extends Call { await this.messaging!.transport.reply(ev.detail, {}); // ack }; - private onScreenshare = async (ev: CustomEvent) => { + private onScreenshareRequest = async (ev: CustomEvent) => { ev.preventDefault(); if (PlatformPeg.get().supportsDesktopCapturer()) { + await this.messaging!.transport.reply(ev.detail, { pending: true }); + const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; - await this.messaging!.transport.reply(ev.detail, { - failed: !source, - desktopCapturerSourceId: source, - }); + if (source) { + await this.messaging!.transport.send(ElementWidgetActions.ScreenshareStart, { + desktopCapturerSourceId: source, + }); + } else { + await this.messaging!.transport.send(ElementWidgetActions.ScreenshareStop, {}); + } } else { - await this.messaging!.transport.reply(ev.detail, {}); + await this.messaging!.transport.reply(ev.detail, { pending: false }); } }; } diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index fa60b9ea82..1d0437a2ce 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -26,10 +26,23 @@ export enum ElementWidgetActions { MuteVideo = "io.element.mute_video", UnmuteVideo = "io.element.unmute_video", StartLiveStream = "im.vector.start_live_stream", + + // Element Call -> host requesting to start a screenshare + // (ie. expects a ScreenshareStart once the user has picked a source) + // replies with { pending } where pending is true if the host has asked + // the user to choose a window and false if not (ie. if the host isn't + // running within Electron) + ScreenshareRequest = "io.element.screenshare_request", + // host -> Element Call telling EC to start screen sharing with + // the given source + ScreenshareStart = "io.element.screenshare_start", + // host -> Element Call telling EC to stop screen sharing, or that + // the user cancelled when selecting a source after a ScreenshareRequest + ScreenshareStop = "io.element.screenshare_stop", + // Actions for switching layouts TileLayout = "io.element.tile_layout", SpotlightLayout = "io.element.spotlight_layout", - Screenshare = "io.element.screenshare", OpenIntegrationManager = "integration_manager_open", diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 3134adf111..df959a44a0 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -820,19 +820,25 @@ describe("ElementCall", () => { await call.connect(); messaging.emit( - `action:${ElementWidgetActions.Screenshare}`, + `action:${ElementWidgetActions.ScreenshareRequest}`, new CustomEvent("widgetapirequest", { detail: {} }), ); - waitFor(() => { + await waitFor(() => { expect(messaging!.transport.reply).toHaveBeenCalledWith( expect.objectContaining({}), - expect.objectContaining({ desktopCapturerSourceId: sourceId }), + expect.objectContaining({ pending: true }), + ); + }); + + await waitFor(() => { + expect(messaging!.transport.send).toHaveBeenCalledWith( + "io.element.screenshare_start", expect.objectContaining({ desktopCapturerSourceId: sourceId }), ); }); }); - it("passes failed if we couldn't get a source id", async () => { + it("sends ScreenshareStop if we couldn't get a source id", async () => { jest.spyOn(Modal, "createDialog").mockReturnValue( { finished: new Promise((r) => r([null])) } as IHandle, ); @@ -841,32 +847,38 @@ describe("ElementCall", () => { await call.connect(); messaging.emit( - `action:${ElementWidgetActions.Screenshare}`, + `action:${ElementWidgetActions.ScreenshareRequest}`, new CustomEvent("widgetapirequest", { detail: {} }), ); - waitFor(() => { + await waitFor(() => { expect(messaging!.transport.reply).toHaveBeenCalledWith( expect.objectContaining({}), - expect.objectContaining({ failed: true }), + expect.objectContaining({ pending: true }), + ); + }); + + await waitFor(() => { + expect(messaging!.transport.send).toHaveBeenCalledWith( + "io.element.screenshare_stop", expect.objectContaining({ }), ); }); }); - it("passes an empty object if we don't support desktop capturer", async () => { + it("replies with pending: false if we don't support desktop capturer", async () => { jest.spyOn(PlatformPeg.get(), "supportsDesktopCapturer").mockReturnValue(false); await call.connect(); messaging.emit( - `action:${ElementWidgetActions.Screenshare}`, + `action:${ElementWidgetActions.ScreenshareRequest}`, new CustomEvent("widgetapirequest", { detail: {} }), ); - waitFor(() => { + await waitFor(() => { expect(messaging!.transport.reply).toHaveBeenCalledWith( expect.objectContaining({}), - expect.objectContaining({}), + expect.objectContaining({ pending: false }), ); }); });