diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts new file mode 100644 index 0000000000..c2a4b57e2e --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts @@ -0,0 +1,97 @@ +/* + * 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 { throttle } from "lodash"; + +import UIStore, { UI_EVENTS } from "../../../../stores/UIStore"; +import { BaseCollapseBehaviour } from "./BaseCollapseBehaviour"; +import type { CollapseHandler } from "../CollapseHandler"; + +/** + * The viewport width below which the left panel will be auto-collapsed. + */ +const AUTO_COLLAPSE_WIDTH = 768; + +/** + * Implements auto-collapse logic that collapses and expands the left panel when the + * app window is resized. + */ +export class CollapseOnWindowResizeBehaviour extends BaseCollapseBehaviour { + /** + * If this boolean is true, we won't auto collapse the panel when the + * window is resized to be smaller than AUTO_COLLAPSE_WIDTH. + * + * If the panel is auto-collapsed and then the user manually expands the + * panel, we want to make sure that further window resizing does not collapse + * the panel. + */ + private disableAutoCollapse = false; + + public constructor(collapseHandler: CollapseHandler) { + super(collapseHandler); + UIStore.instance.on(UI_EVENTS.WidthIncreased, this.onWindowWidthIncreased); + UIStore.instance.on(UI_EVENTS.WidthDecreased, this.onWindowWidthDecreased); + } + + private onWindowWidthDecreased = throttle((currentWindowWidth: number): void => { + // If the panel is already collapsed, we have nothing else left to do. + if (this.collapseHandler.isAutoCollapsed || this.collapseHandler.panelHandle?.isCollapsed()) return; + + // We were already auto-collapsed and the user has manually resized the panel. + // Don't auto-collapse again. + if (this.disableAutoCollapse) return; + + if (currentWindowWidth <= AUTO_COLLAPSE_WIDTH) { + this.collapseHandler.collapse(); + console.log("\t collapsed panel"); + } + }, 50); + + public onLeftPanelResized = (): void => { + if (this.collapseHandler.isAutoCollapsed) { + // Track that the user has manually resized the auto-collapsed panel. + this.disableAutoCollapse = true; + } + }; + + private onWindowWidthIncreased = throttle((currentWindowWidth: number): void => { + if (currentWindowWidth > AUTO_COLLAPSE_WIDTH) { + // Reset the flag when we cross the collapse width boundary. + this.disableAutoCollapse = false; + // If the panel isn't already collapsed, we don't need to expand the panel. + if (!this.collapseHandler.isAutoCollapsed) return; + // As the window is resized, react-resizable-panels is also resizing the panels. + // We'll expand the panel after a second to avoid racing with the library logic. + window.setTimeout(() => { + // this.disableAutoCollapse = false; + this.collapseHandler.expand(); + }, 1000); + } + }, 50); + + /** + * Whether the window is currently being resized. + */ + public get shouldIgnoreResize(): boolean { + // When the window is resized, the panel is resized in various ways. + // These transient changes should not be persisted in settings. + // So early return if that is the case. + return UIStore.instance.isWindowBeingResized; + } + + /** + * Remove's any event listeners used by this class. + */ + public dispose = (): void => { + UIStore.instance.off(UI_EVENTS.WidthIncreased, this.onWindowWidthIncreased); + UIStore.instance.off(UI_EVENTS.WidthDecreased, this.onWindowWidthDecreased); + }; + + public static shouldStartCollapsed(): boolean { + return UIStore.instance.windowWidth <= AUTO_COLLAPSE_WIDTH; + } +} diff --git a/apps/web/test/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour-test.ts b/apps/web/test/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour-test.ts new file mode 100644 index 0000000000..7d096c4497 --- /dev/null +++ b/apps/web/test/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour-test.ts @@ -0,0 +1,63 @@ +/* + * 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 UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore"; +import { CollapseOnWindowResizeBehaviour } from "../../../../../src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour"; +import type { CollapseHandler } from "../../../../../src/viewmodels/structures/auto-collapse/CollapseHandler"; +import { MockCollapseHandler } from "../mocks"; + +jest.useFakeTimers(); + +describe("CollapseOnWindowResizeBehaviour", () => { + it("Should collapse/expand the panel when the window is resized", () => { + const collapseHandler = new MockCollapseHandler() as unknown as CollapseHandler; + new CollapseOnWindowResizeBehaviour(collapseHandler); + // Making the window smaller should collapse the panel. + UIStore.instance.emit(UI_EVENTS.WidthDecreased, 750); + expect(collapseHandler.collapse).toHaveBeenCalledTimes(1); + // Making the window larger should expand the panel. + UIStore.instance.emit(UI_EVENTS.WidthIncreased, 950); + jest.runAllTimers(); + expect(collapseHandler.expand).toHaveBeenCalledTimes(1); + }); + + it("should set shouldIgnoreResize to true when window is being resized", () => { + const collapseHandler = new MockCollapseHandler() as unknown as CollapseHandler; + const behaviour = new CollapseOnWindowResizeBehaviour(collapseHandler); + expect(behaviour.shouldIgnoreResize).toBe(false); + // When the window is being resized, this behaviour should tell the AutoCollapser to + // ignore any panel resize events. + UIStore.instance.isWindowBeingResized = true; + expect(behaviour.shouldIgnoreResize).toBe(true); + }); + + it("should not auto-collapse panel when user has manually resized the panel", () => { + const collapseHandler = new MockCollapseHandler(); + const behaviour = new CollapseOnWindowResizeBehaviour(collapseHandler as unknown as CollapseHandler); + // Let's make the window smaller so that the panel is auto-collapsed. + UIStore.instance.emit(UI_EVENTS.WidthDecreased, 750); + expect(collapseHandler.collapse).toHaveBeenCalledTimes(1); + collapseHandler.collapse.mockClear(); + // Let's say that the user now manually expands the auto-collapsed panel. + behaviour.onLeftPanelResized(); + // Let's say that the window now became even smaller + UIStore.instance.emit(UI_EVENTS.WidthDecreased, 500); + // The panel should not be auto-collapsed again + expect(collapseHandler.collapse).not.toHaveBeenCalled(); + }); + + it("should return correct shouldStartCollapsed", () => { + const collapseHandler = new MockCollapseHandler(); + new CollapseOnWindowResizeBehaviour(collapseHandler as unknown as CollapseHandler); + // When the window is smaller than 768px, start collapsed. + UIStore.instance.windowWidth = 750; + expect(CollapseOnWindowResizeBehaviour.shouldStartCollapsed()).toBe(true); + // When the window is larger than 768px, start expanded. + UIStore.instance.windowWidth = 900; + expect(CollapseOnWindowResizeBehaviour.shouldStartCollapsed()).toBe(false); + }); +}); diff --git a/apps/web/test/viewmodels/structures/auto-collapse/mocks.ts b/apps/web/test/viewmodels/structures/auto-collapse/mocks.ts new file mode 100644 index 0000000000..f3131a0c43 --- /dev/null +++ b/apps/web/test/viewmodels/structures/auto-collapse/mocks.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +export class MockCollapseHandler { + public collapse = jest.fn().mockImplementation(() => { + this.isAutoCollapsed = true; + }); + public expand = jest.fn().mockImplementation(() => { + this.isAutoCollapsed = false; + }); + public isAutoCollapsed = false; + public panelHandle = new MockPanelHandle(); +} + +export class MockPanelHandle { + private _isCollapsed = false; + public inPixels = 100; + public isCollapsed = jest.fn().mockImplementation(() => { + return this._isCollapsed; + }); + public collapse = jest.fn().mockImplementation(() => { + this._isCollapsed = true; + }); + public resize = jest.fn().mockImplementation(() => { + this._isCollapsed = false; + }); + getSize = jest.fn().mockReturnValue({ + inPixels: this.inPixels, + }); +}