Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,47 @@ test.describe("Room list custom sections", () => {
});
});

test.describe("Collapse and expand all sections", () => {
test("should collapse all sections when 'Collapse all sections' button is clicked", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");

const roomList = getRoomList(page);
const header = getRoomListHeader(page);

await expect(getSectionHeader(page, "Chats")).toBeVisible();
await expect(getSectionHeader(page, "Work")).toBeVisible();

const collapseButton = header.getByRole("button", { name: "Collapse all sections" });
await expect(collapseButton).toBeVisible();

await expect(roomList.getByRole("row", { name: "Open room my room" })).toBeVisible();

await collapseButton.click();

await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "false");
await expect(getSectionHeader(page, "Work")).toHaveAttribute("aria-expanded", "false");
});

test("should expand all sections when 'Expand all sections' button is clicked", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");

const roomList = getRoomList(page);
const header = getRoomListHeader(page);

await expect(getSectionHeader(page, "Chats")).toBeVisible();

await header.getByRole("button", { name: "Collapse all sections" }).click();
await expect(roomList.getByRole("row", { name: "Open room my room" })).not.toBeVisible();

await header.getByRole("button", { name: "Expand all sections" }).click();

await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true");
await expect(getSectionHeader(page, "Work")).toHaveAttribute("aria-expanded", "true");
});
});

test.describe("Adding a room to a custom section", () => {
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/dispatcher/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,4 +403,20 @@ export enum Action {
* or keyboard event).
*/
UserActivity = "user_activity",

/**
* Fired to request collapsing all room list sections.
*/
RoomListCollapseAllSections = "room_list_collapse_all_sections",

/**
* Fired to request expanding all room list sections.
*/
RoomListExpandAllSections = "room_list_expand_all_sections",

/**
* Fired to report the collapse state of a given room list section.
* Payload: {@link RoomListSectionsCollapseStateChangedPayload}
*/
RoomListSectionsCollapseStateChanged = "room_list_sections_collapse_state_changed",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 { type CollapseSectionsOption } from "@element-hq/web-shared-components";

import { type ActionPayload } from "../payloads";
import { type Action } from "../actions";

export interface RoomListSectionsCollapseStateChangedPayload extends ActionPayload {
action: Action.RoomListSectionsCollapseStateChanged;
/**
* The new collapse state for the room list sections.
* If undefined, the feature is disabled.
*/
collapseSections?: CollapseSectionsOption;
}
22 changes: 22 additions & 0 deletions apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
showSpaceSettings,
} from "../../utils/space";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import type { RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters";
Expand Down Expand Up @@ -77,6 +78,10 @@ export class RoomListHeaderViewModel
if (this.activeSpace) {
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onSpaceNameChange);
}

// Listen for section collapse state changes from RoomListViewModel
const dispatcherRef = defaultDispatcher.register(this.onDispatch);
this.disposables.track(() => defaultDispatcher.unregister(dispatcherRef));
}

/**
Expand Down Expand Up @@ -203,6 +208,23 @@ export class RoomListHeaderViewModel
public createSection = (): void => {
RoomListStoreV3.instance.createSection();
};

public collapseOrExpandSections = (): void => {
const action =
this.snapshot.current.collapseSections === "expand"
? Action.RoomListExpandAllSections
: Action.RoomListCollapseAllSections;
defaultDispatcher.fire(action);
};

private readonly onDispatch = (payload: { action: string }): void => {
if (payload.action === Action.RoomListSectionsCollapseStateChanged) {
const { collapseSections } = payload as RoomListSectionsCollapseStateChangedPayload;
this.snapshot.merge({
collapseSections: collapseSections && (collapseSections === "collapse" ? "expand" : "collapse"),
});
}
};
}
/**
* Get the initial snapshot for the RoomListHeaderViewModel.
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/viewmodels/room-list/RoomListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Action } from "../../dispatcher/actions";
import dispatcher from "../../dispatcher/dispatcher";
import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
import SpaceStore from "../../stores/spaces/SpaceStore";
import RoomListStoreV3, {
CHATS_TAG,
Expand Down Expand Up @@ -325,9 +326,24 @@ export class RoomListViewModel
// Handle keyboard navigation shortcuts (Alt+ArrowUp/Down)
// This was previously handled by useRoomListNavigation hook
this.handleViewRoomDelta(payload as ViewRoomDeltaPayload);
} else if (payload.action === Action.RoomListCollapseAllSections) {
this.onCollapseAllSections(false);
} else if (payload.action === Action.RoomListExpandAllSections) {
this.onCollapseAllSections(true);
}
};

/**
* Handles the collapse or expansion of all sections in the room list.
* @param expand - Whether to expand or collapse all sections
*/
private onCollapseAllSections(expand: boolean): void {
for (const sectionHeaderVM of this.roomSectionHeaderViewModels.values()) {
sectionHeaderVM.isExpanded = expand;
}
this.updateRoomListData();
}

/**
* Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) to move between rooms.
* Supports both regular navigation and unread-only navigation.
Expand Down Expand Up @@ -581,6 +597,32 @@ export class RoomListViewModel
sections: keepIfSame(previousSections, viewSections),
isFlatList,
});

this.notifyCollapseState(isFlatList);
}

/**
* Notify the dispatcher about the current collapse state of the room list sections.
* @param isFlatList - Whether the room list is currently displayed as a flat list
*/
private notifyCollapseState(isFlatList: boolean): void {
// Hide collapse/expand all button if sections are disabled or if it's a flat list
if (!SettingsStore.getValue("feature_room_list_sections") || isFlatList) {
dispatcher.dispatch<RoomListSectionsCollapseStateChangedPayload>({
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: undefined,
});
return;
}

// Determine if all sections are currently collapsed
const allCollapsed = this.snapshot.current.sections.every(
({ id }) => !(this.roomSectionHeaderViewModels.get(id)?.isExpanded ?? true),
);
dispatcher.dispatch<RoomListSectionsCollapseStateChangedPayload>({
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: allCollapsed ? "collapse" : "expand",
});
}

public createChatRoom = (): void => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,89 @@ describe("RoomListHeaderViewModel", () => {
expect(createSectionSpy).toHaveBeenCalled();
});

describe("collapseOrExpandSections", () => {
it("should dispatch RoomListCollapseAllSections when collapseSections is not 'expand'", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });

vm.collapseOrExpandSections();

expect(fireSpy).toHaveBeenCalledWith(Action.RoomListCollapseAllSections);
});

it("should dispatch RoomListExpandAllSections when collapseSections is 'expand'", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });

// Drive the VM into the "expand" state by simulating all sections collapsed
defaultDispatcher.dispatch(
{
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: "collapse",
},
true,
);
expect(vm.getSnapshot().collapseSections).toBe("expand");
vm.collapseOrExpandSections();

expect(fireSpy).toHaveBeenCalledWith(Action.RoomListExpandAllSections);
});
});

describe("RoomListSectionsCollapseStateChanged handling", () => {
it("should set collapseSections to 'expand' when collapseSections is collapse", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });

defaultDispatcher.dispatch(
{
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: "collapse",
},
true,
);

expect(vm.getSnapshot().collapseSections).toBe("expand");
});

it("should set collapseSections to 'collapse' when collapseSections is expand", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });

defaultDispatcher.dispatch(
{
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: "expand",
},
true,
);

expect(vm.getSnapshot().collapseSections).toBe("collapse");
});

it("should set collapseSections to undefined when collapseSections is undefined", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });

// First drive it into a non-undefined state
defaultDispatcher.dispatch(
{
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: "collapse",
},
true,
);
expect(vm.getSnapshot().collapseSections).toBe("expand");

defaultDispatcher.dispatch(
{
action: Action.RoomListSectionsCollapseStateChanged,
collapseSections: undefined,
},
true,
);

expect(vm.getSnapshot().collapseSections).toBeUndefined();
});
});

it("should toggle message preview from enabled to disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.showMessagePreview") return true;
Expand Down
Loading
Loading