Auto Collapse Behaviour - Collapse left panel during calls (#33771)

* Add call resize behaviour

* Export behaviour from behaviours.ts

* Fix e2e tests

* Fix lint

* Move test to vitest

* Pass call-store instead of global access

* Fix broken collapse behaviour on strict mode

* Remove unused variable from test
This commit is contained in:
R Midhun Suresh
2026-07-24 12:45:34 +00:00
committed by GitHub
parent 5d27663f26
commit 67206624a0
9 changed files with 155 additions and 24 deletions
@@ -501,6 +501,7 @@ test.describe("Element Call", () => {
});
await use({ roomId });
},
lockLeftPanelWidth: false,
});
async function openAndJoinCall(page: Page, existing = false) {
@@ -188,7 +188,7 @@ class LoggedInView extends React.Component<IProps, IState> {
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<IProps, IState> {
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s));
this.resizerViewModel?.dispose();
this.disposeResizerViewModel();
}
private onCallState = (): void => {
@@ -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),
@@ -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,
),
);
}
@@ -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);
});
@@ -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));
}
}
@@ -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);
});
});
@@ -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<Call>): 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);
};
}
@@ -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];