diff --git a/apps/web/playwright/e2e/voip/element-call.spec.ts b/apps/web/playwright/e2e/voip/element-call.spec.ts index a0103d8e2e..c3194c7337 100644 --- a/apps/web/playwright/e2e/voip/element-call.spec.ts +++ b/apps/web/playwright/e2e/voip/element-call.spec.ts @@ -501,6 +501,7 @@ test.describe("Element Call", () => { }); await use({ roomId }); }, + lockLeftPanelWidth: false, }); async function openAndJoinCall(page: Page, existing = false) { diff --git a/apps/web/src/components/structures/LoggedInView.tsx b/apps/web/src/components/structures/LoggedInView.tsx index 63abfd7b3d..51f23ecc55 100644 --- a/apps/web/src/components/structures/LoggedInView.tsx +++ b/apps/web/src/components/structures/LoggedInView.tsx @@ -188,7 +188,7 @@ class LoggedInView extends React.Component { private getResizerViewModel(): ResizerViewModel { if (!this.resizerViewModel) { - this.resizerViewModel = new ResizerViewModel(); + this.resizerViewModel = new ResizerViewModel(this.context.callStore); } return this.resizerViewModel; } @@ -237,7 +237,7 @@ class LoggedInView extends React.Component { SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); SettingsStore.unwatchSetting(this.backgroundImageWatcherRef); this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s)); - this.resizerViewModel?.dispose(); + this.disposeResizerViewModel(); } private onCallState = (): void => { diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts index dceb0a9391..050aa916e3 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts @@ -15,6 +15,7 @@ import { type PanelImperativeHandle } from "@element-hq/web-shared-components"; import { ResizerViewModel } from "./ResizerViewModel"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; +import { CallStore } from "../../stores/CallStore"; describe("LeftPanelResizerViewModel", () => { afterEach(() => { @@ -25,7 +26,7 @@ describe("LeftPanelResizerViewModel", () => { describe("Initial state is correct", () => { it("should have correct initial state when panel was previously collapsed", () => { SettingsStore.setValue("RoomList.isPanelCollapsed", null, SettingLevel.DEVICE, true); - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); expect(vm.getSnapshot()).toStrictEqual({ isCollapsed: true, initialSize: 0, @@ -34,7 +35,7 @@ describe("LeftPanelResizerViewModel", () => { it("should have correct initial state when panel was previously resized", () => { SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34); - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); expect(vm.getSnapshot()).toStrictEqual({ isCollapsed: false, initialSize: 34, @@ -42,7 +43,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should have correct initial state when panel was neither resized nor collapsed", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); expect(vm.getSnapshot()).toStrictEqual({ isCollapsed: false, initialSize: undefined, @@ -51,7 +52,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should update isCollapsed on onLeftPanelResized()", async () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); vm.onLeftPanelResize({ inPixels: 100, asPercentage: 6 }); await waitFor(() => { expect(vm.getSnapshot().isCollapsed).toStrictEqual(false); @@ -63,7 +64,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should noop on click when handle is not yet set", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); expect(() => { // Click vm.onPointerDown(); @@ -72,7 +73,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should noop on mouse drag", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34); const mockHandle = { resize: vi.fn(), @@ -94,7 +95,7 @@ describe("LeftPanelResizerViewModel", () => { describe("should expand panel on double click when panel is collapsed", () => { it("to last non-zero width that the user set", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34); const mockHandle = { resize: vi.fn(), @@ -109,7 +110,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("to maximum size of the panel", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), @@ -124,7 +125,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should collapse panel on click when panel is expanded", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); const mockHandle = { collapse: vi.fn(), isCollapsed: vi.fn().mockReturnValue(false), @@ -136,7 +137,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should ignore first resized event", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); const mockHandle = { resize: vi.fn(), getSize: vi.fn().mockReturnValue(0), @@ -148,7 +149,7 @@ describe("LeftPanelResizerViewModel", () => { }); it("should resize to nearest whole number", () => { - const vm = new ResizerViewModel(); + const vm = new ResizerViewModel(CallStore.instance); const mockHandle = { resize: vi.fn(), getSize: vi.fn().mockReturnValue(0), diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.ts index 3aadd62cb8..4fa52cb19a 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.ts @@ -19,6 +19,7 @@ import { debounce } from "lodash"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; import { AutoCollapse } from "./auto-collapse/AutoCollapse"; +import { type CallStore } from "../../stores/CallStore"; function getInitialState(): ResizerViewSnapshot { const shouldStartCollapsed = @@ -62,16 +63,20 @@ export class ResizerViewModel */ private firstResizedEventSeen = false; - public constructor() { + public constructor(callStore: CallStore) { super(undefined, getInitialState()); // Run onSeparatorClick when the separator is clicked. this.mouseClickHandler = new MouseClickHandler(this.onSeparatorClick); this.autoCollapse = this.disposables.track( - new AutoCollapse(this.onSeparatorClick, () => { - this.panelHandle?.collapse(); - this.snapshot.merge({ isCollapsed: true }); - }), + new AutoCollapse( + this.onSeparatorClick, + () => { + this.panelHandle?.collapse(); + this.snapshot.merge({ isCollapsed: true }); + }, + callStore, + ), ); } diff --git a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts index 5aa49f939e..6db3b01f7c 100644 --- a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts +++ b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts @@ -5,10 +5,13 @@ * Please see LICENSE files in the repository root for full details. */ +// @vitest-environment happy-dom + import { describe, expect, beforeEach, it, vi } from "vitest"; import { AutoCollapse } from "./AutoCollapse"; import { BaseCollapseBehaviour } from "./behaviours/BaseCollapseBehaviour"; import type { CollapseHandler } from "./CollapseHandler"; +import { CallStore } from "../../../stores/CallStore"; let instances: BaseCollapseBehaviour[] = []; @@ -47,14 +50,14 @@ describe("AutoCollapse", () => { }); it("should calculate initial collapse count correctly", () => { - const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn(), CallStore.instance); // Since we have one behaviour that tells the app to start collapsed (MockBehaviourWithStartCollapsed), // isAutoCollapsed should be true from initialization. expect(autoCollapse.isAutoCollapsed).toBe(true); }); it("should proxy onLeftPanelResized to collapseHandler", () => { - const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn(), CallStore.instance); expect(autoCollapse.isAutoCollapsed).toBe(true); autoCollapse.onLeftPanelResized(); expect(autoCollapse.isAutoCollapsed).toBe(false); @@ -65,7 +68,7 @@ describe("AutoCollapse", () => { }); it("should calculate shouldIgnoreResize correctly", () => { - const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn(), CallStore.instance); // Because of MockBehaviourWithIgnoreResize, shouldIgnoreResize should be true. expect(autoCollapse.shouldIgnoreResize).toBe(true); }); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts index 855625b45d..0661ad24b9 100644 --- a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts +++ b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts @@ -5,6 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ +import { type CallStore } from "../../../stores/CallStore"; import { CollapseHandler } from "./CollapseHandler"; import type { BaseCollapseBehaviour } from "./behaviours/BaseCollapseBehaviour"; import { Behaviours } from "./behaviours/behaviours"; @@ -19,8 +20,9 @@ export class AutoCollapse { /** * @param expandPanel Callback that should expand the left panel * @param collapsePanel Callback that should collapse the left panel + * @param callStore Instance of the call store. */ - public constructor(expandPanel: () => void, collapsePanel: () => void) { + public constructor(expandPanel: () => void, collapsePanel: () => void, callStore: CallStore) { // Calculate the initial value for autoCollapsedCount const initialAutoCollapsedCount = Behaviours.reduce( (count, B) => (B.shouldStartCollapsed() ? count + 1 : count), @@ -29,7 +31,7 @@ export class AutoCollapse { this.collapseHandler = new CollapseHandler(expandPanel, collapsePanel, initialAutoCollapsedCount); for (const Behaviour of Behaviours) { - this.behaviours.push(new Behaviour(this.collapseHandler)); + this.behaviours.push(new Behaviour(this.collapseHandler, callStore)); } } diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnCallResizeBehaviour.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnCallResizeBehaviour.test.ts new file mode 100644 index 0000000000..dc7074bf8c --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnCallResizeBehaviour.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Element Creations 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. + */ + +// @vitest-environment happy-dom + +import EventEmitter from "events"; + +import { type CallStore, CallStoreEvent } from "../../../../stores/CallStore"; +import { CollapseOnCallResizeBehaviour } from "./CollapseOnCallResizeBehaviour"; +import { CollapseHandler } from "../CollapseHandler"; +import { describe, it, expect, vi } from "vitest"; + +vi.useFakeTimers(); + +describe("CollapseOnCallResizeBehaviour", () => { + it("Should collapse/expand the panel on call", () => { + const mockCallStore = new EventEmitter() as unknown as CallStore; + + const expandPanel = vi.fn(); + const collapsePanel = vi.fn(); + const collapseHandler = new CollapseHandler(expandPanel, collapsePanel, 0); + new CollapseOnCallResizeBehaviour(collapseHandler, mockCallStore); + + // No calls yet + expect(expandPanel).not.toHaveBeenCalled(); + expect(collapsePanel).not.toHaveBeenCalled(); + + // Let's say we get a call + mockCallStore.emit(CallStoreEvent.ConnectedCalls, new Set([1])); + expect(collapsePanel).toHaveBeenCalledTimes(1); + + // When the call is over + mockCallStore.emit(CallStoreEvent.ConnectedCalls, new Set([])); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); + + it("should set shouldIgnoreResize to true on call", () => { + const mockCallStore = new EventEmitter() as unknown as CallStore; + + const expandPanel = vi.fn(); + const collapsePanel = vi.fn(); + const collapseHandler = new CollapseHandler(expandPanel, collapsePanel, 0); + const behaviour = new CollapseOnCallResizeBehaviour(collapseHandler, mockCallStore); + + // Initially shouldIgnoreResize should be false + expect(behaviour.shouldIgnoreResize).toBe(false); + // Let's say we get a call + mockCallStore.emit(CallStoreEvent.ConnectedCalls, new Set([1])); + // shouldIgnoreResize becomes true + expect(behaviour.shouldIgnoreResize).toBe(true); + // shouldIgnoreResize becomes false after some time + vi.runAllTimers(); + expect(behaviour.shouldIgnoreResize).toBe(false); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnCallResizeBehaviour.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnCallResizeBehaviour.ts new file mode 100644 index 0000000000..46cdec2ae6 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnCallResizeBehaviour.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Element Creations 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 { BaseCollapseBehaviour } from "./BaseCollapseBehaviour"; +import type { Call } from "../../../../models/Call"; +import { type CallStore, CallStoreEvent } from "../../../../stores/CallStore"; +import type { CollapseHandler } from "../CollapseHandler"; + +/** + * This behaviour: + * - Collapses the left-panel when the user joins a call. + * - Expands the left-panel when the user leaves that call. + */ +export class CollapseOnCallResizeBehaviour extends BaseCollapseBehaviour { + private callJustStarted: boolean = false; + private callStartedTimeout: number = 0; + + public constructor( + collapseHandler: CollapseHandler, + private readonly callStore: CallStore, + ) { + super(collapseHandler); + this.callStore.on(CallStoreEvent.ConnectedCalls, this.onCallConnected); + } + + private onCallConnected = (calls: Set): void => { + if (calls.size > 0) { + this.setCallJustStarted(); + this.collapseHandler.collapse(); + } else if (calls.size === 0) this.collapseHandler.expand(); + }; + + /** + * When a call has just started, we'll probably do some manual resizing according + * to this behaviour. We don't want ResizerViewModel to process these events. + */ + private setCallJustStarted = (): void => { + // Indicate that a call was just started, will make shouldIgnoreResize true. + this.callJustStarted = true; + window.clearTimeout(this.callStartedTimeout); + + // We only want shouldIgnoreResize to be true for a second. + this.callStartedTimeout = window.setTimeout(() => { + this.callJustStarted = false; + }, 1000); + }; + + public get shouldIgnoreResize(): boolean { + return this.callJustStarted; + } + + public dispose = (): void => { + this.callStore.off(CallStoreEvent.ConnectedCalls, this.onCallConnected); + }; +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts index 216fae7291..5ac3f24b7d 100644 --- a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts @@ -5,9 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ +import { CollapseOnCallResizeBehaviour } from "./CollapseOnCallResizeBehaviour"; import { CollapseOnWindowResizeBehaviour } from "./CollapseOnWindowResizeBehaviour"; /** * The auto-collapse behaviours used by the app. */ -export const Behaviours = [CollapseOnWindowResizeBehaviour]; +export const Behaviours = [CollapseOnWindowResizeBehaviour, CollapseOnCallResizeBehaviour];