Skip to content

Commit e33a3ee

Browse files
committed
feat(vm): add expand/collpase event to room list events
1 parent 92da75b commit e33a3ee

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
showSpaceSettings,
2727
} from "../../utils/space";
2828
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
29+
import type { RoomListCollapseAllSectionsPayload } from "../../dispatcher/payloads/RoomListCollapseAllSectionsPayload";
30+
import type { RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
2931
import SettingsStore from "../../settings/SettingsStore";
3032
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
3133
import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters";
@@ -77,6 +79,10 @@ export class RoomListHeaderViewModel
7779
if (this.activeSpace) {
7880
this.disposables.trackListener(this.activeSpace, RoomEvent.Name, this.onSpaceNameChange);
7981
}
82+
83+
// Listen for section collapse state changes from RoomListViewModel
84+
const dispatcherRef = defaultDispatcher.register(this.onDispatch);
85+
this.disposables.track(() => defaultDispatcher.unregister(dispatcherRef));
8086
}
8187

8288
/**
@@ -203,6 +209,23 @@ export class RoomListHeaderViewModel
203209
public createSection = (): void => {
204210
RoomListStoreV3.instance.createSection();
205211
};
212+
213+
public collapseOrExpandSections = (): void => {
214+
const expand = this.snapshot.current.collapseSections === "expand";
215+
defaultDispatcher.dispatch<RoomListCollapseAllSectionsPayload>({
216+
action: Action.RoomListCollapseAllSections,
217+
expand,
218+
});
219+
};
220+
221+
private readonly onDispatch = (payload: { action: string }): void => {
222+
if (payload.action === Action.RoomListSectionsCollapseStateChanged) {
223+
const { allCollapsed } = payload as RoomListSectionsCollapseStateChangedPayload;
224+
this.snapshot.merge({
225+
collapseSections: allCollapsed === null ? undefined : allCollapsed ? "expand" : "collapse",
226+
});
227+
}
228+
};
206229
}
207230
/**
208231
* Get the initial snapshot for the RoomListHeaderViewModel.

apps/web/src/viewmodels/room-list/RoomListViewModel.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { Action } from "../../dispatcher/actions";
2121
import dispatcher from "../../dispatcher/dispatcher";
2222
import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
2323
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
24+
import { type RoomListCollapseAllSectionsPayload } from "../../dispatcher/payloads/RoomListCollapseAllSectionsPayload";
25+
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
2426
import SpaceStore from "../../stores/spaces/SpaceStore";
2527
import RoomListStoreV3, {
2628
CHATS_TAG,
@@ -325,9 +327,22 @@ export class RoomListViewModel
325327
// Handle keyboard navigation shortcuts (Alt+ArrowUp/Down)
326328
// This was previously handled by useRoomListNavigation hook
327329
this.handleViewRoomDelta(payload as ViewRoomDeltaPayload);
330+
} else if (payload.action === Action.RoomListCollapseAllSections) {
331+
this.onCollapseAllSections((payload as RoomListCollapseAllSectionsPayload).expand);
328332
}
329333
};
330334

335+
/**
336+
* Handles the collapse or expansion of all sections in the room list.
337+
* @param expand - Whether to expand or collapse all sections
338+
*/
339+
private onCollapseAllSections(expand: boolean): void {
340+
for (const sectionHeaderVM of this.roomSectionHeaderViewModels.values()) {
341+
sectionHeaderVM.isExpanded = expand;
342+
}
343+
this.updateRoomListData();
344+
}
345+
331346
/**
332347
* Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) to move between rooms.
333348
* Supports both regular navigation and unread-only navigation.
@@ -581,6 +596,32 @@ export class RoomListViewModel
581596
sections: keepIfSame(previousSections, viewSections),
582597
isFlatList,
583598
});
599+
600+
this.notifyCollapseState(isFlatList);
601+
}
602+
603+
/**
604+
* Notify the dispatcher about the current collapse state of the room list sections.
605+
* @param isFlatList - Whether the room list is currently displayed as a flat list
606+
*/
607+
private notifyCollapseState(isFlatList: boolean): void {
608+
// Hide collapse/expand all button if sections are disabled or if it's a flat list
609+
if (!SettingsStore.getValue("feature_room_list_sections") || isFlatList) {
610+
dispatcher.dispatch<RoomListSectionsCollapseStateChangedPayload>({
611+
action: Action.RoomListSectionsCollapseStateChanged,
612+
allCollapsed: null,
613+
});
614+
return;
615+
}
616+
617+
// Determine if all sections are currently collapsed
618+
const allCollapsed = this.snapshot.current.sections.every(
619+
({ id }) => !(this.roomSectionHeaderViewModels.get(id)?.isExpanded ?? true),
620+
);
621+
dispatcher.dispatch<RoomListSectionsCollapseStateChangedPayload>({
622+
action: Action.RoomListSectionsCollapseStateChanged,
623+
allCollapsed,
624+
});
584625
}
585626

586627
public createChatRoom = (): void => {

apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,82 @@ describe("RoomListHeaderViewModel", () => {
323323
expect(createSectionSpy).toHaveBeenCalled();
324324
});
325325

326+
describe("collapseOrExpandSections", () => {
327+
it("should dispatch RoomListCollapseAllSections with expand=false when collapseSections is not 'expand'", () => {
328+
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
329+
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
330+
331+
vm.collapseOrExpandSections();
332+
333+
expect(dispatchSpy).toHaveBeenCalledWith({
334+
action: Action.RoomListCollapseAllSections,
335+
expand: false,
336+
});
337+
});
338+
339+
it("should dispatch RoomListCollapseAllSections with expand=true when collapseSections is 'expand'", () => {
340+
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
341+
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
342+
343+
// Drive the VM into the "expand" state by simulating all sections collapsed
344+
defaultDispatcher.dispatch({
345+
action: Action.RoomListSectionsCollapseStateChanged,
346+
allCollapsed: true,
347+
});
348+
expect(vm.getSnapshot().collapseSections).toBe("expand");
349+
350+
dispatchSpy.mockClear();
351+
vm.collapseOrExpandSections();
352+
353+
expect(dispatchSpy).toHaveBeenCalledWith({
354+
action: Action.RoomListCollapseAllSections,
355+
expand: true,
356+
});
357+
});
358+
});
359+
360+
describe("RoomListSectionsCollapseStateChanged handling", () => {
361+
it("should set collapseSections to 'expand' when allCollapsed is true", () => {
362+
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
363+
364+
defaultDispatcher.dispatch({
365+
action: Action.RoomListSectionsCollapseStateChanged,
366+
allCollapsed: true,
367+
});
368+
369+
expect(vm.getSnapshot().collapseSections).toBe("expand");
370+
});
371+
372+
it("should set collapseSections to 'collapse' when allCollapsed is false", () => {
373+
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
374+
375+
defaultDispatcher.dispatch({
376+
action: Action.RoomListSectionsCollapseStateChanged,
377+
allCollapsed: false,
378+
});
379+
380+
expect(vm.getSnapshot().collapseSections).toBe("collapse");
381+
});
382+
383+
it("should set collapseSections to undefined when allCollapsed is null", () => {
384+
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
385+
386+
// First drive it into a non-undefined state
387+
defaultDispatcher.dispatch({
388+
action: Action.RoomListSectionsCollapseStateChanged,
389+
allCollapsed: true,
390+
});
391+
expect(vm.getSnapshot().collapseSections).toBe("expand");
392+
393+
defaultDispatcher.dispatch({
394+
action: Action.RoomListSectionsCollapseStateChanged,
395+
allCollapsed: null,
396+
});
397+
398+
expect(vm.getSnapshot().collapseSections).toBeUndefined();
399+
});
400+
});
401+
326402
it("should toggle message preview from enabled to disabled", () => {
327403
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
328404
if (settingName === "RoomList.showMessagePreview") return true;

apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,20 @@ describe("RoomListViewModel", () => {
465465
});
466466
});
467467

468+
describe("notifyCollapseState", () => {
469+
it("should dispatch allCollapsed=null when feature_room_list_sections is disabled", () => {
470+
viewModel = new RoomListViewModel({ client: matrixClient });
471+
472+
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
473+
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
474+
475+
expect(dispatchSpy).toHaveBeenCalledWith({
476+
action: Action.RoomListSectionsCollapseStateChanged,
477+
allCollapsed: null,
478+
});
479+
});
480+
});
481+
468482
describe("Keyboard navigation (ViewRoomDelta)", () => {
469483
beforeEach(() => {
470484
// stubClient sets up MatrixClientPeg which is needed when ViewRoom action is dispatched
@@ -971,6 +985,96 @@ describe("RoomListViewModel", () => {
971985
expect(favSection!.roomIds).toEqual(["!fav1:server"]);
972986
});
973987

988+
describe("Collapse/expand all sections", () => {
989+
it("should collapse all sections when Action.RoomListCollapseAllSections is dispatched with expand=false", async () => {
990+
viewModel = new RoomListViewModel({ client: matrixClient });
991+
992+
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
993+
const chatsHeader = viewModel.getSectionHeaderViewModel(CHATS_TAG);
994+
expect(favHeader.isExpanded).toBe(true);
995+
996+
dispatcher.dispatch({ action: Action.RoomListCollapseAllSections, expand: false });
997+
await flushPromisesWithFakeTimers();
998+
999+
expect(favHeader.isExpanded).toBe(false);
1000+
expect(chatsHeader.isExpanded).toBe(false);
1001+
1002+
const snapshot = viewModel.getSnapshot();
1003+
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([]);
1004+
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([]);
1005+
});
1006+
1007+
it("should expand all sections when Action.RoomListCollapseAllSections is dispatched with expand=true", async () => {
1008+
viewModel = new RoomListViewModel({ client: matrixClient });
1009+
1010+
// Collapse first
1011+
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
1012+
favHeader.onClick();
1013+
expect(favHeader.isExpanded).toBe(false);
1014+
1015+
dispatcher.dispatch({ action: Action.RoomListCollapseAllSections, expand: true });
1016+
await flushPromisesWithFakeTimers();
1017+
1018+
expect(favHeader.isExpanded).toBe(true);
1019+
const snapshot = viewModel.getSnapshot();
1020+
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([
1021+
"!fav1:server",
1022+
"!fav2:server",
1023+
]);
1024+
});
1025+
});
1026+
1027+
describe("notifyCollapseState", () => {
1028+
it("should dispatch allCollapsed=false when all sections are expanded (default)", () => {
1029+
viewModel = new RoomListViewModel({ client: matrixClient });
1030+
1031+
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
1032+
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
1033+
1034+
expect(dispatchSpy).toHaveBeenCalledWith({
1035+
action: Action.RoomListSectionsCollapseStateChanged,
1036+
allCollapsed: false,
1037+
});
1038+
});
1039+
1040+
it("should dispatch allCollapsed=true when all sections are collapsed", () => {
1041+
viewModel = new RoomListViewModel({ client: matrixClient });
1042+
1043+
// Collapse all sections
1044+
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded = false;
1045+
viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded = false;
1046+
viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded = false;
1047+
1048+
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
1049+
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
1050+
1051+
expect(dispatchSpy).toHaveBeenCalledWith({
1052+
action: Action.RoomListSectionsCollapseStateChanged,
1053+
allCollapsed: true,
1054+
});
1055+
});
1056+
1057+
it("should dispatch allCollapsed=null when it is a flat list", () => {
1058+
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
1059+
spaceId: "home",
1060+
sections: [
1061+
{ tag: DefaultTagID.Favourite, rooms: [] },
1062+
{ tag: CHATS_TAG, rooms: [regularRoom1] },
1063+
{ tag: DefaultTagID.LowPriority, rooms: [] },
1064+
],
1065+
});
1066+
viewModel = new RoomListViewModel({ client: matrixClient });
1067+
1068+
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
1069+
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
1070+
1071+
expect(dispatchSpy).toHaveBeenCalledWith({
1072+
action: Action.RoomListSectionsCollapseStateChanged,
1073+
allCollapsed: null,
1074+
});
1075+
});
1076+
});
1077+
9741078
it("should apply sticky room within the correct section", async () => {
9751079
stubClient();
9761080
viewModel = new RoomListViewModel({ client: matrixClient });

0 commit comments

Comments
 (0)