Room list: add default sections (#32785)

* feat: add sections to RLSV3

* feat: add sections in vms

* feat: add room list section labs flag

* fix: wrong margin for room list item when in sections

* feat: hide favourites and low priority filters

* fix: crash when changing filter

* feat: support sticky room in sections

* test: update SC snapshot

* test: update SC screenshot

* test: update RLS tests

* test: add tests to RoomListSectionHeaderViewModel

* test: fix existing test in RoomListViewModel

* test: add sections tests for RoomListViewModel

* test: add e2e tests for sections

* fix: incorrect selected room when expanding/collasping a section

* fix: typo in `roomSkipList`

* feat: use one skip list with all filters instead of one list by tag

* chore: put back comment about `roomIndexInSection`

* chore: add missing `readonly`

* chore: add doc about possible undefined value for room item vm
This commit is contained in:
Florian Duros
2026-03-31 18:43:32 +00:00
committed by GitHub
parent 1974b50213
commit 0f515f581e
28 changed files with 1299 additions and 202 deletions
+6
View File
@@ -1567,6 +1567,7 @@
"render_reaction_images_description": "Sometimes referred to as \"custom emojis\".",
"report_to_moderators": "Report to moderators",
"report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.",
"room_list_sections": "Room list sections",
"share_history_on_invite": "Share encrypted history with new members",
"share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.",
"share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.",
@@ -2164,6 +2165,11 @@
"one": "Currently removing messages in %(count)s room",
"other": "Currently removing messages in %(count)s rooms"
},
"section": {
"chats": "Chats",
"favourites": "Favourites",
"low_priority": "Low Priority"
},
"show_less": "Show less",
"show_n_more": {
"one": "Show %(count)s more",
+10
View File
@@ -223,6 +223,7 @@ export interface Settings {
"feature_dynamic_room_predecessors": IFeature;
"feature_render_reaction_images": IFeature;
"feature_new_room_list": IFeature;
"feature_room_list_sections": IFeature;
"feature_ask_to_join": IFeature;
"feature_notifications": IFeature;
"feature_msc4362_encrypted_state_events": IFeature;
@@ -695,6 +696,15 @@ export const SETTINGS: Settings = {
default: true,
controller: new ReloadOnChangeController(),
},
"feature_room_list_sections": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
labsGroup: LabGroup.Ui,
displayName: _td("labs|room_list_sections"),
description: _td("labs|under_active_development"),
isFeature: true,
default: false,
controller: new ReloadOnChangeController(),
},
/**
* With the transition to Compound we are moving to a base font size
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
@@ -11,11 +11,10 @@ import { EventType } from "matrix-js-sdk/src/matrix";
import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import type { ActionPayload } from "../../dispatcher/payloads";
import type { FilterKey } from "./skip-list/filters";
import type { Filter, FilterKey } from "./skip-list/filters";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import SettingsStore from "../../settings/SettingsStore";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { RoomSkipList } from "./skip-list/RoomSkipList";
import { RecencySorter } from "./skip-list/sorters/RecencySorter";
import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
@@ -36,6 +35,11 @@ import { Action } from "../../dispatcher/actions";
import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
import { getChangedOverrideRoomMutePushRules } from "./utils";
import { isRoomVisible } from "./isRoomVisible";
import { RoomSkipList } from "./skip-list/RoomSkipList";
import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
/**
* These are the filters passed to the room skip list.
@@ -64,9 +68,25 @@ export type RoomsResult = {
// The filter queried
filterKeys?: FilterKey[];
// The resulting list of rooms
rooms: Room[];
sections: Section[];
};
/**
* Represents a named section of rooms in the room list, identified by a tag.
*/
export interface Section {
/** The tag that identifies this section. */
tag: string;
/** The ordered list of rooms belonging to this section. */
rooms: Room[];
}
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
*/
export const CHATS_TAG = "chats";
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
/**
@@ -75,7 +95,21 @@ export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
* This store is being actively developed so expect the methods to change in future.
*/
export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/**
* Contains all the rooms in the active space
*/
private roomSkipList?: RoomSkipList;
/**
* Maps section tags to their corresponding tag filters, used to determine which rooms belong in which sections.
*/
private readonly filterByTag: Map<string, Filter> = new Map();
/**
* Defines the display order of sections.
*/
private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority];
private readonly msc3946ProcessDynamicPredecessor: boolean;
/**
@@ -126,13 +160,17 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
*/
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult {
const spaceId = SpaceStore.instance.activeSpace;
if (this.roomSkipList?.initialized)
return {
spaceId: spaceId,
filterKeys,
rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)),
};
else return { spaceId: spaceId, filterKeys, rooms: [] };
const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections");
const sections = areSectionsEnabled
? this.getSections(filterKeys)
: [{ tag: CHATS_TAG, rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filterKeys) ?? []) }];
return {
spaceId: spaceId,
filterKeys,
sections,
};
}
/**
@@ -159,7 +197,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
protected async onReady(): Promise<any> {
if (this.roomSkipList?.initialized || !this.matrixClient) return;
const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId());
this.roomSkipList = new RoomSkipList(sorter, FILTERS);
this.roomSkipList = new RoomSkipList(sorter, this.getSkipListFilters());
await SpaceStore.instance.storeReadyPromise;
const rooms = this.getRooms();
this.roomSkipList.seed(rooms);
@@ -276,7 +316,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
const room = payload.room;
this.roomSkipList.removeRoom(room);
this.scheduleEmit();
break;
}
}
@@ -300,7 +339,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
}
this.roomSkipList!.reInsertRoom(room);
this.roomSkipList?.reInsertRoom(room);
needsEmit = true;
}
}
@@ -314,7 +353,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
.map((id) => this.matrixClient?.getRoom(id))
.filter((room) => !!room);
for (const room of rooms) {
this.roomSkipList!.reInsertRoom(room);
this.roomSkipList?.reInsertRoom(room);
needsEmit = true;
}
break;
@@ -395,6 +434,35 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.roomSkipList.calculateActiveSpaceForNodes();
this.scheduleEmit();
}
/**
* Get the list of filters to be used in the skip list, including the tag filters for sectioning.
*/
private getSkipListFilters(): Filter[] {
const tagsToExclude = this.sortedTags.filter((tag) => tag !== CHATS_TAG);
const tagFilters = this.sortedTags.map((tag) =>
tag === CHATS_TAG ? new ExcludeTagsFilter(tagsToExclude) : new TagFilter(tag),
);
this.sortedTags.forEach((tag, index) => this.filterByTag.set(tag, tagFilters[index]));
return [...FILTERS, ...tagFilters];
}
/**
* Get the sections to display in the room list, based on the current active space and the provided filters.
* @param filterKeys - Optional array of filters that the rooms must match against to be included in the sections.
* @returns An array of sections
*/
private getSections(filterKeys?: FilterKey[]): Section[] {
return this.sortedTags.map((tag) => {
const filters = filterBoolean([this.filterByTag.get(tag)?.key, ...(filterKeys || [])]);
return {
tag,
rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filters) || []),
};
});
}
}
export default class RoomListStoreV3 {
@@ -0,0 +1,22 @@
/*
* 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 Room } from "matrix-js-sdk/src/matrix";
import { type Filter, FilterEnum } from ".";
export class ExcludeTagsFilter implements Filter {
public constructor(private readonly tags: string[]) {}
public matches(room: Room): boolean {
return !this.tags.some((tag) => room.tags[tag]);
}
public get key(): FilterEnum.ExcludeTagsFilter {
return FilterEnum.ExcludeTagsFilter;
}
}
@@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { FilterEnum, type Filter } from ".";
import { DefaultTagID } from "../tag";
export class FavouriteFilter implements Filter {
@@ -14,7 +13,7 @@ export class FavouriteFilter implements Filter {
return !!room.tags[DefaultTagID.Favourite];
}
public get key(): FilterKey.FavouriteFilter {
return FilterKey.FavouriteFilter;
public get key(): FilterEnum.FavouriteFilter {
return FilterEnum.FavouriteFilter;
}
}
@@ -6,15 +6,14 @@ Please see LICENSE files in the repository root for full details.
import { type Room, KnownMembership } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { type Filter, FilterEnum } from ".";
export class InvitesFilter implements Filter {
public matches(room: Room): boolean {
return room.getMyMembership() === KnownMembership.Invite;
}
public get key(): FilterKey.InvitesFilter {
return FilterKey.InvitesFilter;
public get key(): FilterEnum.InvitesFilter {
return FilterEnum.InvitesFilter;
}
}
@@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { type Filter, FilterEnum } from ".";
import { DefaultTagID } from "../tag";
export class LowPriorityFilter implements Filter {
@@ -14,7 +13,7 @@ export class LowPriorityFilter implements Filter {
return !!room.tags[DefaultTagID.LowPriority];
}
public get key(): FilterKey.LowPriorityFilter {
return FilterKey.LowPriorityFilter;
public get key(): FilterEnum.LowPriorityFilter {
return FilterEnum.LowPriorityFilter;
}
}
@@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { type Filter, FilterEnum } from ".";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
export class MentionsFilter implements Filter {
@@ -14,7 +13,7 @@ export class MentionsFilter implements Filter {
return RoomNotificationStateStore.instance.getRoomState(room).isMention;
}
public get key(): FilterKey.MentionsFilter {
return FilterKey.MentionsFilter;
public get key(): FilterEnum.MentionsFilter {
return FilterEnum.MentionsFilter;
}
}
@@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { type Filter, FilterEnum } from ".";
import DMRoomMap from "../../../../utils/DMRoomMap";
export class PeopleFilter implements Filter {
@@ -15,7 +14,7 @@ export class PeopleFilter implements Filter {
return !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
}
public get key(): FilterKey.PeopleFilter {
return FilterKey.PeopleFilter;
public get key(): FilterEnum.PeopleFilter {
return FilterEnum.PeopleFilter;
}
}
@@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { type Filter, FilterEnum } from ".";
import DMRoomMap from "../../../../utils/DMRoomMap";
export class RoomsFilter implements Filter {
@@ -15,7 +14,7 @@ export class RoomsFilter implements Filter {
return !DMRoomMap.shared().getUserIdForRoomId(room.roomId);
}
public get key(): FilterKey.RoomsFilter {
return FilterKey.RoomsFilter;
public get key(): FilterEnum.RoomsFilter {
return FilterEnum.RoomsFilter;
}
}
@@ -0,0 +1,22 @@
/*
* 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 Room } from "matrix-js-sdk/src/matrix";
import { type Filter } from ".";
export class TagFilter implements Filter {
public constructor(private readonly tag: string) {}
public matches(room: Room): boolean {
return !!room.tags[this.tag];
}
public get key(): string {
return this.tag;
}
}
@@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Filter } from ".";
import { FilterKey } from ".";
import { type Filter, FilterEnum } from ".";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
import { getMarkedUnreadState } from "../../../../utils/notifications";
@@ -15,7 +14,7 @@ export class UnreadFilter implements Filter {
return RoomNotificationStateStore.instance.getRoomState(room).hasUnreadCount || !!getMarkedUnreadState(room);
}
public get key(): FilterKey.UnreadFilter {
return FilterKey.UnreadFilter;
public get key(): FilterEnum.UnreadFilter {
return FilterEnum.UnreadFilter;
}
}
@@ -6,16 +6,19 @@ Please see LICENSE files in the repository root for full details.
import type { Room } from "matrix-js-sdk/src/matrix";
export const enum FilterKey {
FavouriteFilter,
UnreadFilter,
PeopleFilter,
RoomsFilter,
LowPriorityFilter,
MentionsFilter,
InvitesFilter,
export const enum FilterEnum {
FavouriteFilter = "favourite",
UnreadFilter = "unread",
PeopleFilter = "people",
RoomsFilter = "rooms",
LowPriorityFilter = "low_priority",
MentionsFilter = "mentions",
InvitesFilter = "invites",
ExcludeTagsFilter = "exclude_tags",
}
export type FilterKey = FilterEnum | string;
export interface Filter {
/**
* Boolean return value indicates whether this room satisfies
@@ -0,0 +1,40 @@
/*
* 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 {
BaseViewModel,
type RoomListSectionHeaderActions,
type RoomListSectionHeaderViewSnapshot,
} from "@element-hq/web-shared-components";
interface RoomListSectionHeaderViewModelProps {
tag: string;
title: string;
onToggleExpanded: (isExpanded: boolean) => void;
}
export class RoomListSectionHeaderViewModel
extends BaseViewModel<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderViewModelProps>
implements RoomListSectionHeaderActions
{
public constructor(props: RoomListSectionHeaderViewModelProps) {
super(props, { id: props.tag, title: props.title, isExpanded: true });
}
public onClick = (): void => {
const isExpanded = !this.snapshot.current.isExpanded;
this.snapshot.merge({ isExpanded });
this.props.onToggleExpanded(isExpanded);
};
/**
* Whether the section is currently expanded or not.
*/
public get isExpanded(): boolean {
return this.snapshot.current.isExpanded;
}
}
@@ -11,6 +11,8 @@ import {
type FilterId,
type RoomListViewActions,
type RoomListViewState,
type RoomListSection,
_t,
} from "@element-hq/web-shared-components";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
@@ -19,43 +21,75 @@ import dispatcher from "../../dispatcher/dispatcher";
import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import SpaceStore from "../../stores/spaces/SpaceStore";
import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3";
import { FilterKey } from "../../stores/room-list-v3/skip-list/filters";
import RoomListStoreV3, {
CHATS_TAG,
RoomListStoreV3Event,
type RoomsResult,
type Section,
} from "../../stores/room-list-v3/RoomListStoreV3";
import { FilterEnum } from "../../stores/room-list-v3/skip-list/filters";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { RoomListItemViewModel } from "./RoomListItemViewModel";
import { SdkContextClass } from "../../contexts/SDKContext";
import { hasCreateRoomRights } from "./utils";
import { keepIfSame } from "../../utils/keepIfSame";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
import SettingsStore from "../../settings/SettingsStore";
/**
* Tracks the position of the active room within a specific section.
* Used to implement sticky room behaviour so the selected room doesn't
* jump around when the room list is re-sorted.
*/
interface StickyRoomPosition {
/** The tag of the section the room belongs to. */
sectionTag: string;
/** The index of the room within that section. */
indexInSection: number;
}
interface RoomListViewModelProps {
client: MatrixClient;
}
const filterKeyToIdMap: Map<FilterKey, FilterId> = new Map([
[FilterKey.UnreadFilter, "unread"],
[FilterKey.PeopleFilter, "people"],
[FilterKey.RoomsFilter, "rooms"],
[FilterKey.FavouriteFilter, "favourite"],
[FilterKey.MentionsFilter, "mentions"],
[FilterKey.InvitesFilter, "invites"],
[FilterKey.LowPriorityFilter, "low_priority"],
const filterKeyToIdMap: Map<FilterEnum, FilterId> = new Map([
[FilterEnum.UnreadFilter, "unread"],
[FilterEnum.PeopleFilter, "people"],
[FilterEnum.RoomsFilter, "rooms"],
[FilterEnum.FavouriteFilter, "favourite"],
[FilterEnum.MentionsFilter, "mentions"],
[FilterEnum.InvitesFilter, "invites"],
[FilterEnum.LowPriorityFilter, "low_priority"],
]);
const TAG_TO_TITLE_MAP: Record<string, string> = {
[DefaultTagID.Favourite]: _t("room_list|section|favourites"),
[CHATS_TAG]: _t("room_list|section|chats"),
[DefaultTagID.LowPriority]: _t("room_list|section|low_priority"),
};
export class RoomListViewModel
extends BaseViewModel<RoomListViewSnapshot, RoomListViewModelProps>
implements RoomListViewActions
{
// State tracking
private activeFilter: FilterKey | undefined = undefined;
private activeFilter: FilterEnum | undefined = undefined;
private roomsResult: RoomsResult;
private lastActiveRoomIndex: number | undefined = undefined;
/**
* List of sections to display in the room list, derived from roomsResult and section header view model expansion state.
*/
private sections: Section[] = [];
private lastActiveRoomPosition: StickyRoomPosition | undefined = undefined;
// Child view model management
private roomItemViewModels = new Map<string, RoomListItemViewModel>();
private readonly roomItemViewModels = new Map<string, RoomListItemViewModel>();
// This map is intentionally additive (never cleared except on space changes) to avoid a race condition:
// a list update can refresh roomsResult and roomsMap before the view re-renders, so the view may still
// request a view model for a room that was removed from the latest list. Keeping old entries prevents a crash.
private roomsMap = new Map<string, Room>();
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
public constructor(props: RoomListViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
@@ -63,14 +97,21 @@ export class RoomListViewModel
// Get initial rooms
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined);
const canCreateRoom = hasCreateRoomRights(props.client, activeSpace);
const filterIds = [...filterKeyToIdMap.values()];
const roomIds = roomsResult.rooms.map((room) => room.roomId);
const sections = [{ id: "all", roomIds }];
// Remove favourite and low priority filters if sections are enabled, as they are redundant with the sections
const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections");
const filterIds = [...filterKeyToIdMap.values()].filter(
(id) => !areSectionsEnabled || (id !== "favourite" && id !== "low_priority"),
);
// By default, all sections are expanded
const { sections, isFlatList } = computeSections(roomsResult, (tag) => true);
const isRoomListEmpty = roomsResult.sections.every((section) => section.rooms.length === 0);
super(props, {
// Initial view state - start with empty, will populate in async init
isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms,
isRoomListEmpty: roomsResult.rooms.length === 0,
isRoomListEmpty,
filterIds,
activeFilterId: undefined,
roomListState: {
@@ -78,13 +119,13 @@ export class RoomListViewModel
spaceId: roomsResult.spaceId,
filterKeys: undefined,
},
// Until we implement sections, this view model only supports the flat list mode
isFlatList: true,
sections,
isFlatList,
sections: toRoomListSection(sections),
canCreateRoom,
});
this.roomsResult = roomsResult;
this.sections = sections;
// Build initial roomsMap from roomsResult
this.updateRoomsMap(roomsResult);
@@ -120,7 +161,7 @@ export class RoomListViewModel
public onToggleFilter = (filterId: FilterId): void => {
// Find the FilterKey by matching the filter ID
let filterKey: FilterKey | undefined = undefined;
let filterKey: FilterEnum | undefined = undefined;
for (const [key, id] of filterKeyToIdMap.entries()) {
if (id === filterId) {
filterKey = key;
@@ -150,7 +191,7 @@ export class RoomListViewModel
* This maintains a quick lookup for room objects.
*/
private updateRoomsMap(roomsResult: RoomsResult): void {
for (const room of roomsResult.rooms) {
for (const room of roomsResult.sections.flatMap((section) => section.rooms)) {
this.roomsMap.set(room.roomId, room);
}
}
@@ -170,7 +211,7 @@ export class RoomListViewModel
* Get the ordered list of room IDs.
*/
public get roomIds(): string[] {
return this.roomsResult.rooms.map((room) => room.roomId);
return this.roomsResult.sections.flatMap((section) => section.rooms).map((room) => room.roomId);
}
/**
@@ -179,7 +220,7 @@ export class RoomListViewModel
* The view should call this only for visible rooms from the roomIds list.
* @throws Error if room is not found in roomsMap (indicates a programming error)
*/
public getRoomItemViewModel(roomId: string): RoomListItemViewModel {
public getRoomItemViewModel(roomId: string): RoomListItemViewModel | undefined {
// Check if we have a view model for this room
let viewModel = this.roomItemViewModels.get(roomId);
@@ -191,7 +232,11 @@ export class RoomListViewModel
room = this.roomsMap.get(roomId);
}
if (!room) throw new Error(`Room ${roomId} not found in roomsMap`);
if (!room) {
// Race condition: the room list has changed but the view hasn't re-rendered yet.
// Return undefined so the view can skip rendering this item.
return undefined;
}
// Create new view model
viewModel = new RoomListItemViewModel({
@@ -206,13 +251,17 @@ export class RoomListViewModel
return viewModel;
}
/**
* Not implemented - this view model does not support sections.
* Flat list mode is forced so this method is never be called.
* @throw Error if called
*/
public getSectionHeaderViewModel(): never {
throw new Error("Sections are not supported in this room list");
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
const title = TAG_TO_TITLE_MAP[tag] || tag;
const viewModel = new RoomListSectionHeaderViewModel({
tag,
title,
onToggleExpanded: () => this.updateRoomListData(),
});
this.roomSectionHeaderViewModels.set(tag, viewModel);
return viewModel;
}
/**
@@ -257,7 +306,7 @@ export class RoomListViewModel
if (!currentRoomId) return;
const { delta, unread } = payload;
const rooms = this.roomsResult.rooms;
const rooms = this.sections.flatMap((section) => section.rooms);
const filteredRooms = unread
? // Filter the rooms to only include unread ones and the active room
@@ -349,58 +398,74 @@ export class RoomListViewModel
return undefined;
}
const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId);
const index = this.sections.flatMap((section) => section.rooms).findIndex((room) => room.roomId === roomId);
return index >= 0 ? index : undefined;
}
/**
* Apply sticky room logic to keep the active room at the same index position.
* Find the position of a room within the sections list.
* Returns undefined if the room is not found.
*/
private findRoomPosition(sections: Section[], roomId: string): StickyRoomPosition | undefined {
for (const section of sections) {
const idx = section.rooms.findIndex((room) => room.roomId === roomId);
if (idx !== -1) return { sectionTag: section.tag, indexInSection: idx };
}
return undefined;
}
/**
* Apply sticky room logic to keep the active room at the same position within its section.
* When the room list updates, this prevents the selected room from jumping around in the UI.
*
* @param isRoomChange - Whether this update is due to a room change (not a list update)
* @param roomId - The room ID to apply sticky logic for (can be null/undefined)
* @returns The modified rooms array with sticky positioning applied
* @returns The modified sections array with sticky positioning applied
*/
private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] {
const rooms = this.roomsResult.rooms;
if (!roomId) {
return rooms;
}
const newIndex = rooms.findIndex((room) => room.roomId === roomId);
const oldIndex = this.lastActiveRoomIndex;
private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Section[] {
const sections = this.roomsResult.sections;
// When opening another room, the index should obviously change
if (isRoomChange) {
return rooms;
}
if (!roomId || isRoomChange) return sections;
// If oldIndex is undefined, then there was no active room before
// Similarly, if newIndex is -1, the active room is not in the current list
if (newIndex === -1 || oldIndex === undefined) {
return rooms;
}
// If there was no previously tracked position, nothing to stick to
const oldPosition = this.lastActiveRoomPosition;
if (!oldPosition) return sections;
// If the index hasn't changed, we have nothing to do
if (newIndex === oldIndex) {
return rooms;
}
const newPosition = this.findRoomPosition(sections, roomId);
// If the old index falls out of the bounds of the rooms array
// (usually because rooms were removed), we can no longer place
// the active room in the same old index
if (oldIndex > rooms.length - 1) {
return rooms;
}
// If the room is no longer in the list, nothing to do
if (!newPosition) return sections;
// Making the active room sticky is as simple as removing it from
// its new index and placing it in the old index
const newRooms = [...rooms];
const [stickyRoom] = newRooms.splice(newIndex, 1);
newRooms.splice(oldIndex, 0, stickyRoom);
// If the room moved to a different section, this is an intentional structural
// change (e.g. favourited/unfavourited), so don't apply sticky logic
if (newPosition.sectionTag !== oldPosition.sectionTag) return sections;
return newRooms;
// If the index within the section hasn't changed, nothing to do
if (newPosition.indexInSection === oldPosition.indexInSection) return sections;
// Find the target section and apply the sticky swap within it
return sections.map((section) => {
// Different section - no change
if (section.tag !== oldPosition.sectionTag) return section;
const sectionRooms = section.rooms;
// If the old index falls out of the bounds of the section
// (usually because rooms were removed), we can no longer place
// the active room in the same old position
if (oldPosition.indexInSection > sectionRooms.length - 1) {
return section;
}
// Making the active room sticky is as simple as removing it from
// its new index and placing it in the old index within the section
const newRooms = [...sectionRooms];
const [stickyRoom] = newRooms.splice(newPosition.indexInSection, 1);
newRooms.splice(oldPosition.indexInSection, 0, stickyRoom);
return { ...section, rooms: newRooms };
});
}
private async updateRoomListData(
@@ -411,28 +476,30 @@ export class RoomListViewModel
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId();
// Apply sticky room logic to keep selected room at same position
const stickyRooms = this.applyStickyRoom(isRoomChange, roomId);
// Apply sticky room logic to keep selected room at same position within its section
const stickySections = this.applyStickyRoom(isRoomChange, roomId);
// Update roomsResult with sticky rooms
// Update roomsResult with the sticky-adjusted sections
this.roomsResult = {
...this.roomsResult,
rooms: stickyRooms,
sections: stickySections,
};
// Rebuild roomsMap with the reordered rooms
this.updateRoomsMap(this.roomsResult);
// Calculate the active room index after applying sticky logic
const activeRoomIndex = this.getActiveRoomIndex(roomId);
// Track the current active room index for future sticky calculations
this.lastActiveRoomIndex = activeRoomIndex;
// Track the current active room position for future sticky calculations
this.lastActiveRoomPosition = roomId ? this.findRoomPosition(this.roomsResult.sections, roomId) : undefined;
// Build the complete state atomically to ensure consistency
// roomIds and roomListState must always be in sync
const roomIds = this.roomIds;
const sections = [{ id: "all", roomIds }];
const { sections, isFlatList } = computeSections(
this.roomsResult,
(tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true,
);
this.sections = sections;
// Calculate the active room index from the computed sections (which exclude collapsed sections' rooms)
const activeRoomIndex = this.getActiveRoomIndex(roomId);
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
@@ -444,16 +511,20 @@ export class RoomListViewModel
};
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
const isRoomListEmpty = roomIds.length === 0;
const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0);
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
const viewSections = toRoomListSection(this.sections);
const previousSections = this.snapshot.current.sections;
// Single atomic snapshot update
this.snapshot.merge({
isLoadingRooms,
isRoomListEmpty,
activeFilterId,
roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState),
sections: keepIfSame(this.snapshot.current.sections, sections),
sections: keepIfSame(previousSections, viewSections),
isFlatList,
});
}
@@ -475,3 +546,36 @@ export class RoomListViewModel
}
};
}
/**
* Compute the sections to display in the room list based on the rooms result and section expansion state.
* @param roomsResult - The current rooms result containing sections and rooms
* @param isSectionExpanded - A function that takes a section tag and returns whether that section is currently expanded
* @returns An object containing the computed sections (with rooms removed for collapsed sections) and a boolean indicating if this is a flat list (only one section with all rooms)
*/
function computeSections(
roomsResult: RoomsResult,
isSectionExpanded: (tag: string) => boolean,
): { sections: Section[]; isFlatList: boolean } {
const sections = roomsResult.sections
// Only include sections that have rooms
.filter((section) => section.rooms.length > 0)
// Remove roomIds for sections that are currently collapsed according to their section header view model
.map((section) => ({
...section,
rooms: isSectionExpanded(section.tag) ? section.rooms : [],
}));
const isFlatList = sections.length === 1 && sections[0].tag === CHATS_TAG;
return { sections, isFlatList };
}
/**
* Convert from the internal Section type used in the view model to the RoomListSection type used in the snapshot.
*/
function toRoomListSection(sections: Section[]): RoomListSection[] {
return sections.map(({ tag, rooms }) => ({
id: tag,
roomIds: rooms.map((room) => room.roomId),
}));
}