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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+59
@@ -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);
|
||||
});
|
||||
});
|
||||
+59
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user