mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 04:06:44 +02:00
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:
parent
73d4b63ada
commit
6b67b24254
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
92
apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts
Normal file
92
apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts
Normal 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");
|
||||
}
|
||||
@ -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";
|
||||
|
||||
23
apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss
Normal file
23
apps/web/res/css/views/dialogs/_CreateSectionDialog.pcss
Normal 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%;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
59
apps/web/src/stores/room-list-v3/section.ts
Normal file
59
apps/web/src/stores/room-list-v3/section.ts
Normal 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;
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
`;
|
||||
@ -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", () => {
|
||||
|
||||
71
apps/web/test/unit-tests/stores/room-list-v3/section-test.ts
Normal file
71
apps/web/test/unit-tests/stores/room-list-v3/section-test.ts
Normal 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\./);
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user