mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 04:06:44 +02:00
Merge branch 'develop' into hs/enable-profile-updates
This commit is contained in:
commit
39fb0ec5a2
@ -50,6 +50,7 @@ CHANGELOG.md
|
||||
/apps/desktop/dist/
|
||||
/apps/desktop/build/
|
||||
/apps/desktop/dockerbuild/
|
||||
/apps/desktop/deploys/
|
||||
/apps/desktop/lib/
|
||||
/apps/desktop/webapp
|
||||
/apps/desktop/playwright/html-report
|
||||
|
||||
@ -22,7 +22,9 @@
|
||||
"about": "关于",
|
||||
"brand_help": "%(brand)s帮助",
|
||||
"help": "帮助",
|
||||
"preferences": "偏好"
|
||||
"no": "不",
|
||||
"preferences": "偏好",
|
||||
"yes": "是"
|
||||
},
|
||||
"confirm_quit": "你确定要退出吗?",
|
||||
"edit_menu": {
|
||||
@ -30,9 +32,20 @@
|
||||
"speech_start_speaking": "开始讲话",
|
||||
"speech_stop_speaking": "停止讲话"
|
||||
},
|
||||
"eol": {
|
||||
"no_more_updates": "您正在使用不受支持的macOS版本。请升级以获取%(brand)s 更新。",
|
||||
"title": "系统不支持",
|
||||
"warning": "您正在使用不受支持的macOS版本。请升级系统以确保%(brand)s 能持续正常运行。"
|
||||
},
|
||||
"file_menu": {
|
||||
"label": "文件"
|
||||
},
|
||||
"icon_overlay": {
|
||||
"description_error": "错误",
|
||||
"description_notifications": {
|
||||
"other": "您有%(count)s 条未读通知。"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"hide": "隐藏",
|
||||
"hide_others": "隐藏其他",
|
||||
@ -49,6 +62,21 @@
|
||||
"save_image_as_error_description": "图片保存失败",
|
||||
"save_image_as_error_title": "图片保存失败"
|
||||
},
|
||||
"store": {
|
||||
"error": {
|
||||
"backend_changed": "清除数据并重新加载?",
|
||||
"backend_changed_detail": "无法从系统密钥环访问密钥,该密钥似乎已被更改。",
|
||||
"backend_changed_title": "数据库加载失败",
|
||||
"backend_no_encryption": "您的系统支持密钥环,但加密功能不可用。",
|
||||
"backend_no_encryption_detail": "Electron检测到您的密钥环%(backend)s 不支持加密功能。请确保已安装该密钥环。若已安装,请重启设备后重试。您也可选择允许%(brand)s 使用较弱的加密方式。",
|
||||
"backend_no_encryption_title": "不支持加密",
|
||||
"unsupported_keyring": "您的系统存在未受支持的密钥环,这意味着无法打开数据库。",
|
||||
"unsupported_keyring_detail": "Electron的密钥环检测未找到受支持的后端。您可以尝试通过命令行参数启动%(brand)s 来手动配置后端,此操作仅需执行一次。详情请参阅:%(link)s 。",
|
||||
"unsupported_keyring_title": "系统不支持",
|
||||
"unsupported_keyring_use_basic_text": "使用较弱的加密",
|
||||
"unsupported_keyring_use_plaintext": "不使用加密"
|
||||
}
|
||||
},
|
||||
"view_menu": {
|
||||
"actual_size": "实际大小",
|
||||
"toggle_developer_tools": "切换开发者工具",
|
||||
|
||||
@ -101,7 +101,7 @@
|
||||
"react-transition-group": "^4.4.1",
|
||||
"rfc4648": "^1.4.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "2.17.2",
|
||||
"sanitize-html": "2.17.3",
|
||||
"tar-js": "^0.3.0",
|
||||
"ua-parser-js": "1.0.40",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
@ -91,7 +91,7 @@ export default defineConfig<{}, WorkerOptions>({
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: process.env.CI ? "npx serve -p 8080 -L ./webapp" : "pnpm start",
|
||||
command: process.env.CI ? "npx serve -p 8080 -L ./webapp" : "nx --outputStyle stream start",
|
||||
url: `${baseURL}/config.json`,
|
||||
reuseExistingServer: true,
|
||||
timeout: (process.env.CI ? 30 : 120) * 1000,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
@ -148,7 +148,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
// richvdh: This takes several seconds to happen on a dev instance
|
||||
await expect(page.getByText("Welcome")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Log out
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
@ -162,11 +163,14 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
|
||||
// Log in again
|
||||
await page.goto("/#/login");
|
||||
await expect(page.getByText("Sign in")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByText("Continue to Element?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// We should be being warned that we need to verify (but we can't)
|
||||
await expect(page.getByText("Confirm your digital identity")).toBeVisible();
|
||||
// richvdh: Again, Element takes several seconds to load on a dev instance
|
||||
await expect(page.getByText("Confirm your digital identity")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// And there should be no way to close this prompt
|
||||
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -12,10 +12,9 @@ import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { uniqBy } from "lodash";
|
||||
import { RichList, RichItem, PillInput, Pill } from "@element-hq/web-shared-components";
|
||||
import { Pill, PillInput, RichList } from "@element-hq/web-shared-components";
|
||||
import { DialPadIcon, UserProfileSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks";
|
||||
@ -31,8 +30,6 @@ import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import { SearchResultAvatar } from "../avatars/SearchResultAvatar";
|
||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { selectText } from "../../../utils/strings";
|
||||
@ -43,7 +40,6 @@ import QuestionDialog from "./QuestionDialog";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
|
||||
import LegacyCallHandler from "../../../LegacyCallHandler";
|
||||
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
|
||||
import CopyableText from "../elements/CopyableText";
|
||||
import { type ScreenName } from "../../../PosthogTrackers";
|
||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||
@ -64,6 +60,7 @@ import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
||||
import InviteProgressBody from "./InviteProgressBody.tsx";
|
||||
import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts";
|
||||
import { DMRoomTile } from "./invite/DMRoomTile.tsx";
|
||||
|
||||
interface Result {
|
||||
userId: string;
|
||||
@ -114,62 +111,6 @@ const toMember = (member: RoomMember | Member): Member => {
|
||||
: member;
|
||||
};
|
||||
|
||||
interface IDMRoomTileProps {
|
||||
member: Member;
|
||||
lastActiveTs?: number;
|
||||
onToggle(member: Member): void;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
||||
private onClick = (e: ButtonEvent): void => {
|
||||
// Stop the browser from highlighting text
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onToggle(this.props.member);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const avatarSize = "32px";
|
||||
const avatar = (this.props.member as ThreepidMember).isEmail ? (
|
||||
<EmailPillAvatarIcon width={avatarSize} height={avatarSize} />
|
||||
) : (
|
||||
<BaseAvatar
|
||||
url={
|
||||
this.props.member.getMxcAvatarUrl()
|
||||
? mediaFromMxc(this.props.member.getMxcAvatarUrl()!).getSquareThumbnailHttp(
|
||||
parseInt(avatarSize, 10),
|
||||
)
|
||||
: null
|
||||
}
|
||||
name={this.props.member.name}
|
||||
idName={this.props.member.userId}
|
||||
size={avatarSize}
|
||||
/>
|
||||
);
|
||||
|
||||
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
|
||||
withDisplayName: true,
|
||||
});
|
||||
|
||||
const caption = (this.props.member as ThreepidMember).isEmail
|
||||
? _t("invite|email_caption")
|
||||
: userIdentifier || this.props.member.userId;
|
||||
|
||||
return (
|
||||
<RichItem
|
||||
avatar={avatar}
|
||||
title={this.props.member.name}
|
||||
description={caption}
|
||||
timestamp={this.props.lastActiveTs}
|
||||
onClick={this.onClick}
|
||||
selected={this.props.isSelected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
// Takes a boolean which is true if a user / users were invited /
|
||||
// a call transfer was initiated or false if the dialog was cancelled
|
||||
|
||||
@ -42,6 +42,7 @@ import { type NonEmptyArray } from "../../../@types/common";
|
||||
import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab";
|
||||
import ErrorBoundary from "../elements/ErrorBoundary";
|
||||
import { PeopleRoomSettingsTab } from "../settings/tabs/room/PeopleRoomSettingsTab";
|
||||
import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext";
|
||||
|
||||
export const enum RoomSettingsTab {
|
||||
General = "ROOM_GENERAL_TAB",
|
||||
@ -59,6 +60,7 @@ interface IProps {
|
||||
roomId: string;
|
||||
onFinished: (success?: boolean) => void;
|
||||
initialTabId?: RoomSettingsTab;
|
||||
sdkContext: SdkContextClass;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@ -238,21 +240,23 @@ class RoomSettingsDialog extends React.Component<IProps, IState> {
|
||||
public render(): React.ReactNode {
|
||||
const roomName = this.state.room.name;
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_RoomSettingsDialog"
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("room_settings|title", { roomName })}
|
||||
>
|
||||
<div className="mx_SettingsDialog_content">
|
||||
<TabbedView
|
||||
tabs={this.getTabs()}
|
||||
activeTabId={this.state.activeTabId}
|
||||
screenName="RoomSettings"
|
||||
onChange={this.onTabChange}
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
<SDKContext.Provider value={this.props.sdkContext}>
|
||||
<BaseDialog
|
||||
className="mx_RoomSettingsDialog"
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("room_settings|title", { roomName })}
|
||||
>
|
||||
<div className="mx_SettingsDialog_content">
|
||||
<TabbedView
|
||||
tabs={this.getTabs()}
|
||||
activeTabId={this.state.activeTabId}
|
||||
screenName="RoomSettings"
|
||||
onChange={this.onTabChange}
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</SDKContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
74
apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx
Normal file
74
apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
/*
|
||||
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 from "react";
|
||||
import { RichItem } from "@element-hq/web-shared-components";
|
||||
|
||||
import { type Member, type ThreepidMember } from "../../../../utils/direct-messages.ts";
|
||||
import type { ButtonEvent } from "../../elements/AccessibleButton.tsx";
|
||||
import BaseAvatar from "../../avatars/BaseAvatar.tsx";
|
||||
import { mediaFromMxc } from "../../../../customisations/Media.ts";
|
||||
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier.ts";
|
||||
import { _t } from "../../../../languageHandler.tsx";
|
||||
import { Icon as EmailPillAvatarIcon } from "../../../../../res/img/icon-email-pill-avatar.svg";
|
||||
|
||||
interface IDMRoomTileProps {
|
||||
member: Member;
|
||||
lastActiveTs?: number;
|
||||
onToggle(member: Member): void;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
/** A tile representing a single user in the "suggestions"/"recents" section of the invite dialog. */
|
||||
export class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
||||
private onClick = (e: ButtonEvent): void => {
|
||||
// Stop the browser from highlighting text
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onToggle(this.props.member);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const avatarSize = "32px";
|
||||
const avatar = (this.props.member as ThreepidMember).isEmail ? (
|
||||
<EmailPillAvatarIcon width={avatarSize} height={avatarSize} />
|
||||
) : (
|
||||
<BaseAvatar
|
||||
url={
|
||||
this.props.member.getMxcAvatarUrl()
|
||||
? mediaFromMxc(this.props.member.getMxcAvatarUrl()!).getSquareThumbnailHttp(
|
||||
parseInt(avatarSize, 10),
|
||||
)
|
||||
: null
|
||||
}
|
||||
name={this.props.member.name}
|
||||
idName={this.props.member.userId}
|
||||
size={avatarSize}
|
||||
/>
|
||||
);
|
||||
|
||||
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
|
||||
withDisplayName: true,
|
||||
});
|
||||
|
||||
const caption = (this.props.member as ThreepidMember).isEmail
|
||||
? _t("invite|email_caption")
|
||||
: userIdentifier || this.props.member.userId;
|
||||
|
||||
return (
|
||||
<RichItem
|
||||
avatar={avatar}
|
||||
title={this.props.member.name}
|
||||
description={caption}
|
||||
timestamp={this.props.lastActiveTs}
|
||||
onClick={this.onClick}
|
||||
selected={this.props.isSelected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -53,6 +53,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();
|
||||
|
||||
@ -375,6 +376,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;
|
||||
@ -1397,6 +1400,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.
|
||||
*/
|
||||
|
||||
@ -57,6 +57,7 @@ export class DialogOpener {
|
||||
{
|
||||
roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(),
|
||||
initialTabId: payload.initial_tab_id,
|
||||
sdkContext: SdkContextClass.instance,
|
||||
},
|
||||
/*className=*/ undefined,
|
||||
/*isPriority=*/ false,
|
||||
|
||||
@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { EventStatus, MatrixEventEvent, type Room, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import Modal, { type IHandle } from "../Modal";
|
||||
import Spinner from "../components/views/elements/Spinner";
|
||||
@ -25,6 +26,8 @@ import { type AfterLeaveRoomPayload } from "../dispatcher/payloads/AfterLeaveRoo
|
||||
import { bulkSpaceBehaviour } from "./space";
|
||||
import { SdkContextClass } from "../contexts/SDKContext";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { CallStore } from "../stores/CallStore";
|
||||
import LegacyCallHandler from "../LegacyCallHandler";
|
||||
|
||||
export async function leaveRoomBehaviour(
|
||||
matrixClient: MatrixClient,
|
||||
@ -59,6 +62,23 @@ export async function leaveRoomBehaviour(
|
||||
throw new Error(`Expected to find room for id ${roomId}`);
|
||||
}
|
||||
|
||||
// attempt to hang up legacy based calls
|
||||
try {
|
||||
LegacyCallHandler.instance.hangupOrReject(roomId);
|
||||
} catch (e) {
|
||||
logger.warn("Failed to hangup call before leaving room: ", e);
|
||||
}
|
||||
|
||||
// hang up widget based calls
|
||||
const activeCall = CallStore.instance.getActiveCall(roomId);
|
||||
if (activeCall) {
|
||||
try {
|
||||
await activeCall.disconnect();
|
||||
} catch (e) {
|
||||
logger.warn("Failed to disconnect call before leaving room: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
// await any queued messages being sent so that they do not fail
|
||||
await Promise.all(
|
||||
room
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -24,6 +24,7 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { UIFeature } from "../../../../../src/settings/UIFeature";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
|
||||
describe("<RoomSettingsDialog />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
@ -43,6 +44,8 @@ describe("<RoomSettingsDialog />", () => {
|
||||
const room2 = new Room("!room2:server.org", mockClient, userId);
|
||||
room2.name = "Another Room";
|
||||
|
||||
let sdkContext: SdkContextClass;
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue");
|
||||
|
||||
beforeEach(() => {
|
||||
@ -54,6 +57,9 @@ describe("<RoomSettingsDialog />", () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
sdkContext = new SdkContextClass();
|
||||
sdkContext.client = mockClient;
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false);
|
||||
|
||||
const dmRoomMap = {
|
||||
@ -63,7 +69,7 @@ describe("<RoomSettingsDialog />", () => {
|
||||
});
|
||||
|
||||
const getComponent = (onFinished = jest.fn(), propRoomId = roomId) =>
|
||||
render(<RoomSettingsDialog roomId={propRoomId} onFinished={onFinished} />, {
|
||||
render(<RoomSettingsDialog roomId={propRoomId} onFinished={onFinished} sdkContext={sdkContext} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
|
||||
),
|
||||
@ -79,7 +85,7 @@ describe("<RoomSettingsDialog />", () => {
|
||||
|
||||
expect(getByText(`Room Settings - ${room.name}`)).toBeInTheDocument();
|
||||
|
||||
rerender(<RoomSettingsDialog roomId={room2.roomId} onFinished={jest.fn()} />);
|
||||
rerender(<RoomSettingsDialog roomId={room2.roomId} onFinished={jest.fn()} sdkContext={sdkContext} />);
|
||||
|
||||
expect(getByText(`Room Settings - ${room2.name}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -22,6 +22,9 @@ import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||
import { MetaSpace } from "../../../src/stores/spaces";
|
||||
import { type ActionPayload } from "../../../src/dispatcher/payloads";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { CallStore } from "../../../src/stores/CallStore";
|
||||
import { type Call } from "../../../src/models/Call";
|
||||
import LegacyCallHandler from "../../../src/LegacyCallHandler";
|
||||
|
||||
describe("leaveRoomBehaviour", () => {
|
||||
SdkContextClass.instance.constructEagerStores(); // Initialize RoomViewStore
|
||||
@ -76,6 +79,28 @@ describe("leaveRoomBehaviour", () => {
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
};
|
||||
|
||||
it("hangs up legacy calls when leaving a room", async () => {
|
||||
const hangupSpy = jest.spyOn(LegacyCallHandler.instance, "hangupOrReject").mockImplementation(() => {});
|
||||
|
||||
viewRoom(room);
|
||||
await leaveRoomBehaviour(client, room.roomId);
|
||||
|
||||
expect(hangupSpy).toHaveBeenCalledWith(room.roomId);
|
||||
});
|
||||
|
||||
it("disconnects widget-based calls when leaving a room", async () => {
|
||||
const mockCall = {
|
||||
disconnect: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Call;
|
||||
|
||||
jest.spyOn(CallStore.instance, "getActiveCall").mockReturnValue(mockCall);
|
||||
|
||||
viewRoom(room);
|
||||
await leaveRoomBehaviour(client, room.roomId);
|
||||
|
||||
expect(mockCall.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to the home page after leaving a room outside of a space that was being viewed", async () => {
|
||||
viewRoom(room);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
31
package.json
31
package.json
@ -93,37 +93,22 @@
|
||||
"testcontainers": "^11.0.0",
|
||||
"matrix-widget-api": "^1.17.0",
|
||||
"qs": "6.15.0",
|
||||
"serialize-javascript": "7.0.5",
|
||||
"atomically": "2.1.1",
|
||||
"@types/node": "18.19.130",
|
||||
"config-file-ts": "0.2.8-rc1",
|
||||
"node-abi": "4.27.0",
|
||||
"@types/pg-pool": "2.0.7",
|
||||
"flatted@3.4.1": "3.4.2",
|
||||
"ajv@6.12.6": "6.14.0",
|
||||
"ajv@>=8 <8.18.0": "8.18.0",
|
||||
"bn.js@4.12.2": "4.12.3",
|
||||
"bn.js@5.2.2": "5.2.3",
|
||||
"brace-expansion@1.1.12": "1.1.13",
|
||||
"brace-expansion@2.0.2": "2.0.3",
|
||||
"brace-expansion@5.0.4": "5.0.5",
|
||||
"handlebars@4.7.8": "4.7.9",
|
||||
"path-to-regexp@0.1.12": "1.9.0",
|
||||
"path-to-regexp@8.3.0": "8.4.2",
|
||||
"picomatch@2.3.1": "2.3.2",
|
||||
"picomatch@4.0": "4.0.4",
|
||||
"rollup@4.57.1": "4.60.1",
|
||||
"smol-toml@1.6.0": "1.6.1",
|
||||
"svgo@3.3.2": "3.3.3",
|
||||
"svgo@4.0.0": "4.0.1",
|
||||
"underscore@1.13.7": "1.13.8",
|
||||
"yaml@1.10.2": "1.10.3",
|
||||
"yaml@2.8.2": "2.8.3",
|
||||
"undici@7.22.2": "7.24.7",
|
||||
"@xmldom/xmldom@0.8.11": "0.9.9",
|
||||
"axios@>1.0.0 <1.15.0": "1.15.0",
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"esbuild@<=0.24.2": "0.27.4",
|
||||
"esbuild@~0.27.0": "0.27.4",
|
||||
"minimatch@>=10.0.0 <10.2.3": ">=10.2.3"
|
||||
"minimatch@>=10.0.0 <10.2.3": ">=10.2.3",
|
||||
"lodash@4.17.23": "4.18.1",
|
||||
"follow-redirects@1.15.11": "1.16.0",
|
||||
"@actions/github@6.0.1": "8.0.1",
|
||||
"dompurify@>=3.0.0 <=3.3.3": "3.4.0",
|
||||
"protobufjs@>=7.0.0 <7.5.5": "7.5.5"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-playwright-common",
|
||||
"type": "module",
|
||||
"version": "3.1.0",
|
||||
"version": "4.0.0",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@ -29,81 +29,128 @@ class Toasts {
|
||||
public constructor(public readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Assert that no toasts exist
|
||||
* Assert that no toasts exist.
|
||||
*/
|
||||
public async assertNoToasts(): Promise<void> {
|
||||
await expect(this.page.locator(".mx_Toast_toast")).not.toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a toast with the given title exists, and return it
|
||||
* Return the toast with the supplied title. Fail or return null if it does
|
||||
* not exist.
|
||||
*
|
||||
* @param title - Expected title of the toast
|
||||
* @param timeout - Time to retry the assertion for in milliseconds.
|
||||
* Defaults to `timeout` in `TestConfig.expect`.
|
||||
* @returns the Locator for the matching toast
|
||||
* If `required` is false, you should supply a relatively short `timeout`
|
||||
* (e.g. 2000, meaning 2 seconds) to prevent your test taking too long.
|
||||
*
|
||||
* @param title - Expected title of the toast.
|
||||
* @param timeout - Time in ms before we give up and decide the toast does
|
||||
* not exist. If `required` is true, defaults to `timeout`
|
||||
* in `TestConfig.expect`. Otherwise, defaults to 2000 (2
|
||||
* seconds).
|
||||
* @param required - If true, fail the test (throw an exception) if the
|
||||
* toast is not visible. Otherwise, just return null if
|
||||
* the toast is not visible.
|
||||
* @returns the Locator for the matching toast, or null if it is not
|
||||
* visible. (null will only be returned if `required` is false.)
|
||||
*/
|
||||
public async getToast(title: string, timeout?: number): Promise<Locator> {
|
||||
const toast = this.getToastIfExists(title);
|
||||
await expect(toast).toBeVisible({ timeout });
|
||||
return toast;
|
||||
public async getToast(title: string, timeout?: number, required = true): Promise<Locator | null> {
|
||||
const toast = this.page.locator(".mx_Toast_toast", { hasText: title }).first();
|
||||
|
||||
if (required) {
|
||||
await expect(toast).toBeVisible({ timeout });
|
||||
return toast;
|
||||
} else {
|
||||
// If we don't set a timeout, waitFor will wait forever. Since
|
||||
// required is false, we definitely don't want to wait forever.
|
||||
timeout = timeout ?? 2000;
|
||||
|
||||
try {
|
||||
await toast.waitFor({ state: "visible", timeout });
|
||||
return toast;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a toast with the given title, if it exists.
|
||||
* Accept the toast with the supplied title, or fail if it does not exist.
|
||||
*
|
||||
* @param title - Title of the toast.
|
||||
* @returns the Locator for the matching toast, or an empty locator if it
|
||||
* doesn't exist.
|
||||
*/
|
||||
public getToastIfExists(title: string): Locator {
|
||||
return this.page.locator(".mx_Toast_toast", { hasText: title }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a toast with the given title. Only works for the first toast in
|
||||
* the stack.
|
||||
* Only works if this toast is at the top of the stack of toasts.
|
||||
*
|
||||
* @param title - Expected title of the toast
|
||||
* @param title - Expected title of the toast.
|
||||
*/
|
||||
public async acceptToast(title: string): Promise<void> {
|
||||
const toast = await this.getToast(title);
|
||||
await toast.locator('.mx_Toast_buttons button[data-kind="primary"]').click();
|
||||
return await clickToastButton(this, title, "primary");
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a toast with the given title, if it exists. Only works for the
|
||||
* first toast in the stack.
|
||||
* Accept the toast with the supplied title, if it exists, or return after 2
|
||||
* seconds if it is not found.
|
||||
*
|
||||
* @param title - Title of the toast
|
||||
* Only works if this toast is at the top of the stack of toasts.
|
||||
*
|
||||
* @param title - Expected title of the toast.
|
||||
*/
|
||||
public async acceptToastIfExists(title: string): Promise<void> {
|
||||
const toast = this.getToastIfExists(title).locator('.mx_Toast_buttons button[data-kind="primary"]');
|
||||
if ((await toast.count()) > 0) {
|
||||
await toast.click();
|
||||
}
|
||||
return await clickToastButton(this, title, "primary", 2000, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a toast with the given title. Only works for the first toast in
|
||||
* the stack.
|
||||
* Reject the toast with the supplied title, or fail if it does not exist.
|
||||
*
|
||||
* @param title - Expected title of the toast
|
||||
* Only works if this toast is at the top of the stack of toasts.
|
||||
*
|
||||
* @param title - Expected title of the toast.
|
||||
*/
|
||||
public async rejectToast(title: string): Promise<void> {
|
||||
const toast = await this.getToast(title);
|
||||
await toast.locator('.mx_Toast_buttons button[data-kind="secondary"]').click();
|
||||
return await clickToastButton(this, title, "secondary");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a toast with the given title, if it exists. Only works for the
|
||||
* first toast in the stack.
|
||||
* Reject the toast with the supplied title, if it exists, or return after 2
|
||||
* seconds if it is not found.
|
||||
*
|
||||
* @param title - Title of the toast
|
||||
* Only works if this toast is at the top of the stack of toasts.
|
||||
*
|
||||
* @param title - Expected title of the toast.
|
||||
*/
|
||||
public async rejectToastIfExists(title: string): Promise<void> {
|
||||
const toast = this.getToastIfExists(title).locator('.mx_Toast_buttons button[data-kind="secondary"]');
|
||||
if ((await toast.count()) > 0) {
|
||||
await toast.click();
|
||||
}
|
||||
return await clickToastButton(this, title, "secondary", 2000, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the toast with the supplied title and click a button on it.
|
||||
*
|
||||
* Only works if this toast is at the top of the stack of toasts.
|
||||
*
|
||||
* If `required` is false, you should supply a relatively short `timeout`
|
||||
* (e.g. 2000, meaning 2 seconds) to prevent your test taking too long.
|
||||
*
|
||||
* @param toasts - A Toasts instance.
|
||||
* @param title - Expected title of the toast.
|
||||
* @param button - Which button to click on the toast. Allowed values are
|
||||
* "primary", which will accept the toast, or "secondary",
|
||||
* which will reject it.
|
||||
* @param timeout - Time in ms before we give up and decide the toast does
|
||||
* not exist. If `required` is true, defaults to `timeout`
|
||||
* in `TestConfig.expect`. Otherwise, defaults to 2000 (2
|
||||
* seconds).
|
||||
* @param required - If true, fail the test (throw an exception) if the
|
||||
* toast is not visible. Otherwise, just return after
|
||||
* `timeout` if the toast is not visible.
|
||||
*/
|
||||
async function clickToastButton(
|
||||
toasts: Toasts,
|
||||
title: string,
|
||||
button: "primary" | "secondary",
|
||||
timeout?: number,
|
||||
required = true,
|
||||
): Promise<void> {
|
||||
const toast = await toasts.getToast(title, timeout, required);
|
||||
|
||||
if (toast) {
|
||||
await toast.locator(`.mx_Toast_buttons button[data-kind="${button}"]`).click();
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
"common": {
|
||||
"attachment": "Pièce jointe",
|
||||
"encryption_enabled": "Chiffrement activé",
|
||||
"loading": "Chargement…",
|
||||
"options": "Options",
|
||||
"preferences": "Préférences",
|
||||
"state_encryption_enabled": "Chiffrement expérimental de l'état activé"
|
||||
|
||||
@ -1,15 +1,141 @@
|
||||
{
|
||||
"a11y": {
|
||||
"seek_bar_label": "音频定位栏"
|
||||
},
|
||||
"action": {
|
||||
"back": "返回",
|
||||
"click": "点击",
|
||||
"collapse": "折叠",
|
||||
"delete": "删除",
|
||||
"dismiss": "忽略",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"explore_rooms": "查找房间",
|
||||
"go": "转到",
|
||||
"hide": "隐藏",
|
||||
"invite": "邀请",
|
||||
"new_room": "新房间",
|
||||
"new_video_room": "新视频房间",
|
||||
"pause": "暂停",
|
||||
"pin": "置顶",
|
||||
"play": "播放",
|
||||
"search": "搜索"
|
||||
"react": "反应",
|
||||
"remove": "移除",
|
||||
"reply": "回复",
|
||||
"reply_in_thread": "在消息列中回复",
|
||||
"retry": "重试",
|
||||
"search": "搜索",
|
||||
"start_chat": "开始聊天",
|
||||
"unpin": "取消置顶",
|
||||
"view_source": "查看源代码"
|
||||
},
|
||||
"common": {
|
||||
"attachment": "附件",
|
||||
"encryption_enabled": "加密已启用",
|
||||
"options": "选项",
|
||||
"preferences": "偏好",
|
||||
"state_encryption_enabled": "实验性的状态加密已启用"
|
||||
},
|
||||
"left_panel": {
|
||||
"open_dial_pad": "打开拨号键盘"
|
||||
},
|
||||
"notifications": {
|
||||
"all_messages": "所有消息",
|
||||
"default_settings": "跟随系统设置",
|
||||
"mentions_keywords": "提及与关键词",
|
||||
"mute_room": "静默房间"
|
||||
},
|
||||
"room": {
|
||||
"context_menu": {
|
||||
"title": "房间选项"
|
||||
},
|
||||
"history_visibility_badge": {
|
||||
"private": "新成员不能看到历史",
|
||||
"shared": "新成员可以看到历史",
|
||||
"world_readable": "任何人都可以看到历史"
|
||||
},
|
||||
"jump_to_date": "跳转到日期",
|
||||
"jump_to_date_beginning": "房间的开头",
|
||||
"jump_to_date_prompt": "选择日期以跳转",
|
||||
"pinned_message_badge": "已被置顶的消息",
|
||||
"status_bar": {
|
||||
"delete_all": "全部删除",
|
||||
"exceeded_resource_limit_description": "要继续使用此服务请联系服务器管理员。",
|
||||
"exceeded_resource_limit_title": "你的消息未能发送,因为此服务器已超出资源限制。",
|
||||
"failed_to_create_room_title": "无法与此用户开始聊天",
|
||||
"homeserver_blocked_title": "你的消息未能发送,因为此服务器已被其管理员屏蔽。",
|
||||
"monthly_user_limit_reached_title": "你的消息未能发送,因为此服务器已达到每月活跃用户上限。",
|
||||
"requires_consent_agreement_title": "你需要同意我们的条款与条件才能发送任意消息。",
|
||||
"retry_all": "全部重试",
|
||||
"select_messages_to_retry": "你可以选择全部或个别消息以重试或删除。",
|
||||
"server_connectivity_lost_description": "已发送的消息将保存直到恢复连接。",
|
||||
"server_connectivity_lost_title": "已断开与服务器的连接。",
|
||||
"some_messages_not_sent": "你的某些消息未能发送。"
|
||||
}
|
||||
},
|
||||
"room_list": {
|
||||
"appearance": "外观",
|
||||
"collapse_filters": "折叠过滤器列表",
|
||||
"empty": {
|
||||
"no_chats": "暂无聊天",
|
||||
"no_chats_description": "首先向人们发送消息或创建房间。",
|
||||
"no_chats_description_no_room_rights": "开始向某人发送消息",
|
||||
"no_favourites": "你还没有收藏聊天",
|
||||
"no_favourites_description": "你可以在聊天设置中将聊天添加到收藏夹",
|
||||
"no_invites": "你没有任何未读邀请",
|
||||
"no_lowpriority": "你没有任何低优先级房间",
|
||||
"no_mentions": "你没有任何未读提及",
|
||||
"no_people": "你尚未与任何人私聊",
|
||||
"no_people_description": "你可以取消选择筛选条件,以便查看其它聊天记录",
|
||||
"no_rooms": "尚未处于任何房间",
|
||||
"no_rooms_description": "你可以取消选择筛选条件,以便查看其它聊天记录",
|
||||
"no_unread": "恭喜!你没有任何未读消息",
|
||||
"show_activity": "查看所有活动",
|
||||
"show_chats": "显示所有聊天"
|
||||
},
|
||||
"expand_filters": "展开过滤器列表",
|
||||
"filters": {
|
||||
"favourite": "收藏",
|
||||
"invites": "邀请",
|
||||
"low_priority": "低优先级",
|
||||
"mentions": "提及",
|
||||
"people": "人员",
|
||||
"rooms": "房间",
|
||||
"unread": "未读"
|
||||
},
|
||||
"list_title": "房间列表",
|
||||
"more_options": {
|
||||
"copy_link": "复制房间链接",
|
||||
"favourited": "已收藏",
|
||||
"leave_room": "离开房间",
|
||||
"low_priority": "低优先级",
|
||||
"mark_read": "设为已读",
|
||||
"mark_unread": "设为未读"
|
||||
},
|
||||
"notification_options": "通知选项",
|
||||
"open_space_menu": "打开空间菜单",
|
||||
"primary_filters": "房间列表筛选器",
|
||||
"room": {
|
||||
"more_options": "更多选项"
|
||||
},
|
||||
"room_options": "房间选项",
|
||||
"show_message_previews": "显示消息预览",
|
||||
"sort": "排序",
|
||||
"sort_type": {
|
||||
"activity": "活动",
|
||||
"unread_first": "未读优先"
|
||||
},
|
||||
"space_menu": {
|
||||
"home": "空间主页",
|
||||
"space_settings": "空间设置"
|
||||
}
|
||||
},
|
||||
"terms": {
|
||||
"tac_button": "阅读条款与条件"
|
||||
},
|
||||
"threads": {
|
||||
"error_start_thread_existing_relation": "无法从现有相关的事件中创建消息列"
|
||||
},
|
||||
"time": {
|
||||
"about_day_ago": "约一天前",
|
||||
"about_hour_ago": "约一小时前",
|
||||
@ -27,9 +153,61 @@
|
||||
"n_minutes_ago": "%(num)s分钟前"
|
||||
},
|
||||
"timeline": {
|
||||
"decryption_failure": {
|
||||
"blocked": "由于你的设备未经验证,发件人已阻止你接收此消息。",
|
||||
"historical_event_no_key_backup": "消息历史在此设备上不可用",
|
||||
"historical_event_unverified_device": "你需要验证此设备才能访问历史消息",
|
||||
"historical_event_user_not_joined": "你无权访问此消息",
|
||||
"sender_identity_previously_verified": "发送者的数字身份已重置。",
|
||||
"sender_unsigned_device": "从不安全的设备发送。",
|
||||
"unable_to_decrypt": "无法解密消息"
|
||||
},
|
||||
"download_action_decrypting": "正在解密",
|
||||
"download_action_downloading": "正在下载",
|
||||
"m.audio": {
|
||||
"audio_player": "音频播放器",
|
||||
"error_downloading_audio": "下载音频时出错",
|
||||
"unnamed_audio": "未命名的音频"
|
||||
},
|
||||
"m.file": {
|
||||
"error_invalid": "无效文件"
|
||||
},
|
||||
"m.room.encryption": {
|
||||
"disable_attempt": "已忽略尝试禁用加密",
|
||||
"disabled": "加密未启用",
|
||||
"enabled": "此处的消息是端到端加密的。当人员加入时,你可以在其个人资料中点击其头像验证。",
|
||||
"enabled_dm": "此处的消息是端到端加密的。点击其个人资料图像以验证 %(displayName)s。",
|
||||
"enabled_local": "此聊天中的消息将被端到端加密。",
|
||||
"parameters_changed": "某些加密参数已被更改。",
|
||||
"state_enabled": "此房间内的消息与状态事件已被端到端加密。当人员加入时,你可以点击他们的头像以验证其身份。",
|
||||
"unsupported": "该房间使用的加密方式不受支持。"
|
||||
},
|
||||
"mab": {
|
||||
"collapse_reply_chain": "折叠引用",
|
||||
"copy_link_thread": "复制关联到此消息列的链接",
|
||||
"expand_reply_chain": "展开引用",
|
||||
"label": "消息操作",
|
||||
"view_in_room": "在房间中查看"
|
||||
},
|
||||
"message_timestamp_received_at": "接收于 %(dateTime)s",
|
||||
"message_timestamp_sent_at": "发送于 %(dateTime)s",
|
||||
"url_preview": {
|
||||
"close": "关闭预览",
|
||||
"show_n_more": {
|
||||
"one": "显示剩余 %(count)s 个预览",
|
||||
"other": "显示剩余 %(count)s 个预览"
|
||||
},
|
||||
"view_image": "查看图像"
|
||||
}
|
||||
},
|
||||
"widget": {
|
||||
"context_menu": {
|
||||
"move_left": "向左移动",
|
||||
"move_right": "向右移动",
|
||||
"remove": "为所有人移除",
|
||||
"revoke": "撤消权限",
|
||||
"screenshot": "拍摄照片",
|
||||
"start_audio_stream": "开始音频串流"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2068
pnpm-lock.yaml
generated
2068
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -27,4 +27,8 @@ packageExtensions:
|
||||
dependencies:
|
||||
# Fix missing type dependency
|
||||
"@types/picomatch": 4.0.2
|
||||
"@joshwooding/vite-plugin-react-docgen-typescript":
|
||||
peerDependencies:
|
||||
# Silence warning
|
||||
vite: "^8.0.0"
|
||||
ignorePatchFailures: false
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user