Skip to content

Commit d57acac

Browse files
committed
feat: first draft of sections in vms
1 parent 0a5e8af commit d57acac

3 files changed

Lines changed: 137 additions & 75 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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.props.onToggleExpanded(isExpanded);
31+
this.snapshot.merge({ isExpanded });
32+
};
33+
}

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

Lines changed: 99 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type FilterId,
1212
type RoomListViewActions,
1313
type RoomListViewState,
14+
_t,
1415
} from "@element-hq/web-shared-components";
1516
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
1617

@@ -20,37 +21,41 @@ import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDel
2021
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
2122
import SpaceStore from "../../stores/spaces/SpaceStore";
2223
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3";
23-
import { FilterKey } from "../../stores/room-list-v3/skip-list/filters";
24+
import { FilterEnum, type FilterKey } from "../../stores/room-list-v3/skip-list/filters";
2425
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
2526
import { RoomListItemViewModel } from "./RoomListItemViewModel";
2627
import { SdkContextClass } from "../../contexts/SDKContext";
2728
import { hasCreateRoomRights } from "./utils";
2829
import { keepIfSame } from "../../utils/keepIfSame";
30+
import { CHATS_TAG } from "../../stores/room-list-v3/SectionStore";
31+
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
32+
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
2933

3034
interface RoomListViewModelProps {
3135
client: MatrixClient;
3236
}
3337

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"],
38+
const filterKeyToIdMap: Map<FilterEnum, FilterId> = new Map([
39+
[FilterEnum.UnreadFilter, "unread"],
40+
[FilterEnum.PeopleFilter, "people"],
41+
[FilterEnum.RoomsFilter, "rooms"],
42+
[FilterEnum.FavouriteFilter, "favourite"],
43+
[FilterEnum.MentionsFilter, "mentions"],
44+
[FilterEnum.InvitesFilter, "invites"],
45+
[FilterEnum.LowPriorityFilter, "low_priority"],
4246
]);
4347

4448
export class RoomListViewModel
4549
extends BaseViewModel<RoomListViewSnapshot, RoomListViewModelProps>
4650
implements RoomListViewActions
4751
{
4852
// State tracking
49-
private activeFilter: FilterKey | undefined = undefined;
53+
private activeFilter: FilterEnum | undefined = undefined;
5054
private roomsResult: RoomsResult;
5155
private lastActiveRoomIndex: number | undefined = undefined;
5256

5357
// Child view model management
58+
private roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
5459
private roomItemViewModels = new Map<string, RoomListItemViewModel>();
5560
private roomsMap = new Map<string, Room>();
5661

@@ -61,22 +66,26 @@ export class RoomListViewModel
6166
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined);
6267
const canCreateRoom = hasCreateRoomRights(props.client, activeSpace);
6368
const filterIds = [...filterKeyToIdMap.values()];
64-
const roomIds = roomsResult.rooms.map((room) => room.roomId);
65-
const sections = [{ id: "all", roomIds }];
69+
const sections = roomsResult.sections.map(({ tag, rooms }) => ({
70+
id: tag,
71+
roomIds: rooms.map((room) => room.roomId),
72+
}));
73+
74+
const isRoomListEmpty = roomsResult.sections.every((section) => section.rooms.length === 0);
75+
const isFlatList = roomsResult.sections.length === 1 && roomsResult.sections[0].tag == CHATS_TAG;
6676

6777
super(props, {
6878
// Initial view state - start with empty, will populate in async init
6979
isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms,
70-
isRoomListEmpty: roomsResult.rooms.length === 0,
80+
isRoomListEmpty,
7181
filterIds,
7282
activeFilterId: undefined,
7383
roomListState: {
7484
activeRoomIndex: undefined,
7585
spaceId: roomsResult.spaceId,
7686
filterKeys: undefined,
7787
},
78-
// Until we implement sections, this view model only supports the flat list mode
79-
isFlatList: true,
88+
isFlatList,
8089
sections,
8190
canCreateRoom,
8291
});
@@ -147,7 +156,7 @@ export class RoomListViewModel
147156
*/
148157
private updateRoomsMap(roomsResult: RoomsResult): void {
149158
this.roomsMap.clear();
150-
for (const room of roomsResult.rooms) {
159+
for (const room of roomsResult.sections.flatMap((section) => section.rooms)) {
151160
this.roomsMap.set(room.roomId, room);
152161
}
153162
}
@@ -167,7 +176,7 @@ export class RoomListViewModel
167176
* Get the ordered list of room IDs.
168177
*/
169178
public get roomIds(): string[] {
170-
return this.roomsResult.rooms.map((room) => room.roomId);
179+
return this.roomsResult.sections.flatMap((section) => section.rooms).map((room) => room.roomId);
171180
}
172181

173182
/**
@@ -199,13 +208,22 @@ export class RoomListViewModel
199208
return viewModel;
200209
}
201210

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");
211+
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
212+
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
213+
214+
const title = TAG_TO_TITLE_MAP[tag] || tag;
215+
const viewModel = new RoomListSectionHeaderViewModel({
216+
tag,
217+
title,
218+
onToggleExpanded: () => {
219+
// TODO implement expand/collapse logic
220+
// This will require changes to the data structure to track which sections are expanded, and to slice roomIds accordingly
221+
// For now we can just log the click to verify the button is working
222+
console.log(`Toggled section ${tag}`);
223+
},
224+
});
225+
this.roomSectionHeaderViewModels.set(tag, viewModel);
226+
return viewModel;
209227
}
210228

211229
/**
@@ -250,7 +268,7 @@ export class RoomListViewModel
250268
if (!currentRoomId) return;
251269

252270
const { delta, unread } = payload;
253-
const rooms = this.roomsResult.rooms;
271+
const rooms = this.roomsResult.sections.flatMap((section) => section.rooms);
254272

255273
const filteredRooms = unread
256274
? // Filter the rooms to only include unread ones and the active room
@@ -338,7 +356,9 @@ export class RoomListViewModel
338356
return undefined;
339357
}
340358

341-
const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId);
359+
const index = this.roomsResult.sections
360+
.flatMap((section) => section.rooms)
361+
.findIndex((room) => room.roomId === roomId);
342362
return index >= 0 ? index : undefined;
343363
}
344364

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

395415
private async updateRoomListData(
396416
isRoomChange: boolean = false,
@@ -400,14 +420,15 @@ export class RoomListViewModel
400420
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
401421
const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId();
402422

423+
// TODO to implement for sections
403424
// Apply sticky room logic to keep selected room at same position
404-
const stickyRooms = this.applyStickyRoom(isRoomChange, roomId);
425+
//const stickyRooms = this.applyStickyRoom(isRoomChange, roomId);
405426

406427
// Update roomsResult with sticky rooms
407-
this.roomsResult = {
408-
...this.roomsResult,
409-
rooms: stickyRooms,
410-
};
428+
// this.roomsResult = {
429+
// ...this.roomsResult,
430+
// rooms: stickyRooms,
431+
// };
411432

412433
// Rebuild roomsMap with the reordered rooms
413434
this.updateRoomsMap(this.roomsResult);
@@ -420,8 +441,11 @@ export class RoomListViewModel
420441

421442
// Build the complete state atomically to ensure consistency
422443
// roomIds and roomListState must always be in sync
423-
const roomIds = this.roomIds;
424-
const sections = [{ id: "all", roomIds }];
444+
//const roomIds = this.roomIds;
445+
const sections = this.roomsResult.sections.map(({ tag, rooms }) => ({
446+
id: tag,
447+
roomIds: rooms.map((room) => room.roomId),
448+
}));
425449

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

435459
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
436-
const isRoomListEmpty = roomIds.length === 0;
460+
const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0);
437461
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
438462

439463
// Single atomic snapshot update

0 commit comments

Comments
 (0)