From 8903927e0c9f7ef0e8ab67b4e6abd308e3ec1eae Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Mon, 1 Sep 2025 17:33:33 +0300 Subject: [PATCH] Remember whether sidebar is shown for calls when switching rooms (#30262) * Remember whether sidebar is shown for calls when switching rooms Stores the sidebar state per-room in LegacyCallHandler, along with other details about calls. * Hide the Show/Hide Sidebar from the Picture-in-Picture preview The toggle sidebar button currently does nothing in PIP mode, since PIP mode never shows a sidebar (even when the call is made fullscreen from the PIP preview) * Add test for Show/Hide Sidebar feature * Add more tests for LegacyCallView and LegacyCallViewForRoom Also, fix issue where LegacyCallViewForRoom used roomId and not callId for checking for sidebar state --- src/LegacyCallHandler.tsx | 13 ++++ src/components/structures/PipContainer.tsx | 1 + src/components/views/voip/LegacyCallView.tsx | 30 ++++---- .../views/voip/LegacyCallViewForRoom.tsx | 24 ++++++- test/unit-tests/LegacyCallHandler-test.ts | 38 ++++++++++ .../views/voip/LegacyCallView-test.tsx | 66 ++++++++++++++++- .../views/voip/LegacyCallViewForRoom-test.tsx | 72 +++++++++++++++++++ 7 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 test/unit-tests/components/views/voip/LegacyCallViewForRoom-test.tsx diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 9c3e7073d5..e618fa62a0 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -112,6 +112,7 @@ export enum LegacyCallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", + ShownSidebarsChanged = "shown_sidebars_changed", CallState = "call_state", ProtocolSupport = "protocol_support", } @@ -120,6 +121,7 @@ type EventEmitterMap = { [LegacyCallHandlerEvent.CallsChanged]: (calls: Map) => void; [LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void; [LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set) => void; + [LegacyCallHandlerEvent.ShownSidebarsChanged]: (sidebarsShown: Map) => void; [LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void; [LegacyCallHandlerEvent.ProtocolSupport]: () => void; }; @@ -144,6 +146,8 @@ export default class LegacyCallHandler extends TypedEventEmitter(); // callIds + private shownSidebars = new Map(); // callId (call) -> sidebar show + private backgroundAudio = new BackgroundAudio(); private playingSources: Record = {}; // Record them for stopping @@ -240,6 +244,15 @@ export default class LegacyCallHandler extends TypedEventEmitter { try { const protocols = await MatrixClientPeg.safeGet().getThirdpartyProtocols(); diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index e46267c149..18e8c526db 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -245,6 +245,7 @@ class PipContainerInner extends React.Component { secondaryCall={this.state.secondaryCall} pipMode={pipMode} onResize={onResize} + sidebarShown={false} /> )); } diff --git a/src/components/views/voip/LegacyCallView.tsx b/src/components/views/voip/LegacyCallView.tsx index 255b1ab0f4..e6da10e520 100644 --- a/src/components/views/voip/LegacyCallView.tsx +++ b/src/components/views/voip/LegacyCallView.tsx @@ -50,6 +50,10 @@ interface IProps { onMouseDownOnHeader?: (event: React.MouseEvent) => void; showApps?: boolean; + + sidebarShown: boolean; + + setSidebarShown?: (sidebarShown: boolean) => void; } interface IState { @@ -62,7 +66,6 @@ interface IState { primaryFeed?: CallFeed; secondaryFeed?: CallFeed; sidebarFeeds: Array; - sidebarShown: boolean; } function getFullScreenElement(): Element | null { @@ -97,7 +100,6 @@ export default class LegacyCallView extends React.Component { primaryFeed: primary, secondaryFeed: secondary, sidebarFeeds: sidebar, - sidebarShown: true, }; } @@ -269,8 +271,9 @@ export default class LegacyCallView extends React.Component { isScreensharing = await this.props.call.setScreensharingEnabled(true); } + this.props.setSidebarShown?.(true); + this.setState({ - sidebarShown: true, screensharing: isScreensharing, }); }; @@ -320,12 +323,12 @@ export default class LegacyCallView extends React.Component { }; private onToggleSidebar = (): void => { - this.setState({ sidebarShown: !this.state.sidebarShown }); + this.props.setSidebarShown?.(!this.props.sidebarShown); }; private renderCallControls(): JSX.Element { - const { call, pipMode } = this.props; - const { callState, micMuted, vidMuted, screensharing, sidebarShown, secondaryFeed, sidebarFeeds } = this.state; + const { call, pipMode, sidebarShown } = this.props; + const { callState, micMuted, vidMuted, screensharing, secondaryFeed, sidebarFeeds } = this.state; // If SDPStreamMetadata isn't supported don't show video mute button in voice calls const vidMuteButtonShown = call.opponentSupportsSDPStreamMetadata() || call.hasLocalUserMediaVideoTrack; @@ -337,7 +340,8 @@ export default class LegacyCallView extends React.Component { (call.opponentSupportsSDPStreamMetadata() || call.hasLocalUserMediaVideoTrack) && call.state === CallState.Connected; // Show the sidebar button only if there is something to hide/show - const sidebarButtonShown = (secondaryFeed && !secondaryFeed.isVideoMuted()) || sidebarFeeds.length > 0; + const sidebarButtonShown = + !pipMode && ((secondaryFeed && !secondaryFeed.isVideoMuted()) || sidebarFeeds.length > 0); // The dial pad & 'more' button actions are only relevant in a connected call const contextMenuButtonShown = callState === CallState.Connected; const dialpadButtonShown = callState === CallState.Connected && call.opponentSupportsDTMF(); @@ -372,7 +376,7 @@ export default class LegacyCallView extends React.Component { } private renderToast(): JSX.Element | null { - const { call } = this.props; + const { call, sidebarShown } = this.props; const someoneIsScreensharing = call.getFeeds().some((feed) => { return feed.purpose === SDPStreamMetadataPurpose.Screenshare; }); @@ -380,7 +384,7 @@ export default class LegacyCallView extends React.Component { if (!someoneIsScreensharing) return null; const isScreensharing = call.isScreensharing(); - const { primaryFeed, sidebarShown } = this.state; + const { primaryFeed } = this.state; const sharerName = primaryFeed?.getMember()?.name; if (!sharerName) return null; @@ -393,8 +397,8 @@ export default class LegacyCallView extends React.Component { } private renderContent(): JSX.Element { - const { pipMode, call, onResize } = this.props; - const { isLocalOnHold, isRemoteOnHold, sidebarShown, primaryFeed, secondaryFeed, sidebarFeeds } = this.state; + const { pipMode, call, onResize, sidebarShown } = this.props; + const { isLocalOnHold, isRemoteOnHold, primaryFeed, secondaryFeed, sidebarFeeds } = this.state; const callRoomId = LegacyCallHandler.instance.roomIdForCall(call); const callRoom = (callRoomId ? MatrixClientPeg.safeGet().getRoom(callRoomId) : undefined) ?? undefined; @@ -537,8 +541,8 @@ export default class LegacyCallView extends React.Component { } public render(): React.ReactNode { - const { call, secondaryCall, pipMode, showApps, onMouseDownOnHeader } = this.props; - const { sidebarShown, sidebarFeeds } = this.state; + const { call, secondaryCall, pipMode, showApps, onMouseDownOnHeader, sidebarShown } = this.props; + const { sidebarFeeds } = this.state; const client = MatrixClientPeg.safeGet(); const callRoomId = LegacyCallHandler.instance.roomIdForCall(call); diff --git a/src/components/views/voip/LegacyCallViewForRoom.tsx b/src/components/views/voip/LegacyCallViewForRoom.tsx index bd44af5fa5..f0bc8ffc22 100644 --- a/src/components/views/voip/LegacyCallViewForRoom.tsx +++ b/src/components/views/voip/LegacyCallViewForRoom.tsx @@ -25,6 +25,7 @@ interface IProps { interface IState { call: MatrixCall | null; + sidebarShown: boolean; } /* @@ -34,19 +35,23 @@ interface IState { export default class LegacyCallViewForRoom extends React.Component { public constructor(props: IProps) { super(props); + const call = this.getCall(); this.state = { - call: this.getCall(), + call, + sidebarShown: !!call && LegacyCallHandler.instance.isCallSidebarShown(call.callId), }; } public componentDidMount(): void { LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCall); LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall); + LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.ShownSidebarsChanged, this.updateCall); } public componentWillUnmount(): void { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCall); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall); + LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.ShownSidebarsChanged, this.updateCall); } private updateCall = (): void => { @@ -54,6 +59,10 @@ export default class LegacyCallViewForRoom extends React.Component { + if (!this.state.call) return; + LegacyCallHandler.instance.setCallSidebarShown(this.state.call.callId, sidebarShown); + }; + public render(): React.ReactNode { if (!this.state.call) return null; @@ -99,7 +113,13 @@ export default class LegacyCallViewForRoom extends React.Component - + ); diff --git a/test/unit-tests/LegacyCallHandler-test.ts b/test/unit-tests/LegacyCallHandler-test.ts index 972f17b7e0..b21aa76a17 100644 --- a/test/unit-tests/LegacyCallHandler-test.ts +++ b/test/unit-tests/LegacyCallHandler-test.ts @@ -585,4 +585,42 @@ describe("LegacyCallHandler without third party protocols", () => { expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled(); }); }); + + describe("sidebar state", () => { + const roomId = "test-room-id"; + + it("should default to showing sidebar", () => { + const call = new MatrixCall({ + client: MatrixClientPeg.safeGet(), + roomId, + }); + const cli = MatrixClientPeg.safeGet(); + cli.emit(CallEventHandlerEvent.Incoming, call); + + expect(callHandler.isCallSidebarShown(call.callId)).toEqual(true); + }); + + it("should remember sidebar state per call", () => { + const call = new MatrixCall({ + client: MatrixClientPeg.safeGet(), + roomId, + }); + const cli = MatrixClientPeg.safeGet(); + cli.emit(CallEventHandlerEvent.Incoming, call); + + expect(callHandler.isCallSidebarShown(call.callId)).toEqual(true); + callHandler.setCallSidebarShown(call.callId, false); + expect(callHandler.isCallSidebarShown(call.callId)).toEqual(false); + + call.emit(CallEvent.Hangup, call); + + const call2 = new MatrixCall({ + client: MatrixClientPeg.safeGet(), + roomId, + }); + cli.emit(CallEventHandlerEvent.Incoming, call2); + + expect(callHandler.isCallSidebarShown(call2.callId)).toEqual(true); + }); + }); }); diff --git a/test/unit-tests/components/views/voip/LegacyCallView-test.tsx b/test/unit-tests/components/views/voip/LegacyCallView-test.tsx index ee1f4fe326..08f757067d 100644 --- a/test/unit-tests/components/views/voip/LegacyCallView-test.tsx +++ b/test/unit-tests/components/views/voip/LegacyCallView-test.tsx @@ -8,9 +8,12 @@ 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 { type CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; +import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes"; import LegacyCallView from "../../../../../src/components/views/voip/LegacyCallView"; import { stubClient } from "../../../../test-utils"; +import DMRoomMap from "../../../../../src/utils/DMRoomMap"; describe("LegacyCallView", () => { it("should exit full screen on unmount", () => { @@ -32,9 +35,70 @@ describe("LegacyCallView", () => { isScreensharing: jest.fn().mockReturnValue(false), } as unknown as MatrixCall; - const { unmount } = render(); + const { unmount } = render(); expect(document.exitFullscreen).not.toHaveBeenCalled(); unmount(); expect(document.exitFullscreen).toHaveBeenCalled(); }); + + it("should show/hide the sidebar based on the sidebarShown prop", async () => { + stubClient(); + + const call = { + roomId: "test-room", + on: jest.fn(), + removeListener: jest.fn(), + getFeeds: jest.fn().mockReturnValue( + [{ local: true }, { local: false }, { local: true, screenshare: true }].map( + (x, i) => + ({ + stream: { id: "test-" + i }, + addListener: jest.fn(), + removeListener: jest.fn(), + getMember: jest.fn(), + isAudioMuted: jest.fn().mockReturnValue(true), + isVideoMuted: jest.fn().mockReturnValue(true), + isLocal: jest.fn().mockReturnValue(x.local), + purpose: x.screenshare && SDPStreamMetadataPurpose.Screenshare, + }) as unknown as CallFeed, + ), + ), + isLocalOnHold: jest.fn().mockReturnValue(false), + isRemoteOnHold: jest.fn().mockReturnValue(false), + isMicrophoneMuted: jest.fn().mockReturnValue(true), + isLocalVideoMuted: jest.fn().mockReturnValue(true), + isScreensharing: jest.fn().mockReturnValue(true), + noIncomingFeeds: jest.fn().mockReturnValue(false), + opponentSupportsSDPStreamMetadata: jest.fn().mockReturnValue(true), + } as unknown as MatrixCall; + DMRoomMap.setShared({ + getUserIdForRoomId: jest.fn().mockReturnValue("test-user"), + } as unknown as DMRoomMap); + + const { container, rerender } = render(); + expect(container.querySelector(".mx_LegacyCallViewSidebar")).toBeTruthy(); + rerender(); + expect(container.querySelector(".mx_LegacyCallViewSidebar")).toBeTruthy(); + }); + + it("should not show the sidebar button in picture-in-picture mode", async () => { + 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; + DMRoomMap.setShared({ + getUserIdForRoomId: jest.fn().mockReturnValue("test-user"), + } as unknown as DMRoomMap); + + const { container } = render(); + expect(container.querySelector(".mx_LegacyCallViewButtons_button_sidebar")).toBeFalsy(); + }); }); diff --git a/test/unit-tests/components/views/voip/LegacyCallViewForRoom-test.tsx b/test/unit-tests/components/views/voip/LegacyCallViewForRoom-test.tsx new file mode 100644 index 0000000000..c3514d1aae --- /dev/null +++ b/test/unit-tests/components/views/voip/LegacyCallViewForRoom-test.tsx @@ -0,0 +1,72 @@ +/* +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 { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; + +import LegacyCallView from "../../../../../src/components/views/voip/LegacyCallView"; +import LegacyCallViewForRoom from "../../../../../src/components/views/voip/LegacyCallViewForRoom"; +import { mkStubRoom, stubClient } from "../../../../test-utils"; +import DMRoomMap from "../../../../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import ResizeNotifier from "../../../../../src/utils/ResizeNotifier"; +import LegacyCallHandler from "../../../../../src/LegacyCallHandler"; + +jest.mock("../../../../../src/components/views/voip/LegacyCallView", () => jest.fn(() => "LegacyCallView")); + +describe("LegacyCallViewForRoom", () => { + const LegacyCallViewMock = LegacyCallView as unknown as jest.Mock; + beforeEach(() => { + LegacyCallViewMock.mockClear(); + }); + it("should remember sidebar state, defaulting to shown", async () => { + stubClient(); + + const callHandler = new LegacyCallHandler(); + callHandler.start(); + jest.spyOn(LegacyCallHandler, "instance", "get").mockImplementation(() => callHandler); + + const call = new MatrixCall({ + client: MatrixClientPeg.safeGet(), + roomId: "test-room", + }); + DMRoomMap.setShared({ + getUserIdForRoomId: jest.fn().mockReturnValue("test-user"), + } as unknown as DMRoomMap); + + const room = mkStubRoom(call.roomId, "room", MatrixClientPeg.safeGet()); + MatrixClientPeg.safeGet().getRoom = jest.fn().mockReturnValue(room); + const cli = MatrixClientPeg.safeGet(); + cli.emit(CallEventHandlerEvent.Incoming, call); + + const { rerender } = render( + , + ); + + let props = LegacyCallViewMock.mock.lastCall![0]; + expect(props.sidebarShown).toBeTruthy(); // Sidebar defaults to shown + + props.setSidebarShown(false); // Hide the sidebar + + rerender(); + + console.log(LegacyCallViewMock.mock); + + props = LegacyCallViewMock.mock.lastCall![0]; + expect(props.sidebarShown).toBeFalsy(); + + rerender(
); // Destroy the LegacyCallViewForRoom and LegacyCallView + LegacyCallViewMock.mockClear(); // Drop stored LegacyCallView props + + rerender(); + + props = LegacyCallViewMock.mock.lastCall![0]; + expect(props.sidebarShown).toBeFalsy(); // Value was remembered + }); +});