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:
@@ -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 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 { expect, test } from "../../../element-web-test";
|
||||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||||
|
import { getFilterCollapseButton, getFilterExpandButton, getPrimaryFilters, getRoomOptionsMenu } from "./utils";
|
||||||
|
|
||||||
test.describe("Room list filters and sort", () => {
|
test.describe("Room list filters and sort", () => {
|
||||||
test.use({
|
test.use({
|
||||||
@@ -21,22 +22,6 @@ test.describe("Room list filters and sort", () => {
|
|||||||
labsFlags: ["feature_new_room_list"],
|
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
|
* Get the room list
|
||||||
* @param page
|
* @param page
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../../../element-web-test";
|
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.describe("Header section of the room list", () => {
|
||||||
test.use({
|
test.use({
|
||||||
labsFlags: ["feature_new_room_list"],
|
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 }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The notification toast is displayed above the search section
|
// The notification toast is displayed above the search section
|
||||||
await app.closeNotificationToast();
|
await app.closeNotificationToast();
|
||||||
|
|||||||
@@ -5,23 +5,14 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { test, expect } from "../../../element-web-test";
|
||||||
|
import { getRoomListView } from "./utils";
|
||||||
|
|
||||||
test.describe("Room list panel", () => {
|
test.describe("Room list panel", () => {
|
||||||
test.use({
|
test.use({
|
||||||
labsFlags: ["feature_new_room_list"],
|
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 }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The notification toast is displayed above the search section
|
// The notification toast is displayed above the search section
|
||||||
await app.closeNotificationToast();
|
await app.closeNotificationToast();
|
||||||
|
|||||||
@@ -5,23 +5,14 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { test, expect } from "../../../element-web-test";
|
||||||
|
import { getSearchSection } from "./utils";
|
||||||
|
|
||||||
test.describe("Search section of the room list", () => {
|
test.describe("Search section of the room list", () => {
|
||||||
test.use({
|
test.use({
|
||||||
labsFlags: ["feature_new_room_list"],
|
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 }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The notification toast is displayed above the search section
|
// The notification toast is displayed above the search section
|
||||||
await app.closeNotificationToast();
|
await app.closeNotificationToast();
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { expect, test } from "../../../element-web-test";
|
||||||
|
import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
|
||||||
|
|
||||||
test.describe("Room list sections", () => {
|
test.describe("Room list sections", () => {
|
||||||
test.use({
|
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 }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The notification toast is displayed above the search section
|
// The notification toast is displayed above the search section
|
||||||
await app.closeNotificationToast();
|
await app.closeNotificationToast();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { type Page } from "@playwright/test";
|
|||||||
import { expect, test } from "../../../element-web-test";
|
import { expect, test } from "../../../element-web-test";
|
||||||
import { type Bot } from "../../../pages/bot";
|
import { type Bot } from "../../../pages/bot";
|
||||||
import { type ElementAppPage } from "../../../pages/ElementAppPage";
|
import { type ElementAppPage } from "../../../pages/ElementAppPage";
|
||||||
|
import { getRoomList } from "./utils";
|
||||||
|
|
||||||
test.describe("Room list", () => {
|
test.describe("Room list", () => {
|
||||||
test.use({
|
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 }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The notification toast is displayed above the search section
|
// The notification toast is displayed above the search section
|
||||||
await app.closeNotificationToast();
|
await app.closeNotificationToast();
|
||||||
|
|||||||
@@ -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/_ConfirmSpaceUserActionDialog.pcss";
|
||||||
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
|
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
|
||||||
@import "./views/dialogs/_CreateRoomDialog.pcss";
|
@import "./views/dialogs/_CreateRoomDialog.pcss";
|
||||||
|
@import "./views/dialogs/_CreateSectionDialog.pcss";
|
||||||
@import "./views/dialogs/_CreateSubspaceDialog.pcss";
|
@import "./views/dialogs/_CreateSubspaceDialog.pcss";
|
||||||
@import "./views/dialogs/_Crypto.pcss";
|
@import "./views/dialogs/_Crypto.pcss";
|
||||||
@import "./views/dialogs/_DeactivateAccountDialog.pcss";
|
@import "./views/dialogs/_DeactivateAccountDialog.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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.",
|
"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."
|
"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": {
|
"create_space": {
|
||||||
"add_details_prompt": "Add some details to help people recognise it.",
|
"add_details_prompt": "Add some details to help people recognise it.",
|
||||||
"add_details_prompt_2": "You can change these anytime.",
|
"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 { type ComputedInviteConfig } from "../@types/invite-rules.ts";
|
||||||
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
|
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
|
||||||
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
|
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
|
||||||
|
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
|
||||||
|
|
||||||
export const defaultWatchManager = new WatchManager();
|
export const defaultWatchManager = new WatchManager();
|
||||||
|
|
||||||
@@ -373,6 +374,8 @@ export interface Settings {
|
|||||||
"inviteRules": IBaseSetting<ComputedInviteConfig>;
|
"inviteRules": IBaseSetting<ComputedInviteConfig>;
|
||||||
"blockInvites": IBaseSetting<boolean>;
|
"blockInvites": IBaseSetting<boolean>;
|
||||||
"Developer.elementCallUrl": IBaseSetting<string>;
|
"Developer.elementCallUrl": IBaseSetting<string>;
|
||||||
|
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
|
||||||
|
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingKey = keyof Settings;
|
export type SettingKey = keyof Settings;
|
||||||
@@ -1371,6 +1374,22 @@ export const SETTINGS: Settings = {
|
|||||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||||
default: {},
|
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]: {
|
[UIFeature.RoomHistorySettings]: {
|
||||||
supportedLevels: LEVELS_UI_FEATURE,
|
supportedLevels: LEVELS_UI_FEATURE,
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
|
|||||||
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
|
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
|
||||||
import { TagFilter } from "./skip-list/filters/TagFilter";
|
import { TagFilter } from "./skip-list/filters/TagFilter";
|
||||||
import { filterBoolean } from "../../utils/arrays";
|
import { filterBoolean } from "../../utils/arrays";
|
||||||
|
import { createSection } from "./section";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These are the filters passed to the room skip list.
|
* These are the filters passed to the room skip list.
|
||||||
@@ -59,6 +60,8 @@ export enum RoomListStoreV3Event {
|
|||||||
ListsUpdate = "lists_update",
|
ListsUpdate = "lists_update",
|
||||||
// The event which is called when the room list is loaded.
|
// The event which is called when the room list is loaded.
|
||||||
ListsLoaded = "lists_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
|
// 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_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
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 store allows for fast retrieval of the room list in a sorted and filtered manner.
|
||||||
* This is the third such implementation hence the "V3".
|
* 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.
|
* Defines the display order of sections.
|
||||||
*/
|
*/
|
||||||
private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority];
|
private sortedTags: string[] = [];
|
||||||
|
|
||||||
private readonly msc3946ProcessDynamicPredecessor: boolean;
|
private readonly msc3946ProcessDynamicPredecessor: boolean;
|
||||||
|
|
||||||
@@ -125,6 +130,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
this.onActiveSpaceChanged();
|
this.onActiveSpaceChanged();
|
||||||
});
|
});
|
||||||
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => 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 {
|
export default class RoomListStoreV3 {
|
||||||
|
|||||||
@@ -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);
|
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.
|
* Removes a given room from the skip list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export class RoomListHeaderViewModel
|
|||||||
};
|
};
|
||||||
|
|
||||||
public createSection = (): void => {
|
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 canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
|
||||||
|
|
||||||
|
const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections");
|
||||||
|
const useComposeIcon = !isSectionFeatureEnabled;
|
||||||
|
const canCreateSection = isSectionFeatureEnabled;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
canCreateRoom,
|
canCreateRoom,
|
||||||
@@ -283,8 +287,7 @@ function computeHeaderSpaceState(
|
|||||||
displaySpaceMenu,
|
displaySpaceMenu,
|
||||||
canInviteInSpace,
|
canInviteInSpace,
|
||||||
canAccessSpaceSettings,
|
canAccessSpaceSettings,
|
||||||
// To be implemented when custom section creation is added in vms
|
canCreateSection,
|
||||||
canCreateSection: false,
|
useComposeIcon,
|
||||||
useComposeIcon: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { Action } from "../../dispatcher/actions";
|
|||||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import PosthogTrackers from "../../PosthogTrackers";
|
import PosthogTrackers from "../../PosthogTrackers";
|
||||||
import { type Call, CallEvent } from "../../models/Call";
|
import { type Call, CallEvent } from "../../models/Call";
|
||||||
|
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||||
|
|
||||||
interface RoomItemProps {
|
interface RoomItemProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -276,6 +277,8 @@ export class RoomListItemViewModel
|
|||||||
const callType =
|
const callType =
|
||||||
call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined;
|
call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined;
|
||||||
|
|
||||||
|
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: room.roomId,
|
id: room.roomId,
|
||||||
room,
|
room,
|
||||||
@@ -303,8 +306,7 @@ export class RoomListItemViewModel
|
|||||||
canMarkAsRead,
|
canMarkAsRead,
|
||||||
canMarkAsUnread,
|
canMarkAsUnread,
|
||||||
roomNotifState,
|
roomNotifState,
|
||||||
// To be implemented when custom section creation is added in vms
|
canMoveToSection,
|
||||||
canMoveToSection: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +387,6 @@ export class RoomListItemViewModel
|
|||||||
};
|
};
|
||||||
|
|
||||||
public onCreateSection = (): void => {
|
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.
|
// 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>();
|
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) {
|
public constructor(props: RoomListViewModelProps) {
|
||||||
const activeSpace = SpaceStore.instance.activeSpaceRoom;
|
const activeSpace = SpaceStore.instance.activeSpaceRoom;
|
||||||
|
|
||||||
@@ -144,6 +149,13 @@ export class RoomListViewModel
|
|||||||
this.onListsLoaded,
|
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
|
// Subscribe to active room changes to update selected room
|
||||||
const dispatcherRef = dispatcher.register(this.onDispatch);
|
const dispatcherRef = dispatcher.register(this.onDispatch);
|
||||||
this.disposables.track(() => {
|
this.disposables.track(() => {
|
||||||
@@ -264,7 +276,8 @@ export class RoomListViewModel
|
|||||||
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
|
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
|
||||||
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
|
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({
|
const viewModel = new RoomListSectionHeaderViewModel({
|
||||||
tag,
|
tag,
|
||||||
title,
|
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 = () => {
|
public closeToast: () => void = () => {
|
||||||
|
clearTimeout(this.toastRef);
|
||||||
this.snapshot.merge({
|
this.snapshot.merge({
|
||||||
toast: undefined,
|
toast: undefined,
|
||||||
});
|
});
|
||||||
@@ -590,9 +615,11 @@ function computeSections(
|
|||||||
roomsResult: RoomsResult,
|
roomsResult: RoomsResult,
|
||||||
isSectionExpanded: (tag: string) => boolean,
|
isSectionExpanded: (tag: string) => boolean,
|
||||||
): { sections: Section[]; isFlatList: boolean } {
|
): { sections: Section[]; isFlatList: boolean } {
|
||||||
|
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
|
||||||
|
|
||||||
const sections = roomsResult.sections
|
const sections = roomsResult.sections
|
||||||
// Only include sections that have rooms
|
// 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)
|
.filter((section) => section.rooms.length > 0 || customSections[section.tag])
|
||||||
// Remove roomIds for sections that are currently collapsed according to their section header view model
|
// Remove roomIds for sections that are currently collapsed according to their section header view model
|
||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
...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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
@@ -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 {
|
import {
|
||||||
CHATS_TAG,
|
CHATS_TAG,
|
||||||
LISTS_UPDATE_EVENT,
|
LISTS_UPDATE_EVENT,
|
||||||
|
SECTION_CREATED_EVENT,
|
||||||
RoomListStoreV3Class,
|
RoomListStoreV3Class,
|
||||||
type Section,
|
type Section,
|
||||||
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
|
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||||
|
import * as sectionModule from "../../../../src/stores/room-list-v3/section";
|
||||||
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
|
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
|
||||||
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
|
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
|
||||||
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
|
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
|
||||||
@@ -830,6 +832,7 @@ describe("RoomListStoreV3", () => {
|
|||||||
function enableSections(): void {
|
function enableSections(): void {
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
|
||||||
if (setting === "feature_room_list_sections") return true;
|
if (setting === "feature_room_list_sections") return true;
|
||||||
|
if (setting === "RoomList.OrderedCustomSections") return [];
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1007,6 +1010,84 @@ describe("RoomListStoreV3", () => {
|
|||||||
const favSection = findSection(sections, DefaultTagID.Favourite)!;
|
const favSection = findSection(sections, DefaultTagID.Favourite)!;
|
||||||
expect(favSection.rooms).toContain(rooms[3]);
|
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", () => {
|
describe("Muted rooms", () => {
|
||||||
|
|||||||
@@ -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 SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
|
||||||
import { MetaSpace } from "../../../../../src/stores/spaces";
|
import { MetaSpace } from "../../../../../src/stores/spaces";
|
||||||
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
|
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", () => {
|
describe("RoomSkipList", () => {
|
||||||
function generateSkipList(roomCount?: number): {
|
function generateSkipList(roomCount?: number): {
|
||||||
@@ -99,6 +102,26 @@ describe("RoomSkipList", () => {
|
|||||||
expect(() => skipList.addNewRoom(room)).toThrow("Can't add room to skiplist");
|
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", () => {
|
it("Re-sort works when sorter is swapped", () => {
|
||||||
const { skipList, rooms, sorter } = generateSkipList();
|
const { skipList, rooms, sorter } = generateSkipList();
|
||||||
const sortedByRecency = [...rooms].sort((a, b) => sorter.comparator(a, b));
|
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 === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
|
||||||
if (settingName === "feature_video_rooms") return true;
|
if (settingName === "feature_video_rooms") return true;
|
||||||
if (settingName === "feature_element_call_video_rooms") return true;
|
if (settingName === "feature_element_call_video_rooms") return true;
|
||||||
|
if (settingName === "RoomList.OrderedCustomSections") return [];
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -159,6 +160,23 @@ describe("RoomListHeaderViewModel", () => {
|
|||||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||||
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
|
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", () => {
|
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", () => {
|
it("should toggle message preview from enabled to disabled", () => {
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||||
if (settingName === "RoomList.showMessagePreview") return true;
|
if (settingName === "RoomList.showMessagePreview") return true;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { Action } from "../../../src/dispatcher/actions";
|
|||||||
import { CallStore } from "../../../src/stores/CallStore";
|
import { CallStore } from "../../../src/stores/CallStore";
|
||||||
import { CallEvent, type Call } from "../../../src/models/Call";
|
import { CallEvent, type Call } from "../../../src/models/Call";
|
||||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
||||||
|
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||||
|
|
||||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||||
@@ -75,6 +76,7 @@ describe("RoomListItemViewModel", () => {
|
|||||||
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||||
if (setting === "RoomList.showMessagePreview") return false;
|
if (setting === "RoomList.showMessagePreview") return false;
|
||||||
|
if (setting === "RoomList.OrderedCustomSections") return [];
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
|
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", () => {
|
describe("Actions", () => {
|
||||||
it("should dispatch view room action on openRoom", () => {
|
it("should dispatch view room action on openRoom", () => {
|
||||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||||
@@ -572,6 +588,13 @@ describe("RoomListItemViewModel", () => {
|
|||||||
room_id: "!room:server",
|
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", () => {
|
describe("Cleanup", () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
|||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
import { waitFor } from "jest-matrix-react";
|
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 RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||||
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||||
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
||||||
@@ -589,6 +589,14 @@ describe("RoomListViewModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Cleanup", () => {
|
describe("Cleanup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("should dispose all room item view models on dispose", () => {
|
it("should dispose all room item view models on dispose", () => {
|
||||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||||
|
|
||||||
@@ -604,6 +612,51 @@ describe("RoomListViewModel", () => {
|
|||||||
expect(disposeSpy2).toHaveBeenCalled();
|
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)", () => {
|
describe("Sections (feature_room_list_sections)", () => {
|
||||||
let favRoom1: Room;
|
let favRoom1: Room;
|
||||||
let favRoom2: Room;
|
let favRoom2: Room;
|
||||||
@@ -922,7 +975,7 @@ describe("RoomListViewModel", () => {
|
|||||||
action: Action.ActiveRoomChanged,
|
action: Action.ActiveRoomChanged,
|
||||||
newRoomId: "!fav1:server",
|
newRoomId: "!fav1:server",
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromisesWithFakeTimers();
|
||||||
|
|
||||||
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);
|
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user