Skip to content

Commit 9fa016c

Browse files
committed
feat: first draft of sections in vms
1 parent 3e03425 commit 9fa016c

3 files changed

Lines changed: 176 additions & 77 deletions

File tree

apps/web/src/i18n/strings/en_EN.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,6 +2164,11 @@
21642164
"one": "Currently removing messages in %(count)s room",
21652165
"other": "Currently removing messages in %(count)s rooms"
21662166
},
2167+
"section": {
2168+
"chats": "Chats",
2169+
"favourites": "Favourites",
2170+
"low_priority": "Low Priority"
2171+
},
21672172
"show_less": "Show less",
21682173
"show_n_more": {
21692174
"one": "Show %(count)s more",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import {
9+
BaseViewModel,
10+
type RoomListSectionHeaderActions,
11+
type RoomListSectionHeaderViewSnapshot,
12+
} from "@element-hq/web-shared-components";
13+
14+
interface RoomListSectionHeaderViewModelProps {
15+
tag: string;
16+
title: string;
17+
onToggleExpanded: (isExpanded: boolean) => void;
18+
}
19+
20+
export class RoomListSectionHeaderViewModel
21+
extends BaseViewModel<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderViewModelProps>
22+
implements RoomListSectionHeaderActions
23+
{
24+
public constructor(props: RoomListSectionHeaderViewModelProps) {
25+
super(props, { id: props.tag, title: props.title, isExpanded: true });
26+
}
27+
28+
public onClick = (): void => {
29+
const isExpanded = !this.snapshot.current.isExpanded;
30+
this.snapshot.merge({ isExpanded });
31+
this.props.onToggleExpanded(isExpanded);
32+
};
33+
34+
/**
35+
* Whether the section is currently expanded or not.
36+
*/
37+
public get isExpanded(): boolean {
38+
return this.snapshot.current.isExpanded;
39+
}
40+
}

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

Lines changed: 131 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
type FilterId,
1212
type RoomListViewActions,
1313
type RoomListViewState,
14+
type RoomListSection,
15+
_t,
1416
} from "@element-hq/web-shared-components";
1517
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
1618

@@ -19,39 +21,53 @@ import dispatcher from "../../dispatcher/dispatcher";
1921
import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
2022
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
2123
import SpaceStore from "../../stores/spaces/SpaceStore";
22-
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3";
23-
import { FilterKey } from "../../stores/room-list-v3/skip-list/filters";
24+
import RoomListStoreV3, {
25+
CHATS_TAG,
26+
RoomListStoreV3Event,
27+
type RoomsResult,
28+
} from "../../stores/room-list-v3/RoomListStoreV3";
29+
import { FilterEnum } from "../../stores/room-list-v3/skip-list/filters";
2430
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
2531
import { RoomListItemViewModel } from "./RoomListItemViewModel";
2632
import { SdkContextClass } from "../../contexts/SDKContext";
2733
import { hasCreateRoomRights } from "./utils";
2834
import { keepIfSame } from "../../utils/keepIfSame";
35+
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
36+
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
2937

3038
interface RoomListViewModelProps {
3139
client: MatrixClient;
3240
}
3341

34-
const filterKeyToIdMap: Map<FilterKey, FilterId> = new Map([
35-
[FilterKey.UnreadFilter, "unread"],
36-
[FilterKey.PeopleFilter, "people"],
37-
[FilterKey.RoomsFilter, "rooms"],
38-
[FilterKey.FavouriteFilter, "favourite"],
39-
[FilterKey.MentionsFilter, "mentions"],
40-
[FilterKey.InvitesFilter, "invites"],
41-
[FilterKey.LowPriorityFilter, "low_priority"],
42+
const filterKeyToIdMap: Map<FilterEnum, FilterId> = new Map([
43+
[FilterEnum.UnreadFilter, "unread"],
44+
[FilterEnum.PeopleFilter, "people"],
45+
[FilterEnum.RoomsFilter, "rooms"],
46+
[FilterEnum.FavouriteFilter, "favourite"],
47+
[FilterEnum.MentionsFilter, "mentions"],
48+
[FilterEnum.InvitesFilter, "invites"],
49+
[FilterEnum.LowPriorityFilter, "low_priority"],
4250
]);
4351

52+
const TAG_TO_TITLE_MAP: Record<string, string> = {
53+
[DefaultTagID.Favourite]: _t("room_list|section|favourites"),
54+
[CHATS_TAG]: _t("room_list|section|chats"),
55+
[DefaultTagID.LowPriority]: _t("room_list|section|low_priority"),
56+
};
57+
4458
export class RoomListViewModel
4559
extends BaseViewModel<RoomListViewSnapshot, RoomListViewModelProps>
4660
implements RoomListViewActions
4761
{
4862
// State tracking
49-
private activeFilter: FilterKey | undefined = undefined;
63+
private activeFilter: FilterEnum | undefined = undefined;
5064
private roomsResult: RoomsResult;
5165
private lastActiveRoomIndex: number | undefined = undefined;
5266

5367
// Child view model management
5468
private roomItemViewModels = new Map<string, RoomListItemViewModel>();
69+
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
70+
private roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
5571
private roomsMap = new Map<string, Room>();
5672

5773
public constructor(props: RoomListViewModelProps) {
@@ -61,22 +77,23 @@ export class RoomListViewModel
6177
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined);
6278
const canCreateRoom = hasCreateRoomRights(props.client, activeSpace);
6379
const filterIds = [...filterKeyToIdMap.values()];
64-
const roomIds = roomsResult.rooms.map((room) => room.roomId);
65-
const sections = [{ id: "all", roomIds }];
80+
81+
// By default, all sections are expanded
82+
const { sections, isFlatList } = computeSections(roomsResult, (tag) => true);
83+
const isRoomListEmpty = roomsResult.sections.every((section) => section.rooms.length === 0);
6684

6785
super(props, {
6886
// Initial view state - start with empty, will populate in async init
6987
isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms,
70-
isRoomListEmpty: roomsResult.rooms.length === 0,
88+
isRoomListEmpty,
7189
filterIds,
7290
activeFilterId: undefined,
7391
roomListState: {
7492
activeRoomIndex: undefined,
7593
spaceId: roomsResult.spaceId,
7694
filterKeys: undefined,
7795
},
78-
// Until we implement sections, this view model only supports the flat list mode
79-
isFlatList: true,
96+
isFlatList,
8097
sections,
8198
canCreateRoom,
8299
});
@@ -117,7 +134,7 @@ export class RoomListViewModel
117134

118135
public onToggleFilter = (filterId: FilterId): void => {
119136
// Find the FilterKey by matching the filter ID
120-
let filterKey: FilterKey | undefined = undefined;
137+
let filterKey: FilterEnum | undefined = undefined;
121138
for (const [key, id] of filterKeyToIdMap.entries()) {
122139
if (id === filterId) {
123140
filterKey = key;
@@ -147,7 +164,7 @@ export class RoomListViewModel
147164
*/
148165
private updateRoomsMap(roomsResult: RoomsResult): void {
149166
this.roomsMap.clear();
150-
for (const room of roomsResult.rooms) {
167+
for (const room of roomsResult.sections.flatMap((section) => section.rooms)) {
151168
this.roomsMap.set(room.roomId, room);
152169
}
153170
}
@@ -167,7 +184,7 @@ export class RoomListViewModel
167184
* Get the ordered list of room IDs.
168185
*/
169186
public get roomIds(): string[] {
170-
return this.roomsResult.rooms.map((room) => room.roomId);
187+
return this.roomsResult.sections.flatMap((section) => section.rooms).map((room) => room.roomId);
171188
}
172189

173190
/**
@@ -199,13 +216,17 @@ export class RoomListViewModel
199216
return viewModel;
200217
}
201218

202-
/**
203-
* Not implemented - this view model does not support sections.
204-
* Flat list mode is forced so this method is never be called.
205-
* @throw Error if called
206-
*/
207-
public getSectionHeaderViewModel(): never {
208-
throw new Error("Sections are not supported in this room list");
219+
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
220+
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
221+
222+
const title = TAG_TO_TITLE_MAP[tag] || tag;
223+
const viewModel = new RoomListSectionHeaderViewModel({
224+
tag,
225+
title,
226+
onToggleExpanded: () => this.updateRoomListData(),
227+
});
228+
this.roomSectionHeaderViewModels.set(tag, viewModel);
229+
return viewModel;
209230
}
210231

211232
/**
@@ -250,7 +271,7 @@ export class RoomListViewModel
250271
if (!currentRoomId) return;
251272

252273
const { delta, unread } = payload;
253-
const rooms = this.roomsResult.rooms;
274+
const rooms = this.roomsResult.sections.flatMap((section) => section.rooms);
254275

255276
const filteredRooms = unread
256277
? // Filter the rooms to only include unread ones and the active room
@@ -338,7 +359,9 @@ export class RoomListViewModel
338359
return undefined;
339360
}
340361

341-
const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId);
362+
const index = this.roomsResult.sections
363+
.flatMap((section) => section.rooms)
364+
.findIndex((room) => room.roomId === roomId);
342365
return index >= 0 ? index : undefined;
343366
}
344367

@@ -350,47 +373,47 @@ export class RoomListViewModel
350373
* @param roomId - The room ID to apply sticky logic for (can be null/undefined)
351374
* @returns The modified rooms array with sticky positioning applied
352375
*/
353-
private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] {
354-
const rooms = this.roomsResult.rooms;
355-
356-
if (!roomId) {
357-
return rooms;
358-
}
359-
360-
const newIndex = rooms.findIndex((room) => room.roomId === roomId);
361-
const oldIndex = this.lastActiveRoomIndex;
362-
363-
// When opening another room, the index should obviously change
364-
if (isRoomChange) {
365-
return rooms;
366-
}
367-
368-
// If oldIndex is undefined, then there was no active room before
369-
// Similarly, if newIndex is -1, the active room is not in the current list
370-
if (newIndex === -1 || oldIndex === undefined) {
371-
return rooms;
372-
}
373-
374-
// If the index hasn't changed, we have nothing to do
375-
if (newIndex === oldIndex) {
376-
return rooms;
377-
}
378-
379-
// If the old index falls out of the bounds of the rooms array
380-
// (usually because rooms were removed), we can no longer place
381-
// the active room in the same old index
382-
if (oldIndex > rooms.length - 1) {
383-
return rooms;
384-
}
385-
386-
// Making the active room sticky is as simple as removing it from
387-
// its new index and placing it in the old index
388-
const newRooms = [...rooms];
389-
const [stickyRoom] = newRooms.splice(newIndex, 1);
390-
newRooms.splice(oldIndex, 0, stickyRoom);
391-
392-
return newRooms;
393-
}
376+
// private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] {
377+
// const rooms = this.roomsResult.rooms;
378+
379+
// if (!roomId) {
380+
// return rooms;
381+
// }
382+
383+
// const newIndex = rooms.findIndex((room) => room.roomId === roomId);
384+
// const oldIndex = this.lastActiveRoomIndex;
385+
386+
// // When opening another room, the index should obviously change
387+
// if (isRoomChange) {
388+
// return rooms;
389+
// }
390+
391+
// // If oldIndex is undefined, then there was no active room before
392+
// // Similarly, if newIndex is -1, the active room is not in the current list
393+
// if (newIndex === -1 || oldIndex === undefined) {
394+
// return rooms;
395+
// }
396+
397+
// // If the index hasn't changed, we have nothing to do
398+
// if (newIndex === oldIndex) {
399+
// return rooms;
400+
// }
401+
402+
// // If the old index falls out of the bounds of the rooms array
403+
// // (usually because rooms were removed), we can no longer place
404+
// // the active room in the same old index
405+
// if (oldIndex > rooms.length - 1) {
406+
// return rooms;
407+
// }
408+
409+
// // Making the active room sticky is as simple as removing it from
410+
// // its new index and placing it in the old index
411+
// const newRooms = [...rooms];
412+
// const [stickyRoom] = newRooms.splice(newIndex, 1);
413+
// newRooms.splice(oldIndex, 0, stickyRoom);
414+
415+
// return newRooms;
416+
// }
394417

395418
private async updateRoomListData(
396419
isRoomChange: boolean = false,
@@ -400,14 +423,15 @@ export class RoomListViewModel
400423
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
401424
const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId();
402425

426+
// TODO to implement for sections
403427
// Apply sticky room logic to keep selected room at same position
404-
const stickyRooms = this.applyStickyRoom(isRoomChange, roomId);
428+
//const stickyRooms = this.applyStickyRoom(isRoomChange, roomId);
405429

406430
// Update roomsResult with sticky rooms
407-
this.roomsResult = {
408-
...this.roomsResult,
409-
rooms: stickyRooms,
410-
};
431+
// this.roomsResult = {
432+
// ...this.roomsResult,
433+
// rooms: stickyRooms,
434+
// };
411435

412436
// Rebuild roomsMap with the reordered rooms
413437
this.updateRoomsMap(this.roomsResult);
@@ -420,8 +444,10 @@ export class RoomListViewModel
420444

421445
// Build the complete state atomically to ensure consistency
422446
// roomIds and roomListState must always be in sync
423-
const roomIds = this.roomIds;
424-
const sections = [{ id: "all", roomIds }];
447+
const { sections, isFlatList } = computeSections(
448+
this.roomsResult,
449+
(tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true,
450+
);
425451

426452
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
427453
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
@@ -433,7 +459,7 @@ export class RoomListViewModel
433459
};
434460

435461
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
436-
const isRoomListEmpty = roomIds.length === 0;
462+
const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0);
437463
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
438464

439465
// Single atomic snapshot update
@@ -443,6 +469,7 @@ export class RoomListViewModel
443469
activeFilterId,
444470
roomListState,
445471
sections,
472+
isFlatList,
446473
});
447474
}
448475

@@ -464,3 +491,30 @@ export class RoomListViewModel
464491
}
465492
};
466493
}
494+
495+
/**
496+
* Compute the sections to display in the room list based on the rooms result and section expansion state.
497+
* @param roomsResult - The current rooms result containing sections and rooms
498+
* @param isSectionExpanded - A function that takes a section tag and returns whether that section is currently expanded
499+
* @returns An object containing the computed sections with room IDs (empty if section is collapsed) and a boolean indicating if the list should be displayed as a flat list (only one section with all rooms)
500+
*/
501+
function computeSections(
502+
roomsResult: RoomsResult,
503+
isSectionExpanded: (tag: string) => boolean,
504+
): { sections: RoomListSection[]; isFlatList: boolean } {
505+
const sections = roomsResult.sections
506+
.map(({ tag, rooms }) => ({
507+
id: tag,
508+
roomIds: rooms.map((room) => room.roomId),
509+
}))
510+
// Only include sections that have rooms
511+
.filter((section) => section.roomIds.length > 0)
512+
// Remove roomIds for sections that are currently collapsed according to their section header view model
513+
.map((section) => ({
514+
...section,
515+
roomIds: isSectionExpanded(section.id) ? section.roomIds : [],
516+
}));
517+
const isFlatList = sections.length === 1 && sections[0].id === CHATS_TAG;
518+
519+
return { sections, isFlatList };
520+
}

0 commit comments

Comments
 (0)