From 6b67b24254ab676e3135b4bc568ebdfe019643e9 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 17 Apr 2026 14:02:42 +0200 Subject: [PATCH] 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 --- .../room-list-custom-sections.spec.ts | 178 ++++++++++++++++++ .../room-list-filter-sort.spec.ts | 19 +- .../room-list-panel/room-list-header.spec.ts | 10 +- .../room-list-panel/room-list-panel.spec.ts | 11 +- .../room-list-panel/room-list-search.spec.ts | 11 +- .../room-list-sections.spec.ts | 31 +-- .../room-list-panel/room-list.spec.ts | 9 +- .../e2e/left-panel/room-list-panel/utils.ts | 92 +++++++++ apps/web/res/css/_components.pcss | 1 + .../views/dialogs/_CreateSectionDialog.pcss | 23 +++ .../views/dialogs/CreateSectionDialog.tsx | 65 +++++++ apps/web/src/i18n/strings/en_EN.json | 6 + apps/web/src/settings/Settings.tsx | 19 ++ .../stores/room-list-v3/RoomListStoreV3.ts | 40 +++- apps/web/src/stores/room-list-v3/section.ts | 59 ++++++ .../room-list-v3/skip-list/RoomSkipList.ts | 11 ++ .../room-list/RoomListHeaderViewModel.ts | 11 +- .../room-list/RoomListItemViewModel.ts | 8 +- .../viewmodels/room-list/RoomListViewModel.ts | 33 +++- .../dialogs/CreateSectionDialog-test.tsx | 59 ++++++ .../CreateSectionDialog-test.tsx.snap | 106 +++++++++++ .../room-list-v3/RoomListStoreV3-test.ts | 81 ++++++++ .../stores/room-list-v3/section-test.ts | 71 +++++++ .../skip-list/RoomSkipList-test.ts | 23 +++ .../room-list/RoomListHeaderViewModel-test.ts | 25 +++ .../room-list/RoomListItemViewModel-test.tsx | 23 +++ .../room-list/RoomListViewModel-test.tsx | 57 +++++- 27 files changed, 985 insertions(+), 97 deletions(-) create mode 100644 apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts create mode 100644 apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts create mode 100644 apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss create mode 100644 apps/web/src/components/views/dialogs/CreateSectionDialog.tsx create mode 100644 apps/web/src/stores/room-list-v3/section.ts create mode 100644 apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx create mode 100644 apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap create mode 100644 apps/web/test/unit-tests/stores/room-list-v3/section-test.ts diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts new file mode 100644 index 0000000000..a390d3e610 --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -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 { + 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(); + }); + }); +}); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts index 99ffed3ff8..7356a2e6bf 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -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 diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts index 96e0ca8597..1ffa49429d 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -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(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index bc1387cbce..ad87eac89b 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -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(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts index 028503f622..37263d9b67 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts @@ -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(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts index 9bc9bbe2b0..80651c409f 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -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(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index 79cc0b4cf3..30dd461041 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -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(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts new file mode 100644 index 0000000000..523b268c80 --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts @@ -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"); +} diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index fdf774a754..c208a71ef7 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss b/apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss new file mode 100644 index 0000000000..7c941be39f --- /dev/null +++ b/apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss @@ -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%; + } +} diff --git a/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx b/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx new file mode 100644 index 0000000000..295aa9c643 --- /dev/null +++ b/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx @@ -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 ( + onFinished(false, value)} + title={_t("create_section_dialog|title")} + hasCancel={true} + > + + + {_t("create_section_dialog|description")} + + { + onFinished(true, value); + e.preventDefault(); + }} + > + + {_t("create_section_dialog|label")} + setValue(evt.target.value)} required={true} /> + + + + onFinished(false, "")} + onPrimaryButtonClick={() => onFinished(true, value)} + /> + + ); +} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index ac4b3d3e9a..ec66fdade7 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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.", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index b70095125f..4b05f7320c 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -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; "blockInvites": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; + "RoomList.CustomSectionData": IBaseSetting; + "RoomList.OrderedCustomSections": IBaseSetting; } 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, diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 71c5a1a9cb..ec10fe1468 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -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 { /** * 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 { 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 { }; }); } + + /** + * 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 { + 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 { diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts new file mode 100644 index 0000000000..f11a028693 --- /dev/null +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -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; +/** + * 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 { + 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; +} diff --git a/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 93c898ee21..dfa5b678e0 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -76,6 +76,17 @@ export class RoomSkipList implements Iterable { 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. */ diff --git a/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts index fa7697d084..55626cb252 100644 --- a/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListHeaderViewModel.ts @@ -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, }; } diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index ac3f32ad61..f40b60eebd 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -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(); }; } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index a3e516ea2d..99e2b3dc19 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -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(); + /** + * 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, diff --git a/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx new file mode 100644 index 0000000000..ad22a7d74b --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx @@ -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(); + } + + it("renders the dialog", () => { + const { container } = render(); + 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"); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap new file mode 100644 index 0000000000..7cff6e3769 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`CreateSectionDialog renders the dialog 1`] = ` +
+
+ +
+
+`; diff --git a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 1944efe7e6..11ba95d864 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -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", () => { diff --git a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts new file mode 100644 index 0000000000..9fb8f40d33 --- /dev/null +++ b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts @@ -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)[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\./); + }); +}); diff --git a/apps/web/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts index a742e56370..8640f3491e 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts @@ -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)); diff --git a/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts index 936389603d..608e990af6 100644 --- a/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts @@ -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; diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index e3b349e085..294476d075 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -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", () => { diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 2207f08d19..4ae01433ce 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -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);