feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,214 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
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 { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { getSampleFilePath } from "../../sample-files";
const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
botCreateOpts: {
displayName: "Bob",
},
});
test.use({
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Composing Room" });
await app.viewRoomByName("Composing Room");
await use({ roomId });
},
});
test.beforeEach(async ({ room }) => {}); // trigger room fixture
test.describe("CIDER", () => {
test("sends a message when you click send or press Enter", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
// Type a message
await composer.pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.getByRole("button", { name: "Send message" }).click();
// It has been sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }),
).toBeVisible();
// Type another and press Enter afterward
await composer.pressSequentially("my message 1");
await composer.press("Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }),
).toBeVisible();
});
test("can write formatted text", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
await composer.pressSequentially("my bold");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
// Note: both "bold" and "message" are bold, which is probably surprising
await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible();
});
test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click();
await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Send message
await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
});
test.describe("render emoji picker with larger viewport height", async () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
// Mask the background of the screenshot to avoid failing the test just because some
// other component have changed its rendering.
await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker.png", {
css: `
.mx_ContextualMenu_background {
background-color: magenta !important;
}
`,
});
});
});
test.describe("render emoji picker with small viewport height", async () => {
test.use({ viewport: { width: 1280, height: 360 } });
test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
// Mask the background of the screenshot to avoid failing the test just because some
// other component have changed its rendering.
await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker-small.png", {
css: `
.mx_ContextualMenu_background {
background-color: magenta !important;
}
`,
});
});
});
test("should have focus lock in emoji picker", async ({ page, app }) => {
const emojiButton = app.getComposer(false).getByRole("button", { name: "Emoji" });
// Open emoji picker by clicking the button
await emojiButton.click();
// Wait for emoji picker to be visible
const emojiPicker = page.getByTestId("mx_EmojiPicker");
await expect(emojiPicker).toBeVisible();
// Get initial focused element (should be search input)
const searchInput = emojiPicker.getByRole("textbox", { name: "Search" });
await expect(searchInput).toBeFocused();
// Try to tab multiple times - focus should stay within emoji picker
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
// Verify we're still within the emoji picker (not back to composer)
const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker"));
expect(focusedElement).not.toBeNull();
// Close with Escape key
await page.keyboard.press("Escape");
// Verify emoji picker is closed
await expect(emojiPicker).not.toBeVisible();
// Verify focus returns to emoji button
await expect(emojiButton).toBeFocused();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
});
test("only sends when you press Control+Enter", async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
// Type a message and press Enter
await composer.pressSequentially("my message 3");
await composer.press("Enter");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
// Press Control+Enter
await composer.press(`${CtrlOrMeta}+Enter`);
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }),
).toBeVisible();
});
});
test("can send mention", { tag: "@screenshot" }, async ({ page, bot, app }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bot.credentials.userId],
});
await app.viewRoomByName("Bob");
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
await composer.pressSequentially("@bob");
// Note that we include the user ID here as the room tile is also an 'option' role
// with text 'Bob'
await page.getByRole("option", { name: `Bob ${bot.credentials.userId}` }).click();
await expect(composer.getByText("Bob")).toBeVisible();
await expect(composer).toMatchScreenshot("mention.png");
await composer.press("Enter");
await expect(page.locator(".mx_EventTile_body", { hasText: "Bob" })).toBeVisible();
});
test("renders emoji autocomplete", { tag: "@screenshot" }, async ({ page }) => {
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
// Type ":+1" to trigger emoji autocomplete
await composer.pressSequentially(":+1");
// Wait for autocomplete to appear
const autocomplete = page.locator("#mx_Autocomplete");
await expect(autocomplete).toBeVisible();
// Take a screenshot of the autocomplete
await expect(autocomplete).toMatchScreenshot("emoji-autocomplete.png");
});
test("can paste a file", async ({ page, bot, app }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bot.credentials.userId],
});
await app.viewRoomByName("Bob");
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_MImageBody")).toBeVisible();
});
});
});
@@ -0,0 +1,369 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
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 { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { getSampleFilePath } from "../../sample-files";
const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
test.describe("Composer", () => {
test.use({
displayName: "Janet",
});
test.use({
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Composing Room" });
await app.viewRoomByName("Composing Room");
await use({ roomId });
},
});
test.beforeEach(async ({ room }) => {}); // trigger room fixture
test.describe("Rich text editor", () => {
test.use({
labsFlags: ["feature_wysiwyg_composer"],
});
test.describe("Commands", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test("autocomplete behaviour tests", async ({ page }) => {
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single / displays the autocomplete menu and contents
await page.getByRole("textbox").press("/");
// Check that the autocomplete options are visible and there are more than 0 items
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Entering `//` or `/ ` hides the autocomplete contents
// Add an extra slash for `//`
await page.getByRole("textbox").press("/");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Remove the extra slash to go back to `/`
await page.getByRole("textbox").press("Backspace");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Add a trailing space for `/ `
await page.getByRole("textbox").press(" ");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Typing a command that takes no arguments (/devtools) and selecting by click works
await page.getByRole("textbox").press("Backspace");
await page.getByRole("textbox").pressSequentially("dev");
await page.getByTestId("autocomplete-wrapper").getByText("/devtools").click();
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/devtools")).toBeVisible();
// Send the message and check the devtools dialog appeared, then close it
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.getByRole("dialog").getByText("Developer Tools")).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click();
// Typing a command that takes arguments (/spoiler) and selecting with enter works
await page.getByRole("textbox").pressSequentially("/spoil");
await expect(page.getByTestId("autocomplete-wrapper").getByText("/spoiler")).toBeVisible();
await page.getByRole("textbox").press("Enter");
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/spoiler")).toBeVisible();
// Enter some more text, then send the message
await page.getByRole("textbox").pressSequentially("this is the spoiler text ");
await page.getByRole("button", { name: "Send message" }).click();
// Check that a spoiler item has appeared in the timeline and contains the spoiler text
await expect(page.locator("button.mx_EventTile_spoiler")).toHaveText("this is the spoiler text");
});
});
});
test.describe("Mentions", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test.use({
botCreateOpts: {
displayName: "Bob",
},
});
test("autocomplete behaviour tests", async ({ page, app, bot: bob }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bob.credentials.userId],
});
await app.viewRoomByName("Bob");
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single @ does not display the autocomplete menu and contents
await page.getByRole("textbox").press("@");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Entering the first letter of the other user's name opens the autocomplete...
await page.getByRole("textbox").pressSequentially(bob.credentials.displayName.slice(0, 1));
// ...with the other user name visible, and clicking that username...
await page.getByTestId("autocomplete-wrapper").getByText(bob.credentials.displayName).click();
// ...inserts the username into the composer
const pill = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill).toHaveAttribute("contenteditable", "false");
await expect(pill).toHaveAttribute("data-mention-type", "user");
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Typing an @, then other user's name, then trailing space closes the autocomplete
await page.getByRole("textbox").pressSequentially(`@${bob.credentials.displayName} `);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Moving the cursor back to an "incomplete" mention opens the autocomplete
await page
.getByRole("textbox")
.pressSequentially(`initial text @${bob.credentials.displayName.slice(0, 1)} abc`);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays
await page.getByRole("textbox").press("ArrowLeft");
await page.getByRole("textbox").press("ArrowLeft");
await page.getByRole("textbox").press("ArrowLeft");
await page.getByRole("textbox").press("ArrowLeft");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Selecting the autocomplete option using Enter inserts it into the composer
await page.getByRole("textbox").press("Enter");
const pill2 = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill2).toHaveAttribute("contenteditable", "false");
await expect(pill2).toHaveAttribute("data-mention-type", "user");
});
});
});
test("sends a message when you click send or press Enter", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.getByRole("button", { name: "Send message" }).click();
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
// Type another
await page.locator("div[contenteditable=true]").pressSequentially("my message 1");
// Send message
await page.locator("div[contenteditable=true]").press("Enter");
// It was sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 1")).toBeVisible();
});
test("sends only one message when you press Enter multiple times", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
// Click send
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body")).toHaveCount(1);
});
test("can write formatted text", async ({ page }) => {
await page.locator("div[contenteditable=true]").pressSequentially("my ");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially("bold");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_body strong").getByText("bold")).toBeVisible();
});
test("can paste a file", async ({ page, bot, app }) => {
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_MImageBody")).toBeVisible();
});
test("can paste a file in a thread", async ({ page, app }) => {
// Send a message
const composer = page.locator("div[contenteditable=true]");
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_MImageBody")).toBeVisible();
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
});
test("only sends when you press Control+Enter", async ({ page }) => {
// Type a message and press Enter
await page.locator("div[contenteditable=true]").pressSequentially("my message 3");
await page.locator("div[contenteditable=true]").press("Enter");
// It has not been sent yet
await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
// Press Control+Enter
await page.locator("div[contenteditable=true]").press("Control+Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 3"),
).toBeVisible();
});
});
test.describe("links", () => {
test("create link with a forward selection", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+A`);
// Open link modal
await page.getByRole("button", { name: "Link" }).click();
// Fill the link field
await page.getByRole("textbox", { name: "Link" }).pressSequentially("https://matrix.org/");
// Click on save
await page.getByRole("button", { name: "Save" }).click();
// Send the message
await page.getByRole("button", { name: "Send message" }).click();
// It was sent
await expect(page.locator(".mx_EventTile_body a").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_body a")).toHaveAttribute(
"href",
new RegExp("https://matrix.org/"),
);
});
});
test.describe("Drafts", () => {
test("drafts with rich and plain text", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Type some formatted text
await composer.pressSequentially("my ");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially("bold");
// Change to plain text mode
await page.getByRole("button", { name: "Hide formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// assert the markdown
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();
// Change to plain text mode and assert the markdown
await page.getByRole("button", { name: "Show formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Send the message and assert the message
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
});
test("draft with replies", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Type reply text
await composer.pressSequentially("my reply");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert reply mode and reply text
await expect(page.getByText("Replying")).toBeVisible();
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
});
test("draft in threads", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
const thread = page.locator(".mx_ThreadView");
const threadComposer = thread.locator("div[contenteditable=true]");
// Type threaded text
await threadComposer.pressSequentially("my threaded message");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert threaded draft
await expect(
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
).toBeVisible();
});
});
});
});