Room list: add custom section creation (#33155)

* feat: add creation section dialog

* feat: add in skip list a method to change filters

* feat: add helper to creation section

* feat: add custom sections data to Settings

* feat: add custom section to room list store v3

* feat: update header and room list item vms

* feat: add toast to room list vm

* feat: add new translation

* chore: move util functions of room list specs

* test: add custom section playwright tests

* chore: call loadCustomSections in RoomListStoreV3 ctor
This commit is contained in:
Florian Duros 2026-04-17 14:02:42 +02:00 committed by GitHub
parent 73d4b63ada
commit 6b67b24254
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 985 additions and 97 deletions

View File

@ -0,0 +1,178 @@
/*
* 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 Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test";
import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
test.describe("Room list custom sections", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list", "feature_room_list_sections"],
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
},
});
/**
* Create a custom section via the header compose menu and dialog.
* @param page
* @param sectionName The name of the section to create
*/
async function createCustomSection(page: Page, sectionName: string): Promise<void> {
const composeMenu = getRoomListHeader(page).getByRole("button", { name: "New conversation" });
await composeMenu.click();
await page.getByRole("menuitem", { name: "New section" }).click();
// Fill in the section name in the dialog
const dialog = page.getByRole("dialog", { name: "Create a section" });
await expect(dialog).toBeVisible();
await dialog.getByRole("textbox", { name: "Section name" }).fill(sectionName);
await dialog.getByRole("button", { name: "Create section" }).click();
// Wait for the dialog to close
await expect(dialog).not.toBeVisible();
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
// Focus the user menu to avoid hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
});
test.describe("Section creation", () => {
test("should create a custom section via the header compose menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
// The custom section header should be visible (even though it is empty)
await expect(getSectionHeader(page, "Work")).toBeVisible();
// The Chats section should also be visible
await expect(getSectionHeader(page, "Chats")).toBeVisible();
});
test("should show 'Section created' toast after creating a section", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Personal");
// The "Section created" toast should appear
await expect(page.getByText("Section created")).toBeVisible();
});
test("should create a custom section via the room option menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
const roomList = getRoomList(page);
const roomItem = roomList.getByRole("option", { name: "Open room my room" });
await expect(roomItem).toBeVisible();
// Open the More Options menu
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
// Open the "Move to" submenu
await page.getByRole("menuitem", { name: "Move to" }).hover();
// Click on "New section"
await page.getByRole("menuitem", { name: "New section" }).click();
// Fill in the section name in the dialog
const dialog = page.getByRole("dialog", { name: "Create a section" });
await expect(dialog).toBeVisible();
await dialog.getByRole("textbox", { name: "Section name" }).fill("Projects");
await dialog.getByRole("button", { name: "Create section" }).click();
// Wait for the dialog to close
await expect(dialog).not.toBeVisible();
// The custom section should be created
await expect(getSectionHeader(page, "Projects")).toBeVisible();
});
test("should cancel section creation when dialog is dismissed", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
const composeMenu = getRoomListHeader(page).getByRole("button", { name: "New conversation" });
await composeMenu.click();
await page.getByRole("menuitem", { name: "New section" }).click();
// The dialog should appear
const dialog = page.getByRole("dialog", { name: "Create a section" });
await expect(dialog).toBeVisible();
// Cancel the dialog
await dialog.getByRole("button", { name: "Cancel" }).click();
// The dialog should close
await expect(dialog).not.toBeVisible();
// No custom section should be created - should remain a flat list
await expect(getSectionHeader(page, "Chats")).not.toBeVisible();
});
test("should create multiple custom sections", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");
// Both custom sections should be visible
await expect(getSectionHeader(page, "Work")).toBeVisible();
await expect(getSectionHeader(page, "Personal")).toBeVisible();
await expect(getSectionHeader(page, "Chats")).toBeVisible();
});
});
test.describe("Custom section display", () => {
test("should show empty custom sections", async ({ page, app }) => {
// Create a room so the Chats section has something
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Empty Section");
// The custom section should be visible even with no rooms
await expect(getSectionHeader(page, "Empty Section")).toBeVisible();
// The room should still be in the Chats section
const roomList = getRoomList(page);
await expect(roomList.getByRole("row", { name: "Open room my room" })).toBeVisible();
});
test("should display custom sections between Favourites and Chats", async ({ page, app }) => {
// Create a favourite room
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
// Create a low priority room
const lowPrioId = await app.client.createRoom({ name: "low prio room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.lowpriority");
}, lowPrioId);
// Create a regular room
await app.client.createRoom({ name: "regular room" });
// Create a custom section
await createCustomSection(page, "Work");
// All section headers should be visible
await expect(getSectionHeader(page, "Favourites")).toBeVisible();
await expect(getSectionHeader(page, "Work")).toBeVisible();
// Should be expanded by default
await expect(getSectionHeader(page, "Work")).toHaveAttribute("aria-expanded", "true");
await expect(getSectionHeader(page, "Chats")).toBeVisible();
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
});
});
});

View File

@ -6,10 +6,11 @@
*/
import { type Visibility } from "matrix-js-sdk/src/matrix";
import { type Locator, type Page } from "@playwright/test";
import { type Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { getFilterCollapseButton, getFilterExpandButton, getPrimaryFilters, getRoomOptionsMenu } from "./utils";
test.describe("Room list filters and sort", () => {
test.use({
@ -21,22 +22,6 @@ test.describe("Room list filters and sort", () => {
labsFlags: ["feature_new_room_list"],
});
function getPrimaryFilters(page: Page): Locator {
return page.getByTestId("primary-filters");
}
function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}
function getFilterExpandButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
}
function getFilterCollapseButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
}
/**
* Get the room list
* @param page

View File

@ -6,21 +6,13 @@
*/
import { test, expect } from "../../../element-web-test";
import type { Page } from "@playwright/test";
import { getHeaderSection } from "./utils";
test.describe("Header section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the header section of the room list
* @param page
*/
function getHeaderSection(page: Page) {
return page.getByTestId("room-list-header");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();

View File

@ -5,23 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
import { getRoomListView } from "./utils";
test.describe("Room list panel", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list view
* @param page
*/
function getRoomListView(page: Page) {
return page.getByRole("navigation", { name: "Room list" });
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();

View File

@ -5,23 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
import { getSearchSection } from "./utils";
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the search section of the room list
* @param page
*/
function getSearchSection(page: Page) {
return page.getByRole("search");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();

View File

@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test";
import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
test.describe("Room list sections", () => {
test.use({
@ -19,34 +18,6 @@ test.describe("Room list sections", () => {
},
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page): Locator {
return page.getByTestId("room-list");
}
/**
* Get the primary filters
* @param page
*/
function getPrimaryFilters(page: Page): Locator {
return page.getByTestId("primary-filters");
}
/**
* Get a section header toggle button by section name
* @param page
* @param sectionName The display name of the section (e.g. "Favourites", "Chats", "Low Priority")
* @param isUnread Whether to look for the unread version of the section header
*/
function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
return getRoomList(page).getByRole("gridcell", {
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
});
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();

View File

@ -10,6 +10,7 @@ import { type Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test";
import { type Bot } from "../../../pages/bot";
import { type ElementAppPage } from "../../../pages/ElementAppPage";
import { getRoomList } from "./utils";
test.describe("Room list", () => {
test.use({
@ -20,14 +21,6 @@ test.describe("Room list", () => {
},
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();

View File

@ -0,0 +1,92 @@
/*
* 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 Locator, type Page } from "@playwright/test";
/**
* Get the room list
* @param page
*/
export function getRoomList(page: Page): Locator {
return page.getByTestId("room-list");
}
/**
* Get the room list header
* @param page
*/
export function getRoomListHeader(page: Page): Locator {
return page.getByTestId("room-list-header");
}
/**
* Get a section header toggle button by section name
* @param page
* @param sectionName The display name of the section
* @param isUnread Whether to look for the unread version of the section header
*/
export function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
return getRoomList(page).getByRole("gridcell", {
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
});
}
/**
* Get the primary filters container
* @param page
*/
export function getPrimaryFilters(page: Page): Locator {
return page.getByTestId("primary-filters");
}
/**
* Get the room options menu button in the room list header
* @param page
*/
export function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}
/**
* Get the filter list expand button in the room list header
* @param page
*/
export function getFilterExpandButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
}
/**
* Get the filter list collapse button in the room list header
* @param page
*/
export function getFilterCollapseButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
}
/**
* Get the header section of the room list
* @param page
*/
export function getHeaderSection(page: Page) {
return page.getByTestId("room-list-header");
}
/**
* Get the room list view
* @param page
*/
export function getRoomListView(page: Page) {
return page.getByRole("navigation", { name: "Room list" });
}
/**
* Get the search section of the room list
* @param page
*/
export function getSearchSection(page: Page) {
return page.getByRole("search");
}

View File

@ -128,6 +128,7 @@
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
@import "./views/dialogs/_CreateRoomDialog.pcss";
@import "./views/dialogs/_CreateSectionDialog.pcss";
@import "./views/dialogs/_CreateSubspaceDialog.pcss";
@import "./views/dialogs/_Crypto.pcss";
@import "./views/dialogs/_DeactivateAccountDialog.pcss";

View File

@ -0,0 +1,23 @@
/*
* 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.
*/
.mx_CreateSectionDialog {
color: var(--cpd-color-text-primary);
&.mx_Dialog_fixedWidth {
/* 576px coming from Figma and remove external padding */
max-width: calc(576px - var(--cpd-space-20x));
}
.mx_CreateSectionDialog_content {
min-height: 346px;
}
.mx_CreateSectionDialog_form {
width: 100%;
}
}

View File

@ -0,0 +1,65 @@
/*
* 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 React, { useState, type JSX } from "react";
import { Flex } from "@element-hq/web-shared-components";
import { Form, Text } from "@vector-im/compound-web";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { _t } from "../../../languageHandler";
interface CreateSectionDialogProps {
/**
* Callback called when the dialog is closed.
* @param shouldCreateSection Whether a section should be created or not. This will be false if the user cancels the dialog.
* @param sectionName The name of the section to create.
*/
onFinished: (shouldCreateSection: boolean, sectionName: string) => void;
}
/**
* Dialog shown to the user to create a new section in the room list.
*/
export function CreateSectionDialog({ onFinished }: CreateSectionDialogProps): JSX.Element {
const [value, setValue] = useState("");
const isInvalid = Boolean(value.trim().length === 0);
return (
<BaseDialog
className="mx_CreateSectionDialog"
onFinished={() => onFinished(false, value)}
title={_t("create_section_dialog|title")}
hasCancel={true}
>
<Flex gap="var(--cpd-space-6x)" direction="column" className="mx_CreateSectionDialog_content">
<Text as="span" weight="semibold">
{_t("create_section_dialog|description")}
</Text>
<Form.Root
className="mx_CreateSectionDialog_form"
onSubmit={(e) => {
onFinished(true, value);
e.preventDefault();
}}
>
<Form.Field name="sectionName">
<Form.Label> {_t("create_section_dialog|label")}</Form.Label>
<Form.TextControl onChange={(evt) => setValue(evt.target.value)} required={true} />
</Form.Field>
</Form.Root>
</Flex>
<DialogButtons
primaryButton={_t("create_section_dialog|create_section")}
primaryDisabled={isInvalid}
hasCancel={true}
onCancel={() => onFinished(false, "")}
onPrimaryButtonClick={() => onFinished(true, value)}
/>
</BaseDialog>
);
}

View File

@ -681,6 +681,12 @@
"unfederated_label_default_on": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.",
"unsupported_version": "The server does not support the room version specified."
},
"create_section_dialog": {
"create_section": "Create section",
"description": "Sections are only for you",
"label": "Section name",
"title": "Create a section"
},
"create_space": {
"add_details_prompt": "Add some details to help people recognise it.",
"add_details_prompt_2": "You can change these anytime.",

View File

@ -52,6 +52,7 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
export const defaultWatchManager = new WatchManager();
@ -373,6 +374,8 @@ export interface Settings {
"inviteRules": IBaseSetting<ComputedInviteConfig>;
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
}
export type SettingKey = keyof Settings;
@ -1371,6 +1374,22 @@ export const SETTINGS: Settings = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: {},
},
/**
* Managed by the {@link RoomListStoreV3}
* Store the custom section data for the room list
*/
"RoomList.CustomSectionData": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: {},
},
/**
* Managed by the {@link RoomListStoreV3}
* Store the ordering of the custom sections for the room list
*/
"RoomList.OrderedCustomSections": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: [],
},
[UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE,
default: true,

View File

@ -40,6 +40,7 @@ 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";
import { createSection } from "./section";
/**
* These are the filters passed to the room skip list.
@ -59,6 +60,8 @@ export enum RoomListStoreV3Event {
ListsUpdate = "lists_update",
// The event which is called when the room list is loaded.
ListsLoaded = "lists_loaded",
/** Fired when a new section is created in the room list. */
SectionCreated = "section_created",
}
// The result object for returning rooms from the store
@ -89,6 +92,8 @@ export const CHATS_TAG = "chats";
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
/**
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
* This is the third such implementation hence the "V3".
@ -108,7 +113,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/**
* Defines the display order of sections.
*/
private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority];
private sortedTags: string[] = [];
private readonly msc3946ProcessDynamicPredecessor: boolean;
@ -125,6 +130,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.onActiveSpaceChanged();
});
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged());
SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => this.onOrderedCustomSectionsChange());
this.loadCustomSections();
}
/**
@ -463,6 +470,37 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
};
});
}
/**
* Handle changes to the order of custom sections.
* Reloads the custom sections, updates the skip list filters to reflect the new order and emits an update.
* Emit {@link LISTS_UPDATE_EVENT}.
*/
private onOrderedCustomSectionsChange(): void {
this.loadCustomSections();
if (!this.roomSkipList) return;
this.roomSkipList.useNewFilters(this.getSkipListFilters());
this.scheduleEmit();
}
/**
* Create a new section.
* Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created.
*/
public async createSection(): Promise<void> {
const sectionIsCreated = await createSection();
if (!sectionIsCreated) return;
this.emit(SECTION_CREATED_EVENT);
this.scheduleEmit();
}
/**
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
}
}
export default class RoomListStoreV3 {

View File

@ -0,0 +1,59 @@
/*
* 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 { v4 as uuidv4 } from "uuid";
import { SettingLevel } from "../../settings/SettingLevel";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
type Tag = string;
/**
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
*/
type CustomSection = {
tag: Tag;
name: string;
};
/**
* The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag).
*/
export type CustomSectionsData = Record<Tag, CustomSection>;
/**
* Ordered list of custom section tags.
*/
export type OrderedCustomSections = Tag[];
/**
* Creates a new custom section by showing a dialog to the user to enter the section name.
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
*
* @return A promise that resolves to true if the section was created, or false if the user cancelled the creation or if there was an error.
*/
export async function createSection(): Promise<boolean> {
const modal = Modal.createDialog(CreateSectionDialog);
const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return false;
const tag = `element.io.section.${uuidv4()}`;
const newSection: CustomSection = { tag, name: sectionName };
// Save the new section data
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
sectionData[tag] = newSection;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
// Add the new section to the ordered list of sections
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return true;
}

View File

@ -76,6 +76,17 @@ export class RoomSkipList implements Iterable<Room> {
this.seed(rooms);
}
/**
* Change the filters used by the skip list.
* This will apply the new filters to all existing nodes.
*/
public useNewFilters(filters: Filter[]): void {
this.filters = filters;
for (const node of this.roomNodeMap.values()) {
node.applyFilters(this.filters);
}
}
/**
* Removes a given room from the skip list.
*/

View File

@ -201,7 +201,7 @@ export class RoomListHeaderViewModel
};
public createSection = (): void => {
// To be implemented when custom section creation is added in vms
RoomListStoreV3.instance.createSection();
};
}
/**
@ -275,6 +275,10 @@ function computeHeaderSpaceState(
);
const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections");
const useComposeIcon = !isSectionFeatureEnabled;
const canCreateSection = isSectionFeatureEnabled;
return {
title,
canCreateRoom,
@ -283,8 +287,7 @@ function computeHeaderSpaceState(
displaySpaceMenu,
canInviteInSpace,
canAccessSpaceSettings,
// To be implemented when custom section creation is added in vms
canCreateSection: false,
useComposeIcon: true,
canCreateSection,
useComposeIcon,
};
}

View File

@ -37,6 +37,7 @@ import { Action } from "../../dispatcher/actions";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../PosthogTrackers";
import { type Call, CallEvent } from "../../models/Call";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
interface RoomItemProps {
room: Room;
@ -276,6 +277,8 @@ export class RoomListItemViewModel
const callType =
call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined;
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
return {
id: room.roomId,
room,
@ -303,8 +306,7 @@ export class RoomListItemViewModel
canMarkAsRead,
canMarkAsUnread,
roomNotifState,
// To be implemented when custom section creation is added in vms
canMoveToSection: false,
canMoveToSection,
};
}
@ -385,6 +387,6 @@ export class RoomListItemViewModel
};
public onCreateSection = (): void => {
// To be implemented when custom section creation is added in vms
RoomListStoreV3.instance.createSection();
};
}

View File

@ -91,6 +91,11 @@ export class RoomListViewModel
// 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>();
/**
* Reference to the currently displayed toast, used to automatically close the toast after a timeout.
*/
private toastRef?: number;
public constructor(props: RoomListViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
@ -144,6 +149,13 @@ export class RoomListViewModel
this.onListsLoaded,
);
// Subscribe to section creation
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.SectionCreated as any,
this.onSectionCreated,
);
// Subscribe to active room changes to update selected room
const dispatcherRef = dispatcher.register(this.onDispatch);
this.disposables.track(() => {
@ -264,7 +276,8 @@ export class RoomListViewModel
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
const title = TAG_TO_TITLE_MAP[tag] || tag;
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
const title = TAG_TO_TITLE_MAP[tag] || customSections[tag]?.name || tag;
const viewModel = new RoomListSectionHeaderViewModel({
tag,
title,
@ -573,7 +586,19 @@ export class RoomListViewModel
}
};
public onSectionCreated = (): void => {
clearTimeout(this.toastRef);
this.snapshot.merge({
toast: "section_created",
});
// Automatically close the toast after 15 seconds
this.toastRef = setTimeout(() => {
this.closeToast();
}, 15 * 1000);
};
public closeToast: () => void = () => {
clearTimeout(this.toastRef);
this.snapshot.merge({
toast: undefined,
});
@ -590,9 +615,11 @@ function computeSections(
roomsResult: RoomsResult,
isSectionExpanded: (tag: string) => boolean,
): { sections: Section[]; isFlatList: boolean } {
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
const sections = roomsResult.sections
// Only include sections that have rooms
.filter((section) => section.rooms.length > 0)
// Only include sections that have rooms or are custom sections (which may be empty but should still be shown)
.filter((section) => section.rooms.length > 0 || customSections[section.tag])
// Remove roomIds for sections that are currently collapsed according to their section header view model
.map((section) => ({
...section,

View File

@ -0,0 +1,59 @@
/*
* 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 { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { CreateSectionDialog } from "../../../../../src/components/views/dialogs/CreateSectionDialog";
describe("CreateSectionDialog", () => {
const onFinished: jest.Mock = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
function renderComponent(): void {
render(<CreateSectionDialog onFinished={onFinished} />);
}
it("renders the dialog", () => {
const { container } = render(<CreateSectionDialog onFinished={onFinished} />);
expect(container).toMatchSnapshot();
});
it("has the create section button disabled when the input is empty", () => {
renderComponent();
const createButton = screen.getByRole("button", { name: "Create section" });
expect(createButton).toBeDisabled();
});
it("calls onFinished with true and the section name when create section is clicked", async () => {
renderComponent();
const input = screen.getByRole("textbox");
await userEvent.type(input, "My section");
const createButton = screen.getByRole("button", { name: "Create section" });
await userEvent.click(createButton);
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
it("calls onFinished with false when the dialog is cancelled", async () => {
renderComponent();
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(onFinished).toHaveBeenCalledWith(false, "");
});
it("calls onFinished with true and the section name when the form is submitted", async () => {
renderComponent();
const input = screen.getByRole("textbox");
await userEvent.type(input, "My section");
await userEvent.keyboard("{Enter}");
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
});

View File

@ -0,0 +1,106 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CreateSectionDialog renders the dialog 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateSectionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Create a section
</h1>
</div>
<div
class="_flex_4dswl_9 mx_CreateSectionDialog_content"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Sections are only for you
</span>
<form
class="_root_19upo_16 mx_CreateSectionDialog_form"
>
<div
class="_field_19upo_26"
>
<label
class="_label_19upo_59"
for="radix-_r_0_"
>
Section name
</label>
<input
class="_control_sqdq4_10"
id="radix-_r_0_"
name="sectionName"
required=""
title=""
/>
</div>
</form>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
disabled=""
type="button"
>
Create section
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;

View File

@ -14,9 +14,11 @@ import type { RoomNotificationState } from "../../../../src/stores/notifications
import {
CHATS_TAG,
LISTS_UPDATE_EVENT,
SECTION_CREATED_EVENT,
RoomListStoreV3Class,
type Section,
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import * as sectionModule from "../../../../src/stores/room-list-v3/section";
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
@ -830,6 +832,7 @@ describe("RoomListStoreV3", () => {
function enableSections(): void {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
return false;
});
}
@ -1007,6 +1010,84 @@ describe("RoomListStoreV3", () => {
const favSection = findSection(sections, DefaultTagID.Favourite)!;
expect(favSection.rooms).toContain(rooms[3]);
});
describe("createSection", () => {
it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(true);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const sectionCreatedListener = jest.fn();
const listsUpdateListener = jest.fn();
store.on(SECTION_CREATED_EVENT, sectionCreatedListener);
store.on(LISTS_UPDATE_EVENT, listsUpdateListener);
await store.createSection();
expect(sectionCreatedListener).toHaveBeenCalled();
expect(listsUpdateListener).toHaveBeenCalled();
});
it("does not emit when section creation is cancelled", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(false);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const sectionCreatedListener = jest.fn();
store.on(SECTION_CREATED_EVENT, sectionCreatedListener);
await store.createSection();
expect(sectionCreatedListener).not.toHaveBeenCalled();
});
});
it("updates sections when RoomList.OrderedCustomSections setting changes", async () => {
enableSections();
const { rooms } = getClientAndRooms();
let settingsWatcher: (settingName: string) => void = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => {
if (settingName === "RoomList.OrderedCustomSections") settingsWatcher = callback as () => void;
return "watcher-id";
});
const customTag = "element.io.section.custom";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
return false;
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
// Initial state: 3 sections (Favourite, Chats, LowPriority)
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(3);
// Mark a room with the custom tag and update the settings
rooms[0].tags = { [customTag]: { order: 0 } };
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [customTag];
return false;
});
// Trigger the settings watcher
settingsWatcher("RoomList.OrderedCustomSections");
// Now there should be 4 sections (Favourite, custom, Chats, LowPriority)
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4);
const customSection = findSection(store.getSortedRoomsInActiveSpace().sections, customTag)!;
expect(customSection.rooms).toContain(rooms[0]);
});
});
describe("Muted rooms", () => {

View File

@ -0,0 +1,71 @@
/*
* 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 Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { createSection } from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
[false, "", false],
[true, "", false],
[true, "My Section", true],
])("returns %s when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([shouldCreate, name]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toBe(expected);
});
it("opens the CreateSectionDialog", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]),
close: jest.fn(),
} as any);
await createSection();
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog);
});
it("saves section data and ordered sections at ACCOUNT level when confirmed", async () => {
const existingTag = "element.io.section.existing";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [existingTag];
return null;
});
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await createSection();
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = Object.values(customDataCall![3] as Record<string, { tag: string; name: string }>)[0];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toMatch(/^element\.io\.section\./);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
const savedOrder = orderedCall![3] as string[];
expect(savedOrder[0]).toBe(existingTag);
expect(savedOrder[1]).toMatch(/^element\.io\.section\./);
});
});

View File

@ -18,6 +18,9 @@ import { getMockedRooms } from "./getMockedRooms";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../../../../src/stores/spaces";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
import { FavouriteFilter } from "../../../../../src/stores/room-list-v3/skip-list/filters/FavouriteFilter";
import { FilterEnum } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
describe("RoomSkipList", () => {
function generateSkipList(roomCount?: number): {
@ -99,6 +102,26 @@ describe("RoomSkipList", () => {
expect(() => skipList.addNewRoom(room)).toThrow("Can't add room to skiplist");
});
it("Filters are applied to existing nodes when useNewFilters is called", () => {
const { skipList, rooms } = generateSkipList(10);
// Mark some rooms as favourite
const favouriteRooms = [rooms[2], rooms[5], rooms[8]];
for (const room of favouriteRooms) {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
}
// No filters yet — all rooms are in the list
expect(skipList.size).toEqual(10);
// Apply the favourite filter
skipList.useNewFilters([new FavouriteFilter()]);
// Only favourite rooms should be returned when filtering by favourite
const filteredRooms = Array.from(skipList.getRoomsInActiveSpace([FilterEnum.FavouriteFilter]));
expect(filteredRooms).toHaveLength(favouriteRooms.length);
});
it("Re-sort works when sorter is swapped", () => {
const { skipList, rooms, sorter } = generateSkipList();
const sortedByRecency = [...rooms].sort((a, b) => sorter.comparator(a, b));

View File

@ -61,6 +61,7 @@ describe("RoomListHeaderViewModel", () => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
if (settingName === "feature_video_rooms") return true;
if (settingName === "feature_element_call_video_rooms") return true;
if (settingName === "RoomList.OrderedCustomSections") return [];
return false;
});
});
@ -159,6 +160,23 @@ describe("RoomListHeaderViewModel", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
});
it.each([
[true, true, false],
[false, false, true],
])(
"when feature_room_list_sections is %s: canCreateSection=%s, useComposeIcon=%s",
(featureEnabled, expectedCanCreateSection, expectedUseComposeIcon) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_room_list_sections") return featureEnabled;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canCreateSection).toBe(expectedCanCreateSection);
expect(vm.getSnapshot().useComposeIcon).toBe(expectedUseComposeIcon);
},
);
});
describe("event listeners", () => {
@ -296,6 +314,13 @@ describe("RoomListHeaderViewModel", () => {
);
});
it("should call createSection on RoomListStoreV3 when createSection is called", () => {
const createSectionSpy = jest.spyOn(RoomListStoreV3.instance, "createSection").mockResolvedValue();
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createSection();
expect(createSectionSpy).toHaveBeenCalled();
});
it("should toggle message preview from enabled to disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.showMessagePreview") return true;

View File

@ -29,6 +29,7 @@ import { Action } from "../../../src/dispatcher/actions";
import { CallStore } from "../../../src/stores/CallStore";
import { CallEvent, type Call } from "../../../src/models/Call";
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
@ -75,6 +76,7 @@ describe("RoomListItemViewModel", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
if (setting === "RoomList.OrderedCustomSections") return [];
return false;
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
@ -501,6 +503,20 @@ describe("RoomListItemViewModel", () => {
});
});
describe("canMoveToSection", () => {
it.each([
[true, true],
[false, false],
])("should be %s when feature_room_list_sections is %s", (featureEnabled, expected) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "feature_room_list_sections") return featureEnabled;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().canMoveToSection).toBe(expected);
});
});
describe("Actions", () => {
it("should dispatch view room action on openRoom", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
@ -572,6 +588,13 @@ describe("RoomListItemViewModel", () => {
room_id: "!room:server",
});
});
it("should call createSection on RoomListStoreV3 when onCreateSection is called", () => {
const createSectionSpy = jest.spyOn(RoomListStoreV3.instance, "createSection").mockResolvedValue();
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
viewModel.onCreateSection();
expect(createSectionSpy).toHaveBeenCalled();
});
});
describe("Cleanup", () => {

View File

@ -9,7 +9,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { waitFor } from "jest-matrix-react";
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
@ -589,6 +589,14 @@ describe("RoomListViewModel", () => {
});
describe("Cleanup", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("should dispose all room item view models on dispose", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
@ -604,6 +612,51 @@ describe("RoomListViewModel", () => {
expect(disposeSpy2).toHaveBeenCalled();
});
describe("Toast", () => {
it("should show toast when SectionCreated event fires", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
expect(viewModel.getSnapshot().toast).toBe("section_created");
});
it("should clear toast when closeToast is called", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
expect(viewModel.getSnapshot().toast).toBe("section_created");
viewModel.closeToast();
expect(viewModel.getSnapshot().toast).toBeUndefined();
});
it("should auto-close toast after 15 seconds", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
expect(viewModel.getSnapshot().toast).toBe("section_created");
jest.advanceTimersByTime(15 * 1000);
expect(viewModel.getSnapshot().toast).toBeUndefined();
});
it("should reset the auto-close timer when a new section is created", () => {
viewModel = new RoomListViewModel({ client: matrixClient });
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
jest.advanceTimersByTime(10 * 1000);
// Second section created — resets the timer
RoomListStoreV3.instance.emit(RoomListStoreV3Event.SectionCreated);
jest.advanceTimersByTime(10 * 1000);
// Toast should still be visible (only 10s since last emit)
expect(viewModel.getSnapshot().toast).toBe("section_created");
jest.advanceTimersByTime(5 * 1000);
expect(viewModel.getSnapshot().toast).toBeUndefined();
});
});
describe("Sections (feature_room_list_sections)", () => {
let favRoom1: Room;
let favRoom2: Room;
@ -922,7 +975,7 @@ describe("RoomListViewModel", () => {
action: Action.ActiveRoomChanged,
newRoomId: "!fav1:server",
});
await flushPromises();
await flushPromisesWithFakeTimers();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);