Merge branch 'develop' into hs/enable-profile-updates

This commit is contained in:
David Baker 2026-04-17 17:04:52 +01:00 committed by GitHub
commit 39fb0ec5a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 5277 additions and 3142 deletions

View File

@ -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

View File

@ -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": "切换开发者工具",

View File

@ -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",

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,92 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
/**
* Get the room list
* @param page
*/
export function getRoomList(page: Page): Locator {
return page.getByTestId("room-list");
}
/**
* Get the room list header
* @param page
*/
export function getRoomListHeader(page: Page): Locator {
return page.getByTestId("room-list-header");
}
/**
* Get a section header toggle button by section name
* @param page
* @param sectionName The display name of the section
* @param isUnread Whether to look for the unread version of the section header
*/
export function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
return getRoomList(page).getByRole("gridcell", {
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
});
}
/**
* Get the primary filters container
* @param page
*/
export function getPrimaryFilters(page: Page): Locator {
return page.getByTestId("primary-filters");
}
/**
* Get the room options menu button in the room list header
* @param page
*/
export function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}
/**
* Get the filter list expand button in the room list header
* @param page
*/
export function getFilterExpandButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
}
/**
* Get the filter list collapse button in the room list header
* @param page
*/
export function getFilterCollapseButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
}
/**
* Get the header section of the room list
* @param page
*/
export function getHeaderSection(page: Page) {
return page.getByTestId("room-list-header");
}
/**
* Get the room list view
* @param page
*/
export function getRoomListView(page: Page) {
return page.getByRole("navigation", { name: "Room list" });
}
/**
* Get the search section of the room list
* @param page
*/
export function getSearchSection(page: Page) {
return page.getByRole("search");
}

View File

@ -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();

View File

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

View File

@ -0,0 +1,23 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_CreateSectionDialog {
color: var(--cpd-color-text-primary);
&.mx_Dialog_fixedWidth {
/* 576px coming from Figma and remove external padding */
max-width: calc(576px - var(--cpd-space-20x));
}
.mx_CreateSectionDialog_content {
min-height: 346px;
}
.mx_CreateSectionDialog_form {
width: 100%;
}
}

View File

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

View File

@ -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

View File

@ -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>
);
}
}

View 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}
/>
);
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,59 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { CreateSectionDialog } from "../../../../../src/components/views/dialogs/CreateSectionDialog";
describe("CreateSectionDialog", () => {
const onFinished: jest.Mock = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
function renderComponent(): void {
render(<CreateSectionDialog onFinished={onFinished} />);
}
it("renders the dialog", () => {
const { container } = render(<CreateSectionDialog onFinished={onFinished} />);
expect(container).toMatchSnapshot();
});
it("has the create section button disabled when the input is empty", () => {
renderComponent();
const createButton = screen.getByRole("button", { name: "Create section" });
expect(createButton).toBeDisabled();
});
it("calls onFinished with true and the section name when create section is clicked", async () => {
renderComponent();
const input = screen.getByRole("textbox");
await userEvent.type(input, "My section");
const createButton = screen.getByRole("button", { name: "Create section" });
await userEvent.click(createButton);
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
it("calls onFinished with false when the dialog is cancelled", async () => {
renderComponent();
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(onFinished).toHaveBeenCalledWith(false, "");
});
it("calls onFinished with true and the section name when the form is submitted", async () => {
renderComponent();
const input = screen.getByRole("textbox");
await userEvent.type(input, "My section");
await userEvent.keyboard("{Enter}");
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
});

View File

@ -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();
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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);

View File

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

View File

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

View File

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

View File

@ -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": {

View File

@ -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",

View File

@ -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();
}
}

View File

@ -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é"

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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