diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index acd59406e9..97d9692a38 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -190,13 +190,14 @@ jobs: - name: Merge into HTML Report if: inputs.skip != true - run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts ./all-blob-reports + run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports env: # Only pass creds to the flaky-reporter on main branch runs GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} + # Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected - name: Upload HTML report - if: inputs.skip != true + if: always() && inputs.skip != true uses: actions/upload-artifact@v4 with: name: html-report diff --git a/package.json b/package.json index 7221d3a3bd..1520771cec 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/analytics-events": "^0.23.0", + "@matrix-org/analytics-events": "^0.24.0", "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.37.4", "@matrix-org/react-sdk-module-api": "^2.4.0", @@ -81,7 +81,7 @@ "@sentry/browser": "^8.0.0", "@testing-library/react-hooks": "^8.0.1", "@vector-im/compound-design-tokens": "^1.2.0", - "@vector-im/compound-web": "^5.2.3", + "@vector-im/compound-web": "^5.4.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -91,12 +91,13 @@ "classnames": "^2.2.6", "commonmark": "^0.31.0", "counterpart": "^0.18.6", + "css-tree": "^2.3.1", "diff-dom": "^5.0.0", "diff-match-patch": "^1.0.5", "emojibase-regex": "15.3.2", "escape-html": "^1.0.3", "file-saver": "^2.0.5", - "filesize": "10.1.2", + "filesize": "10.1.4", "github-markdown-css": "^5.5.1", "glob-to-regexp": "^0.4.1", "highlight.js": "^11.3.1", @@ -121,7 +122,7 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.141.3", + "posthog-js": "1.145.0", "qrcode": "1.5.3", "re-resizable": "^6.9.0", "react": "17.0.2", @@ -167,6 +168,7 @@ "@types/commonmark": "^0.27.4", "@types/content-type": "^1.1.5", "@types/counterpart": "^0.18.1", + "@types/css-tree": "^2.3.8", "@types/diff-match-patch": "^1.0.32", "@types/escape-html": "^1.0.1", "@types/express": "^4.17.21", @@ -211,6 +213,7 @@ "fake-indexeddb": "^6.0.0", "fetch-mock-jest": "^1.5.1", "fs-extra": "^11.0.0", + "glob": "^11.0.0", "jest": "^29.6.2", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.6.2", @@ -221,15 +224,16 @@ "matrix-web-i18n": "^3.2.1", "mocha-junit-reporter": "^2.2.0", "node-fetch": "2", + "playwright-core": "^1.45.1", "postcss-scss": "^4.0.4", "prettier": "3.3.2", "raw-loader": "^4.0.2", - "rimraf": "^5.0.0", + "rimraf": "^6.0.0", "stylelint": "^16.1.0", "stylelint-config-standard": "^36.0.0", "stylelint-scss": "^6.0.0", "ts-node": "^10.9.1", - "typescript": "5.5.2", + "typescript": "5.5.3", "web-streams-polyfill": "^4.0.0" }, "peerDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 458cf98daf..ba491ff82a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -37,9 +37,10 @@ export default defineConfig({ }, testDir: "playwright/e2e", outputDir: "playwright/test-results", - workers: 1, + workers: process.env.CI ? "50%" : 1, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { outputFolder: "playwright/html-report" }]], snapshotDir: "playwright/snapshots", snapshotPathTemplate: "{snapshotDir}/{testFilePath}/{arg}-{platform}{ext}", + forbidOnly: !!process.env.CI, }); diff --git a/res/css/structures/_NotificationPanel.pcss b/playwright/@types/playwright-core.d.ts similarity index 71% rename from res/css/structures/_NotificationPanel.pcss rename to playwright/@types/playwright-core.d.ts index 7a3ede9e50..0ef2ca0ece 100644 --- a/res/css/structures/_NotificationPanel.pcss +++ b/playwright/@types/playwright-core.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_NotificationPanel_empty::before { - --maskImage: url("$(res)/img/element-icons/notifications.svg"); /* See: _RightPanel.pcss */ +declare module "playwright-core/lib/utils" { + // This type is not public in playwright-core utils + export function sanitizeForFilePath(filePath: string): string; } diff --git a/playwright/Dockerfile b/playwright/Dockerfile index f20a77b952..c5a9cdbb81 100644 --- a/playwright/Dockerfile +++ b/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.45.0-jammy +FROM mcr.microsoft.com/playwright:v1.45.1-jammy WORKDIR /work/matrix-react-sdk VOLUME ["/work/element-web/node_modules"] diff --git a/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/playwright/e2e/accessibility/keyboard-navigation.spec.ts new file mode 100644 index 0000000000..b4b74f5187 --- /dev/null +++ b/playwright/e2e/accessibility/keyboard-navigation.spec.ts @@ -0,0 +1,166 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; + +test.describe("Landmark navigation tests", () => { + test.use({ + displayName: "Alice", + }); + + test("without any rooms", async ({ page, homeserver, app, user }) => { + /** + * Without any rooms, there is no tile in the roomlist to be focused. + * So the next landmark in the list should be focused instead. + */ + + // Pressing Control+F6 will first focus the space button + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + + // Pressing Control+F6 again will focus room search + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + + // Pressing Control+F6 again will focus the message composer + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_HomePage")).toBeFocused(); + + // Pressing Control+F6 again will bring focus back to the space button + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + + // Now go back in the same order + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_HomePage")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + }); + + test("with an open room", async ({ page, homeserver, app, user }) => { + const bob = new Bot(page, homeserver, { displayName: "Bob" }); + await bob.prepareClient(); + + // create dm with bob + await app.client.evaluate( + async (cli, { bob }) => { + const bobRoom = await cli.createRoom({ is_direct: true }); + await cli.invite(bobRoom.room_id, bob); + }, + { + bob: bob.credentials.userId, + }, + ); + + await app.viewRoomByName("Bob"); + // confirm the room was loaded + await expect(page.getByText("Bob joined the room")).toBeVisible(); + + // Pressing Control+F6 will first focus the space button + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + + // Pressing Control+F6 again will focus room search + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + + // Pressing Control+F6 again will focus the room tile in the room list + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_RoomTile_selected")).toBeFocused(); + + // Pressing Control+F6 again will focus the message composer + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused(); + + // Pressing Control+F6 again will bring focus back to the space button + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + + // Now go back in the same order + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_RoomTile_selected")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + }); + + test("without an open room", async ({ page, homeserver, app, user }) => { + const bob = new Bot(page, homeserver, { displayName: "Bob" }); + await bob.prepareClient(); + + // create a dm with bob + await app.client.evaluate( + async (cli, { bob }) => { + const bobRoom = await cli.createRoom({ is_direct: true }); + await cli.invite(bobRoom.room_id, bob); + }, + { + bob: bob.credentials.userId, + }, + ); + + await app.viewRoomByName("Bob"); + // confirm the room was loaded + await expect(page.getByText("Bob joined the room")).toBeVisible(); + + // Close the room + page.goto("/#/home"); + + // Pressing Control+F6 will first focus the space button + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + + // Pressing Control+F6 again will focus room search + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + + // Pressing Control+F6 again will focus the room tile in the room list + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_RoomTile")).toBeFocused(); + + // Pressing Control+F6 again will focus the home section + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_HomePage")).toBeFocused(); + + // Pressing Control+F6 will bring focus back to the space button + await page.keyboard.press("ControlOrMeta+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + + // Now go back in same order + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_HomePage")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_RoomTile")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + + await page.keyboard.press("ControlOrMeta+Shift+F6"); + await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); + }); +}); diff --git a/playwright/e2e/app-loading/feature-detection.spec.ts b/playwright/e2e/app-loading/feature-detection.spec.ts new file mode 100644 index 0000000000..2acde32c37 --- /dev/null +++ b/playwright/e2e/app-loading/feature-detection.spec.ts @@ -0,0 +1,42 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; + +test(`shows error page if browser lacks Intl support`, async ({ page }) => { + await page.addInitScript({ content: `delete window.Intl;` }); + await page.goto("/"); + + // Lack of Intl support causes the app bundle to fail to load, so we get the iframed + // static error page and need to explicitly look in the iframe becuse Playwright doesn't + // recurse into iframes when looking for elements + const header = await page.frameLocator("iframe").getByText("Unsupported browser"); + await expect(header).toBeVisible(); + + await expect(page).toMatchScreenshot("unsupported-browser.png"); +}); + +test(`shows error page if browser lacks WebAssembly support`, async ({ page }) => { + await page.addInitScript({ content: `delete window.WebAssembly;` }); + await page.goto("/"); + + // Lack of WebAssembly support doesn't cause the bundle to fail loading, so we get + // CompatibilityView, ie. no iframes. + const header = await page.getByText("Unsupported browser"); + await expect(header).toBeVisible(); + + await expect(page).toMatchScreenshot("unsupported-browser-CompatibilityView.png"); +}); diff --git a/playwright/e2e/app-loading/stored-credentials.spec.ts b/playwright/e2e/app-loading/stored-credentials.spec.ts index 1fe89d37e5..f720a54559 100644 --- a/playwright/e2e/app-loading/stored-credentials.spec.ts +++ b/playwright/e2e/app-loading/stored-credentials.spec.ts @@ -48,7 +48,7 @@ test("Shows the last known page on reload", async ({ pageWithCredentials: page } // Check that the room reloaded await expect(page).toHaveURL(/\/#\/room\//); - await expect(page.locator(".mx_LegacyRoomHeader")).toContainText("Test Room"); + await expect(page.locator(".mx_RoomHeader")).toContainText("Test Room"); }); test("Room link correctly loads a room view", async ({ pageWithCredentials: page }) => { diff --git a/playwright/e2e/chat-export/html-export.spec.ts b/playwright/e2e/chat-export/html-export.spec.ts new file mode 100644 index 0000000000..b142fcec4e --- /dev/null +++ b/playwright/e2e/chat-export/html-export.spec.ts @@ -0,0 +1,133 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import os from "node:os"; +import path from "node:path"; +import * as fsp from "node:fs/promises"; +import * as fs from "node:fs"; +import JSZip from "jszip"; + +import { test, expect } from "../../element-web-test"; + +// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912 +async function extractZipFileToPath(file: string, outputPath: string): Promise { + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }); + } + + const data = await fsp.readFile(file); + const zip = await JSZip.loadAsync(data, { createFolders: true }); + + await new Promise((resolve, reject) => { + let entryCount = 0; + let errorOut = false; + + zip.forEach(() => { + entryCount++; + }); // there is no other way to count the number of entries within the zip file. + + zip.forEach((relativePath, zipEntry) => { + if (errorOut) { + return; + } + + const outputEntryPath = path.join(outputPath, relativePath); + if (zipEntry.dir) { + if (!fs.existsSync(outputEntryPath)) { + fs.mkdirSync(outputEntryPath, { recursive: true }); + } + + entryCount--; + + if (entryCount === 0) { + resolve(); + } + } else { + void zipEntry + .async("blob") + .then(async (content) => Buffer.from(await content.arrayBuffer())) + .then((buffer) => { + const stream = fs.createWriteStream(outputEntryPath); + stream.write(buffer, (error) => { + if (error) { + reject(error); + errorOut = true; + } + }); + stream.on("finish", () => { + entryCount--; + + if (entryCount === 0) { + resolve(); + } + }); + stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that. + }) + .catch((e) => { + errorOut = true; + reject(e); + }); + } + }); + }); + + return zip; +} + +test.describe("HTML Export", () => { + test.use({ + displayName: "Alice", + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ name: "Important Room" }); + await app.viewRoomByName("Important Room"); + await use({ roomId }); + }, + }); + + test("should export html successfully and match screenshot", async ({ page, app, room }) => { + // Send a bunch of messages to populate the room + for (let i = 1; i < 10; i++) { + await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" }); + } + + // Wait for all the messages to be displayed + await expect( + page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"), + ).toBeVisible(); + + await app.toggleRoomInfoPanel(); + await page.getByRole("menuitem", { name: "Export Chat" }).click(); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export", exact: true }).click(); + const download = await downloadPromise; + + const dirPath = path.join(os.tmpdir(), "html-export-test"); + const zipPath = `${dirPath}.zip`; + await download.saveAs(zipPath); + + const zip = await extractZipFileToPath(zipPath, dirPath); + await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`); + await expect(page).toMatchScreenshot("html-export.png", { + mask: [ + page.getByText("This is the start of export", { exact: false }), + // We need to mask the whole thing because the width of the time part changes + page.locator(".mx_TimelineSeparator"), + page.locator(".mx_MessageTimestamp"), + ], + }); + }); +}); diff --git a/playwright/e2e/composer/CIDER.spec.ts b/playwright/e2e/composer/CIDER.spec.ts new file mode 100644 index 0000000000..779babdaf2 --- /dev/null +++ b/playwright/e2e/composer/CIDER.spec.ts @@ -0,0 +1,106 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +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("CIDER", () => { + test("sends a message when you click send or press Enter", async ({ page }) => { + const composer = page.getByRole("textbox", { name: "Send a 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 a 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 a message…" }).press("Enter"); // Send message + + await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).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 }) => { + const composer = page.getByRole("textbox", { name: "Send a 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(); + }); + }); + }); +}); diff --git a/playwright/e2e/composer/composer.spec.ts b/playwright/e2e/composer/RTE.spec.ts similarity index 80% rename from playwright/e2e/composer/composer.spec.ts rename to playwright/e2e/composer/RTE.spec.ts index e7be457f83..53599d5320 100644 --- a/playwright/e2e/composer/composer.spec.ts +++ b/playwright/e2e/composer/RTE.spec.ts @@ -34,76 +34,6 @@ test.describe("Composer", () => { 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 a 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 a 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 a message…" }).press("Enter"); // Send message - - await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).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 }) => { - const composer = page.getByRole("textbox", { name: "Send a 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.describe("Rich text editor", () => { test.use({ labsFlags: ["feature_wysiwyg_composer"], diff --git a/playwright/e2e/create-room/create-room.spec.ts b/playwright/e2e/create-room/create-room.spec.ts index 0e5882e23c..651439302d 100644 --- a/playwright/e2e/create-room/create-room.spec.ts +++ b/playwright/e2e/create-room/create-room.spec.ts @@ -36,7 +36,7 @@ test.describe("Create Room", () => { await dialog.getByRole("button", { name: "Create room" }).click(); await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/); - const header = page.locator(".mx_LegacyRoomHeader"); + const header = page.locator(".mx_RoomHeader"); await expect(header).toContainText(name); await expect(header).toContainText(topic); }); diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 98f75d54e1..3f4621f90f 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -15,29 +15,10 @@ limitations under the License. */ import type { Page } from "@playwright/test"; -import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix"; import { expect, test } from "../../element-web-test"; -import { - copyAndContinue, - createRoom, - createSharedRoomWithUser, - doTwoWaySasVerification, - enableKeyBackup, - logIntoElement, - logOutOfElement, - sendMessageInCurrentRoom, - verifySession, - waitForVerificationRequest, -} from "./utils"; +import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; import { Bot } from "../../pages/bot"; import { ElementAppPage } from "../../pages/ElementAppPage"; -import { Client } from "../../pages/client"; -import { isDendrite } from "../../plugins/homeserver/dendrite"; - -const openRoomInfo = async (page: Page) => { - await page.getByRole("button", { name: "Room info" }).click(); - return page.locator(".mx_RightPanel"); -}; const checkDMRoom = async (page: Page) => { const body = page.locator(".mx_RoomView_body"); @@ -88,38 +69,6 @@ const bobJoin = async (page: Page, bob: Bot) => { return roomId; }; -/** configure the given MatrixClient to auto-accept any invites */ -async function autoJoin(client: Client) { - await client.evaluate((cli) => { - cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { - if (member.membership === "invite" && member.userId === cli.getUserId()) { - cli.joinRoom(member.roomId); - } - }); - }); -} - -const verify = async (page: Page, bob: Bot) => { - const bobsVerificationRequestPromise = waitForVerificationRequest(bob); - - const roomInfo = await openRoomInfo(page); - await page.locator(".mx_RightPanelTabs").getByText("People").click(); - await roomInfo.getByText("Bob").click(); - await roomInfo.getByRole("button", { name: "Verify" }).click(); - await roomInfo.getByRole("button", { name: "Start Verification" }).click(); - - // this requires creating a DM, so can take a while. Give it a longer timeout. - await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); - - const request = await bobsVerificationRequestPromise; - // the bot user races with the Element user to hit the "verify by emoji" button - const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1")); - await doTwoWaySasVerification(page, verifier); - await roomInfo.getByRole("button", { name: "They match" }).click(); - await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible(); - await roomInfo.getByRole("button", { name: "Got it" }).click(); -}; - test.describe("Cryptography", function () { test.use({ displayName: "Alice", @@ -275,10 +224,10 @@ test.describe("Cryptography", function () { await checkDMRoom(page); const bobRoomId = await bobJoin(page, bob); await testMessages(page, bob, bobRoomId); - await verify(page, bob); + await verify(app, bob); // Assert that verified icon is rendered - await page.getByRole("button", { name: "Room members" }).click(); + await page.getByTestId("base-card-back-button").click(); await page.locator(".mx_RightPanelTabs").getByText("Info").click(); await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted"); @@ -297,511 +246,6 @@ test.describe("Cryptography", function () { // we need to have a room with the other user present, so we can open the verification panel await createSharedRoomWithUser(app, bob.credentials.userId); - await verify(page, bob); - }); - - test.describe("event shields", () => { - let testRoomId: string; - - test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => { - await app.client.bootstrapCrossSigning(aliceCredentials); - await autoJoin(bob); - - // create an encrypted room - testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { - name: "TestRoom", - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); - }); - - test("should show the correct shield on e2e events", async ({ page, app, bot: bob, homeserver }) => { - // Bob has a second, not cross-signed, device - const bobSecondDevice = new Bot(page, homeserver, { - bootstrapSecretStorage: false, - bootstrapCrossSigning: false, - }); - bobSecondDevice.setCredentials( - await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), - ); - await bobSecondDevice.prepareClient(); - - await bob.sendEvent(testRoomId, null, "m.room.encrypted", { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "the bird is in the hand", - }); - - const last = page.locator(".mx_EventTile_last"); - await expect(last).toContainText("Unable to decrypt message"); - const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); - await lastE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("This message could not be decrypted"); - - /* Should show a red padlock for an unencrypted message in an e2e room */ - await bob.evaluate( - (cli, testRoomId) => - cli.http.authedRequest( - window.matrixcs.Method.Put, - `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, - undefined, - { - msgtype: "m.text", - body: "test unencrypted", - }, - ), - testRoomId, - ); - - await expect(last).toContainText("test unencrypted"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("Not encrypted"); - - /* Should show no padlock for an unverified user */ - // bob sends a valid event - await bob.sendMessage(testRoomId, "test encrypted 1"); - - // the message should appear, decrypted, with no warning, but also no "verified" - const lastTile = page.locator(".mx_EventTile_last"); - const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); - await expect(lastTile).toContainText("test encrypted 1"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); - - /* Now verify Bob */ - await verify(page, bob); - - /* Existing message should be updated when user is verified. */ - await expect(last).toContainText("test encrypted 1"); - // still no e2e icon - await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); - - /* should show no padlock, and be verified, for a message from a verified device */ - await bob.sendMessage(testRoomId, "test encrypted 2"); - - await expect(lastTile).toContainText("test encrypted 2"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); - - /* should show red padlock for a message from an unverified device */ - await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); - await expect(lastTile).toContainText("test encrypted from unverified"); - await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastTileE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner."); - - /* Should show a grey padlock for a message from an unknown device */ - // bob deletes his second device - await bobSecondDevice.evaluate((cli) => cli.logout(true)); - - // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. - async function awaitOneDevice(iterations = 1) { - const rightPanel = page.locator(".mx_RightPanel"); - await rightPanel.getByRole("button", { name: "Room members" }).click(); - await rightPanel.getByText("Bob").click(); - const sessionCountText = await rightPanel - .locator(".mx_UserInfo_devices") - .getByText(" session", { exact: false }) - .textContent(); - // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here - if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { - if (iterations >= 10) { - throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); - } - await awaitOneDevice(iterations + 1); - } - } - - await awaitOneDevice(); - - // close and reopen the room, to get the shield to update. - await app.viewRoomByName("Bob"); - await app.viewRoomByName("TestRoom"); - - await expect(last).toContainText("test encrypted from unverified"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device."); - }); - - test("Should show a grey padlock for a key restored from backup", async ({ - page, - app, - bot: bob, - homeserver, - user: aliceCredentials, - }) => { - test.slow(); - const securityKey = await enableKeyBackup(app); - - // bob sends a valid event - await bob.sendMessage(testRoomId, "test encrypted 1"); - - const lastTile = page.locator(".mx_EventTile_last"); - const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); - await expect(lastTile).toContainText("test encrypted 1"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); - - // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for - // the key to be backed up. - await page.waitForTimeout(10000); - - /* log out, and back in */ - await logOutOfElement(page); - // Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout - // https://github.com/element-hq/element-web/issues/25779 - await page.addInitScript(() => { - // When we reload, the initScript created by the `user`/`pageWithCredentials` fixtures - // will re-inject the original credentials into localStorage, which we don't want. - // To work around, we add a second initScript which will clear localStorage again. - window.localStorage.clear(); - }); - await page.reload(); - await logIntoElement(page, homeserver, aliceCredentials, securityKey); - - /* go back to the test room and find Bob's message again */ - await app.viewRoomById(testRoomId); - await expect(lastTile).toContainText("test encrypted 1"); - // The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning. - // No shield would have no div mx_EventTile_e2eIcon at all. - await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/); - await lastTileE2eIcon.hover(); - // The key is coming from backup, so it is not anymore possible to establish if the claimed device - // creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device." - // It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted. - await expect(page.getByRole("tooltip")).toContainText( - "The authenticity of this encrypted message can't be guaranteed on this device.", - ); - }); - - test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => { - // bob has a second, not cross-signed, device - const bobSecondDevice = new Bot(page, homeserver, { - bootstrapSecretStorage: false, - bootstrapCrossSigning: false, - }); - bobSecondDevice.setCredentials( - await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), - ); - await bobSecondDevice.prepareClient(); - - // verify Bob - await verify(page, bob); - - // bob sends a valid event - const testEvent = await bob.sendMessage(testRoomId, "Hoo!"); - - // the message should appear, decrypted, with no warning - await expect( - page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"), - ).not.toBeVisible(); - - // bob sends an edit to the first message with his unverified device - await bobSecondDevice.sendMessage(testRoomId, { - "m.new_content": { - msgtype: "m.text", - body: "Haa!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - - // the edit should have a warning - await expect( - page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"), - ).toBeVisible(); - - // a second edit from the verified device should be ok - await bob.sendMessage(testRoomId, { - "m.new_content": { - msgtype: "m.text", - body: "Hee!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - - await expect( - page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"), - ).not.toBeVisible(); - }); - }); - - test.describe("decryption failure messages", () => { - test("should handle device-relative historical messages", async ({ - homeserver, - page, - app, - credentials, - user, - }) => { - test.setTimeout(60000); - - // Start with a logged-in session, without key backup, and send a message. - await createRoom(page, "Test room", true); - await sendMessageInCurrentRoom(page, "test test"); - - // Log out, discarding the key for the sent message. - await logOutOfElement(page, true); - - // Log in again, and see how the message looks. - await logIntoElement(page, homeserver, credentials); - await app.viewRoomByName("Test room"); - const lastTile = page.locator(".mx_EventTile").last(); - await expect(lastTile).toContainText("Historical messages are not available on this device"); - await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - - // Now, we set up key backup, and then send another message. - const secretStorageKey = await enableKeyBackup(app); - await app.viewRoomByName("Test room"); - await sendMessageInCurrentRoom(page, "test2 test2"); - - // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for - // the key to be backed up. - await page.waitForTimeout(10000); - - // Finally, log out again, and back in, skipping verification for now, and see what we see. - await logOutOfElement(page); - await logIntoElement(page, homeserver, credentials); - await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click(); - await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); - await app.viewRoomByName("Test room"); - - // There should be two historical events in the timeline - const tiles = await page.locator(".mx_EventTile").all(); - expect(tiles.length).toBeGreaterThanOrEqual(2); - // look at the last two tiles only - for (const tile of tiles.slice(-2)) { - await expect(tile).toContainText("You need to verify this device for access to historical messages"); - await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - } - - // Now verify our device (setting up key backup), and check what happens - await verifySession(app, secretStorageKey); - const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2); - - // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though. - await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message"); - await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - - // The second message should now be decrypted, with a grey shield - await expect(tilesAfterVerify[1]).toContainText("test2 test2"); - await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible(); - }); - - test.describe("non-joined historical messages", () => { - test.skip(isDendrite, "does not yet support membership on events"); - - test("should display undecryptable non-joined historical messages with a different message", async ({ - homeserver, - page, - app, - credentials: aliceCredentials, - user: alice, - bot: bob, - }) => { - // Bob creates an encrypted room and sends a message to it. He then invites Alice - const roomId = await bob.evaluate( - async (client, { alice }) => { - const encryptionStatePromise = new Promise((resolve) => { - client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => { - if (event.getType() === "m.room.encryption") { - resolve(); - } - }); - }); - - const { room_id: roomId } = await client.createRoom({ - initial_state: [ - { - type: "m.room.encryption", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - name: "Test room", - preset: "private_chat" as Preset, - }); - - // wait for m.room.encryption event, so that when we send a - // message, it will be encrypted - await encryptionStatePromise; - - await client.sendTextMessage(roomId, "This should be undecryptable"); - - await client.invite(roomId, alice.userId); - - return roomId; - }, - { alice }, - ); - - // Alice accepts the invite - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(1); - await page.getByRole("treeitem", { name: "Test room" }).click(); - await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); - - // Bob sends an encrypted event and an undecryptable event - await bob.evaluate( - async (client, { roomId }) => { - await client.sendTextMessage(roomId, "This should be decryptable"); - await client.sendEvent( - roomId, - "m.room.encrypted" as any, - { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "this+message+will+be+undecryptable", - device_id: client.getDeviceId()!, - sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519, - session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - } as any, - ); - }, - { roomId }, - ); - - // We wait for the event tiles that we expect from the messages that - // Bob sent, in sequence. - await expect( - page.locator(`.mx_EventTile`).getByText("You don't have access to this message"), - ).toBeVisible(); - await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); - await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible(); - - // And then we ensure that they are where we expect them to be - // Alice should see these event tiles: - // - first message sent by Bob (undecryptable) - // - Bob invited Alice - // - Alice joined the room - // - second message sent by Bob (decryptable) - // - third message sent by Bob (undecryptable) - const tiles = await page.locator(".mx_EventTile").all(); - expect(tiles.length).toBeGreaterThanOrEqual(5); - - // The first message from Bob was sent before Alice was in the room, so should - // be different from the standard UTD message - await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message"); - await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - - // The second message from Bob should be decryptable - await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable"); - // this tile won't have an e2e icon since we got the key from the sender - - // The third message from Bob is undecryptable, but was sent while Alice was - // in the room and is expected to be decryptable, so this should have the - // standard UTD message - await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message"); - await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - }); - - test("should be able to jump to a message sent before our last join event", async ({ - homeserver, - page, - app, - credentials: aliceCredentials, - user: alice, - bot: bob, - }) => { - // Bob: - // - creates an encrypted room, - // - invites Alice, - // - sends a message to it, - // - kicks Alice, - // - sends a bunch more events - // - invites Alice again - // In this way, there will be an event that Alice can decrypt, - // followed by a bunch of undecryptable events which Alice shouldn't - // expect to be able to decrypt. The old code would have hidden all - // the events, even the decryptable event (which it wouldn't have - // even tried to fetch, if it was far enough back). - const { roomId, eventId } = await bob.evaluate( - async (client, { alice }) => { - const { room_id: roomId } = await client.createRoom({ - initial_state: [ - { - type: "m.room.encryption", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - name: "Test room", - preset: "private_chat" as Preset, - }); - - // invite Alice - const inviteAlicePromise = new Promise((resolve) => { - client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { - if (member.userId === alice.userId && member.membership === "invite") { - resolve(); - } - }); - }); - await client.invite(roomId, alice.userId); - // wait for the invite to come back so that we encrypt to Alice - await inviteAlicePromise; - - // send a message that Alice should be able to decrypt - const { event_id: eventId } = await client.sendTextMessage( - roomId, - "This should be decryptable", - ); - - // kick Alice - const kickAlicePromise = new Promise((resolve) => { - client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { - if (member.userId === alice.userId && member.membership === "leave") { - resolve(); - } - }); - }); - await client.kick(roomId, alice.userId); - await kickAlicePromise; - - // send a bunch of messages that Alice won't be able to decrypt - for (let i = 0; i < 20; i++) { - await client.sendTextMessage(roomId, `${i}`); - } - - // invite Alice again - await client.invite(roomId, alice.userId); - - return { roomId, eventId }; - }, - { alice }, - ); - - // Alice accepts the invite - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(1); - await page.getByRole("treeitem", { name: "Test room" }).click(); - await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); - - // wait until we're joined and see the timeline - await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible(); - - // we should be able to jump to the decryptable message that Bob sent - await page.goto(`#/room/${roomId}/${eventId}`); - - await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); - }); - }); + await verify(app, bob); }); }); diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts new file mode 100644 index 0000000000..bcefa947ad --- /dev/null +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -0,0 +1,302 @@ +/* +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix"; +import { expect, test } from "../../element-web-test"; +import { + createRoom, + enableKeyBackup, + logIntoElement, + logOutOfElement, + sendMessageInCurrentRoom, + verifySession, +} from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("Cryptography", function () { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + autoAcceptInvites: false, + }, + }); + + test.describe("decryption failure messages", () => { + test("should handle device-relative historical messages", async ({ + homeserver, + page, + app, + credentials, + user, + }) => { + test.setTimeout(60000); + + // Start with a logged-in session, without key backup, and send a message. + await createRoom(page, "Test room", true); + await sendMessageInCurrentRoom(page, "test test"); + + // Log out, discarding the key for the sent message. + await logOutOfElement(page, true); + + // Log in again, and see how the message looks. + await logIntoElement(page, homeserver, credentials); + await app.viewRoomByName("Test room"); + const lastTile = page.locator(".mx_EventTile").last(); + await expect(lastTile).toContainText("Historical messages are not available on this device"); + await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // Now, we set up key backup, and then send another message. + const secretStorageKey = await enableKeyBackup(app); + await app.viewRoomByName("Test room"); + await sendMessageInCurrentRoom(page, "test2 test2"); + + // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for + // the key to be backed up. + await page.waitForTimeout(10000); + + // Finally, log out again, and back in, skipping verification for now, and see what we see. + await logOutOfElement(page); + await logIntoElement(page, homeserver, credentials); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); + await app.viewRoomByName("Test room"); + + // There should be two historical events in the timeline + const tiles = await page.locator(".mx_EventTile").all(); + expect(tiles.length).toBeGreaterThanOrEqual(2); + // look at the last two tiles only + for (const tile of tiles.slice(-2)) { + await expect(tile).toContainText("You need to verify this device for access to historical messages"); + await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + } + + // Now verify our device (setting up key backup), and check what happens + await verifySession(app, secretStorageKey); + const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2); + + // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though. + await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message"); + await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // The second message should now be decrypted, with a grey shield + await expect(tilesAfterVerify[1]).toContainText("test2 test2"); + await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible(); + }); + + test.describe("non-joined historical messages", () => { + test.skip(isDendrite, "does not yet support membership on events"); + + test("should display undecryptable non-joined historical messages with a different message", async ({ + homeserver, + page, + app, + credentials: aliceCredentials, + user: alice, + bot: bob, + }) => { + // Bob creates an encrypted room and sends a message to it. He then invites Alice + const roomId = await bob.evaluate( + async (client, { alice }) => { + const encryptionStatePromise = new Promise((resolve) => { + client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => { + if (event.getType() === "m.room.encryption") { + resolve(); + } + }); + }); + + const { room_id: roomId } = await client.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + name: "Test room", + preset: "private_chat" as Preset, + }); + + // wait for m.room.encryption event, so that when we send a + // message, it will be encrypted + await encryptionStatePromise; + + await client.sendTextMessage(roomId, "This should be undecryptable"); + + await client.invite(roomId, alice.userId); + + return roomId; + }, + { alice }, + ); + + // Alice accepts the invite + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(1); + await page.getByRole("treeitem", { name: "Test room" }).click(); + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + // Bob sends an encrypted event and an undecryptable event + await bob.evaluate( + async (client, { roomId }) => { + await client.sendTextMessage(roomId, "This should be decryptable"); + await client.sendEvent( + roomId, + "m.room.encrypted" as any, + { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "this+message+will+be+undecryptable", + device_id: client.getDeviceId()!, + sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519, + session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } as any, + ); + }, + { roomId }, + ); + + // We wait for the event tiles that we expect from the messages that + // Bob sent, in sequence. + await expect( + page.locator(`.mx_EventTile`).getByText("You don't have access to this message"), + ).toBeVisible(); + await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); + await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible(); + + // And then we ensure that they are where we expect them to be + // Alice should see these event tiles: + // - first message sent by Bob (undecryptable) + // - Bob invited Alice + // - Alice joined the room + // - second message sent by Bob (decryptable) + // - third message sent by Bob (undecryptable) + const tiles = await page.locator(".mx_EventTile").all(); + expect(tiles.length).toBeGreaterThanOrEqual(5); + + // The first message from Bob was sent before Alice was in the room, so should + // be different from the standard UTD message + await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message"); + await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // The second message from Bob should be decryptable + await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable"); + // this tile won't have an e2e icon since we got the key from the sender + + // The third message from Bob is undecryptable, but was sent while Alice was + // in the room and is expected to be decryptable, so this should have the + // standard UTD message + await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message"); + await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + }); + + test("should be able to jump to a message sent before our last join event", async ({ + homeserver, + page, + app, + credentials: aliceCredentials, + user: alice, + bot: bob, + }) => { + // Bob: + // - creates an encrypted room, + // - invites Alice, + // - sends a message to it, + // - kicks Alice, + // - sends a bunch more events + // - invites Alice again + // In this way, there will be an event that Alice can decrypt, + // followed by a bunch of undecryptable events which Alice shouldn't + // expect to be able to decrypt. The old code would have hidden all + // the events, even the decryptable event (which it wouldn't have + // even tried to fetch, if it was far enough back). + const { roomId, eventId } = await bob.evaluate( + async (client, { alice }) => { + const { room_id: roomId } = await client.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + name: "Test room", + preset: "private_chat" as Preset, + }); + + // invite Alice + const inviteAlicePromise = new Promise((resolve) => { + client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { + if (member.userId === alice.userId && member.membership === "invite") { + resolve(); + } + }); + }); + await client.invite(roomId, alice.userId); + // wait for the invite to come back so that we encrypt to Alice + await inviteAlicePromise; + + // send a message that Alice should be able to decrypt + const { event_id: eventId } = await client.sendTextMessage( + roomId, + "This should be decryptable", + ); + + // kick Alice + const kickAlicePromise = new Promise((resolve) => { + client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { + if (member.userId === alice.userId && member.membership === "leave") { + resolve(); + } + }); + }); + await client.kick(roomId, alice.userId); + await kickAlicePromise; + + // send a bunch of messages that Alice won't be able to decrypt + for (let i = 0; i < 20; i++) { + await client.sendTextMessage(roomId, `${i}`); + } + + // invite Alice again + await client.invite(roomId, alice.userId); + + return { roomId, eventId }; + }, + { alice }, + ); + + // Alice accepts the invite + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(1); + await page.getByRole("treeitem", { name: "Test room" }).click(); + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + // wait until we're joined and see the timeline + await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible(); + + // we should be able to jump to the decryptable message that Bob sent + await page.goto(`#/room/${roomId}/${eventId}`); + + await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); + }); + }); + }); +}); diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts similarity index 67% rename from playwright/e2e/crypto/verification.spec.ts rename to playwright/e2e/crypto/device-verification.spec.ts index 167c302b47..929da09106 100644 --- a/playwright/e2e/crypto/verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -15,19 +15,18 @@ limitations under the License. */ import jsQR from "jsqr"; -import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; import type { JSHandle, Locator, Page } from "@playwright/test"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; +import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { test, expect } from "../../element-web-test"; import { + awaitVerifier, checkDeviceIsConnectedKeyBackup, checkDeviceIsCrossSigned, doTwoWaySasVerification, logIntoElement, waitForVerificationRequest, } from "./utils"; -import { Client } from "../../pages/client"; import { Bot } from "../../pages/bot"; test.describe("Device verification", () => { @@ -235,112 +234,6 @@ test.describe("Device verification", () => { }); }); -test.describe("User verification", () => { - // note that there are other tests that check user verification works in `crypto.spec.ts`. - - test.use({ - displayName: "Alice", - botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, - room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { - await app.client.bootstrapCrossSigning(aliceCredentials); - - // the other user creates a DM - const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); - - // accept the DM - await app.viewRoomByName("Bob"); - await page.getByRole("button", { name: "Start chatting" }).click(); - await use({ roomId: dmRoomId }); - }, - }); - - test("can receive a verification request when there is no existing DM", async ({ - page, - bot: bob, - user: aliceCredentials, - toasts, - room: { roomId: dmRoomId }, - }) => { - // once Alice has joined, Bob starts the verification - const bobVerificationRequest = await bob.evaluateHandle( - async (client, { dmRoomId, aliceCredentials }) => { - const room = client.getRoom(dmRoomId); - while (room.getMember(aliceCredentials.userId)?.membership !== "join") { - await new Promise((resolve) => { - room.once(window.matrixcs.RoomStateEvent.Members, resolve); - }); - } - - return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); - }, - { dmRoomId, aliceCredentials }, - ); - - // there should also be a toast - const toast = await toasts.getToast("Verification requested"); - // it should contain the details of the requesting user - await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); - // Accept - await toast.getByRole("button", { name: "Verify User" }).click(); - - // request verification by emoji - await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); - - /* on the bot side, wait for the verifier to exist ... */ - const botVerifier = await awaitVerifier(bobVerificationRequest); - // ... confirm ... - botVerifier.evaluate((verifier) => verifier.verify()); - // ... and then check the emoji match - await doTwoWaySasVerification(page, botVerifier); - - await page.getByRole("button", { name: "They match" }).click(); - await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); - await page.getByRole("button", { name: "Got it" }).click(); - }); - - test("can abort emoji verification when emoji mismatch", async ({ - page, - bot: bob, - user: aliceCredentials, - toasts, - room: { roomId: dmRoomId }, - }) => { - // once Alice has joined, Bob starts the verification - const bobVerificationRequest = await bob.evaluateHandle( - async (client, { dmRoomId, aliceCredentials }) => { - const room = client.getRoom(dmRoomId); - while (room.getMember(aliceCredentials.userId)?.membership !== "join") { - await new Promise((resolve) => { - room.once(window.matrixcs.RoomStateEvent.Members, resolve); - }); - } - - return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); - }, - { dmRoomId, aliceCredentials }, - ); - - // Accept verification via toast - const toast = await toasts.getToast("Verification requested"); - await toast.getByRole("button", { name: "Verify User" }).click(); - - // request verification by emoji - await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); - - /* on the bot side, wait for the verifier to exist ... */ - const botVerifier = await awaitVerifier(bobVerificationRequest); - // ... confirm ... - botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {}); - // ... and abort the verification - await page.getByRole("button", { name: "They don't match" }).click(); - - const dialog = page.locator(".mx_Dialog"); - await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); - await dialog.getByRole("button", { name: "OK" }).click(); - await expect(dialog).not.toBeVisible(); - }); -}); - /** Extract the qrcode out of an on-screen html element */ async function readQrCode(base: Locator) { const qrCode = base.locator('[alt="QR Code"]'); @@ -372,35 +265,3 @@ async function readQrCode(base: Locator) { const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height); return new Uint8Array(result.binaryData); } - -async function createDMRoom(client: Client, userId: string): Promise { - return client.createRoom({ - preset: "trusted_private_chat" as Preset, - visibility: "private" as Visibility, - invite: [userId], - is_direct: true, - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); -} - -/** - * Wait for a verifier to exist for a VerificationRequest - * - * @param botVerificationRequest - */ -async function awaitVerifier(botVerificationRequest: JSHandle): Promise> { - return botVerificationRequest.evaluateHandle(async (verificationRequest) => { - while (!verificationRequest.verifier) { - await new Promise((r) => verificationRequest.once("change" as any, r)); - } - return verificationRequest.verifier; - }); -} diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts new file mode 100644 index 0000000000..b242dd060c --- /dev/null +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -0,0 +1,269 @@ +/* +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect, test } from "../../element-web-test"; +import { autoJoin, createSharedRoomWithUser, enableKeyBackup, logIntoElement, logOutOfElement, verify } from "./utils"; +import { Bot } from "../../pages/bot"; + +test.describe("Cryptography", function () { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + autoAcceptInvites: false, + }, + }); + + test.describe("event shields", () => { + let testRoomId: string; + + test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + await autoJoin(bob); + + // create an encrypted room + testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { + name: "TestRoom", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + }); + + test("should show the correct shield on e2e events", async ({ page, app, bot: bob, homeserver }) => { + // Bob has a second, not cross-signed, device + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: false, + bootstrapCrossSigning: false, + }); + bobSecondDevice.setCredentials( + await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), + ); + await bobSecondDevice.prepareClient(); + + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "the bird is in the hand", + }); + + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("Unable to decrypt message"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); + await lastE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("This message could not be decrypted"); + + /* Should show a red padlock for an unencrypted message in an e2e room */ + await bob.evaluate( + (cli, testRoomId) => + cli.http.authedRequest( + window.matrixcs.Method.Put, + `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, + undefined, + { + msgtype: "m.text", + body: "test unencrypted", + }, + ), + testRoomId, + ); + + await expect(last).toContainText("test unencrypted"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("Not encrypted"); + + /* Should show no padlock for an unverified user */ + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); + + // the message should appear, decrypted, with no warning, but also no "verified" + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + /* Now verify Bob */ + await verify(app, bob); + + /* Existing message should be updated when user is verified. */ + await expect(last).toContainText("test encrypted 1"); + // still no e2e icon + await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); + + /* should show no padlock, and be verified, for a message from a verified device */ + await bob.sendMessage(testRoomId, "test encrypted 2"); + + await expect(lastTile).toContainText("test encrypted 2"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + /* should show red padlock for a message from an unverified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); + await expect(lastTile).toContainText("test encrypted from unverified"); + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastTileE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner."); + + /* Should show a grey padlock for a message from an unknown device */ + // bob deletes his second device + await bobSecondDevice.evaluate((cli) => cli.logout(true)); + + // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. + async function awaitOneDevice(iterations = 1) { + const rightPanel = page.locator(".mx_RightPanel"); + await rightPanel.getByTestId("base-card-back-button").click(); + await rightPanel.getByText("Bob").click(); + const sessionCountText = await rightPanel + .locator(".mx_UserInfo_devices") + .getByText(" session", { exact: false }) + .textContent(); + // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here + if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { + if (iterations >= 10) { + throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); + } + await awaitOneDevice(iterations + 1); + } + } + + await awaitOneDevice(); + + // close and reopen the room, to get the shield to update. + await app.viewRoomByName("Bob"); + await app.viewRoomByName("TestRoom"); + + await expect(last).toContainText("test encrypted from unverified"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device."); + }); + + test("Should show a grey padlock for a key restored from backup", async ({ + page, + app, + bot: bob, + homeserver, + user: aliceCredentials, + }) => { + test.slow(); + const securityKey = await enableKeyBackup(app); + + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); + + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for + // the key to be backed up. + await page.waitForTimeout(10000); + + /* log out, and back in */ + await logOutOfElement(page); + // Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout + // https://github.com/element-hq/element-web/issues/25779 + await page.addInitScript(() => { + // When we reload, the initScript created by the `user`/`pageWithCredentials` fixtures + // will re-inject the original credentials into localStorage, which we don't want. + // To work around, we add a second initScript which will clear localStorage again. + window.localStorage.clear(); + }); + await page.reload(); + await logIntoElement(page, homeserver, aliceCredentials, securityKey); + + /* go back to the test room and find Bob's message again */ + await app.viewRoomById(testRoomId); + await expect(lastTile).toContainText("test encrypted 1"); + // The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning. + // No shield would have no div mx_EventTile_e2eIcon at all. + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/); + await lastTileE2eIcon.hover(); + // The key is coming from backup, so it is not anymore possible to establish if the claimed device + // creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device." + // It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted. + await expect(page.getByRole("tooltip")).toContainText( + "The authenticity of this encrypted message can't be guaranteed on this device.", + ); + }); + + test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => { + // bob has a second, not cross-signed, device + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: false, + bootstrapCrossSigning: false, + }); + bobSecondDevice.setCredentials( + await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), + ); + await bobSecondDevice.prepareClient(); + + // verify Bob + await verify(app, bob); + + // bob sends a valid event + const testEvent = await bob.sendMessage(testRoomId, "Hoo!"); + + // the message should appear, decrypted, with no warning + await expect( + page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + + // bob sends an edit to the first message with his unverified device + await bobSecondDevice.sendMessage(testRoomId, { + "m.new_content": { + msgtype: "m.text", + body: "Haa!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + + // the edit should have a warning + await expect( + page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).toBeVisible(); + + // a second edit from the verified device should be ok + await bob.sendMessage(testRoomId, { + "m.new_content": { + msgtype: "m.text", + body: "Hee!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + + await expect( + page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + }); + }); +}); diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts new file mode 100644 index 0000000000..eac0fb639e --- /dev/null +++ b/playwright/e2e/crypto/user-verification.spec.ts @@ -0,0 +1,145 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; + +import { test, expect } from "../../element-web-test"; +import { doTwoWaySasVerification, awaitVerifier } from "./utils"; +import { Client } from "../../pages/client"; + +test.describe("User verification", () => { + // note that there are other tests that check user verification works in `crypto.spec.ts`. + + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, + room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + + // accept the DM + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Start chatting" }).click(); + await use({ roomId: dmRoomId }); + }, + }); + + test("can receive a verification request when there is no existing DM", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + }) => { + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // there should also be a toast + const toast = await toasts.getToast("Verification requested"); + // it should contain the details of the requesting user + await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); + // Accept + await toast.getByRole("button", { name: "Verify User" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()); + // ... and then check the emoji match + await doTwoWaySasVerification(page, botVerifier); + + await page.getByRole("button", { name: "They match" }).click(); + await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); + await page.getByRole("button", { name: "Got it" }).click(); + }); + + test("can abort emoji verification when emoji mismatch", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + }) => { + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // Accept verification via toast + const toast = await toasts.getToast("Verification requested"); + await toast.getByRole("button", { name: "Verify User" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {}); + // ... and abort the verification + await page.getByRole("button", { name: "They don't match" }).click(); + + const dialog = page.locator(".mx_Dialog"); + await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); + await dialog.getByRole("button", { name: "OK" }).click(); + await expect(dialog).not.toBeVisible(); + }); +}); + +async function createDMRoom(client: Client, userId: string): Promise { + return client.createRoom({ + preset: "trusted_private_chat" as Preset, + visibility: "private" as Visibility, + invite: [userId], + is_direct: true, + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); +} diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index b5109490a9..3c1e267111 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -27,6 +27,7 @@ import type { import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; import { Client } from "../../pages/client"; import { ElementAppPage } from "../../pages/ElementAppPage"; +import { Bot } from "../../pages/bot"; /** * wait for the given client to receive an incoming verification request, and automatically accept it @@ -327,3 +328,60 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool await expect(page.getByText("Encryption enabled")).toBeVisible(); } } + +/** + * Configure the given MatrixClient to auto-accept any invites + * @param client - the client to configure + */ +export async function autoJoin(client: Client) { + await client.evaluate((cli) => { + cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { + if (member.membership === "invite" && member.userId === cli.getUserId()) { + cli.joinRoom(member.roomId); + } + }); + }); +} + +/** + * Verify a user by emoji + * @param page - the page to use + * @param bob - the user to verify + */ +export const verify = async (app: ElementAppPage, bob: Bot) => { + const page = app.page; + const bobsVerificationRequestPromise = waitForVerificationRequest(bob); + + const roomInfo = await app.toggleRoomInfoPanel(); + await page.locator(".mx_RightPanelTabs").getByText("People").click(); + await roomInfo.getByText("Bob").click(); + await roomInfo.getByRole("button", { name: "Verify" }).click(); + await roomInfo.getByRole("button", { name: "Start Verification" }).click(); + + // this requires creating a DM, so can take a while. Give it a longer timeout. + await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); + + const request = await bobsVerificationRequestPromise; + // the bot user races with the Element user to hit the "verify by emoji" button + const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1")); + await doTwoWaySasVerification(page, verifier); + await roomInfo.getByRole("button", { name: "They match" }).click(); + await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible(); + await roomInfo.getByRole("button", { name: "Got it" }).click(); +}; + +/** + * Wait for a verifier to exist for a VerificationRequest + * + * @param botVerificationRequest + */ +export async function awaitVerifier( + botVerificationRequest: JSHandle, +): Promise> { + return botVerificationRequest.evaluateHandle(async (verificationRequest) => { + while (!verificationRequest.verifier) { + await new Promise((r) => verificationRequest.once("change" as any, r)); + } + return verificationRequest.verifier; + }); +} diff --git a/playwright/e2e/file-upload/image-upload.spec.ts b/playwright/e2e/file-upload/image-upload.spec.ts index 8f0403af31..d75d20f441 100644 --- a/playwright/e2e/file-upload/image-upload.spec.ts +++ b/playwright/e2e/file-upload/image-upload.spec.ts @@ -38,8 +38,8 @@ test.describe("Image Upload", () => { .locator(".mx_MessageComposer_actions input[type='file']") .setInputFiles("playwright/sample-files/riot.png"); - expect(page.getByRole("button", { name: "Upload" })).toBeEnabled(); - expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled(); - expect(page).toMatchScreenshot("image-upload-preview.png"); + await expect(page.getByRole("button", { name: "Upload" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled(); + await expect(page).toMatchScreenshot("image-upload-preview.png"); }); }); diff --git a/playwright/e2e/integration-manager/get-openid-token.spec.ts b/playwright/e2e/integration-manager/get-openid-token.spec.ts index c107bb2cbc..a0f099cb62 100644 --- a/playwright/e2e/integration-manager/get-openid-token.spec.ts +++ b/playwright/e2e/integration-manager/get-openid-token.spec.ts @@ -118,8 +118,8 @@ test.describe("Integration Manager: Get OpenID Token", () => { await app.viewRoomByName(ROOM_NAME); }); - test("should successfully obtain an openID token", async ({ page }) => { - await openIntegrationManager(page); + test("should successfully obtain an openID token", async ({ page, app }) => { + await openIntegrationManager(app); await sendActionFromIntegrationManager(page, integrationManagerUrl); const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); diff --git a/playwright/e2e/integration-manager/kick.spec.ts b/playwright/e2e/integration-manager/kick.spec.ts index b5ca6a1b3a..afe2de0f19 100644 --- a/playwright/e2e/integration-manager/kick.spec.ts +++ b/playwright/e2e/integration-manager/kick.spec.ts @@ -167,7 +167,7 @@ test.describe("Integration Manager: Kick", () => { await app.client.inviteUser(room.roomId, targetUser.credentials.userId); await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); - await openIntegrationManager(page); + await openIntegrationManager(app); await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, true); @@ -185,7 +185,7 @@ test.describe("Integration Manager: Kick", () => { }, }); - await openIntegrationManager(page); + await openIntegrationManager(app); await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); @@ -197,7 +197,7 @@ test.describe("Integration Manager: Kick", () => { await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); await targetUser.leave(room.roomId); - await openIntegrationManager(page); + await openIntegrationManager(app); await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); @@ -209,7 +209,7 @@ test.describe("Integration Manager: Kick", () => { await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); await app.client.ban(room.roomId, targetUser.credentials.userId); - await openIntegrationManager(page); + await openIntegrationManager(app); await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); @@ -218,7 +218,7 @@ test.describe("Integration Manager: Kick", () => { test("should no-op if the target was never a room member", async ({ page, app, bot: targetUser, room }) => { await app.viewRoomByName(ROOM_NAME); - await openIntegrationManager(page); + await openIntegrationManager(app); await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); await closeIntegrationManager(page, integrationManagerUrl); await expectKickedMessage(page, false); diff --git a/playwright/e2e/integration-manager/read_events.spec.ts b/playwright/e2e/integration-manager/read_events.spec.ts index b178596674..2e2ee8d187 100644 --- a/playwright/e2e/integration-manager/read_events.spec.ts +++ b/playwright/e2e/integration-manager/read_events.spec.ts @@ -142,7 +142,7 @@ test.describe("Integration Manager: Read Events", () => { // Send a state event const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey); - await openIntegrationManager(page); + await openIntegrationManager(app); // Read state events await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); @@ -162,7 +162,7 @@ test.describe("Integration Manager: Read Events", () => { // Send a state event const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey); - await openIntegrationManager(page); + await openIntegrationManager(app); // Read state events await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); @@ -196,7 +196,7 @@ test.describe("Integration Manager: Read Events", () => { app.client.sendStateEvent(room.roomId, eventType, eventContent3, stateKey3), ]); - await openIntegrationManager(page); + await openIntegrationManager(app); // Read state events await sendActionFromIntegrationManager( @@ -217,11 +217,11 @@ test.describe("Integration Manager: Read Events", () => { await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent3)}`); }); - test("should fail to read an event type which is not allowed", async ({ page, room }) => { + test("should fail to read an event type which is not allowed", async ({ page, app, room }) => { const eventType = "com.example.event"; const stateKey = ""; - await openIntegrationManager(page); + await openIntegrationManager(app); // Read state events await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); diff --git a/playwright/e2e/integration-manager/send_event.spec.ts b/playwright/e2e/integration-manager/send_event.spec.ts index 61bad8a3ec..ea2c355304 100644 --- a/playwright/e2e/integration-manager/send_event.spec.ts +++ b/playwright/e2e/integration-manager/send_event.spec.ts @@ -137,7 +137,7 @@ test.describe("Integration Manager: Send Event", () => { ); await app.viewRoomByName(ROOM_NAME); - await openIntegrationManager(page); + await openIntegrationManager(app); }); test("should send a state event", async ({ page, app, room }) => { diff --git a/playwright/e2e/integration-manager/utils.ts b/playwright/e2e/integration-manager/utils.ts index 259ff732c7..c6a2fb998e 100644 --- a/playwright/e2e/integration-manager/utils.ts +++ b/playwright/e2e/integration-manager/utils.ts @@ -14,10 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { Page } from "@playwright/test"; +import type { ElementAppPage } from "../../pages/ElementAppPage"; -export async function openIntegrationManager(page: Page) { - await page.getByRole("button", { name: "Room info" }).click(); +export async function openIntegrationManager(app: ElementAppPage) { + const { page } = app; + await app.toggleRoomInfoPanel(); await page .locator(".mx_RoomSummaryCard_appsGroup") .getByRole("button", { name: "Add widgets, bridges & bots" }) diff --git a/playwright/e2e/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts index 98a57c8eb1..d9e086aaa1 100644 --- a/playwright/e2e/invite/invite-dialog.spec.ts +++ b/playwright/e2e/invite/invite-dialog.spec.ts @@ -36,7 +36,7 @@ test.describe("Invite dialog", function () { await expect(page.getByText("Hanako created and configured the room.")).toBeVisible(); // Open the room info panel - await page.getByRole("button", { name: "Room info" }).click(); + await app.toggleRoomInfoPanel(); await page.locator(".mx_BaseCard").getByRole("menuitem", { name: "Invite" }).click(); @@ -114,12 +114,9 @@ test.describe("Invite dialog", function () { // Assert that the hovered user name on invitation UI does not have background color // TODO: implement the test on room-header.spec.ts - const roomHeader = page.locator(".mx_LegacyRoomHeader"); - await roomHeader.locator(".mx_LegacyRoomHeader_name--textonly").hover(); - await expect(roomHeader.locator(".mx_LegacyRoomHeader_name--textonly")).toHaveCSS( - "background-color", - "rgba(0, 0, 0, 0)", - ); + const roomHeader = page.locator(".mx_RoomHeader"); + await roomHeader.locator(".mx_RoomHeader_heading").hover(); + await expect(roomHeader.locator(".mx_RoomHeader_heading")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); // Send a message to invite the bots const composer = app.getComposer().locator("[contenteditable]"); diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts index 8763c0fd6a..9e610766d3 100644 --- a/playwright/e2e/knock/create-knock-room.spec.ts +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -31,7 +31,7 @@ test.describe("Create Knock Room", () => { await dialog.getByRole("option", { name: "Ask to join" }).click(); await dialog.getByRole("button", { name: "Create room" }).click(); - await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible(); + await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible(); const urlHash = await page.evaluate(() => window.location.hash); const roomId = urlHash.replace("#/room/", ""); @@ -48,7 +48,7 @@ test.describe("Create Knock Room", () => { await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity"); await dialog.getByRole("button", { name: "Create room" }).click(); - await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible(); + await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible(); const urlHash = await page.evaluate(() => window.location.hash); const roomId = urlHash.replace("#/room/", ""); @@ -74,7 +74,7 @@ test.describe("Create Knock Room", () => { await dialog.getByText("Make this room visible in the public room directory.").click(); await dialog.getByRole("button", { name: "Create room" }).click(); - await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible(); + await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible(); const urlHash = await page.evaluate(() => window.location.hash); const roomId = urlHash.replace("#/room/", ""); diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index c04bcb8c64..1a20100d1a 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -78,8 +78,9 @@ test.describe("Lazy Loading", () => { } } - async function openMemberlist(page: Page): Promise { - await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click(); + async function openMemberlist(app: ElementAppPage): Promise { + await app.toggleRoomInfoPanel(); + const { page } = app; await page.locator(".mx_RightPanelTabs").getByText("People").click(); } @@ -123,7 +124,7 @@ test.describe("Lazy Loading", () => { // Alice should see 2 messages from every charly with the correct display name await checkPaginatedDisplayNames(app, charly1to5); - await openMemberlist(page); + await openMemberlist(app); await checkMemberList(page, charly1to5); await joinCharliesWhileAliceIsOffline(page, app, charly6to10); await checkMemberList(page, charly6to10); diff --git a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts index 287ac77cd4..3070d5fad0 100644 --- a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts +++ b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -35,10 +35,10 @@ test.describe("1:1 chat room", () => { await page.goto(`/#/user/${user2.userId}?action=chat`); }); - test("should open new 1:1 chat room after leaving the old one", async ({ page, user2 }) => { + test("should open new 1:1 chat room after leaving the old one", async ({ page, app, user2 }) => { // leave 1:1 chat room - await page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName).click(); - await page.getByRole("menuitem", { name: "Leave" }).click(); + await app.toggleRoomInfoPanel(); + await page.getByRole("menuitem", { name: "Leave room" }).click(); await page.getByRole("button", { name: "Leave" }).click(); // wait till the room was left @@ -49,6 +49,6 @@ test.describe("1:1 chat room", () => { // open new 1:1 chat room await page.goto(`/#/user/${user2.userId}?action=chat`); - await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName)).toBeVisible(); + await expect(page.locator(".mx_RoomHeader_heading").getByText(user2.displayName)).toBeVisible(); }); }); diff --git a/playwright/e2e/polls/pollHistory.spec.ts b/playwright/e2e/polls/pollHistory.spec.ts index 458bb544c7..e9ebf0a30d 100644 --- a/playwright/e2e/polls/pollHistory.spec.ts +++ b/playwright/e2e/polls/pollHistory.spec.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ import { test, expect } from "../../element-web-test"; -import type { Page } from "@playwright/test"; import type { Bot } from "../../pages/bot"; import type { Client } from "../../pages/client"; +import { ElementAppPage } from "../../pages/ElementAppPage"; test.describe("Poll history", () => { type CreatePollOptions = { @@ -66,8 +66,9 @@ test.describe("Poll history", () => { }); }; - async function openPollHistory(page: Page): Promise { - await page.getByRole("button", { name: "Room info" }).click(); + async function openPollHistory(app: ElementAppPage): Promise { + const { page } = app; + await app.toggleRoomInfoPanel(); await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "Poll history" }).click(); } @@ -116,7 +117,7 @@ test.describe("Poll history", () => { await botVoteForOption(bot, roomId, pollId2, pollParams1.options[1].id); await endPoll(bot, roomId, pollId2); - await openPollHistory(page); + await openPollHistory(app); // these polls are also in the timeline // focus on the poll history dialog diff --git a/playwright/e2e/presence/presence.spec.ts b/playwright/e2e/presence/presence.spec.ts index 861181ba56..e52b97844b 100644 --- a/playwright/e2e/presence/presence.spec.ts +++ b/playwright/e2e/presence/presence.spec.ts @@ -59,7 +59,7 @@ test.describe("Presence tests", () => { ); await app.client.createRoom({}); // trigger sync - await page.getByRole("button", { name: "Room info" }).click(); + await app.toggleRoomInfoPanel(); await page.locator(".mx_RightPanel").getByText("People").click(); await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("Bob"); await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("User's server unreachable"); diff --git a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts new file mode 100644 index 0000000000..62394cccb5 --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts @@ -0,0 +1,191 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("in threads", () => { + test("An edit of a threaded message makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given we have read the thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Resp1"); + await util.goTo(room1); + + // When a message inside it is edited + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + + // Then the room and thread are read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Msg1"); + }); + + test("Reading an edit of a threaded message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edited thread message appears after we read it + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Resp1"); + await util.goTo(room1); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertStillRead(room2); + + // When I read it + await util.goTo(room2); + await util.openThread("Msg1"); + + // Then the room and thread are still read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Marking a room as read after an edit in a thread makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edit in a thread is making the room unread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.editOf("Resp1", "Edit1"), + ]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it is read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Editing a thread message after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room with an edited threaded message is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edit in a thread is leaving a room read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.markAsRead(room2); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertStillRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then is it still read + await util.assertRead(room2); + }); + + test("A room where all threaded edits are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertUnread(room2, 1); + + await util.goTo(room2); + + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + await util.goTo(room1); // Make sure we are looking at room1 after reload + await util.assertStillRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room where all threaded edits are marked as read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.editOf("Resp1", "Edit1"), + ]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When I restart + await util.saveAndReload(); + + // It is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts new file mode 100644 index 0000000000..e03a011a4d --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts @@ -0,0 +1,180 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("in the main timeline", () => { + test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given I am not looking at the room + await util.goTo(room1); + + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When an edit appears in the room + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + }); + test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given an edit is making the room unread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + await util.assertStillRead(room2); + + // When I read it + await util.goTo(room2); + + // Then the room stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + test("Editing a message after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given the room is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("Editing a reply after reading it makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given the room is all read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + }); + test("Editing a reply after marking as read makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a reply is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When the reply is edited + await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("A room with an edit is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When an edit appears in the room + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + + // And remains so after a reload + await util.saveAndReload(); + await util.assertStillRead(room2); + }); + test("An edited message becomes read if it happens while I am looking", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message is marked as read + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertRead(room2); + + // When I see an edit appear in the room I am looking at + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("A room where all edits are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message was edited and read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + + // When I reload + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts new file mode 100644 index 0000000000..279845f5d2 --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts @@ -0,0 +1,179 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("thread roots", () => { + test("An edit of a thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read a thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.backToThreadsList(); + await util.assertRead(room2); + await util.goTo(room1); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]); + + // Then the room is read + await util.assertStillRead(room2); + + // And the thread is read + await util.goTo(room2); + await util.assertStillRead(room2); + await util.assertReadThread("Edit1"); + }); + + test("Reading an edit of a thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // And I read that edit + await util.goTo(room2); + + // Then the room becomes read and stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + + test("Editing a thread root after reading leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then the room stays read + await util.assertStillRead(room2); + }); + + test("Marking a room as read after an edit of a thread root keeps it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When the thread root is edited (and I receive another message + // to allow Mark as read) + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]); + + // And when I mark the room as read + await util.markAsRead(room2); + + // Then the room becomes read and stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + + test("Editing a thread root that is a reply after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread based on a reply exists and is read because it is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg", + msg.replyTo("Msg", "Reply"), + msg.threadedOff("Reply", "InThread"), + ]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I edit the thread root + await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); + + // Then the room is read + await util.assertStillRead(room2); + + // And the thread is read + await util.goTo(room2); + await util.assertReadThread("Edited Reply"); + }); + + test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread based on a reply exists and the reply has been edited + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg", + msg.replyTo("Msg", "Reply"), + msg.threadedOff("Reply", "InThread"), + ]); + await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); + await util.assertUnread(room2, 2); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then the room and thread are read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Edited Reply"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/editing-messages.spec.ts b/playwright/e2e/read-receipts/editing-messages.spec.ts deleted file mode 100644 index 5005ad62bf..0000000000 --- a/playwright/e2e/read-receipts/editing-messages.spec.ts +++ /dev/null @@ -1,504 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* See readme.md for tips on writing these tests. */ - -import { test } from "."; - -test.describe("Read receipts", () => { - test.describe("editing messages", () => { - test.describe("in the main timeline", () => { - test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given I am not looking at the room - await util.goTo(room1); - - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When an edit appears in the room - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then it remains read - await util.assertStillRead(room2); - }); - test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given an edit is making the room unread - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - await util.assertStillRead(room2); - - // When I read it - await util.goTo(room2); - - // Then the room stays read - await util.assertStillRead(room2); - await util.goTo(room1); - await util.assertStillRead(room2); - }); - test("Editing a message after marking as read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given the room is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When a message is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then the room remains read - await util.assertStillRead(room2); - }); - test("Editing a reply after reading it makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given the room is all read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); - await util.assertUnread(room2, 2); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When a message is edited - await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); - - // Then it remains read - await util.assertStillRead(room2); - }); - test("Editing a reply after marking as read makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a reply is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); - await util.assertUnread(room2, 2); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When the reply is edited - await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); - - // Then the room remains read - await util.assertStillRead(room2); - }); - test("A room with an edit is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When an edit appears in the room - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then it remains read - await util.assertStillRead(room2); - - // And remains so after a reload - await util.saveAndReload(); - await util.assertStillRead(room2); - }); - test("An edited message becomes read if it happens while I am looking", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message is marked as read - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertRead(room2); - - // When I see an edit appear in the room I am looking at - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then it becomes read - await util.assertStillRead(room2); - }); - test("A room where all edits are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message was edited and read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - - // When I reload - await util.saveAndReload(); - - // Then the room is still read - await util.assertRead(room2); - }); - }); - - test.describe("in threads", () => { - test("An edit of a threaded message makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given we have read the thread - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Resp1"); - await util.goTo(room1); - - // When a message inside it is edited - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - - // Then the room and thread are read - await util.assertStillRead(room2); - await util.goTo(room2); - await util.assertReadThread("Msg1"); - }); - - test("Reading an edit of a threaded message makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an edited thread message appears after we read it - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Resp1"); - await util.goTo(room1); - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - await util.assertStillRead(room2); - - // When I read it - await util.goTo(room2); - await util.openThread("Msg1"); - - // Then the room and thread are still read - await util.assertStillRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("Marking a room as read after an edit in a thread makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an edit in a thread is making the room unread - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg1", - msg.threadedOff("Msg1", "Resp1"), - msg.editOf("Resp1", "Edit1"), - ]); - await util.assertUnread(room2, 1); - - // When I mark the room as read - await util.markAsRead(room2); - - // Then it is read - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("Editing a thread message after marking as read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a room is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When a message is edited - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - - // Then the room remains read - await util.assertStillRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("A room with an edited threaded message is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an edit in a thread is leaving a room read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.markAsRead(room2); - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - await util.assertStillRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then is it still read - await util.assertRead(room2); - }); - - test("A room where all threaded edits are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - await util.assertUnread(room2, 1); - - await util.goTo(room2); - - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - await util.goTo(room1); // Make sure we are looking at room1 after reload - await util.assertStillRead(room2); - - await util.saveAndReload(); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("A room where all threaded edits are marked as read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg1", - msg.threadedOff("Msg1", "Resp1"), - msg.editOf("Resp1", "Edit1"), - ]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When I restart - await util.saveAndReload(); - - // It is still read - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - }); - - test.describe("thread roots", () => { - test("An edit of a thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have read a thread - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.backToThreadsList(); - await util.assertRead(room2); - await util.goTo(room1); - - // When the thread root is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]); - - // Then the room is read - await util.assertStillRead(room2); - - // And the thread is read - await util.goTo(room2); - await util.assertStillRead(room2); - await util.assertReadThread("Edit1"); - }); - - test("Reading an edit of a thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a fully-read thread exists - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - await util.assertRead(room2); - - // When the thread root is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // And I read that edit - await util.goTo(room2); - - // Then the room becomes read and stays read - await util.assertStillRead(room2); - await util.goTo(room1); - await util.assertStillRead(room2); - }); - - test("Editing a thread root after reading leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a fully-read thread exists - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - - // When the thread root is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then the room stays read - await util.assertStillRead(room2); - }); - - test("Marking a room as read after an edit of a thread root keeps it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a fully-read thread exists - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - await util.assertRead(room2); - - // When the thread root is edited (and I receive another message - // to allow Mark as read) - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]); - - // And when I mark the room as read - await util.markAsRead(room2); - - // Then the room becomes read and stays read - await util.assertStillRead(room2); - await util.goTo(room1); - await util.assertStillRead(room2); - }); - - test("Editing a thread root that is a reply after marking as read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread based on a reply exists and is read because it is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg", - msg.replyTo("Msg", "Reply"), - msg.threadedOff("Reply", "InThread"), - ]); - await util.assertUnread(room2, 2); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When I edit the thread root - await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); - - // Then the room is read - await util.assertStillRead(room2); - - // And the thread is read - await util.goTo(room2); - await util.assertReadThread("Edited Reply"); - }); - - test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread based on a reply exists and the reply has been edited - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg", - msg.replyTo("Msg", "Reply"), - msg.threadedOff("Reply", "InThread"), - ]); - await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); - await util.assertUnread(room2, 2); - - // When I mark the room as read - await util.markAsRead(room2); - - // Then the room and thread are read - await util.assertStillRead(room2); - await util.goTo(room2); - await util.assertReadThread("Edited Reply"); - }); - }); - }); -}); diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts index 30a3788e3e..a3c2c0de3d 100644 --- a/playwright/e2e/read-receipts/high-level.spec.ts +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -19,77 +19,6 @@ limitations under the License. import { customEvent, many, test } from "."; test.describe("Read receipts", () => { - test.describe("Message ordering", () => { - test.describe("in the main timeline", () => { - test.fixme( - "A receipt for the last event in sync order (even with wrong ts) marks a room as read", - () => {}, - ); - test.fixme( - "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - }); - - test.describe("in threads", () => { - // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet - test.fixme( - "A receipt for the last event in sync order (even with wrong ts) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", - () => {}, - ); - - // These pass now and should not later - we should use order from MSC4033 instead of ts - // These are broken out - test.fixme( - "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - test.fixme( - "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - test.fixme( - "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - }); - - test.describe("thread roots", () => { - test.fixme( - "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", - () => {}, - ); - test.fixme( - "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - test.fixme( - "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", - () => {}, - ); - test.fixme( - "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - }); - }); - test.describe("Ignored events", () => { test("If all events after receipt are unimportant, the room is read", async ({ roomAlpha: room1, @@ -414,79 +343,4 @@ test.describe("Read receipts", () => { await util.assertReadThread("Root3"); }); }); - - test.describe("Room list order", () => { - test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - page, - }) => { - await util.goTo(room2); - - // Display the unread first room - await util.toggleRoomUnreadOrder(); - await util.receiveMessages(room1, ["Msg1"]); - await page.reload(); - - // Room 1 has an unread message and should be displayed first - await util.assertRoomListOrder([room1, room2]); - }); - - test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room2); - await util.receiveMessages(room1, ["Msg1"]); - await util.markAsRead(room1); - await util.assertRead(room1); - - // Display the unread first room - await util.toggleRoomUnreadOrder(); - await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); - await util.saveAndReload(); - - // Room 1 has an unread message and should be displayed first - await util.assertRoomListOrder([room1, room2]); - }); - }); - - test.describe("Notifications", () => { - test.describe("in the main timeline", () => { - test.fixme("A new message that mentions me shows a notification", () => {}); - test.fixme( - "Reading a notifying message reduces the notification count in the room list, space and tab", - () => {}, - ); - test.fixme( - "Reading the last notifying message removes the notification marker from room list, space and tab", - () => {}, - ); - test.fixme("Editing a message to mentions me shows a notification", () => {}); - test.fixme("Reading the last notifying edited message removes the notification marker", () => {}); - test.fixme("Redacting a notifying message removes the notification marker", () => {}); - }); - - test.describe("in threads", () => { - test.fixme("A new threaded message that mentions me shows a notification", () => {}); - test.fixme("Reading a notifying threaded message removes the notification count", () => {}); - test.fixme( - "Notification count remains steady when reading threads that contain seen notifications", - () => {}, - ); - test.fixme( - "Notification count remains steady when paging up thread view even when threads contain seen notifications", - () => {}, - ); - test.fixme( - "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", - () => {}, - ); - test.fixme("Redacting a notifying threaded message removes the notification marker", () => {}); - }); - }); }); diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts index 484df2251d..1b67192907 100644 --- a/playwright/e2e/read-receipts/index.ts +++ b/playwright/e2e/read-receipts/index.ts @@ -402,7 +402,7 @@ class Helpers { * Close the threads panel. */ async closeThreadsPanel() { - await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click(); + await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click(); await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible(); } @@ -410,7 +410,7 @@ class Helpers { * Return to the list of threads, given we are viewing a single thread. */ async backToThreadsList() { - await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click(); + await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click(); } /** @@ -530,15 +530,14 @@ class Helpers { // whether it's open or not - wait here to give it a chance to settle. await this.page.waitForTimeout(200); - const ariaCurrent = await this.page.getByTestId("threadsButton").getAttribute("aria-current"); - if (ariaCurrent !== "true") { - await this.page.getByTestId("threadsButton").click(); - } - const threadPanel = this.page.locator(".mx_ThreadPanel"); + const isThreadPanelOpen = (await threadPanel.count()) !== 0; + if (!isThreadPanelOpen) { + await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click(); + } await expect(threadPanel).toBeVisible(); await threadPanel.evaluate(($panel) => { - const $button = $panel.querySelector('.mx_BaseCard_back[aria-label="Threads"]'); + const $button = $panel.querySelector('[data-testid="base-card-back-button"]'); // If the Threads back button is present then click it - the // threads button can open either threads list or thread panel if ($button) { diff --git a/playwright/e2e/read-receipts/message-ordering.spec.ts b/playwright/e2e/read-receipts/message-ordering.spec.ts new file mode 100644 index 0000000000..73c640d35a --- /dev/null +++ b/playwright/e2e/read-receipts/message-ordering.spec.ts @@ -0,0 +1,92 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("Message ordering", () => { + test.describe("in the main timeline", () => { + test.fixme( + "A receipt for the last event in sync order (even with wrong ts) marks a room as read", + () => {}, + ); + test.fixme( + "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + }); + + test.describe("in threads", () => { + // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet + test.fixme( + "A receipt for the last event in sync order (even with wrong ts) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", + () => {}, + ); + + // These pass now and should not later - we should use order from MSC4033 instead of ts + // These are broken out + test.fixme( + "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + test.fixme( + "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + test.fixme( + "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + }); + + test.describe("thread roots", () => { + test.fixme( + "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", + () => {}, + ); + test.fixme( + "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + test.fixme( + "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", + () => {}, + ); + test.fixme( + "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/new-messages.spec.ts b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts similarity index 55% rename from playwright/e2e/read-receipts/new-messages.spec.ts rename to playwright/e2e/read-receipts/new-messages-in-threads.spec.ts index 97308a4bb2..37b43bae1d 100644 --- a/playwright/e2e/read-receipts/new-messages.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts @@ -20,151 +20,6 @@ import { many, test } from "."; test.describe("Read receipts", () => { test.describe("new messages", () => { - test.describe("in the main timeline", () => { - test("Receiving a message makes a room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I am in a different room - await util.goTo(room1); - await util.assertRead(room2); - - // When I receive some messages - await util.receiveMessages(room2, ["Msg1"]); - - // Then the room is marked as unread - await util.assertUnread(room2, 1); - }); - test("Reading latest message makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have some unread messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I read the main timeline - await util.goTo(room2); - - // Then the room becomes read - await util.assertRead(room2); - }); - test("Reading an older message leaves the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given there are lots of messages in a room - await util.goTo(room1); - await util.receiveMessages(room2, many("Msg", 30)); - await util.assertUnread(room2, 30); - - // When I jump to one of the older messages - await msg.jumpTo(room2.name, "Msg0001"); - - // Then the room is still unread, but some messages were read - await util.assertUnreadLessThan(room2, 30); - }); - test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given I have some unread messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I mark the room as read - await util.markAsRead(room2); - - // Then it is read - await util.assertRead(room2); - }); - test("Receiving a new message after marking as read makes it unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have marked my messages as read - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When I receive a new message - await util.receiveMessages(room2, ["Msg2"]); - - // Then the room is unread - await util.assertUnread(room2, 1); - }); - test("A room with a new message is still unread after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have an unread message - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I restart - await util.saveAndReload(); - - // Then I still have an unread message - await util.assertUnread(room2, 1); - }); - test("A room where all messages are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have read all messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then all messages are still read - await util.assertRead(room2); - }); - test("A room that was marked as read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have marked all messages as read - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then all messages are still read - await util.assertRead(room2); - }); - }); - test.describe("in threads", () => { test("Receiving a message makes a room unread", async ({ roomAlpha: room1, @@ -450,100 +305,5 @@ test.describe("Read receipts", () => { await util.assertReadThread("Msg1"); }); }); - - test.describe("thread roots", () => { - test("Reading a thread root does not mark the thread as read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); // (Sanity) - - // When I read the main timeline - await util.goTo(room2); - - // Then room doesn't appear unread but the thread does - await util.assertRead(room2); - await util.assertUnreadThread("Msg1"); - }); - - test("Reading a thread root within the thread view marks it as read in the main timeline", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given lots of messages are on the main timeline, and one has a thread off it - await util.goTo(room1); - await util.receiveMessages(room2, [ - ...many("beforeThread", 30), - "ThreadRoot", - msg.threadedOff("ThreadRoot", "InThread"), - ...many("afterThread", 30), - ]); - await util.assertUnread(room2, 61); // Sanity - - // When I jump to an old message and read the thread - await msg.jumpTo(room2.name, "beforeThread0000"); - // When the thread is opened, the timeline is scrolled until the thread root reached the center - await util.openThread("ThreadRoot"); - - // Then the thread root is marked as read in the main timeline, - // 30 remaining messages are unread - 7 messages are displayed under the thread root - await util.assertUnread(room2, 30 - 7); - }); - - test("Creating a new thread based on a reply makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message and reply exist and are read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - await util.assertRead(room2); - - // When I receive a thread message created on the reply - await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]); - - // Then the thread is unread - await util.goTo(room2); - await util.assertUnreadThread("Reply1"); - }); - - test("Reading a thread whose root is a reply makes the thread read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread thread off a reply exists - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg1", - msg.replyTo("Msg1", "Reply1"), - msg.threadedOff("Reply1", "Resp1"), - ]); - await util.assertUnread(room2, 2); - await util.goTo(room2); - await util.assertRead(room2); - await util.assertUnreadThread("Reply1"); - - // When I read the thread - await util.openThread("Reply1"); - - // Then the room and thread are read - await util.assertRead(room2); - await util.assertReadThread("Reply1"); - }); - }); }); }); diff --git a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts new file mode 100644 index 0000000000..eb528f2816 --- /dev/null +++ b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts @@ -0,0 +1,168 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { many, test } from "."; + +test.describe("Read receipts", () => { + test.describe("new messages", () => { + test.describe("in the main timeline", () => { + test("Receiving a message makes a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I am in a different room + await util.goTo(room1); + await util.assertRead(room2); + + // When I receive some messages + await util.receiveMessages(room2, ["Msg1"]); + + // Then the room is marked as unread + await util.assertUnread(room2, 1); + }); + test("Reading latest message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have some unread messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I read the main timeline + await util.goTo(room2); + + // Then the room becomes read + await util.assertRead(room2); + }); + test("Reading an older message leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given there are lots of messages in a room + await util.goTo(room1); + await util.receiveMessages(room2, many("Msg", 30)); + await util.assertUnread(room2, 30); + + // When I jump to one of the older messages + await msg.jumpTo(room2.name, "Msg0001"); + + // Then the room is still unread, but some messages were read + await util.assertUnreadLessThan(room2, 30); + }); + test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given I have some unread messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it is read + await util.assertRead(room2); + }); + test("Receiving a new message after marking as read makes it unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have marked my messages as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I receive a new message + await util.receiveMessages(room2, ["Msg2"]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + test("A room with a new message is still unread after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have an unread message + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I restart + await util.saveAndReload(); + + // Then I still have an unread message + await util.assertUnread(room2, 1); + }); + test("A room where all messages are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read all messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then all messages are still read + await util.assertRead(room2); + }); + test("A room that was marked as read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have marked all messages as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then all messages are still read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts new file mode 100644 index 0000000000..526bac4bff --- /dev/null +++ b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts @@ -0,0 +1,118 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { many, test } from "."; + +test.describe("Read receipts", () => { + test.describe("new messages", () => { + test.describe("thread roots", () => { + test("Reading a thread root does not mark the thread as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I read the main timeline + await util.goTo(room2); + + // Then room doesn't appear unread but the thread does + await util.assertRead(room2); + await util.assertUnreadThread("Msg1"); + }); + + test("Reading a thread root within the thread view marks it as read in the main timeline", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given lots of messages are on the main timeline, and one has a thread off it + await util.goTo(room1); + await util.receiveMessages(room2, [ + ...many("beforeThread", 30), + "ThreadRoot", + msg.threadedOff("ThreadRoot", "InThread"), + ...many("afterThread", 30), + ]); + await util.assertUnread(room2, 61); // Sanity + + // When I jump to an old message and read the thread + await msg.jumpTo(room2.name, "beforeThread0000"); + // When the thread is opened, the timeline is scrolled until the thread root reached the center + await util.openThread("ThreadRoot"); + + // Then the thread root is marked as read in the main timeline, + // 30 remaining messages are unread - 7 messages are displayed under the thread root + await util.assertUnread(room2, 30 - 7); + }); + + test("Creating a new thread based on a reply makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message and reply exist and are read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When I receive a thread message created on the reply + await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]); + + // Then the thread is unread + await util.goTo(room2); + await util.assertUnreadThread("Reply1"); + }); + + test("Reading a thread whose root is a reply makes the thread read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread off a reply exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.replyTo("Msg1", "Reply1"), + msg.threadedOff("Reply1", "Resp1"), + ]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Reply1"); + + // When I read the thread + await util.openThread("Reply1"); + + // Then the room and thread are read + await util.assertRead(room2); + await util.assertReadThread("Reply1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/notifications.spec.ts b/playwright/e2e/read-receipts/notifications.spec.ts new file mode 100644 index 0000000000..5d87de1bb6 --- /dev/null +++ b/playwright/e2e/read-receipts/notifications.spec.ts @@ -0,0 +1,56 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("Notifications", () => { + test.describe("in the main timeline", () => { + test.fixme("A new message that mentions me shows a notification", () => {}); + test.fixme( + "Reading a notifying message reduces the notification count in the room list, space and tab", + () => {}, + ); + test.fixme( + "Reading the last notifying message removes the notification marker from room list, space and tab", + () => {}, + ); + test.fixme("Editing a message to mentions me shows a notification", () => {}); + test.fixme("Reading the last notifying edited message removes the notification marker", () => {}); + test.fixme("Redacting a notifying message removes the notification marker", () => {}); + }); + + test.describe("in threads", () => { + test.fixme("A new threaded message that mentions me shows a notification", () => {}); + test.fixme("Reading a notifying threaded message removes the notification count", () => {}); + test.fixme( + "Notification count remains steady when reading threads that contain seen notifications", + () => {}, + ); + test.fixme( + "Notification count remains steady when paging up thread view even when threads contain seen notifications", + () => {}, + ); + test.fixme( + "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", + () => {}, + ); + test.fixme("Redacting a notifying threaded message removes the notification marker", () => {}); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/reactions.spec.ts b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts similarity index 56% rename from playwright/e2e/read-receipts/reactions.spec.ts rename to playwright/e2e/read-receipts/reactions-in-threads.spec.ts index 69208e5fc9..dcd97ac431 100644 --- a/playwright/e2e/read-receipts/reactions.spec.ts +++ b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts @@ -20,82 +20,6 @@ import { test, expect } from "."; test.describe("Read receipts", () => { test.describe("reactions", () => { - test.describe("in the main timeline", () => { - test("Receiving a reaction to a message does not make a room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - // When I read the main timeline - await util.goTo(room2); - await util.assertRead(room2); - - await util.goTo(room1); - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - await util.assertRead(room2); - }); - test("Reacting to a message after marking as read does not make the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - await util.markAsRead(room2); - await util.assertRead(room2); - - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - await util.assertRead(room2); - }); - test("A room with an unread reaction is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - await util.markAsRead(room2); - await util.assertRead(room2); - - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - await util.assertRead(room2); - - await util.saveAndReload(); - await util.assertRead(room2); - }); - test("A room where all reactions are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]); - await util.assertUnread(room2, 2); - - await util.markAsRead(room2); - await util.assertRead(room2); - - await util.saveAndReload(); - await util.assertRead(room2); - }); - }); - test.describe("in threads", () => { test("A reaction to a threaded message does not make the room unread", async ({ roomAlpha: room1, @@ -281,97 +205,5 @@ test.describe("Read receipts", () => { await expect(await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible(); }); }); - - test.describe("thread roots", () => { - test("A reaction to a thread root does not make the room unread", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a read thread root exists - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When someone reacts to it - await util.goTo(room1); - await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); - await page.waitForTimeout(200); - - // Then the room is still read - await util.assertRead(room2); - // as is the thread - await util.assertReadThread("Msg1"); - }); - - test("Reading a reaction to a thread root leaves the room read", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a read thread root exists - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - - // And the reaction to it does not make us unread - await util.goTo(room1); - await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When we read the reaction and go away again - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - await page.waitForTimeout(200); - - // Then the room is still read - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread root exists - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); - await util.assertUnread(room2, 1); - - // And we have marked the room as read - await util.markAsRead(room2); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When someone reacts to it - await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); - await page.waitForTimeout(200); - - // Then the room is still read - await util.assertRead(room2); - // as is the thread - await util.assertReadThread("Msg1"); - }); - }); }); }); diff --git a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts new file mode 100644 index 0000000000..54f0c89afe --- /dev/null +++ b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts @@ -0,0 +1,99 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("reactions", () => { + test.describe("in the main timeline", () => { + test("Receiving a reaction to a message does not make a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // When I read the main timeline + await util.goTo(room2); + await util.assertRead(room2); + + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + }); + test("Reacting to a message after marking as read does not make the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + }); + test("A room with an unread reaction is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + }); + test("A room where all reactions are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts new file mode 100644 index 0000000000..9c1be63e5b --- /dev/null +++ b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts @@ -0,0 +1,115 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("reactions", () => { + test.describe("thread roots", () => { + test("A reaction to a thread root does not make the room unread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a read thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When someone reacts to it + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Msg1"); + }); + + test("Reading a reaction to a thread root leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a read thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + + // And the reaction to it does not make us unread + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When we read the reaction and go away again + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + + // And we have marked the room as read + await util.markAsRead(room2); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When someone reacts to it + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Msg1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/redactions.spec.ts b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts similarity index 52% rename from playwright/e2e/read-receipts/redactions.spec.ts rename to playwright/e2e/read-receipts/redactions-in-threads.spec.ts index f7affbed21..323748e7e0 100644 --- a/playwright/e2e/read-receipts/redactions.spec.ts +++ b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts @@ -20,314 +20,6 @@ import { test } from "."; test.describe("Read receipts", () => { test.describe("redactions", () => { - test.describe("in the main timeline", () => { - test("Redacting the message pointed to by my receipt leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have read the messages in a room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When the latest message is redacted - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - - // Then the room remains read - await util.assertStillRead(room2); - }); - - test("Reading an unread room after a redaction of the latest message makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - // And the latest message has been redacted - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - - // When I read the room - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // Then it becomes read - await util.assertStillRead(room2); - }); - test("Reading an unread room after a redaction of an older message makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room with an earlier redaction - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - - // When I read the room - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // Then it becomes read - await util.assertStillRead(room2); - }); - test("Marking an unread room as read after a redaction makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room where latest message is redacted - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // When I mark it as read - await util.markAsRead(room2); - - // Then it becomes read - await util.assertRead(room2); - }); - test("Sending and redacting a message after marking the room as read makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a room that is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When a message is sent and then redacted - await util.receiveMessages(room2, ["Msg3"]); - await util.assertUnread(room2, 1); - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - - // Then the room is read - await util.assertRead(room2); - }); - test("Redacting a message after marking the room as read leaves it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a room that is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); - await util.assertUnread(room2, 3); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When we redact some messages - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - - // Then it is still read - await util.assertStillRead(room2); - }); - test("Redacting one of the unread messages reduces the unread count", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); - await util.assertUnread(room2, 3); - - // When I redact a non-latest message - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - - // Then the unread count goes down - await util.assertUnread(room2, 2); - - // And when I redact the latest message - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - - // Then the unread count goes down again - await util.assertUnread(room2, 1); - }); - test("Redacting one of the unread messages reduces the unread count after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given unread count was reduced by redacting messages - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); - await util.assertUnread(room2, 3); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - await util.assertUnread(room2, 1); - - // When I restart - await util.saveAndReload(); - - // Then the unread count is still reduced - await util.assertUnread(room2, 1); - }); - test("Redacting all unread messages makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - // When I redact all the unread messages - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - - // Then the room is back to being read - await util.assertRead(room2); - }); - test("Redacting all unread messages makes the room read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given all unread messages were redacted - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - await util.assertRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then the room is still read - await util.assertRead(room2); - }); - test("Reacting to a redacted message leaves the room read", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a redacted message exists - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // And the room is read - await util.goTo(room2); - await util.assertRead(room2); - await page.waitForTimeout(200); - await util.goTo(room1); - - // When I react to the redacted message - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - - // Then the room is still read - await util.assertStillRead(room2); - }); - test("Editing a redacted message leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a redacted message exists - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // And the room is read - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When I attempt to edit the redacted message - await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]); - - // Then the room is still read - await util.assertStillRead(room2); - }); - test("A reply to a redacted message makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message was redacted - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // And the room is read - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When I receive a reply to the redacted message - await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); - - // Then the room is unread - await util.assertUnread(room2, 1); - }); - test("Reading a reply to a redacted message marks the room as read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given someone replied to a redacted message - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); - await util.assertUnread(room2, 1); - - // When I read the reply - await util.goTo(room2); - await util.assertRead(room2); - - // Then the room is unread - await util.goTo(room1); - await util.assertStillRead(room2); - }); - }); - test.describe("in threads", () => { test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ roomAlpha: room1, @@ -866,214 +558,5 @@ test.describe("Read receipts", () => { await util.assertReadThread("Root"); }); }); - - test.describe("thread roots", () => { - test("Redacting a thread root after it was read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - test.slow(); - - // Given a thread exists and is read - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - - // When someone redacts the thread root - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // Then the room is still read - await util.assertStillRead(room2); - }); - - /* - * Disabled for the same reason as "A thread with a read redaction is still read after restart" - * above - */ - test.skip("Redacting a thread root still allows us to read the thread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread thread exists - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - - // When someone redacts the thread root - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // Then the room is still unread - await util.assertUnread(room2, 1); - - // And I can open the thread and read it - await util.goTo(room2); - await util.assertRead(room2); - // The redacted message gets collapsed into, "foo was invited, joined and removed a message" - await util.openCollapsedMessage(1); - await util.openThread("Message deleted"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - }); - - test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and its root is redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When we receive a new message on it - await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]); - - // Then the room is read but the thread is unread - await util.assertRead(room2); - await util.goTo(room2); - await util.assertUnreadThread("Message deleted"); - }); - - test("Reacting to a redacted thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When I react to the old root - await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]); - - // Then the room is still read - await util.assertRead(room2); - await util.assertReadThread("Root"); - }); - - test("Editing a redacted thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When I edit the old root - await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]); - - // Then the room is still read - await util.assertRead(room2); - // as is the thread - await util.assertReadThread("Root"); - }); - - test("Replying to a redacted thread root makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When I reply to the old root - await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); - - // Then the room is unread - await util.assertUnread(room2, 1); - }); - - test("Reading a reply to a redacted thread root makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted, and - // someone replied to it - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - await util.assertStillRead(room2); - await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); - await util.assertUnread(room2, 1); - - // When I read the room - await util.goTo(room2); - - // Then it becomes read - await util.assertRead(room2); - }); - }); }); }); diff --git a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts new file mode 100644 index 0000000000..cb7393a63f --- /dev/null +++ b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts @@ -0,0 +1,331 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("redactions", () => { + test.describe("in the main timeline", () => { + test("Redacting the message pointed to by my receipt leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read the messages in a room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When the latest message is redacted + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + + test("Reading an unread room after a redaction of the latest message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // And the latest message has been redacted + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // When I read the room + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("Reading an unread room after a redaction of an older message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room with an earlier redaction + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // When I read the room + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("Marking an unread room as read after a redaction makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room where latest message is redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // When I mark it as read + await util.markAsRead(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + test("Sending and redacting a message after marking the room as read makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room that is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is sent and then redacted + await util.receiveMessages(room2, ["Msg3"]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the room is read + await util.assertRead(room2); + }); + test("Redacting a message after marking the room as read leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room that is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When we redact some messages + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // Then it is still read + await util.assertStillRead(room2); + }); + test("Redacting one of the unread messages reduces the unread count", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + + // When I redact a non-latest message + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // Then the unread count goes down + await util.assertUnread(room2, 2); + + // And when I redact the latest message + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the unread count goes down again + await util.assertUnread(room2, 1); + }); + test("Redacting one of the unread messages reduces the unread count after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given unread count was reduced by redacting messages + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.assertUnread(room2, 1); + + // When I restart + await util.saveAndReload(); + + // Then the unread count is still reduced + await util.assertUnread(room2, 1); + }); + test("Redacting all unread messages makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // When I redact all the unread messages + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // Then the room is back to being read + await util.assertRead(room2); + }); + test("Redacting all unread messages makes the room read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given all unread messages were redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + }); + test("Reacting to a redacted message leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await page.waitForTimeout(200); + await util.goTo(room1); + + // When I react to the redacted message + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + test("Editing a redacted message leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I attempt to edit the redacted message + await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + test("A reply to a redacted message makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message was redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I receive a reply to the redacted message + await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + test("Reading a reply to a redacted message marks the room as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given someone replied to a redacted message + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); + await util.assertUnread(room2, 1); + + // When I read the reply + await util.goTo(room2); + await util.assertRead(room2); + + // Then the room is unread + await util.goTo(room1); + await util.assertStillRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts new file mode 100644 index 0000000000..0ded3957fb --- /dev/null +++ b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts @@ -0,0 +1,232 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("redactions", () => { + test.describe("thread roots", () => { + test("Redacting a thread root after it was read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + test.slow(); + + // Given a thread exists and is read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + + // When someone redacts the thread root + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + + /* + * Disabled for the same reason as "A thread with a read redaction is still read after restart" + * above + */ + test.skip("Redacting a thread root still allows us to read the thread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + + // When someone redacts the thread root + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // Then the room is still unread + await util.assertUnread(room2, 1); + + // And I can open the thread and read it + await util.goTo(room2); + await util.assertRead(room2); + // The redacted message gets collapsed into, "foo was invited, joined and removed a message" + await util.openCollapsedMessage(1); + await util.openThread("Message deleted"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and its root is redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When we receive a new message on it + await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]); + + // Then the room is read but the thread is unread + await util.assertRead(room2); + await util.goTo(room2); + await util.assertUnreadThread("Message deleted"); + }); + + test("Reacting to a redacted thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I react to the old root + await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Editing a redacted thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I edit the old root + await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Root"); + }); + + test("Replying to a redacted thread root makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I reply to the old root + await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + + test("Reading a reply to a redacted thread root makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted, and + // someone replied to it + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + await util.assertStillRead(room2); + await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); + await util.assertUnread(room2, 1); + + // When I read the room + await util.goTo(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/room-list-order.spec.ts b/playwright/e2e/read-receipts/room-list-order.spec.ts new file mode 100644 index 0000000000..2b43022918 --- /dev/null +++ b/playwright/e2e/read-receipts/room-list-order.spec.ts @@ -0,0 +1,61 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("Room list order", () => { + test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + page, + }) => { + await util.goTo(room2); + + // Display the unread first room + await util.toggleRoomUnreadOrder(); + await util.receiveMessages(room1, ["Msg1"]); + await page.reload(); + + // Room 1 has an unread message and should be displayed first + await util.assertRoomListOrder([room1, room2]); + }); + + test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room2); + await util.receiveMessages(room1, ["Msg1"]); + await util.markAsRead(room1); + await util.assertRead(room1); + + // Display the unread first room + await util.toggleRoomUnreadOrder(); + await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); + await util.saveAndReload(); + + // Room 1 has an unread message and should be displayed first + await util.assertRoomListOrder([room1, room2]); + }); + }); +}); diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts index 84e7614e8e..52dd113314 100644 --- a/playwright/e2e/right-panel/file-panel.spec.ts +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -50,7 +50,7 @@ test.describe("FilePanel", () => { test.describe("render", () => { test("should render empty state", async ({ page }) => { // Wait until the information about the empty state is rendered - await expect(page.locator(".mx_FilePanel_empty")).toBeVisible(); + await expect(page.locator(".mx_EmptyState")).toBeVisible(); // Take a snapshot of RightPanel - fix https://github.com/vector-im/element-web/issues/25332 await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png"); diff --git a/playwright/e2e/right-panel/notification-panel.spec.ts b/playwright/e2e/right-panel/notification-panel.spec.ts index 6223c1c13f..aa7dedf73a 100644 --- a/playwright/e2e/right-panel/notification-panel.spec.ts +++ b/playwright/e2e/right-panel/notification-panel.spec.ts @@ -35,7 +35,7 @@ test.describe("NotificationPanel", () => { await page.getByRole("button", { name: "Notifications" }).click(); // Wait until the information about the empty state is rendered - await expect(page.locator(".mx_NotificationPanel_empty")).toBeVisible(); + await expect(page.locator(".mx_EmptyState")).toBeVisible(); // Take a snapshot of RightPanel await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png"); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index e323a4b24f..f282d83d62 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -63,9 +63,9 @@ test.describe("RightPanel", () => { await app.closeDialog(); // Close and reopen the right panel to render the room address - await page.getByRole("button", { name: "Room info" }).click(); + await app.toggleRoomInfoPanel(); await expect(page.locator(".mx_RightPanel")).not.toBeVisible(); - await page.getByRole("button", { name: "Room info" }).click(); + await app.toggleRoomInfoPanel(); await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png"); }); @@ -104,9 +104,9 @@ test.describe("RightPanel", () => { await page.getByRole("menuitem", { name: "Files" }).click(); await expect(page.locator(".mx_FilePanel")).toBeVisible(); - await expect(page.locator(".mx_FilePanel_empty")).toBeVisible(); + await expect(page.locator(".mx_EmptyState")).toBeVisible(); - await page.getByRole("button", { name: "Room information" }).click(); + await page.getByTestId("base-card-back-button").click(); await checkRoomSummaryCard(page, ROOM_NAME); }); @@ -120,7 +120,7 @@ test.describe("RightPanel", () => { await expect(page.locator(".mx_UserInfo")).toBeVisible(); await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); - await page.getByRole("button", { name: "Room members" }).click(); + await page.getByTestId("base-card-back-button").click(); await expect(page.locator(".mx_MemberList")).toBeVisible(); await page.locator(".mx_RightPanelTabs").getByText("Info").click(); @@ -138,14 +138,12 @@ test.describe("RightPanel", () => { .getByRole("button", { name: /\d member/ }) .click(); await expect(page.locator(".mx_MemberList")).toBeVisible(); - await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible(); await getMemberTileByName(page, NAME).click(); await expect(page.locator(".mx_UserInfo")).toBeVisible(); await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); - await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible(); - await page.getByRole("button", { name: "Back" }).click(); + await page.getByTestId("base-card-back-button").click(); await expect(page.locator(".mx_MemberList")).toBeVisible(); }); }); diff --git a/playwright/e2e/right-panel/utils.ts b/playwright/e2e/right-panel/utils.ts index a8dac8394d..5e2e39be0d 100644 --- a/playwright/e2e/right-panel/utils.ts +++ b/playwright/e2e/right-panel/utils.ts @@ -20,7 +20,7 @@ import { ElementAppPage } from "../../pages/ElementAppPage"; export async function viewRoomSummaryByName(page: Page, app: ElementAppPage, name: string): Promise { await app.viewRoomByName(name); - await page.getByRole("button", { name: "Room info" }).click(); + await app.toggleRoomInfoPanel(); return checkRoomSummaryCard(page, name); } diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 4008517d09..ca49f1190b 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -18,7 +18,6 @@ import { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { ElementAppPage } from "../../pages/ElementAppPage"; -import type { Container } from "../../../src/stores/widgets/types"; test.describe("Room Header", () => { test.use({ @@ -33,24 +32,28 @@ test.describe("Room Header", () => { await app.client.createRoom({ name: "Test Room" }); await app.viewRoomByName("Test Room"); - const header = page.locator(".mx_LegacyRoomHeader"); - // Names (aria-label) of every button rendered on mx_LegacyRoomHeader by default - const expectedButtonNames = [ - "Room options", // The room name button next to the room avatar, which renders dropdown menu on click - "Voice call", - "Video call", - "Search", - "Threads", - "Notifications", - "Room info", - ]; + const header = page.locator(".mx_RoomHeader"); - // Assert they are found and visible - for (const name of expectedButtonNames) { - await expect(header.getByRole("button", { name })).toBeVisible(); - } + // There's two room info button - the header itself and the i button + const infoButtons = header.getByRole("button", { name: "Room info" }); + await expect(infoButtons).toHaveCount(2); + await expect(infoButtons.first()).toBeVisible(); + await expect(infoButtons.last()).toBeVisible(); - // Assert that just those seven buttons exist on mx_LegacyRoomHeader by default + // Memberlist button + await expect(header.locator(".mx_FacePile")).toBeVisible(); + + // There should be both a voice and a video call button + // but they'll be disabled + const callButtons = header.getByRole("button", { name: "There's no one here to call" }); + await expect(callButtons).toHaveCount(2); + await expect(callButtons.first()).toBeVisible(); + await expect(callButtons.last()).toBeVisible(); + + await expect(header.getByRole("button", { name: "Threads" })).toBeVisible(); + await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible(); + + // Assert that there are six buttons in total await expect(header.getByRole("button")).toHaveCount(7); await expect(header).toMatchScreenshot("room-header.png"); @@ -67,14 +70,15 @@ test.describe("Room Header", () => { await app.client.createRoom({ name: LONG_ROOM_NAME }); await app.viewRoomByName(LONG_ROOM_NAME); - const header = page.locator(".mx_LegacyRoomHeader"); + const header = page.locator(".mx_RoomHeader"); // Wait until the room name is set - await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(LONG_ROOM_NAME)).toBeVisible(); + await expect(page.locator(".mx_RoomHeader_heading").getByText(LONG_ROOM_NAME)).toBeVisible(); // Assert the size of buttons on RoomHeader are specified and the buttons are not compressed // Note these assertions do not check the size of mx_LegacyRoomHeader_name button - const buttons = page.locator(".mx_LegacyRoomHeader_button"); - await expect(buttons).toHaveCount(6); + const buttons = header.locator(".mx_Flex").getByRole("button"); + await expect(buttons).toHaveCount(5); + for (const button of await buttons.all()) { await expect(button).toBeVisible(); await expect(button).toHaveCSS("height", "32px"); @@ -83,44 +87,6 @@ test.describe("Room Header", () => { await expect(header).toMatchScreenshot("room-header-long-name.png"); }); - - test("should have buttons highlighted by being clicked", async ({ page, app, user }) => { - await app.client.createRoom({ name: "Test Room" }); - await app.viewRoomByName("Test Room"); - - const header = page.locator(".mx_LegacyRoomHeader"); - // Check these buttons - const buttonsHighlighted = ["Threads", "Notifications", "Room info"]; - - for (const name of buttonsHighlighted) { - await header.getByRole("button", { name: name }).click(); // Highlight the button - } - - await expect(header).toMatchScreenshot("room-header-highlighted.png"); - }); - }); - - test.describe("with feature_pinning enabled", () => { - test.use({ labsFlags: ["feature_pinning"] }); - - test("should render the pin button for pinned messages card", async ({ page, app, user }) => { - await app.client.createRoom({ name: "Test Room" }); - await app.viewRoomByName("Test Room"); - - const composer = app.getComposer().locator("[contenteditable]"); - await composer.fill("Test message"); - await composer.press("Enter"); - - const lastTile = page.locator(".mx_EventTile_last"); - await lastTile.hover(); - await lastTile.getByRole("button", { name: "Options" }).click(); - - await page.getByRole("menuitem", { name: "Pin" }).click(); - - await expect( - page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Pinned messages" }), - ).toBeVisible(); - }); }); test.describe("with a video room", () => { @@ -141,30 +107,27 @@ test.describe("Room Header", () => { test.describe("and with feature_notifications enabled", () => { test.use({ labsFlags: ["feature_video_rooms", "feature_notifications"] }); - test("should render buttons for room options, beta pill, invite, chat, and room info", async ({ - page, - app, - user, - }) => { + test("should render buttons for chat, room info, threads and facepile", async ({ page, app, user }) => { await createVideoRoom(page, app); - const header = page.locator(".mx_LegacyRoomHeader"); - // Names (aria-label) of the buttons on the video room header - const expectedButtonNames = [ - "Room options", - "Video rooms are a beta feature Click for more info", // Beta pill - "Invite", - "Chat", - "Room info", - ]; + const header = page.locator(".mx_RoomHeader"); - // Assert they are found and visible - for (const name of expectedButtonNames) { - await expect(header.getByRole("button", { name })).toBeVisible(); - } + // There's two room info button - the header itself and the i button + const infoButtons = header.getByRole("button", { name: "Room info" }); + await expect(infoButtons).toHaveCount(2); + await expect(infoButtons.first()).toBeVisible(); + await expect(infoButtons.last()).toBeVisible(); + + // Facepile + await expect(header.locator(".mx_FacePile")).toBeVisible(); + + // Chat, Threads and Notification buttons + await expect(header.getByRole("button", { name: "Chat" })).toBeVisible(); + await expect(header.getByRole("button", { name: "Threads" })).toBeVisible(); + await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible(); // Assert that there is not a button except those buttons - await expect(header.getByRole("button")).toHaveCount(7); + await expect(header.getByRole("button")).toHaveCount(6); await expect(header).toMatchScreenshot("room-header-video-room.png"); }); @@ -177,7 +140,7 @@ test.describe("Room Header", () => { }) => { await createVideoRoom(page, app); - await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click(); + await page.locator(".mx_RoomHeader").getByRole("button", { name: "Chat" }).click(); // Assert that the call view is still visible await expect(page.locator(".mx_CallView")).toBeVisible(); @@ -188,114 +151,4 @@ test.describe("Room Header", () => { ).toBeVisible(); }); }); - - test.describe("with a widget", () => { - const ROOM_NAME = "Test Room with a widget"; - const WIDGET_ID = "fake-widget"; - const WIDGET_HTML = ` - - - Fake Widget - - - Hello World - - - `; - - test.beforeEach(async ({ page, app, user, webserver }) => { - const widgetUrl = webserver.start(WIDGET_HTML); - const roomId = await app.client.createRoom({ name: ROOM_NAME }); - - // setup widget via state event - await app.client.evaluate( - async (matrixClient, { roomId, widgetUrl, id }) => { - await matrixClient.sendStateEvent( - roomId, - "im.vector.modular.widgets", - { - id, - creatorUserId: "somebody", - type: "widget", - name: "widget", - url: widgetUrl, - }, - id, - ); - await matrixClient.sendStateEvent( - roomId, - "io.element.widgets.layout", - { - widgets: { - [id]: { - container: "top" as Container, - index: 1, - width: 100, - height: 0, - }, - }, - }, - "", - ); - }, - { - roomId, - widgetUrl, - id: WIDGET_ID, - }, - ); - - // open the room - await app.viewRoomByName(ROOM_NAME); - }); - - test("should highlight the apps button", async ({ page, app, user }) => { - // Assert that AppsDrawer is rendered - await expect(page.locator(".mx_AppsDrawer")).toBeVisible(); - - const header = page.locator(".mx_LegacyRoomHeader"); - // Assert that "Hide Widgets" button is rendered and aria-checked is set to true - await expect(header.getByRole("button", { name: "Hide Widgets" })).toHaveAttribute("aria-checked", "true"); - - await expect(header).toMatchScreenshot("room-header-with-apps-button-highlighted.png"); - }); - - test("should support hiding a widget", async ({ page, app, user }) => { - await expect(page.locator(".mx_AppsDrawer")).toBeVisible(); - - const header = page.locator(".mx_LegacyRoomHeader"); - // Click the apps button to hide AppsDrawer - await header.getByRole("button", { name: "Hide Widgets" }).click(); - - // Assert that "Show widgets" button is rendered and aria-checked is set to false - await expect(header.getByRole("button", { name: "Show Widgets" })).toHaveAttribute("aria-checked", "false"); - - // Assert that AppsDrawer is not rendered - await expect(page.locator(".mx_AppsDrawer")).not.toBeVisible(); - - await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png"); - }); - }); - - test.describe("with encryption", () => { - test("should render the E2E icon and the buttons", async ({ page, app, user }) => { - // Create an encrypted room - await app.client.createRoom({ - name: "Test Encrypted Room", - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); - await app.viewRoomByName("Test Encrypted Room"); - - const header = page.locator(".mx_LegacyRoomHeader"); - await expect(header).toMatchScreenshot("encrypted-room-header.png"); - }); - }); }); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts index 79c78b71f7..aa00681f61 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts @@ -60,113 +60,4 @@ test.describe("Appearance user settings tab", () => { // Assert that the font-family value was removed await expect(page.locator("body")).toHaveCSS("font-family", '""'); }); - - test.describe("Message Layout Panel", () => { - test.beforeEach(async ({ app, user, util }) => { - await util.createAndDisplayRoom(); - await util.assertModernLayout(); - await util.openAppearanceTab(); - }); - - test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => { - await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png"); - - await util.getBubbleLayout().click(); - - // Assert that modern are irc layout are not selected - await expect(util.getBubbleLayout()).toBeChecked(); - await expect(util.getModernLayout()).not.toBeChecked(); - await expect(util.getIRCLayout()).not.toBeChecked(); - - // Assert that the room layout is set to bubble layout - await util.assertBubbleLayout(); - await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png"); - }); - - test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => { - await expect(util.getCompactLayoutCheckbox()).not.toBeChecked(); - - await util.getCompactLayoutCheckbox().click(); - await util.assertCompactLayout(); - }); - - test("should disable compact layout when the modern layout is not selected", async ({ - page, - app, - user, - util, - }) => { - await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled(); - - // Select the bubble layout, which should disable the compact layout checkbox - await util.getBubbleLayout().click(); - await expect(util.getCompactLayoutCheckbox()).toBeDisabled(); - }); - }); - - test.describe("Theme Choice Panel", () => { - test.beforeEach(async ({ app, user, util }) => { - // Disable the default theme for consistency in case ThemeWatcher automatically chooses it - await util.disableSystemTheme(); - await util.openAppearanceTab(); - }); - - test("should be rendered with the light theme selected", async ({ page, app, util }) => { - // Assert that 'Match system theme' is not checked - await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked(); - - // Assert that the light theme is selected - await expect(util.getLightTheme()).toBeChecked(); - // Assert that the dark and high contrast themes are not selected - await expect(util.getDarkTheme()).not.toBeChecked(); - await expect(util.getHighContrastTheme()).not.toBeChecked(); - - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png"); - }); - - test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => { - await util.getMatchSystemThemeCheckbox().click(); - - // Assert that the themes are disabled - await expect(util.getLightTheme()).toBeDisabled(); - await expect(util.getDarkTheme()).toBeDisabled(); - await expect(util.getHighContrastTheme()).toBeDisabled(); - - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png"); - }); - - test("should change the theme to dark", async ({ page, app, util }) => { - // Assert that the light theme is selected - await expect(util.getLightTheme()).toBeChecked(); - - await util.getDarkTheme().click(); - - // Assert that the light and high contrast themes are not selected - await expect(util.getLightTheme()).not.toBeChecked(); - await expect(util.getDarkTheme()).toBeChecked(); - await expect(util.getHighContrastTheme()).not.toBeChecked(); - - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png"); - }); - - test.describe("custom theme", () => { - test.use({ - labsFlags: ["feature_custom_themes"], - }); - - test("should render the custom theme section", async ({ page, app, util }) => { - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); - }); - - test("should be able to add and remove a custom theme", async ({ page, app, util }) => { - await util.addCustomTheme(); - - await expect(util.getCustomTheme()).not.toBeChecked(); - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png"); - - await util.removeCustomTheme(); - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); - }); - }); - }); }); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts new file mode 100644 index 0000000000..1a22696da1 --- /dev/null +++ b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts @@ -0,0 +1,66 @@ +/* +Copyright 2023 Suguru Hirahara + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect, test } from "."; + +test.describe("Appearance user settings tab", () => { + test.use({ + displayName: "Hanako", + }); + + test.describe("Message Layout Panel", () => { + test.beforeEach(async ({ app, user, util }) => { + await util.createAndDisplayRoom(); + await util.assertModernLayout(); + await util.openAppearanceTab(); + }); + + test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => { + await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png"); + + await util.getBubbleLayout().click(); + + // Assert that modern are irc layout are not selected + await expect(util.getBubbleLayout()).toBeChecked(); + await expect(util.getModernLayout()).not.toBeChecked(); + await expect(util.getIRCLayout()).not.toBeChecked(); + + // Assert that the room layout is set to bubble layout + await util.assertBubbleLayout(); + await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png"); + }); + + test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => { + await expect(util.getCompactLayoutCheckbox()).not.toBeChecked(); + + await util.getCompactLayoutCheckbox().click(); + await util.assertCompactLayout(); + }); + + test("should disable compact layout when the modern layout is not selected", async ({ + page, + app, + user, + util, + }) => { + await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled(); + + // Select the bubble layout, which should disable the compact layout checkbox + await util.getBubbleLayout().click(); + await expect(util.getCompactLayoutCheckbox()).toBeDisabled(); + }); + }); +}); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts new file mode 100644 index 0000000000..2b1e8cc14d --- /dev/null +++ b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts @@ -0,0 +1,89 @@ +/* +Copyright 2023 Suguru Hirahara + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect, test } from "."; + +test.describe("Appearance user settings tab", () => { + test.use({ + displayName: "Hanako", + }); + + test.describe("Theme Choice Panel", () => { + test.beforeEach(async ({ app, user, util }) => { + // Disable the default theme for consistency in case ThemeWatcher automatically chooses it + await util.disableSystemTheme(); + await util.openAppearanceTab(); + }); + + test("should be rendered with the light theme selected", async ({ page, app, util }) => { + // Assert that 'Match system theme' is not checked + await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked(); + + // Assert that the light theme is selected + await expect(util.getLightTheme()).toBeChecked(); + // Assert that the dark and high contrast themes are not selected + await expect(util.getDarkTheme()).not.toBeChecked(); + await expect(util.getHighContrastTheme()).not.toBeChecked(); + + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png"); + }); + + test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => { + await util.getMatchSystemThemeCheckbox().click(); + + // Assert that the themes are disabled + await expect(util.getLightTheme()).toBeDisabled(); + await expect(util.getDarkTheme()).toBeDisabled(); + await expect(util.getHighContrastTheme()).toBeDisabled(); + + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png"); + }); + + test("should change the theme to dark", async ({ page, app, util }) => { + // Assert that the light theme is selected + await expect(util.getLightTheme()).toBeChecked(); + + await util.getDarkTheme().click(); + + // Assert that the light and high contrast themes are not selected + await expect(util.getLightTheme()).not.toBeChecked(); + await expect(util.getDarkTheme()).toBeChecked(); + await expect(util.getHighContrastTheme()).not.toBeChecked(); + + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png"); + }); + + test.describe("custom theme", () => { + test.use({ + labsFlags: ["feature_custom_themes"], + }); + + test("should render the custom theme section", async ({ page, app, util }) => { + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); + }); + + test("should be able to add and remove a custom theme", async ({ page, app, util }) => { + await util.addCustomTheme(); + + await expect(util.getCustomTheme()).not.toBeChecked(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png"); + + await util.removeCustomTheme(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); + }); + }); + }); +}); diff --git a/playwright/e2e/settings/general-room-settings-tab.spec.ts b/playwright/e2e/settings/general-room-settings-tab.spec.ts index ec3c14b2ca..123c214288 100644 --- a/playwright/e2e/settings/general-room-settings-tab.spec.ts +++ b/playwright/e2e/settings/general-room-settings-tab.spec.ts @@ -34,7 +34,7 @@ test.describe("General room settings tab", () => { // Assert that "Show less" details element is rendered await expect(settings.getByText("Show less")).toBeVisible(); - await expect(settings).toMatchScreenshot(); + await expect(settings).toMatchScreenshot("General-room-settings-tab-should-be-rendered-properly-1.png"); // Click the "Show less" details element await settings.getByText("Show less").click(); diff --git a/playwright/e2e/settings/general-user-settings-tab.spec.ts b/playwright/e2e/settings/general-user-settings-tab.spec.ts index 050cd76d00..0ba85e890b 100644 --- a/playwright/e2e/settings/general-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/general-user-settings-tab.spec.ts @@ -18,7 +18,6 @@ import { test, expect } from "../../element-web-test"; const USER_NAME = "Bob"; const USER_NAME_NEW = "Alice"; -const IntegrationManager = "scalar.vector.im"; test.describe("General user settings tab", () => { test.use({ @@ -73,17 +72,6 @@ test.describe("General user settings tab", () => { // Assert that the add button is rendered await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible(); - const setIntegrationManager = uut.locator(".mx_SetIntegrationManager"); - await setIntegrationManager.scrollIntoViewIfNeeded(); - await expect( - setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", { hasText: IntegrationManager }), - ).toBeVisible(); - // Make sure integration manager's toggle switch is enabled - await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible(); - await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText( - "Manage integrations(scalar.vector.im)", - ); - // Assert the account deactivation button is displayed const accountManagementSection = uut.getByTestId("account-management-section"); await accountManagementSection.scrollIntoViewIfNeeded(); diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts index 22baa19a8a..a67909b47b 100644 --- a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts @@ -31,7 +31,7 @@ test.describe("Preferences user settings tab", () => { // Assert that the top heading is rendered await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); - await expect(tab).toMatchScreenshot(); + await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png"); }); test("should be able to change the app language", async ({ uut, user }) => { diff --git a/playwright/e2e/settings/security-user-settings-tab.spec.ts b/playwright/e2e/settings/security-user-settings-tab.spec.ts index 5cd2a92c16..8d1f442ba5 100644 --- a/playwright/e2e/settings/security-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/security-user-settings-tab.spec.ts @@ -1,5 +1,6 @@ /* Copyright 2023 Suguru Hirahara +Copyright 2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,6 +17,8 @@ limitations under the License. import { test, expect } from "../../element-web-test"; +const IntegrationManager = "scalar.vector.im"; + test.describe("Security user settings tab", () => { test.describe("with posthog enabled", () => { test.use({ @@ -44,7 +47,9 @@ test.describe("Security user settings tab", () => { test("should be rendered properly", async ({ app, page }) => { const tab = await app.settings.openUserSettings("Security"); await tab.getByRole("button", { name: "Learn more" }).click(); - await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(); + await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot( + "Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1.png", + ); }); }); @@ -56,5 +61,22 @@ test.describe("Security user settings tab", () => { // Assert that an input area for identity server exists await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible(); }); + + test("should enable show integrations as enabled", async ({ app, page }) => { + const tab = await app.settings.openUserSettings("Security"); + + const setIntegrationManager = tab.locator(".mx_SetIntegrationManager"); + await setIntegrationManager.scrollIntoViewIfNeeded(); + await expect( + setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", { + hasText: IntegrationManager, + }), + ).toBeVisible(); + // Make sure integration manager's toggle switch is enabled + await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible(); + await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText( + "Manage integrations(scalar.vector.im)", + ); + }); }); }); diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index 177eccdc10..5d10937b67 100644 --- a/playwright/e2e/spotlight/spotlight.spec.ts +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -21,7 +21,7 @@ import type { Locator, Page } from "@playwright/test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; function roomHeaderName(page: Page): Locator { - return page.locator(".mx_LegacyRoomHeader_nametext"); + return page.locator(".mx_RoomHeader_heading"); } async function startDM(app: ElementAppPage, page: Page, name: string): Promise { diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts index 9b5ea46511..7898457d05 100644 --- a/playwright/e2e/threads/threads.spec.ts +++ b/playwright/e2e/threads/threads.spec.ts @@ -252,7 +252,8 @@ test.describe("Threads", () => { await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); locator = page.getByRole("button", { name: "Threads" }); - await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); // User asserts thread list unread indicator + await expect(locator).toHaveAttribute("data-indicator", "default"); // User asserts thread list unread indicator + // await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); await locator.click(); // User opens thread list // User asserts thread with correct root & latest events & unread dot @@ -433,7 +434,7 @@ test.describe("Threads", () => { await textbox.press("Enter"); await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached(); // Close thread - await locator.getByRole("button", { name: "Close" }).click(); + await locator.getByTestId("base-card-close-button").click(); // Open existing thread locator = page @@ -486,7 +487,7 @@ test.describe("Threads", () => { await textbox.press("Enter"); await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible(); // Close thread - await threadPanel.getByRole("button", { name: "Close" }).click(); + await threadPanel.getByTestId("base-card-close-button").click(); }; await sendMessage("Hello Mr. Bot"); @@ -495,14 +496,12 @@ test.describe("Threads", () => { await createThread("Hello again Mr. Bot", "Hello again Mr. User in a thread"); // Open thread panel - await page.getByTestId("threadsButton").click(); + await page.locator(".mx_RoomHeader").getByRole("button", { name: "Threads" }).click(); const threadPanel = page.locator(".mx_ThreadPanel"); await expect( threadPanel.locator(".mx_EventTile_last").getByText("Hello again Mr. User in a thread"), ).toBeVisible(); - // Open threads list - await page.locator(".mx_BaseCard_back").click(); const rightPanel = page.locator(".mx_RightPanel"); // Check that the threads are listed await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible(); diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 47ec61aecf..6068385194 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -410,6 +410,7 @@ test.describe("Timeline", () => { { // Exclude timestamp from snapshot of mx_MainSplit mask: [page.locator(".mx_MessageTimestamp")], + hideTooltips: true, }, ); @@ -427,6 +428,7 @@ test.describe("Timeline", () => { await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-and-messages-irc-layout.png", { // Exclude timestamp from snapshot of mx_MainSplit mask: [page.locator(".mx_MessageTimestamp")], + hideTooltips: true, }); // 3. Alignment of expanded GELS and placeholder of deleted message @@ -447,6 +449,7 @@ test.describe("Timeline", () => { await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-redaction-placeholder.png", { // Exclude timestamp from snapshot of mx_MainSplit mask: [page.locator(".mx_MessageTimestamp")], + hideTooltips: true, }); // 4. Alignment of expanded GELS, placeholder of deleted message, and emote @@ -469,6 +472,7 @@ test.describe("Timeline", () => { await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-emote-irc-layout.png", { // Exclude timestamp from snapshot of mx_MainSplit mask: [page.locator(".mx_MessageTimestamp")], + hideTooltips: true, }); }); @@ -481,6 +485,7 @@ test.describe("Timeline", () => { display: none !important; } `, + hideTooltips: true, }; await sendEvent(app.client, room.roomId); @@ -779,7 +784,7 @@ test.describe("Timeline", () => { await sendEvent(app.client, room.roomId, true); await page.goto(`/#/room/${room.roomId}`); - await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); + await app.toggleRoomInfoPanel(); await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill("Message"); await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter"); @@ -804,7 +809,7 @@ test.describe("Timeline", () => { await page.goto(`/#/room/${room.roomId}`); // Open a room setting dialog - await page.getByRole("button", { name: "Room options" }).click(); + await app.toggleRoomInfoPanel(); await page.getByRole("menuitem", { name: "Settings" }).click(); // Set a room topic to render a TextualEvent @@ -818,9 +823,6 @@ test.describe("Timeline", () => { page.getByText(`${OLD_NAME} changed the topic to "This is a room for ${stringToSearch}.".`), ).toHaveClass(/mx_TextualEvent/); - // Display the room search bar - await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); - // Search the string to display both the message and TextualEvent on search results panel await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch); await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter"); diff --git a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts index 09a140d441..0799dc0f60 100644 --- a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts +++ b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts @@ -35,7 +35,9 @@ test.describe("User Onboarding (new user)", () => { }); test("page is shown and preference exists", async ({ page, app }) => { - await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot(); + await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot( + "User-Onboarding-new-user-page-is-shown-and-preference-exists-1.png", + ); await app.settings.openUserSettings("Preferences"); await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible(); }); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 75235fe03d..d6fd1b48c1 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -15,9 +15,10 @@ limitations under the License. */ import { test as base, expect as baseExpect, Locator, Page, ExpectMatcherState, ElementHandle } from "@playwright/test"; +import { sanitizeForFilePath } from "playwright-core/lib/utils"; import AxeBuilder from "@axe-core/playwright"; import _ from "lodash"; -import { basename } from "node:path"; +import { basename, extname } from "node:path"; import type mailhog from "mailhog"; import type { IConfigOptions } from "../src/IConfigOptions"; @@ -298,20 +299,40 @@ export const test = base.extend<{ }, }); +// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2 +function sanitizeFilePathBeforeExtension(filePath: string): string { + const ext = extname(filePath); + const base = filePath.substring(0, filePath.length - ext.length); + return sanitizeForFilePath(base) + ext; +} + export const expect = baseExpect.extend({ async toMatchScreenshot( this: ExpectMatcherState, receiver: Page | Locator, - name?: `${string}.png`, + name: `${string}.png`, options?: { mask?: Array; omitBackground?: boolean; + hideTooltips?: boolean; timeout?: number; css?: string; }, ) { + const testInfo = test.info(); + if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`); + const page = "page" in receiver ? receiver.page() : receiver; + let hideTooltipsCss: string | undefined; + if (options?.hideTooltips) { + hideTooltipsCss = ` + .mx_Tooltip_visible { + visibility: hidden !important; + } + `; + } + // We add a custom style tag before taking screenshots const style = (await page.addStyleTag({ content: ` @@ -339,13 +360,23 @@ export const expect = baseExpect.extend({ .mx_MessageTimestamp { font-family: Inconsolata !important; } + ${hideTooltipsCss ?? ""} ${options?.css ?? ""} `, })) as ElementHandle; - await baseExpect(receiver).toHaveScreenshot(name, options); + const screenshotName = sanitizeFilePathBeforeExtension(name); + await baseExpect(receiver).toHaveScreenshot(screenshotName, options); await style.evaluate((tag) => tag.remove()); + + testInfo.annotations.push({ + // `_` prefix hides it from the HTML reporter + type: "_screenshot", + // include a path relative to `playwright/snapshots/` + description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1], + }); + return { pass: true, message: () => "", name: "toMatchScreenshot" }; }, }); diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index ac9b4ffef8..af79994358 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -171,4 +171,13 @@ export class ElementAppPage { await spotlight.open(); return spotlight; } + + /** + * Opens/closes the room info panel + * @returns locator to the right panel + */ + public async toggleRoomInfoPanel(): Promise { + await this.page.getByRole("button", { name: "Room info" }).first().click(); + return this.page.locator(".mx_RightPanel"); + } } diff --git a/playwright/pages/settings.ts b/playwright/pages/settings.ts index c0efb6770c..1b7d099c6c 100644 --- a/playwright/pages/settings.ts +++ b/playwright/pages/settings.ts @@ -91,12 +91,17 @@ export class Settings { } /** - * Open room settings (via room header menu), returns a locator to the dialog + * Open room settings (via room info panel), returns a locator to the dialog * @param tab the name of the tab to switch to after opening, optional. */ public async openRoomSettings(tab?: string): Promise { - await this.page.getByRole("banner").getByRole("button", { name: "Room options", exact: true }).click(); - await this.page.locator(".mx_RoomTile_contextMenu").getByRole("menuitem", { name: "Settings" }).click(); + // Open right panel if not open + const rightPanel = this.page.locator(".mx_RightPanel"); + if ((await rightPanel.count()) === 0) { + await this.page.getByRole("button", { name: "Room info" }).first().click(); + } + await rightPanel.getByRole("menuitem", { name: "Settings" }).click(); + if (tab) await this.switchTab(tab); return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_RoomSettingsDialog") }); } diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 0385f64d03..5cf6391720 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for `matrixdotorg/synapse` image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:db5f8e8ca4a903379ea18b010ac3360bd843c9ac7eb2e73ad89f5059d01f8104"; +const DOCKER_TAG = "develop@sha256:9e193236098ae5ff66c9bf79252e318fd561ceb1322d5495780a11d9dbdcfb17"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png new file mode 100644 index 0000000000..df1a44f523 Binary files /dev/null and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png differ diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png new file mode 100644 index 0000000000..96ad96e3e1 Binary files /dev/null and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png differ diff --git a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png new file mode 100644 index 0000000000..ae11ec9eec Binary files /dev/null and b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png differ diff --git a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png index 75a9c353de..c188081189 100644 Binary files a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png and b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png differ diff --git a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png index db91140763..2214206cab 100644 Binary files a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png and b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png index c85c583a19..9992923226 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png index b6990e727e..050a82a8af 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png index e4f6313c97..fdbec37b70 100644 Binary files a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png and b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png differ diff --git a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png index 7d8884dc4d..d18266534d 100644 Binary files a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png and b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png deleted file mode 100644 index 6dced2e990..0000000000 Binary files a/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png and /dev/null differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png deleted file mode 100644 index c792c4bcf0..0000000000 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png and /dev/null differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png index c5fd90be57..ea591089b4 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png index 0cfb88b523..ded42af7a1 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png index 5d79ae740c..7271a50cf9 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png deleted file mode 100644 index 6016bb7e7a..0000000000 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png and /dev/null differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png deleted file mode 100644 index 498853a973..0000000000 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png deleted file mode 100644 index c16b95d1de..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png deleted file mode 100644 index 12996f4e5b..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png deleted file mode 100644 index f523146348..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png deleted file mode 100644 index 6b1e058c6a..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png deleted file mode 100644 index 502e60cb1f..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png deleted file mode 100644 index 7d9400b0bd..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/message-layout-panel-bubble-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/message-layout-panel-bubble-linux.png deleted file mode 100644 index 3f39a3b01d..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/message-layout-panel-bubble-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/message-layout-panel-modern-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/message-layout-panel-modern-linux.png deleted file mode 100644 index 74aaf9a763..0000000000 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/message-layout-panel-modern-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png index 281f1cebe5..d30d8e98a8 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png index 5703f38449..f8e94f9269 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png index 0a3d265a60..9d59bfa56f 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png index 0453bf932a..1c07ac3697 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png index 077ecbf717..48e82f72af 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png index 194ecd07fb..9f510402e6 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png index 2b990bb3b8..0430d95ac5 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png index 294cd3ec7a..80666c5ccf 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png index f0064c81e1..c08856b44e 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png index 001ac64f2a..eb5a0fca3b 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png index f7a276d2f7..594ac521e0 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png index de056e0fa5..1c83d343d0 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png index 077ecbf717..48e82f72af 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png index e1cd5ab19c..6ac01ff51b 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png index 4fb2902456..2149c46220 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png deleted file mode 100644 index ceddab3312..0000000000 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png and /dev/null differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png deleted file mode 100644 index 5fba124a92..0000000000 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png and /dev/null differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png index 0d0cc0eec3..2685052da9 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png index dfc55550aa..1d05e32105 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png index 800ceefc6e..8826429198 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png index 9d2fcdf272..937bcb3417 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png index f85715b076..a11b81693d 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png index 89ce0a9f2d..b83fe895f7 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png deleted file mode 100644 index 75b64546d6..0000000000 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png and /dev/null differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index 61ab660157..b6d6d2a210 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/playwright/stale-screenshot-reporter.ts b/playwright/stale-screenshot-reporter.ts new file mode 100644 index 0000000000..3a3d18e28c --- /dev/null +++ b/playwright/stale-screenshot-reporter.ts @@ -0,0 +1,74 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Test reporter which compares the reported screenshots vs those on disk to find stale screenshots + * Only intended to run from within GitHub Actions + */ + +import path from "node:path"; +import { glob } from "glob"; + +import type { Reporter, TestCase } from "@playwright/test/reporter"; + +const snapshotRoot = path.join(__dirname, "snapshots"); + +class StaleScreenshotReporter implements Reporter { + private screenshots = new Set(); + private success = true; + + public onTestEnd(test: TestCase): void { + for (const annotation of test.annotations) { + if (annotation.type === "_screenshot") { + this.screenshots.add(annotation.description); + } + } + } + + private error(msg: string, file: string) { + if (process.env.GITHUB_ACTIONS) { + console.log(`::error file=${file}::${msg}`); + } + console.error(msg, file); + this.success = false; + } + + public async onExit(): Promise { + const screenshotFiles = new Set(await glob(`**/*.png`, { cwd: snapshotRoot })); + for (const screenshot of screenshotFiles) { + if (screenshot.split("-").at(-1) !== "linux.png") { + this.error( + "Found screenshot belonging to different platform, this should not be checked in", + screenshot, + ); + } + } + for (const screenshot of this.screenshots) { + screenshotFiles.delete(screenshot); + } + if (screenshotFiles.size > 0) { + for (const screenshot of screenshotFiles) { + this.error("Stale screenshot file", screenshot); + } + } + + if (!this.success) { + process.exit(1); + } + } +} + +export default StaleScreenshotReporter; diff --git a/res/css/_common.pcss b/res/css/_common.pcss index a454789efc..8264ccb704 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -851,7 +851,7 @@ legend { @define-mixin ThreadSummaryIcon { content: ""; display: inline-block; - mask-image: url("$(res)/img/element-icons/thread-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/threads.svg"); mask-position: center; mask-repeat: no-repeat; mask-size: contain; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 327b86da08..20a6d2fe54 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -73,7 +73,6 @@ @import "./structures/_MatrixChat.pcss"; @import "./structures/_MessagePanel.pcss"; @import "./structures/_NonUrgentToastContainer.pcss"; -@import "./structures/_NotificationPanel.pcss"; @import "./structures/_QuickSettingsButton.pcss"; @import "./structures/_RightPanel.pcss"; @import "./structures/_RoomSearch.pcss"; @@ -259,6 +258,7 @@ @import "./views/polls/pollHistory/_PollHistory.pcss"; @import "./views/polls/pollHistory/_PollHistoryList.pcss"; @import "./views/right_panel/_BaseCard.pcss"; +@import "./views/right_panel/_EmptyState.pcss"; @import "./views/right_panel/_EncryptionInfo.pcss"; @import "./views/right_panel/_PinnedMessagesCard.pcss"; @import "./views/right_panel/_RightPanelTabs.pcss"; @@ -312,7 +312,6 @@ @import "./views/rooms/_RoomTile.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_SendMessageComposer.pcss"; -@import "./views/rooms/_SpaceScopeHeader.pcss"; @import "./views/rooms/_Stickers.pcss"; @import "./views/rooms/_ThirdPartyMemberInfo.pcss"; @import "./views/rooms/_ThreadSummary.pcss"; diff --git a/res/css/structures/_FilePanel.pcss b/res/css/structures/_FilePanel.pcss index 1c80cde901..186893b24b 100644 --- a/res/css/structures/_FilePanel.pcss +++ b/res/css/structures/_FilePanel.pcss @@ -102,7 +102,3 @@ limitations under the License. padding-inline-start: 0; } } - -.mx_FilePanel_empty::before { - --maskImage: url("$(res)/img/element-icons/room/files.svg"); /* See: _RightPanel.pcss */ -} diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index f8b5cb4408..d21c435b24 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -72,30 +72,3 @@ limitations under the License. order: 2; margin: auto; } - -.mx_RightPanel_empty { - margin-right: -28px; - - h2 { - font-weight: 700; - margin: 16px 0; - } - - h2, - p { - font: var(--cpd-font-body-md-regular); - } - - &::before { - content: ""; - display: block; - margin: 11px auto 29px auto; - height: 42px; - width: 42px; - background-color: $header-panel-text-primary-color; - mask-image: var(--maskImage); - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } -} diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 67eb9b7e49..692f7d23b3 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -27,7 +27,7 @@ limitations under the License. .mx_BaseCard_header { height: 64px; - padding: var(--cpd-space-3x); + padding: var(--cpd-space-4x); box-sizing: border-box; /* changing the color from $separator to transparent as it is the best visual output during the transition period. This will be @@ -36,8 +36,13 @@ limitations under the License. display: flex; align-items: center; justify-content: space-between; - gap: var(--cpd-space-2x); + gap: var(--cpd-space-3x); flex-shrink: 0; + border-block-end: var(--cpd-border-width-1) solid $separator; + + .mx_BaseCard_header_spacer { + flex: 1; + } > h2 { margin: 0 44px; @@ -155,52 +160,6 @@ limitations under the License. } } -.mx_BaseCard_back, -.mx_BaseCard_close { - flex-shrink: 0; - position: relative; - /* @TODO(kerrya) background colours here are not semantic - these buttons to be replaced with IconButton after secondary variant is added - https://github.com/vector-im/compound/issues/279 */ - background-color: var(--cpd-color-bg-subtle-secondary); - width: var(--BaseCard_header-button-size); - height: var(--BaseCard_header-button-size); - border-radius: 50%; - - &:hover { - background-color: var(--cpd-color-bg-subtle-primary); - } - - &::before { - content: ""; - position: absolute; - height: inherit; - width: inherit; - top: 0; - left: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 20px; - background-color: var(--cpd-color-icon-secondary); - } -} - -.mx_BaseCard_back { - order: 0; /* always first! */ - &::before { - transform: rotate(90deg); - mask-size: 22px; - mask-image: url("$(res)/img/feather-customised/chevron-down.svg"); - } -} - -.mx_BaseCard_close { - order: 999; /* always last */ - &::before { - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); - } -} - .mx_ContextualMenu_wrapper.mx_BaseCard_header_title { .mx_ContextualMenu { position: initial; @@ -235,7 +194,3 @@ limitations under the License. } } } - -.mx_BaseCard_headerProp { - flex: 1 1 100%; -} diff --git a/res/css/views/right_panel/_EmptyState.pcss b/res/css/views/right_panel/_EmptyState.pcss new file mode 100644 index 0000000000..4bf620bae3 --- /dev/null +++ b/res/css/views/right_panel/_EmptyState.pcss @@ -0,0 +1,45 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_EmptyState { + height: 100%; + box-sizing: border-box; + padding: var(--cpd-space-4x); + text-align: center; + + svg { + width: 56px; + height: 56px; + box-sizing: border-box; + border-radius: 8px; + padding: var(--cpd-space-3x); + background-color: $panel-actions; + } + + &::before { + /* Bloom using magic numbers directly out of Figma */ + content: ""; + position: absolute; + z-index: -1; + width: 642px; + height: 775px; + right: -253.77px; + top: 0; + background: radial-gradient(49.95% 49.95% at 50% 50%, rgba(13, 189, 139, 0.12) 0%, rgba(18, 115, 235, 0) 100%); + transform: rotate(-89.69deg); + overflow: hidden; + } +} diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index 104430c190..fc1d39c9ca 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -18,12 +18,13 @@ limitations under the License. height: 100px; overflow: visible; + /* Unset flex on the thread list, but not the thread view */ + &:not(.mx_ThreadView) .mx_BaseCard_header .mx_BaseCard_header_title { + flex: unset; + } + .mx_BaseCard_header { .mx_BaseCard_header_title { - .mx_BaseCard_header_title_heading { - margin-right: auto; - } - .mx_AccessibleButton { font-size: 12px; color: $secondary-content; @@ -106,10 +107,17 @@ limitations under the License. } .mx_RoomView_messagePanel { - /* To avoid the rule from being applied to .mx_ThreadPanel_empty */ + &.mx_RoomView_messageListWrapper { + position: initial; + } + .mx_RoomView_messageListWrapper { width: calc(100% + 6px); /* 8px - 2px */ } + + .mx_RoomView_empty { + display: contents; + } } .mx_RoomView_MessageList { @@ -168,72 +176,6 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/link.svg"); } -.mx_ThreadPanel_empty { - border-radius: 8px; - background: $background; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - position: absolute; - top: 0; - bottom: 0; - left: 0; - padding: 20px; - box-sizing: border-box; /* Include padding and border */ - width: 100%; - - h2 { - color: $primary-content; - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-18px; - margin-top: 24px; - margin-bottom: 10px; - } - - p { - font-size: $font-15px; - color: $secondary-content; - margin: 10px 0; - } - - button { - border: none; - background: none; - color: $accent; - font-size: $font-15px; - - &:hover, - &:active { - text-decoration: underline; - cursor: pointer; - } - } - - .mx_ThreadPanel_empty_tip { - font-size: $font-12px; - line-height: $font-15px; - - > b { - font-weight: var(--cpd-font-weight-semibold); - } - } -} - -.mx_ThreadPanel_largeIcon { - width: 28px; - height: 28px; - padding: 18px; - background: $system; - border-radius: 50%; - - &::after { - @mixin ThreadSummaryIcon; - width: inherit; - height: inherit; - } -} - .mx_ContextualMenu_wrapper { .mx_ThreadPanel_Header_FilterOptionItem { display: flex; diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 1dfd39dd25..a2e156e0e5 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -41,35 +41,17 @@ limitations under the License. } } - h2 { - font-size: $font-18px; - font-weight: var(--cpd-font-weight-semibold); - margin: 18px 0 0 0; - } - .mx_UserInfo_container { - padding: $spacing-8 $spacing-16; - - &:not(.mx_UserInfo_separator) { - padding-top: $spacing-16; - padding-bottom: 0; - - > :not(h3) { - margin-inline-start: $spacing-8; - display: flex; - flex-flow: column; - align-items: flex-start; - row-gap: $spacing-8; - } - } + padding: var(--cpd-space-4x) 0; + margin: 0 var(--cpd-space-4x); .mx_UserInfo_container_verifyButton { margin-top: $spacing-8; } - } - .mx_UserInfo_separator { - border-bottom: 1px solid $separator; + & + .mx_UserInfo_container { + border-top: 1px solid $separator; + } } .mx_UserInfo_memberDetailsContainer { @@ -94,7 +76,7 @@ limitations under the License. margin: $spacing-24 $spacing-32 0 $spacing-32; .mx_UserInfo_avatar_transition { - max-width: 30vh; + max-width: 120px; aspect-ratio: 1 / 1; margin: 0 auto; transition: 0.5s; @@ -112,7 +94,7 @@ limitations under the License. } } - h3 { + h2 { text-transform: uppercase; color: $tertiary-content; font: var(--cpd-font-heading-sm-semibold); @@ -125,41 +107,36 @@ limitations under the License. } .mx_UserInfo_profile { - text-align: center; - - h2 { - display: flex; - font-size: $font-17px; + h1 { + font-size: $font-20px; line-height: $font-25px; - flex: 1; - justify-content: center; - /* We reverse things here so for accessible technologies the name comes before the e2e shield */ - flex-direction: row-reverse; - span { - /* limit to 2 lines, show an ellipsis if it overflows */ - /* this looks webkit specific but is supported by Firefox 68+ */ - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + /* limit to 2 lines, show an ellipsis if it overflows */ + /* this looks webkit specific but is supported by Firefox 68+ */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; - overflow: hidden; - word-break: break-all; - text-overflow: ellipsis; - } + overflow: hidden; + word-break: break-all; + text-overflow: ellipsis; - .mx_E2EIcon { - margin-top: 3px; /* visual vertical centering to the top line of text. */ - margin-inline-end: $spacing-4; /* margin from displayName */ - min-width: 18px; /* convince flexbox to not collapse it */ + /* E2E icon wrapper */ + .mx_Flex > span { + display: inline-block; } } .mx_UserInfo_profileStatus { - margin-top: $spacing-12; + margin: var(--cpd-space-1x) 0; } } + .mx_PresenceLabel { + font: var(--cpd-font-body-sm-regular); + opacity: 1; + } + .mx_UserInfo_memberDetails { .mx_UserInfo_profileField { display: flex; @@ -184,10 +161,6 @@ limitations under the License. .mx_UserInfo_field { line-height: $font-16px; - - &.mx_UserInfo_destructive { - color: $alert; - } } .mx_UserInfo_statusMessage { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 46f0ba900f..66c60f5f15 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -1024,7 +1024,7 @@ $left-gutter: 64px; $notification-dot-size: 8px; /* notification dot next to the timestamp */ margin: calc(var(--topOffset) + $hrHeight) 0 var(--topOffset); /* include the height of horizontal line */ - padding: $padding $spacing-24 $padding $padding; + padding: $padding; border-radius: $borderRadius; display: flex; @@ -1039,7 +1039,7 @@ $left-gutter: 64px; &::after { $inset-block-start: auto; - $inset-inline-end: calc(32px - $padding); + $inset-inline-end: calc(-1 * var(--cpd-space-2x)); $inset-block-end: calc(-1 * var(--topOffset) - $hrHeight); /* exclude the height of horizontal line */ $inset-inline-start: calc(var(--leftOffset) + $padding); inset: $inset-block-start $inset-inline-end $inset-block-end $inset-inline-start; diff --git a/res/css/views/rooms/_PresenceLabel.pcss b/res/css/views/rooms/_PresenceLabel.pcss index 5be83c77d7..e775fb08ea 100644 --- a/res/css/views/rooms/_PresenceLabel.pcss +++ b/res/css/views/rooms/_PresenceLabel.pcss @@ -18,3 +18,7 @@ limitations under the License. font-size: $font-11px; opacity: 0.5; } + +.mx_PresenceLabel_online { + color: var(--cpd-color-text-success-primary); +} diff --git a/res/css/views/rooms/_SpaceScopeHeader.pcss b/res/css/views/rooms/_SpaceScopeHeader.pcss deleted file mode 100644 index 4a94793ba7..0000000000 --- a/res/css/views/rooms/_SpaceScopeHeader.pcss +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_SpaceScopeHeader { - text-align: center; - - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - overflow: hidden; - - .mx_BaseAvatar { - margin-right: var(--cpd-space-2x); - vertical-align: middle; - } -} diff --git a/res/img/element-icons/thread-summary.svg b/res/img/element-icons/thread-summary.svg deleted file mode 100644 index 2c4f0ead0c..0000000000 --- a/res/img/element-icons/thread-summary.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 326debc062..d398fb5967 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -14,7 +14,7 @@ $overlay-background: var(--cpd-color-alpha-gray-1300); $panels: var(--cpd-color-bg-subtle-secondary); $panel-actions: var(--cpd-color-alpha-gray-300); -$separator: var(--cpd-color-alpha-gray-400); +$separator: var(--cpd-color-gray-400); /* ******************** */ diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 7e14e85f10..c6840f5b90 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -105,7 +105,7 @@ $overlay-background: rgba($background, 0.85); $panels: rgba($system, 0.9); $panel-actions: $roomtile-selected-bg-color; -$separator: var(--cpd-color-alpha-gray-400); +$separator: var(--cpd-color-gray-400); /** * Creating a `semantic` color scale. This will not be needed with the new diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 5f9b8fd452..e40fbde72b 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -163,7 +163,7 @@ $overlay-background: rgba($background, 0.85); $panels: rgba($system, 0.9); $panel-actions: $roomtile-selected-bg-color; -$separator: var(--cpd-color-alpha-gray-400); +$separator: var(--cpd-color-gray-400); /* Legacy theme backports */ diff --git a/res/themes/light-custom/css/_custom.pcss b/res/themes/light-custom/css/_custom.pcss index 7fadb2cd0a..7912902645 100644 --- a/res/themes/light-custom/css/_custom.pcss +++ b/res/themes/light-custom/css/_custom.pcss @@ -18,7 +18,7 @@ $font-family: var(--font-family, $font-family); $monospace-font-family: var(--font-family-monospace, $monospace-font-family); /* Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741 */ -$accent: var(--accent, $accent); +$accent: var(--accent-color, $accent); $alert: var(--alert, $alert); $links: var(--links, $links); $primary-content: var(--primary-content, $primary-content); diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 730c115514..1a237427f2 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -32,7 +32,7 @@ $overlay-background: var(--cpd-color-alpha-gray-1300); $panels: var(--cpd-color-bg-subtle-secondary); $panel-actions: var(--cpd-color-alpha-gray-300); -$separator: var(--cpd-color-alpha-gray-400); +$separator: var(--cpd-color-gray-400); $accent: var(--cpd-color-text-action-accent); $alert: var(--cpd-color-text-critical-primary); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index c62733c0f0..e42e83d58d 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -224,6 +224,14 @@ declare global { readonly port: MessagePort; } + /** + * In future, browsers will support focusVisible option. + * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible + */ + interface FocusOptions { + focusVisible: boolean; + } + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 function registerProcessor( name: string, diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index d16ddedbde..99ad2e8caa 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -78,7 +78,10 @@ export class DecryptionFailureTracker { // Map JS-SDK error codes to tracker codes for aggregation switch (errorCode) { case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID: + case DecryptionFailureCode.MEGOLM_KEY_WITHHELD: return "OlmKeysNotSentError"; + case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: + return "RoomKeysWithheldForUnverifiedDevice"; case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX: return "OlmIndexError"; case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP: diff --git a/src/Keyboard.ts b/src/Keyboard.ts index 7b1ea4031b..33ce4e4e72 100644 --- a/src/Keyboard.ts +++ b/src/Keyboard.ts @@ -29,6 +29,7 @@ export const Key = { ARROW_DOWN: "ArrowDown", ARROW_LEFT: "ArrowLeft", ARROW_RIGHT: "ArrowRight", + F6: "F6", TAB: "Tab", ESCAPE: "Escape", ENTER: "Enter", @@ -77,6 +78,7 @@ export const Key = { }; export const IS_MAC = navigator.platform.toUpperCase().includes("MAC"); +export const IS_ELECTRON = window.electron; export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean { if (IS_MAC) { diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index c8ee1d5c74..145784fe0c 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -861,6 +861,12 @@ export default class LegacyCallHandler extends EventEmitter { public async placeCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise { const cli = MatrixClientPeg.safeGet(); + const room = cli.getRoom(roomId); + if (!room) { + logger.error(`Room ${roomId} does not exist.`); + return; + } + // Pause current broadcast, if any SdkContextClass.instance.voiceBroadcastPlaybacksStore.getCurrent()?.pause(); @@ -871,8 +877,8 @@ export default class LegacyCallHandler extends EventEmitter { } // We might be using managed hybrid widgets - if (isManagedHybridWidgetEnabled(roomId)) { - await addManagedHybridWidget(roomId); + if (isManagedHybridWidgetEnabled(room)) { + await addManagedHybridWidget(room); return; } @@ -902,12 +908,6 @@ export default class LegacyCallHandler extends EventEmitter { return; } - const room = cli.getRoom(roomId); - if (!room) { - logger.error(`Room ${roomId} does not exist.`); - return; - } - // We leave the check for whether there's already a call in this room until later, // otherwise it can race. diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index f851ddddf6..e2141cd94d 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -1056,6 +1056,7 @@ export async function onLoggedOut(): Promise { await clearStorage({ deleteEverything: true }); LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); await PlatformPeg.get()?.clearStorage(); + SettingsStore.reset(); // Do this last, so we can make sure all storage has been cleared and all // customisations got the memo. diff --git a/src/Modal.tsx b/src/Modal.tsx index b72180097c..b8dacdbc1e 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -22,9 +22,10 @@ import { IDeferred, defer, sleep } from "matrix-js-sdk/src/utils"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { Glass } from "@vector-im/compound-web"; -import dis from "./dispatcher/dispatcher"; +import dis, { defaultDispatcher } from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; import { Defaultize } from "./@types/common"; +import { ActionPayload } from "./dispatcher/payloads"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; @@ -114,6 +115,21 @@ export class ModalManager extends TypedEventEmitter { + if (payload.action === "logout") { + this.forceCloseAllModals(); + } + }; + public toggleCurrentDialogVisibility(): void { const modal = this.getCurrentModal(); if (!modal) return; diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 873aec08e2..c1c4e7a72b 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -25,7 +25,6 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from "./languageHandler"; import { isSecureBackupRequired } from "./utils/WellKnownUtils"; import AccessSecretStorageDialog, { KeyParams } from "./components/views/dialogs/security/AccessSecretStorageDialog"; -import RestoreKeyBackupDialog from "./components/views/dialogs/security/RestoreKeyBackupDialog"; import SettingsStore from "./settings/SettingsStore"; import { ModuleRunner } from "./modules/ModuleRunner"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; @@ -45,10 +44,6 @@ let dehydrationCache: { keyInfo?: SecretStorage.SecretStorageKeyDescription; } = {}; -function isCachingAllowed(): boolean { - return secretStorageBeingAccessed; -} - /** * This can be used by other components to check if secret storage access is in * progress, so that we can e.g. avoid intermittently showing toasts during @@ -116,14 +111,17 @@ async function getSecretStorageKey({ } [keyId, keyInfo] = keyInfoEntries[0]; } + logger.debug(`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}]: looking for key ${keyId}`); // Check the in-memory cache - if (isCachingAllowed() && secretStorageKeys[keyId]) { + if (secretStorageBeingAccessed && secretStorageKeys[keyId]) { + logger.debug(`getSecretStorageKey: returning key ${keyId} from cache`); return [keyId, secretStorageKeys[keyId]]; } if (dehydrationCache.key) { if (await MatrixClientPeg.safeGet().checkSecretStorageKey(dehydrationCache.key, keyInfo)) { + logger.debug("getSecretStorageKey: returning key from dehydration cache"); cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key); return [keyId, dehydrationCache.key]; } @@ -131,11 +129,12 @@ async function getSecretStorageKey({ const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey(); if (keyFromCustomisations) { - logger.log("CryptoSetupExtension: Using key from extension (secret storage)"); + logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension"); cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); return [keyId, keyFromCustomisations]; } + logger.debug("getSecretStorageKey: prompting user for key"); const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createDialog( AccessSecretStorageDialog, @@ -163,6 +162,7 @@ async function getSecretStorageKey({ if (!keyParams) { throw new AccessCancelledError(); } + logger.debug("getSecretStorageKey: got key from user"); const key = await inputToKey(keyParams); // Save to cache to avoid future prompts in the current session @@ -226,7 +226,7 @@ function cacheSecretStorageKey( keyInfo: SecretStorage.SecretStorageKeyDescription, key: Uint8Array, ): void { - if (isCachingAllowed()) { + if (secretStorageBeingAccessed) { secretStorageKeys[keyId] = key; secretStorageKeyInfo[keyId] = keyInfo; } @@ -278,26 +278,6 @@ export const crossSigningCallbacks: ICryptoCallbacks = { getDehydrationKey, }; -export async function promptForBackupPassphrase(): Promise { - let key!: Uint8Array; - - const { finished } = Modal.createDialog( - RestoreKeyBackupDialog, - { - showSummary: false, - keyCallback: (k: Uint8Array) => (key = k), - }, - undefined, - /* priority = */ false, - /* static = */ true, - ); - - const success = await finished; - if (!success) throw new Error("Key backup prompt cancelled"); - - return key; -} - /** * Carry out an operation that may require multiple accesses to secret storage, caching the key. * @@ -307,16 +287,16 @@ export async function promptForBackupPassphrase(): Promise { * @param func - The operation to be wrapped. */ export async function withSecretStorageKeyCache(func: () => Promise): Promise { + logger.debug("SecurityManager: enabling 4S key cache"); secretStorageBeingAccessed = true; try { return await func(); } finally { // Clear secret storage key cache now that work is complete + logger.debug("SecurityManager: disabling 4S key cache"); secretStorageBeingAccessed = false; - if (!isCachingAllowed()) { - secretStorageKeys = {}; - secretStorageKeyInfo = {}; - } + secretStorageKeys = {}; + secretStorageKeyInfo = {}; } } @@ -349,7 +329,21 @@ export async function accessSecretStorage(func = async (): Promise => {}, async function doAccessSecretStorage(func: () => Promise, forceReset: boolean): Promise { try { const cli = MatrixClientPeg.safeGet(); - if (!(await cli.hasSecretStorageKey()) || forceReset) { + const crypto = cli.getCrypto(); + if (!crypto) { + throw new Error("End-to-end encryption is disabled - unable to access secret storage."); + } + + let createNew = false; + if (forceReset) { + logger.debug("accessSecretStorage: resetting 4S"); + createNew = true; + } else if (!(await cli.secretStorage.hasKey())) { + logger.debug("accessSecretStorage: no 4S key configured, creating a new one"); + createNew = true; + } + + if (createNew) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createDialogAsync( @@ -377,13 +371,10 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool throw new Error("Secret storage creation canceled"); } } else { - const crypto = cli.getCrypto(); - if (!crypto) { - throw new Error("End-to-end encryption is disabled - unable to access secret storage."); - } - + logger.debug("accessSecretStorage: bootstrapCrossSigning"); await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest): Promise => { + logger.debug("accessSecretStorage: performing UIA to upload cross-signing keys"); const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("encryption|bootstrap_title"), matrixClient: cli, @@ -393,11 +384,11 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool if (!confirmed) { throw new Error("Cross-signing key upload auth canceled"); } + logger.debug("accessSecretStorage: Cross-signing key upload successful"); }, }); - await crypto.bootstrapSecretStorage({ - getKeyBackupPassphrase: promptForBackupPassphrase, - }); + logger.debug("accessSecretStorage: bootstrapSecretStorage"); + await crypto.bootstrapSecretStorage({}); const keyId = Object.keys(secretStorageKeys)[0]; if (keyId && SettingsStore.getValue("feature_dehydration")) { @@ -405,21 +396,23 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) { dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase }; } - logger.log("Setting dehydration key"); + logger.log("accessSecretStorage: Setting dehydration key"); await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); } else if (!keyId) { - logger.warn("Not setting dehydration key: no SSSS key found"); + logger.warn("accessSecretStorage: Not setting dehydration key: no SSSS key found"); } else { - logger.log("Not setting dehydration key: feature disabled"); + logger.log("accessSecretStorage: Not setting dehydration key: feature disabled"); } } + logger.debug("accessSecretStorage: 4S now ready"); // `return await` needed here to ensure `finally` block runs after the // inner operation completes. - return await func(); + await func(); + logger.debug("accessSecretStorage: operation complete"); } catch (e) { ModuleRunner.instance.extensions.cryptoSetup.catchAccessSecretStorageError(e as Error); - logger.error(e); + logger.error("accessSecretStorage: error during operation", e); // Re-throw so that higher level logic can abort as needed throw e; } diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 9a78f07df4..f5e18d205d 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -17,7 +17,7 @@ limitations under the License. */ import { _td, TranslationKey } from "../languageHandler"; -import { IS_MAC, Key } from "../Keyboard"; +import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard"; import { IBaseSetting } from "../settings/Settings"; import { KeyCombo } from "../KeyBindingsManager"; @@ -129,6 +129,10 @@ export enum KeyBindingAction { PreviousVisitedRoomOrSpace = "KeyBinding.PreviousVisitedRoomOrSpace", /** Navigates forward */ NextVisitedRoomOrSpace = "KeyBinding.NextVisitedRoomOrSpace", + /** Navigates to the next Landmark */ + NextLandmark = "KeyBinding.nextLandmark", + /** Navigates to the next Landmark */ + PreviousLandmark = "KeyBinding.previousLandmark", /** Toggles microphone while on a call */ ToggleMicInCall = "KeyBinding.toggleMicInCall", @@ -291,6 +295,8 @@ export const CATEGORIES: Record = { KeyBindingAction.SwitchToSpaceByNumber, KeyBindingAction.PreviousVisitedRoomOrSpace, KeyBindingAction.NextVisitedRoomOrSpace, + KeyBindingAction.NextLandmark, + KeyBindingAction.PreviousLandmark, ], }, [CategoryName.AUTOCOMPLETE]: { @@ -714,4 +720,19 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { key: Key.COMMA, }, }, + [KeyBindingAction.NextLandmark]: { + default: { + ctrlOrCmdKey: !IS_ELECTRON, + key: Key.F6, + }, + displayName: _td("keyboard|next_landmark"), + }, + [KeyBindingAction.PreviousLandmark]: { + default: { + ctrlOrCmdKey: !IS_ELECTRON, + key: Key.F6, + shiftKey: true, + }, + displayName: _td("keyboard|prev_landmark"), + }, }; diff --git a/src/accessibility/LandmarkNavigation.ts b/src/accessibility/LandmarkNavigation.ts new file mode 100644 index 0000000000..50ec478d2a --- /dev/null +++ b/src/accessibility/LandmarkNavigation.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TimelineRenderingType } from "../contexts/RoomContext"; +import { Action } from "../dispatcher/actions"; +import defaultDispatcher from "../dispatcher/dispatcher"; + +export const enum Landmark { + // This is the space/home button in the left panel. + ACTIVE_SPACE_BUTTON, + // This is the room filter in the left panel. + ROOM_SEARCH, + // This is the currently opened room/first room in the room list in the left panel. + ROOM_LIST, + // This is the message composer within the room if available or it is the welcome screen shown when no room is selected + MESSAGE_COMPOSER_OR_HOME, +} + +const ORDERED_LANDMARKS = [ + Landmark.ACTIVE_SPACE_BUTTON, + Landmark.ROOM_SEARCH, + Landmark.ROOM_LIST, + Landmark.MESSAGE_COMPOSER_OR_HOME, +]; + +/** + * The landmarks are cycled through in the following order: + * ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER/HOME <-> ACTIVE_SPACE_BUTTON + */ +export class LandmarkNavigation { + /** + * Get the next/previous landmark that must be focused from a given landmark + * @param currentLandmark The current landmark + * @param backwards If true, the landmark before currentLandmark in ORDERED_LANDMARKS is returned + * @returns The next landmark to focus + */ + private static getLandmark(currentLandmark: Landmark, backwards = false): Landmark { + const currentIndex = ORDERED_LANDMARKS.findIndex((l) => l === currentLandmark); + const offset = backwards ? -1 : 1; + const newLandmark = ORDERED_LANDMARKS.at((currentIndex + offset) % ORDERED_LANDMARKS.length)!; + return newLandmark; + } + + /** + * Focus the next landmark from a given landmark. + * This method will skip over any missing landmarks. + * @param currentLandmark The current landmark + * @param backwards If true, search the next landmark to the left in ORDERED_LANDMARKS + */ + public static findAndFocusNextLandmark(currentLandmark: Landmark, backwards = false): void { + let landmark = currentLandmark; + let element: HTMLElement | null | undefined = null; + while (element === null) { + landmark = LandmarkNavigation.getLandmark(landmark, backwards); + element = landmarkToDomElementMap[landmark](); + } + element?.focus({ focusVisible: true }); + } +} + +/** + * The functions return: + * - The DOM element of the landmark if it exists + * - undefined if the DOM element exists but focus is given through an action + * - null if the landmark does not exist + */ +const landmarkToDomElementMap: Record HTMLElement | null | undefined> = { + [Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector(".mx_SpaceButton_active"), + + [Landmark.ROOM_SEARCH]: () => document.querySelector(".mx_RoomSearch"), + [Landmark.ROOM_LIST]: () => + document.querySelector(".mx_RoomTile_selected") || + document.querySelector(".mx_RoomTile"), + + [Landmark.MESSAGE_COMPOSER_OR_HOME]: () => { + const isComposerOpen = !!document.querySelector(".mx_MessageComposer"); + if (isComposerOpen) { + const inThread = !!document.activeElement?.closest(".mx_ThreadView"); + defaultDispatcher.dispatch( + { + action: Action.FocusSendMessageComposer, + context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room, + }, + true, + ); + // Special case where the element does exist but we focus it through an action. + return undefined; + } else { + return document.querySelector(".mx_HomePage"); + } + }, +}; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 0316c43994..97469177d9 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -26,7 +26,6 @@ import { BackupTrustInfo, GeneratedSecretStorageKey, KeyBackupInfo } from "matri import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { _t, _td } from "../../../../languageHandler"; import Modal from "../../../../Modal"; -import { promptForBackupPassphrase } from "../../../../SecurityManager"; import { copyNode } from "../../../../utils/strings"; import { SSOAuthEntry } from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; @@ -123,7 +122,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent(); private passphraseField = createRef(); @@ -384,15 +382,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, keyBackupInfo: this.state.backupInfo!, setupNewKeyBackup: !this.state.backupInfo, - getKeyBackupPassphrase: async (): Promise => { - // We may already have the backup key if we earlier went - // through the restore backup path, so pass it along - // rather than prompting again. - if (this.backupKey) { - return this.backupKey; - } - return promptForBackupPassphrase(); - }, }); } await initialiseDehydration(true); @@ -424,11 +413,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent => { - // It's possible we'll need the backup key later on for bootstrapping, - // so let's stash it here, rather than prompting for it twice. - const keyCallback = (k: Uint8Array): void => { - this.backupKey = k; - }; + const keyCallback = (k: Uint8Array): void => {}; const { finished } = Modal.createDialog( RestoreKeyBackupDialog, diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 3836863431..c4bf2c3ff7 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -28,6 +28,7 @@ import { TimelineWindow, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { Icon as FilesIcon } from "@vector-im/compound-design-tokens/icons/files.svg"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import EventIndexPeg from "../../indexing/EventIndexPeg"; @@ -40,6 +41,7 @@ import Spinner from "../views/elements/Spinner"; import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; +import EmptyState from "../views/right_panel/EmptyState"; interface IProps { roomId: string; @@ -255,10 +257,11 @@ class FilePanel extends React.Component { // wrap a TimelinePanel with the jump-to-event bits turned off. const emptyState = ( -
-

{_t("file_panel|empty_heading")}

-

{_t("file_panel|empty_description")}

-
+ ); const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.safeGet().isRoomEncrypted(this.props.roomId); diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 084afdaf8b..7ef3bc4ba3 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -44,6 +44,7 @@ import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButto import PosthogTrackers from "../../PosthogTrackers"; import PageType from "../../PageTypes"; import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton"; +import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; interface IProps { isMinimized: boolean; @@ -308,6 +309,16 @@ export default class LeftPanel extends React.Component { } break; } + + const navAction = getKeyBindingsManager().getNavigationAction(ev); + if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) { + ev.stopPropagation(); + ev.preventDefault(); + LandmarkNavigation.findAndFocusNextLandmark( + Landmark.ROOM_SEARCH, + navAction === KeyBindingAction.PreviousLandmark, + ); + } }; private renderBreadcrumbs(): React.ReactNode { diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 7687d0f3ea..755d2c1156 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -75,6 +75,7 @@ import { PipContainer } from "./PipContainer"; import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules"; import { ConfigOptions } from "../../SdkConfig"; import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; +import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -470,6 +471,14 @@ class LoggedInView extends React.Component { const navAction = getKeyBindingsManager().getNavigationAction(ev); switch (navAction) { + case KeyBindingAction.NextLandmark: + case KeyBindingAction.PreviousLandmark: + LandmarkNavigation.findAndFocusNextLandmark( + Landmark.MESSAGE_COMPOSER_OR_HOME, + navAction === KeyBindingAction.PreviousLandmark, + ); + handled = true; + break; case KeyBindingAction.FilterRooms: dis.dispatch({ action: "focus_room_filter", diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 70c4f9ce5f..f9d6db3f53 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -551,7 +551,10 @@ export default class MatrixChat extends React.PureComponent { .then((loadedSession) => { if (!loadedSession) { // fall back to showing the welcome screen... unless we have a 3pid invite pending - if (ThreepidInviteStore.instance.pickBestInvite()) { + if ( + ThreepidInviteStore.instance.pickBestInvite() && + SettingsStore.getValue(UIFeature.Registration) + ) { dis.dispatch({ action: "start_registration" }); } else { dis.dispatch({ action: "view_welcome_page" }); @@ -951,6 +954,11 @@ export default class MatrixChat extends React.PureComponent { } private async startRegistration(params: { [key: string]: string }): Promise { + if (!SettingsStore.getValue(UIFeature.Registration)) { + this.showScreen("welcome"); + return; + } + const newState: Partial = { view: Views.REGISTER, }; diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx index 0da27a19b1..9c56da9609 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications.svg"; import { _t } from "../../languageHandler"; import { MatrixClientPeg } from "../../MatrixClientPeg"; @@ -26,6 +27,7 @@ import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; import Heading from "../views/typography/Heading"; +import EmptyState from "../views/right_panel/EmptyState"; interface IProps { onClose(): void; @@ -57,10 +59,11 @@ export default class NotificationPanel extends React.PureComponent -

{_t("notif_panel|empty_heading")}

-

{_t("notif_panel|empty_description")}

- + ); let content: JSX.Element; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 1fca77c27e..547d185bf1 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -19,6 +19,7 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix"; import { IconButton, Tooltip } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/src/logger"; +import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads.svg"; import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg"; import BaseCard from "../views/right_panel/BaseCard"; @@ -35,8 +36,8 @@ import Measured from "../views/elements/Measured"; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; -import Heading from "../views/typography/Heading"; import { clearRoomNotification } from "../../utils/notifications"; +import EmptyState from "../views/right_panel/EmptyState"; interface IProps { roomId: string; @@ -73,8 +74,7 @@ export const ThreadPanelHeaderFilterOptionItem: React.FC< export const ThreadPanelHeader: React.FC<{ filterOption: ThreadFilterType; setFilterOption: (filterOption: ThreadFilterType) => void; - empty: boolean; -}> = ({ filterOption, setFilterOption, empty }) => { +}> = ({ filterOption, setFilterOption }) => { const mxClient = useMatrixClientContext(); const roomContext = useRoomContext(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -137,89 +137,24 @@ export const ThreadPanelHeader: React.FC<{ return (
- - {_t("common|threads")} - - {!empty && ( - <> - - - - - -
- { - openMenu(); - PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev); - }} - > - {`${_t("threads|show_thread_filter")} ${value?.label}`} - - {contextMenu} - - )} -
- ); -}; - -interface EmptyThreadIProps { - hasThreads: boolean; - filterOption: ThreadFilterType; - showAllThreadsCallback: () => void; -} - -const EmptyThread: React.FC = ({ hasThreads, filterOption, showAllThreadsCallback }) => { - let body: JSX.Element; - if (hasThreads) { - body = ( - <> -

- {_t("threads|empty_has_threads_tip", { - replyInThread: _t("action|reply_in_thread"), - })} -

-

- {/* Always display that paragraph to prevent layout shift when hiding the button */} - {filterOption === ThreadFilterType.My ? ( - - ) : ( - <>  - )} -

- - ); - } else { - body = ( - <> -

{_t("threads|empty_explainer")}

-

- {_t( - "threads|empty_tip", - { - replyInThread: _t("action|reply_in_thread"), - }, - { - b: (sub) => {sub}, - }, - )} -

- - ); - } - - return ( -
-
-

{_t("threads|empty_heading")}

- {body} + + + + + +
+ { + openMenu(); + PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev); + }} + > + {`${_t("threads|show_thread_filter")} ${value?.label}`} + + {contextMenu}
); }; @@ -229,7 +164,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => const roomContext = useContext(RoomContext); const timelinePanel = useRef(null); const card = useRef(null); - const closeButonRef = useRef(null); + const closeButonRef = useRef(null); const [filterOption, setFilterOption] = useState(ThreadFilterType.All); const [room, setRoom] = useState(null); @@ -268,11 +203,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => + hasThreads && } id="thread-panel" className="mx_ThreadPanel" @@ -295,10 +226,12 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => timelineSet={timelineSet} showUrlPreview={false} // No URL previews at the threads list level empty={ - setFilterOption(ThreadFilterType.All)} + } alwaysShowTimestamps={true} diff --git a/src/components/views/messages/DecryptionFailureBody.tsx b/src/components/views/messages/DecryptionFailureBody.tsx index 718fa492e2..7789e2d1da 100644 --- a/src/components/views/messages/DecryptionFailureBody.tsx +++ b/src/components/views/messages/DecryptionFailureBody.tsx @@ -23,8 +23,10 @@ import { IBodyProps } from "./IBodyProps"; import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string { - if (mxEvent.isEncryptedDisabledForUnverifiedDevices) return _t("timeline|decryption_failure|blocked"); switch (mxEvent.decryptionFailureReason) { + case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: + return _t("timeline|decryption_failure|blocked"); + case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP: return _t("timeline|decryption_failure|historical_event_no_key_backup"); diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index bb07426a11..d8b0c1b71b 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { forwardRef, ReactNode, KeyboardEvent, Ref } from "react"; +import React, { forwardRef, ReactNode, KeyboardEvent, Ref, MouseEvent } from "react"; import classNames from "classnames"; +import { IconButton, Text } from "@vector-im/compound-web"; +import { Icon as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg"; +import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { _t } from "../../../languageHandler"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { backLabelForPhase } from "../../../stores/right-panel/RightPanelStorePhases"; import { CardContext } from "./context"; @@ -34,13 +36,13 @@ interface IProps { ariaLabelledBy?: string; withoutScrollContainer?: boolean; closeLabel?: string; - onClose?(ev: ButtonEvent): void; - onBack?(ev: ButtonEvent): void; + onClose?(ev: MouseEvent): void; + onBack?(ev: MouseEvent): void; onKeyDown?(ev: KeyboardEvent): void; cardState?: any; ref?: Ref; // Ref for the 'close' button the the card - closeButtonRef?: Ref; + closeButtonRef?: Ref; children: ReactNode; } @@ -81,26 +83,39 @@ const BaseCard: React.FC = forwardRef( ) => { let backButton; const cardHistory = RightPanelStore.instance.roomPhaseHistory; - if (cardHistory.length > 1) { + if (cardHistory.length > 1 && !hideHeaderButtons) { const prevCard = cardHistory[cardHistory.length - 2]; - const onBackClick = (ev: ButtonEvent): void => { + const onBackClick = (ev: MouseEvent): void => { onBack?.(ev); RightPanelStore.instance.popCard(); }; const label = backLabelForPhase(prevCard.phase) ?? _t("action|back"); - backButton = ; + backButton = ( + + + + ); } let closeButton; - if (onClose) { + if (onClose && !hideHeaderButtons) { closeButton = ( - + tooltip={closeLabel ?? _t("action|close")} + subtleBackground + > + + ); } @@ -108,16 +123,6 @@ const BaseCard: React.FC = forwardRef( children = {children}; } - let headerButtons: React.ReactElement | undefined; - if (!hideHeaderButtons) { - headerButtons = ( - <> - {backButton} - {closeButton} - - ); - } - const shouldRenderHeader = header || !hideHeaderButtons; return ( @@ -132,8 +137,15 @@ const BaseCard: React.FC = forwardRef( > {shouldRenderHeader && (
- {headerButtons} -
{header}
+ {backButton} + {typeof header === "string" ? ( + + {header} + + ) : ( + header ??
+ )} + {closeButton}
)} {children} diff --git a/src/components/views/right_panel/EmptyState.tsx b/src/components/views/right_panel/EmptyState.tsx new file mode 100644 index 0000000000..7189cb8b3a --- /dev/null +++ b/src/components/views/right_panel/EmptyState.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ComponentType } from "react"; +import { Text } from "@vector-im/compound-web"; + +import { Flex } from "../../utils/Flex"; + +interface Props { + Icon: ComponentType>; + title: string; + description: string; +} + +const EmptyState: React.FC = ({ Icon, title, description }) => { + return ( + + + + {title} + + + {description} + + + ); +}; + +export default EmptyState; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 5f9830f5d6..1f9843d708 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -34,6 +34,18 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { Heading, MenuItem, Text } from "@vector-im/compound-web"; +import { Icon as ChatIcon } from "@vector-im/compound-design-tokens/icons/chat.svg"; +import { Icon as CheckIcon } from "@vector-im/compound-design-tokens/icons/check.svg"; +import { Icon as ShareIcon } from "@vector-im/compound-design-tokens/icons/share.svg"; +import { Icon as MentionIcon } from "@vector-im/compound-design-tokens/icons/mention.svg"; +import { Icon as InviteIcon } from "@vector-im/compound-design-tokens/icons/user-add.svg"; +import { Icon as BlockIcon } from "@vector-im/compound-design-tokens/icons/block.svg"; +import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg"; +import { Icon as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg"; +import { Icon as ChatProblemIcon } from "@vector-im/compound-design-tokens/icons/chat-problem.svg"; +import { Icon as VisibilityOffIcon } from "@vector-im/compound-design-tokens/icons/visibility-off.svg"; +import { Icon as LeaveIcon } from "@vector-im/compound-design-tokens/icons/leave.svg"; import dis from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; @@ -79,8 +91,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { asyncSome } from "../../../utils/arrays"; -import UIStore from "../../../stores/UIStore"; -import { createSpaceScopeHeader } from "../rooms/SpaceScopeHeader"; +import { Flex } from "../../utils/Flex"; +import CopyableText from "../elements/CopyableText"; export interface IDevice extends Device { ambiguous?: boolean; @@ -392,31 +404,29 @@ const MessageButton = ({ member }: { member: Member }): JSX.Element => { const [busy, setBusy] = useState(false); return ( - { + { + ev.preventDefault(); if (busy) return; setBusy(true); await openDmForUser(cli, member); setBusy(false); }} - className="mx_UserInfo_field" disabled={busy} - > - {_t("common|message")} - + label={_t("user_info|send_message")} + Icon={ChatIcon} + /> ); }; export const UserOptionsSection: React.FC<{ member: Member; - isIgnored: boolean; canInvite: boolean; isSpace?: boolean; -}> = ({ member, isIgnored, canInvite, isSpace }) => { +}> = ({ member, canInvite, isSpace, children }) => { const cli = useContext(MatrixClientContext); - let ignoreButton: JSX.Element | undefined; let insertPillButton: JSX.Element | undefined; let inviteUserButton: JSX.Element | undefined; let readReceiptButton: JSX.Element | undefined; @@ -428,42 +438,9 @@ export const UserOptionsSection: React.FC<{ }); }; - const unignore = useCallback(() => { - const ignoredUsers = cli.getIgnoredUsers(); - const index = ignoredUsers.indexOf(member.userId); - if (index !== -1) ignoredUsers.splice(index, 1); - cli.setIgnoredUsers(ignoredUsers); - }, [cli, member]); - - const ignore = useCallback(async () => { - const name = (member instanceof User ? member.displayName : member.name) || member.userId; - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("user_info|ignore_confirm_title", { user: name }), - description:
{_t("user_info|ignore_confirm_description")}
, - button: _t("action|ignore"), - }); - const [confirmed] = await finished; - - if (confirmed) { - const ignoredUsers = cli.getIgnoredUsers(); - ignoredUsers.push(member.userId); - cli.setIgnoredUsers(ignoredUsers); - } - }, [cli, member]); - // Only allow the user to ignore the user if its not ourselves // same goes for jumping to read receipt if (!isMe) { - ignoreButton = ( - - {isIgnored ? _t("action|unignore") : _t("action|ignore")} - - ); - if (member instanceof RoomMember && member.roomId && !isSpace) { const onReadReceiptButton = function (): void { const room = cli.getRoom(member.roomId); @@ -488,16 +465,28 @@ export const UserOptionsSection: React.FC<{ const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : undefined; if (room?.getEventReadUpTo(member.userId)) { readReceiptButton = ( - - {_t("user_info|jump_to_rr_button")} - + { + ev.preventDefault(); + onReadReceiptButton(); + }} + label={_t("user_info|jump_to_rr_button")} + Icon={CheckIcon} + /> ); } insertPillButton = ( - - {_t("action|mention")} - + { + ev.preventDefault(); + onInsertPillButton(); + }} + label={_t("action|mention")} + Icon={MentionIcon} + /> ); } @@ -508,7 +497,7 @@ export const UserOptionsSection: React.FC<{ shouldShowComponent(UIComponent.InviteUsers) ) { const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); - const onInviteUserButton = async (ev: ButtonEvent): Promise => { + const onInviteUserButton = async (ev: Event): Promise => { try { // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. const inviter = new MultiInviter(cli, roomId || ""); @@ -539,34 +528,43 @@ export const UserOptionsSection: React.FC<{ }; inviteUserButton = ( - - {_t("action|invite")} - + { + ev.preventDefault(); + onInviteUserButton(ev); + }} + label={_t("action|invite")} + Icon={InviteIcon} + /> ); } } const shareUserButton = ( - - {_t("user_info|share_button")} - + { + ev.preventDefault(); + onShareUserClick(); + }} + label={_t("user_info|share_button")} + Icon={ShareIcon} + /> ); const directMessageButton = isMe || !shouldShowComponent(UIComponent.CreateRooms) ? null : ; return ( -
-

{_t("common|options")}

-
- {directMessageButton} - {readReceiptButton} - {shareUserButton} - {insertPillButton} - {inviteUserButton} - {ignoreButton} -
-
+ + {children} + {directMessageButton} + {inviteUserButton} + {readReceiptButton} + {shareUserButton} + {insertPillButton} + ); }; @@ -587,15 +585,10 @@ export const warnSelfDemote = async (isSpace: boolean): Promise => { return !!confirmed; }; -const GenericAdminToolsContainer: React.FC<{ +const Container: React.FC<{ children: ReactNode; }> = ({ children }) => { - return ( -
-

{_t("user_info|admin_tools_section")}

-
{children}
-
- ); + return
{children}
; }; interface IPowerLevelsContent { @@ -757,14 +750,17 @@ export const RoomKickButton = ({ : _t("user_info|kick_button_room"); return ( - { + ev.preventDefault(); + onKick(); + }} disabled={isUpdating} - > - {kickLabel} - + label={kickLabel} + kind="critical" + Icon={LeaveIcon} + /> ); }; @@ -783,13 +779,16 @@ const RedactMessagesButton: React.FC = ({ member }) => { }; return ( - - {_t("user_info|redact_button")} - + { + ev.preventDefault(); + onRedactAllMessages(); + }} + label={_t("user_info|redact_button")} + kind="critical" + Icon={CloseIcon} + /> ); }; @@ -905,14 +904,18 @@ export const BanToggleButton = ({ label = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room"); } - const classes = classNames("mx_UserInfo_field", { - mx_UserInfo_destructive: !isBanned, - }); - return ( - - {label} - + { + ev.preventDefault(); + onBanOrUnban(); + }} + disabled={isUpdating} + label={label} + kind="critical" + Icon={ChatProblemIcon} + /> ); }; @@ -982,15 +985,81 @@ const MuteToggleButton: React.FC = ({ }); }; - const classes = classNames("mx_UserInfo_field", { - mx_UserInfo_destructive: !muted, - }); - const muteLabel = muted ? _t("common|unmute") : _t("common|mute"); return ( - - {muteLabel} - + { + ev.preventDefault(); + onMuteToggle(); + }} + disabled={isUpdating} + label={muteLabel} + kind="critical" + Icon={VisibilityOffIcon} + /> + ); +}; + +const IgnoreToggleButton: React.FC<{ + member: User | RoomMember; +}> = ({ member }) => { + const cli = useContext(MatrixClientContext); + const unignore = useCallback(() => { + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + cli.setIgnoredUsers(ignoredUsers); + }, [cli, member]); + + const ignore = useCallback(async () => { + const name = (member instanceof User ? member.displayName : member.name) || member.userId; + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|ignore_confirm_title", { user: name }), + description:
{_t("user_info|ignore_confirm_description")}
, + button: _t("action|ignore"), + }); + const [confirmed] = await finished; + + if (confirmed) { + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(member.userId); + cli.setIgnoredUsers(ignoredUsers); + } + }, [cli, member]); + + // Check whether the user is ignored + const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId)); + // Recheck if the user or client changes + useEffect(() => { + setIsIgnored(cli.isUserIgnored(member.userId)); + }, [cli, member.userId]); + // Recheck also if we receive new accountData m.ignored_user_list + const accountDataHandler = useCallback( + (ev) => { + if (ev.getType() === "m.ignored_user_list") { + setIsIgnored(cli.isUserIgnored(member.userId)); + } + }, + [cli, member.userId], + ); + useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); + + return ( + { + ev.preventDefault(); + if (isIgnored) { + unignore(); + } else { + ignore(); + } + }} + label={isIgnored ? _t("user_info|unignore_button") : _t("user_info|ignore_button")} + kind="critical" + Icon={BlockIcon} + /> ); }; @@ -1071,13 +1140,13 @@ export const RoomAdminToolsContainer: React.FC = ({ if (kickButton || banButton || muteButton || redactButton || children) { return ( - + {muteButton} + {redactButton} {kickButton} {banButton} - {redactButton} {children} - + ); } @@ -1353,23 +1422,6 @@ const BasicUserInfo: React.FC<{ // Load whether or not we are a Synapse Admin const isSynapseAdmin = useIsSynapseAdmin(cli); - // Check whether the user is ignored - const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId)); - // Recheck if the user or client changes - useEffect(() => { - setIsIgnored(cli.isUserIgnored(member.userId)); - }, [cli, member.userId]); - // Recheck also if we receive new accountData m.ignored_user_list - const accountDataHandler = useCallback( - (ev) => { - if (ev.getType() === "m.ignored_user_list") { - setIsIgnored(cli.isUserIgnored(member.userId)); - } - }, - [cli, member.userId], - ); - useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); - // Count of how many operations are currently in progress, if > 0 then show a Spinner const [pendingUpdateCount, setPendingUpdateCount] = useState(0); const startUpdating = useCallback(() => { @@ -1413,13 +1465,16 @@ const BasicUserInfo: React.FC<{ // someone does figure out how to bypass this check the worst that happens is an error. if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) { synapseDeactivateButton = ( - - {_t("user_info|deactivate_confirm_action")} - + { + ev.preventDefault(); + onSynapseDeactivate(); + }} + label={_t("user_info|deactivate_confirm_action")} + kind="critical" + Icon={DeleteIcon} + /> ); } @@ -1429,23 +1484,12 @@ const BasicUserInfo: React.FC<{ // hide the Roles section for DMs as it doesn't make sense there if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) { memberDetails = ( -
-

- {_t( - "user_info|role_label", - {}, - { - RoomName: () => {room.name}, - }, - )} -

- -
+ ); } @@ -1462,7 +1506,7 @@ const BasicUserInfo: React.FC<{ ); } else if (synapseDeactivateButton) { - adminToolsContainer = {synapseDeactivateButton}; + adminToolsContainer = {synapseDeactivateButton}; } if (pendingUpdateCount > 0) { @@ -1560,8 +1604,8 @@ const BasicUserInfo: React.FC<{ } const securitySection = ( -
-

{_t("common|security")}

+ +

{_t("common|security")}

{text}

{verifyButton} {cryptoEnabled && ( @@ -1573,23 +1617,29 @@ const BasicUserInfo: React.FC<{ /> )} {editDevices} -
+ ); return ( - {memberDetails} - {securitySection} + + > + {memberDetails} + {adminToolsContainer} + {!isMe && ( + + + + )} + {spinner} ); @@ -1622,24 +1672,6 @@ export const UserInfoHeader: React.FC<{ const avatarUrl = (member as User).avatarUrl; - const avatarElement = ( -
-
-
- -
-
-
- ); - let presenceState: string | undefined; let presenceLastActiveAgo: number | undefined; let presenceCurrentlyActive: boolean | undefined; @@ -1662,36 +1694,52 @@ export const UserInfoHeader: React.FC<{ activeAgo={presenceLastActiveAgo} currentlyActive={presenceCurrentlyActive} presenceState={presenceState} + className="mx_UserInfo_profileStatus" + coloured /> ); } const e2eIcon = e2eStatus ? : null; - + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { + roomId, + withDisplayName: true, + }); const displayName = (member as RoomMember).rawDisplayName; return ( - {avatarElement} - -
-
-
-

- - {displayName} - - {e2eIcon} -

+
+
+
+
-
- {UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { - roomId, - withDisplayName: true, - })} -
-
{presenceLabel}
+ + + + + + {displayName} + {e2eIcon} + + + {presenceLabel} + + userIdentifier} border={false}> + {userIdentifier} + + + + ); }; @@ -1778,7 +1826,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha return ( } } + const navAction = getKeyBindingsManager().getNavigationAction(event); + + if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) { + LandmarkNavigation.findAndFocusNextLandmark( + Landmark.MESSAGE_COMPOSER_OR_HOME, + navAction === KeyBindingAction.PreviousLandmark, + ); + handled = true; + } + const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(event); if (model.autoComplete?.hasCompletions()) { diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index 130daf50bd..dea4dd58d3 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -55,7 +55,6 @@ import { SDKContext } from "../../../contexts/SDKContext"; import { canInviteTo } from "../../../utils/room/canInviteTo"; import { inviteToRoom } from "../../../utils/room/inviteToRoom"; import { Action } from "../../../dispatcher/actions"; -import { createSpaceScopeHeader } from "./SpaceScopeHeader"; const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_INVITED = 5; @@ -429,7 +428,6 @@ export default class MemberList extends React.Component { className="mx_MemberList" ariaLabelledBy="memberlist-panel-tab" role="tabpanel" - header={createSpaceScopeHeader(room)} hideHeaderButtons={this.props.hideHeaderButtons} footer={footer} onClose={this.props.onClose} diff --git a/src/components/views/rooms/PresenceLabel.tsx b/src/components/views/rooms/PresenceLabel.tsx index 24e144c8ef..bdbc7e23e2 100644 --- a/src/components/views/rooms/PresenceLabel.tsx +++ b/src/components/views/rooms/PresenceLabel.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; +import classNames from "classnames"; import { _t } from "../../../languageHandler"; import { formatDuration } from "../../../DateUtils"; @@ -31,6 +32,9 @@ interface IProps { currentlyActive?: boolean; // offline, online, etc presenceState?: string; + // whether to apply colouring to the label + coloured?: boolean; + className?: string; } export default class PresenceLabel extends React.Component { @@ -62,7 +66,11 @@ export default class PresenceLabel extends React.Component { public render(): React.ReactNode { return ( -
+
{this.getPrettyPresence(this.props.presenceState, this.props.activeAgo, this.props.currentlyActive)}
); diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 19b368cd18..c7b1673166 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -57,6 +57,9 @@ import { isVideoRoom } from "../../../utils/video-rooms"; import { notificationLevelToIndicator } from "../../../utils/notifications"; import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; +import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnouncementOpen"; +import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore"; export default function RoomHeader({ room, @@ -238,74 +241,87 @@ export default function RoomHeader({ voiceCallButton = undefined; } + const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("newRoomHeader"); + return ( <> - + {roomTopic && ( + + {roomTopicBody} + + )} + + + {additionalButtons?.map((props) => { const label = props.label(); diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index c277bd5aca..d088fbc927 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -60,7 +60,10 @@ import IconizedContextMenu, { import ExtraTile from "./ExtraTile"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import AccessibleButton from "../elements/AccessibleButton"; +import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -652,7 +655,22 @@ export default class RoomList extends React.PureComponent {
{ + const navAction = getKeyBindingsManager().getNavigationAction(ev); + if ( + navAction === KeyBindingAction.NextLandmark || + navAction === KeyBindingAction.PreviousLandmark + ) { + LandmarkNavigation.findAndFocusNextLandmark( + Landmark.ROOM_LIST, + navAction === KeyBindingAction.PreviousLandmark, + ); + ev.stopPropagation(); + ev.preventDefault(); + return; + } + onKeyDownHandler(ev); + }} className="mx_RoomList" role="tree" aria-label={_t("common|rooms")} diff --git a/src/components/views/rooms/SpaceScopeHeader.tsx b/src/components/views/rooms/SpaceScopeHeader.tsx deleted file mode 100644 index b9f53b7b8a..0000000000 --- a/src/components/views/rooms/SpaceScopeHeader.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; -import { Text } from "@vector-im/compound-web"; - -import RoomAvatar from "../avatars/RoomAvatar"; -import { useRoomName } from "../../../hooks/useRoomName"; - -/** - * Returns a space scope header if needed - * @param room The room object - * @returns rendered component if the room is a space room, otherwise returns null - */ -export function createSpaceScopeHeader(room?: Room | null): React.JSX.Element | null { - if (room?.isSpaceRoom()) return ; - else return null; -} - -/** - * Scope header used to decorate right panels that are scoped to a space. - * It renders room avatar and name. - */ -export const SpaceScopeHeader: React.FC<{ room: Room }> = ({ room }) => { - const roomName = useRoomName(room); - - return ( - - - {roomName} - - ); -}; diff --git a/src/components/views/rooms/ThirdPartyMemberInfo.tsx b/src/components/views/rooms/ThirdPartyMemberInfo.tsx index c2b772e78f..b510d5e002 100644 --- a/src/components/views/rooms/ThirdPartyMemberInfo.tsx +++ b/src/components/views/rooms/ThirdPartyMemberInfo.tsx @@ -28,7 +28,6 @@ import { Action } from "../../../dispatcher/actions"; import ErrorDialog from "../dialogs/ErrorDialog"; import BaseCard from "../right_panel/BaseCard"; import { Flex } from "../../utils/Flex"; -import { createSpaceScopeHeader } from "./SpaceScopeHeader"; interface IProps { event: MatrixEvent; @@ -134,7 +133,7 @@ export default class ThirdPartyMemberInfo extends React.Component + {/* same as userinfo name style */} diff --git a/src/components/views/rooms/WhoIsTypingTile.tsx b/src/components/views/rooms/WhoIsTypingTile.tsx index 1b5636cd15..2bc6337945 100644 --- a/src/components/views/rooms/WhoIsTypingTile.tsx +++ b/src/components/views/rooms/WhoIsTypingTile.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from "react"; import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { compare } from "matrix-js-sdk/src/utils"; import * as WhoIsTyping from "../../../WhoIsTyping"; import Timer from "../../../utils/Timer"; @@ -208,7 +207,8 @@ export default class WhoIsTypingTile extends React.Component { // sort them so the typing members don't change order when // moved to delayedStopTypingTimers - usersTyping.sort((a, b) => compare(a.name, b.name)); + const collator = new Intl.Collator(); + usersTyping.sort((a, b) => collator.compare(a.name, b.name)); const typingString = WhoIsTyping.whoIsTypingString(usersTyping, this.props.whoIsTypingLimit); if (!typingString) { diff --git a/src/components/views/settings/E2eAdvancedPanel.tsx b/src/components/views/settings/E2eAdvancedPanel.tsx deleted file mode 100644 index f63cbefb22..0000000000 --- a/src/components/views/settings/E2eAdvancedPanel.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; - -import { _t } from "../../../languageHandler"; -import { SettingLevel } from "../../../settings/SettingLevel"; -import SettingsStore from "../../../settings/SettingsStore"; -import SettingsFlag from "../elements/SettingsFlag"; -import SettingsSubsection, { SettingsSubsectionText } from "./shared/SettingsSubsection"; - -const SETTING_MANUALLY_VERIFY_ALL_SESSIONS = "e2ee.manuallyVerifyAllSessions"; - -const E2eAdvancedPanel: React.FC = () => { - return ( - - - - {_t("settings|security|encryption_individual_verification_mode")} - - - ); -}; - -export default E2eAdvancedPanel; - -export function isE2eAdvancedPanelPossible(): boolean { - return SettingsStore.canSetValue(SETTING_MANUALLY_VERIFY_ALL_SESSIONS, null, SettingLevel.DEVICE); -} diff --git a/src/components/views/settings/PowerLevelSelector.tsx b/src/components/views/settings/PowerLevelSelector.tsx index 5d823c885d..dcb1590c07 100644 --- a/src/components/views/settings/PowerLevelSelector.tsx +++ b/src/components/views/settings/PowerLevelSelector.tsx @@ -18,7 +18,6 @@ import React, { useState, JSX, PropsWithChildren } from "react"; import { Button } from "@vector-im/compound-web"; -import { compare } from "matrix-js-sdk/src/utils"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import PowerSelector from "../elements/PowerSelector"; @@ -78,9 +77,11 @@ export function PowerLevelSelector({ currentPowerLevel && currentPowerLevel.value !== userLevels[currentPowerLevel?.userId], ); + const collator = new Intl.Collator(); + // We sort the users by power level, then we filter them const users = Object.keys(userLevels) - .sort((userA, userB) => sortUser(userA, userB, userLevels)) + .sort((userA, userB) => sortUser(collator, userA, userB, userLevels)) .filter(filter); // No user to display, we return the children into fragment to convert it to JSX.Element type @@ -136,7 +137,14 @@ export function PowerLevelSelector({ * @param userB * @param userLevels */ -function sortUser(userA: string, userB: string, userLevels: PowerLevelSelectorProps["userLevels"]): number { +function sortUser( + collator: Intl.Collator, + userA: string, + userB: string, + userLevels: PowerLevelSelectorProps["userLevels"], +): number { const powerLevelDiff = userLevels[userA] - userLevels[userB]; - return powerLevelDiff !== 0 ? powerLevelDiff : compare(userA.toLocaleLowerCase(), userB.toLocaleLowerCase()); + return powerLevelDiff !== 0 + ? powerLevelDiff + : collator.compare(userA.toLocaleLowerCase(), userB.toLocaleLowerCase()); } diff --git a/src/components/views/settings/SetIntegrationManager.tsx b/src/components/views/settings/SetIntegrationManager.tsx index 374508d9c6..fe55efd90f 100644 --- a/src/components/views/settings/SetIntegrationManager.tsx +++ b/src/components/views/settings/SetIntegrationManager.tsx @@ -25,6 +25,7 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import ToggleSwitch from "../elements/ToggleSwitch"; import Heading from "../typography/Heading"; import { SettingsSubsectionText } from "./shared/SettingsSubsection"; +import { UIFeature } from "../../../settings/UIFeature"; interface IProps {} @@ -71,6 +72,8 @@ export default class SetIntegrationManager extends React.Component
- {_t("integration_manager|manage_title")} - {managerName} + {_t("integration_manager|manage_title")} + {managerName}
- {_t("settings|general|password_change_section")} - - - ); - } + private renderAccountSection(): JSX.Element | undefined { + if (!this.state.canChangePassword) return undefined; return ( <> @@ -171,7 +156,14 @@ export default class GeneralUserSettingsTab extends React.Component - {passwordChangeSection} + {_t("settings|general|password_change_section")} + ); @@ -194,12 +186,6 @@ export default class GeneralUserSettingsTab extends React.Component; - } - public render(): React.ReactNode { let accountManagementSection: JSX.Element | undefined; const isAccountManagedExternally = !!this.state.externalAccountManagementUrl; @@ -218,7 +204,6 @@ export default class GeneralUserSettingsTab extends React.Component {this.renderAccountSection()} - {this.renderIntegrationManagerSection()} {accountManagementSection} ); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index c636721201..64c16bcc90 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -28,7 +28,6 @@ import { SettingLevel } from "../../../../../settings/SettingLevel"; import SecureBackupPanel from "../../SecureBackupPanel"; import SettingsStore from "../../../../../settings/SettingsStore"; import { UIFeature } from "../../../../../settings/UIFeature"; -import E2eAdvancedPanel, { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import { ActionPayload } from "../../../../../dispatcher/payloads"; import CryptographyPanel from "../../CryptographyPanel"; import SettingsFlag from "../../../elements/SettingsFlag"; @@ -44,6 +43,7 @@ import { SettingsSection } from "../../shared/SettingsSection"; import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection"; import { useOwnDevices } from "../../devices/useOwnDevices"; import DiscoverySettings from "../../discovery/DiscoverySettings"; +import SetIntegrationManager from "../../SetIntegrationManager"; interface IIgnoredUserProps { userId: string; @@ -360,14 +360,12 @@ export default class SecurityUserSettingsTab extends React.Component : null; // only show the section if there's something to show - if (ignoreUsersPanel || invitesPanel || e2ePanel) { + if (ignoreUsersPanel || invitesPanel) { advancedSection = ( {ignoreUsersPanel} {invitesPanel} - {e2ePanel} ); } @@ -376,6 +374,7 @@ export default class SecurityUserSettingsTab extends React.Component {warning} + {secureBackup} {eventIndex} diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 942be74bb9..5bd9a90850 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -67,10 +67,13 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { ActionPayload } from "../../../dispatcher/payloads"; import { Action } from "../../../dispatcher/actions"; import { NotificationState } from "../../../stores/notifications/NotificationState"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import { ThreadsActivityCentre } from "./threads-activity-centre/"; import AccessibleButton from "../elements/AccessibleButton"; +import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; import { KeyboardShortcut } from "../settings/KeyboardShortcut"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { @@ -383,7 +386,22 @@ const SpacePanel: React.FC = () => { >
-
`; diff --git a/src/utils/exportUtils/exportCSS.ts b/src/utils/exportUtils/exportCSS.ts index bd7ddac01b..15716ad544 100644 --- a/src/utils/exportUtils/exportCSS.ts +++ b/src/utils/exportUtils/exportCSS.ts @@ -14,74 +14,80 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { Rule, StyleSheet } from "css-tree"; + import customCSS from "!!raw-loader!./exportCustomCSS.css"; const cssSelectorTextClassesRegex = /\.[\w-]+/g; function mutateCssText(css: string): string { // replace used fonts so that we don't have to bundle Inter & Inconsalata + const sansFont = `-apple-system, BlinkMacSystemFont, avenir next, + avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`; return css - .replace( - /font-family: ?(Inter|'Inter'|"Inter")/g, - `font-family: -apple-system, BlinkMacSystemFont, avenir next, - avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`, - ) + .replace(/font-family: ?(Inter|'Inter'|"Inter")/g, `font-family: ${sansFont}`) + .replace(/--cpd-font-family-sans: ?(Inter|'Inter'|"Inter")/g, `--cpd-font-family-sans: ${sansFont}`) .replace( /font-family: ?Inconsolata/g, "font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace", ); } -function isLightTheme(sheet: CSSStyleSheet): boolean { - return (sheet.ownerNode)?.dataset.mxTheme?.toLowerCase() === "light"; -} +function includeRule(rule: Rule, usedClasses: Set): boolean { + if (rule.prelude.type === "Raw") { + // cull empty rules + if (rule.block.children.isEmpty) return false; -async function getRulesFromCssFile(path: string): Promise { - const doc = document.implementation.createHTMLDocument(""); - const styleElement = document.createElement("style"); - - const res = await fetch(path); - styleElement.textContent = await res.text(); - // the style will only be parsed once it is added to a document - doc.body.appendChild(styleElement); - - return styleElement.sheet!; + return rule.prelude.value.split(",").some((subselector) => { + const classes = subselector.trim().match(cssSelectorTextClassesRegex); + if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) { + return false; + } + return true; + }); + } + return true; } // naively culls unused css rules based on which classes are present in the html, // doesn't cull rules which won't apply due to the full selector not matching but gets rid of a LOT of cruft anyway. +// We cannot use document.styleSheets as it does not handle variables in shorthand properties sanely, +// see https://github.com/element-hq/element-web/issues/26761 const getExportCSS = async (usedClasses: Set): Promise => { - // only include bundle.css and the data-mx-theme=light styling - const stylesheets = Array.from(document.styleSheets).filter((s) => { - return s.href?.endsWith("bundle.css") || isLightTheme(s); + const csstree = await import("css-tree"); + + // only include bundle.css and light theme styling + const hrefs = ["bundle.css", "theme-light.css"].map((name) => { + return document.querySelector(`link[rel="stylesheet"][href$="${name}"]`)?.href; }); - // If the light theme isn't loaded we will have to fetch & parse it manually - if (!stylesheets.some(isLightTheme)) { - const href = document.querySelector('link[rel="stylesheet"][href$="theme-light.css"]')?.href; - if (href) stylesheets.push(await getRulesFromCssFile(href)); - } - let css = ""; - for (const stylesheet of stylesheets) { - for (const rule of stylesheet.cssRules) { - if (rule instanceof CSSFontFaceRule) continue; // we don't want to bundle any fonts - const selectorText = (rule as CSSStyleRule).selectorText; + for (const href of hrefs) { + if (!href) continue; + const res = await fetch(href); + const text = await res.text(); - // only skip the rule if all branches (,) of the selector are redundant - if ( - selectorText?.split(",").every((selector) => { - const classes = selector.match(cssSelectorTextClassesRegex); - if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) { - return true; // signal as a redundant selector - } - }) - ) { - continue; // skip this rule as it is redundant + const ast = csstree.parse(text, { + context: "stylesheet", + parseAtrulePrelude: false, + parseRulePrelude: false, + parseValue: false, + parseCustomProperty: false, + }) as StyleSheet; + + for (const rule of ast.children) { + if (rule.type === "Atrule") { + if (rule.name === "font-face") { + continue; + } } - css += mutateCssText(rule.cssText) + "\n"; + if (rule.type === "Rule" && !includeRule(rule, usedClasses)) { + continue; + } + + css += mutateCssText(csstree.generate(rule)); } } diff --git a/src/utils/exportUtils/exportCustomCSS.css b/src/utils/exportUtils/exportCustomCSS.css index 4807e316a8..bd0de64265 100644 --- a/src/utils/exportUtils/exportCustomCSS.css +++ b/src/utils/exportUtils/exportCustomCSS.css @@ -18,6 +18,11 @@ limitations under the License. This file is raw-imported (imported as plain text) for the export bundle, which is the reason for the .css format and the colours being hard-coded hard-coded. */ +html, +body { + font-size: var(--cpd-font-size-root) !important; +} + #snackbar { display: flex; visibility: hidden; diff --git a/src/widgets/ManagedHybrid.ts b/src/widgets/ManagedHybrid.ts index ff06c295e6..6617933d97 100644 --- a/src/widgets/ManagedHybrid.ts +++ b/src/widgets/ManagedHybrid.ts @@ -16,6 +16,7 @@ limitations under the License. import { IWidget } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { Room } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getCallBehaviourWellKnown } from "../utils/WellKnownUtils"; @@ -24,7 +25,7 @@ import { IStoredLayout, WidgetLayoutStore } from "../stores/widgets/WidgetLayout import WidgetEchoStore from "../stores/WidgetEchoStore"; import WidgetStore, { IApp } from "../stores/WidgetStore"; import SdkConfig from "../SdkConfig"; -import DMRoomMap from "../utils/DMRoomMap"; +import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers"; /* eslint-disable camelcase */ interface IManagedHybridWidgetData { @@ -34,8 +35,9 @@ interface IManagedHybridWidgetData { } /* eslint-enable camelcase */ -function getWidgetBuildUrl(roomId: string): string | undefined { - const isDm = !!DMRoomMap.shared().getUserIdForRoomId(roomId); +function getWidgetBuildUrl(room: Room): string | undefined { + const functionalMembers = getJoinedNonFunctionalMembers(room); + const isDm = functionalMembers.length === 2; if (SdkConfig.get().widget_build_url) { if (isDm && SdkConfig.get().widget_build_url_ignore_dm) { return undefined; @@ -51,35 +53,29 @@ function getWidgetBuildUrl(roomId: string): string | undefined { return wellKnown?.widget_build_url; } -export function isManagedHybridWidgetEnabled(roomId: string): boolean { - return !!getWidgetBuildUrl(roomId); +export function isManagedHybridWidgetEnabled(room: Room): boolean { + return !!getWidgetBuildUrl(room); } -export async function addManagedHybridWidget(roomId: string): Promise { - const cli = MatrixClientPeg.safeGet(); - const room = cli.getRoom(roomId); - if (!room) { - return; - } - +export async function addManagedHybridWidget(room: Room): Promise { // Check for permission - if (!WidgetUtils.canUserModifyWidgets(cli, roomId)) { - logger.error(`User not allowed to modify widgets in ${roomId}`); + if (!WidgetUtils.canUserModifyWidgets(room.client, room.roomId)) { + logger.error(`User not allowed to modify widgets in ${room.roomId}`); return; } // Get widget data /* eslint-disable-next-line camelcase */ - const widgetBuildUrl = getWidgetBuildUrl(roomId); + const widgetBuildUrl = getWidgetBuildUrl(room); if (!widgetBuildUrl) { return; } let widgetData: IManagedHybridWidgetData; try { - const response = await fetch(`${widgetBuildUrl}?roomId=${roomId}`); + const response = await fetch(`${widgetBuildUrl}?roomId=${room.roomId}`); widgetData = await response.json(); } catch (e) { - logger.error(`Managed hybrid widget builder failed for room ${roomId}`, e); + logger.error(`Managed hybrid widget builder failed for room ${room.roomId}`, e); return; } if (!widgetData) { @@ -88,21 +84,21 @@ export async function addManagedHybridWidget(roomId: string): Promise { const { widget_id: widgetId, widget: widgetContent, layout } = widgetData; // Ensure the widget is not already present in the room - let widgets = WidgetStore.instance.getApps(roomId); - const existing = widgets.some((w) => w.id === widgetId) || WidgetEchoStore.roomHasPendingWidgets(roomId, []); + let widgets = WidgetStore.instance.getApps(room.roomId); + const existing = widgets.some((w) => w.id === widgetId) || WidgetEchoStore.roomHasPendingWidgets(room.roomId, []); if (existing) { - logger.error(`Managed hybrid widget already present in room ${roomId}`); + logger.error(`Managed hybrid widget already present in room ${room.roomId}`); return; } // Add the widget try { - await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, { + await WidgetUtils.setRoomWidgetContent(room.client, room.roomId, widgetId, { ...widgetContent, "io.element.managed_hybrid": true, }); } catch (e) { - logger.error(`Unable to add managed hybrid widget in room ${roomId}`, e); + logger.error(`Unable to add managed hybrid widget in room ${room.roomId}`, e); return; } @@ -110,7 +106,7 @@ export async function addManagedHybridWidget(roomId: string): Promise { if (!WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { return; } - widgets = WidgetStore.instance.getApps(roomId); + widgets = WidgetStore.instance.getApps(room.roomId); const installedWidget = widgets.find((w) => w.id === widgetId); if (!installedWidget) { return; diff --git a/test/DecryptionFailureTracker-test.ts b/test/DecryptionFailureTracker-test.ts index ca8c4b3660..d465d9b2aa 100644 --- a/test/DecryptionFailureTracker-test.ts +++ b/test/DecryptionFailureTracker-test.ts @@ -490,47 +490,22 @@ describe("DecryptionFailureTracker", function () { const now = Date.now(); - const event1 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, - }); - tracker.addVisibleEvent(event1); - eventDecrypted(tracker, event1, now); + async function createAndTrackEventWithError(code: DecryptionFailureCode) { + const event = await createFailedDecryptionEvent({ code }); + tracker.addVisibleEvent(event); + eventDecrypted(tracker, event, now); + return event; + } - const event2 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX, - }); - tracker.addVisibleEvent(event2); - eventDecrypted(tracker, event2, now); - - const event3 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP, - }); - tracker.addVisibleEvent(event3); - eventDecrypted(tracker, event3, now); - - const event4 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, - }); - tracker.addVisibleEvent(event4); - eventDecrypted(tracker, event4, now); - - const event5 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP, - }); - tracker.addVisibleEvent(event5); - eventDecrypted(tracker, event5, now); - - const event6 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED, - }); - tracker.addVisibleEvent(event6); - eventDecrypted(tracker, event6, now); - - const event7 = await createFailedDecryptionEvent({ - code: DecryptionFailureCode.UNKNOWN_ERROR, - }); - tracker.addVisibleEvent(event7); - eventDecrypted(tracker, event7, now); + await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID); + await createAndTrackEventWithError(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX); + await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); + await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED); + await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP); + await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED); + await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_KEY_WITHHELD); + await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE); + await createAndTrackEventWithError(DecryptionFailureCode.UNKNOWN_ERROR); // Pretend "now" is Infinity tracker.checkFailures(Infinity); @@ -542,6 +517,8 @@ describe("DecryptionFailureTracker", function () { "HistoricalMessage", "HistoricalMessage", "ExpectedDueToMembership", + "OlmKeysNotSentError", + "RoomKeysWithheldForUnverifiedDevice", "UnknownError", ]); }); diff --git a/test/LegacyCallHandler-test.ts b/test/LegacyCallHandler-test.ts index 005119cb7b..c310db55ab 100644 --- a/test/LegacyCallHandler-test.ts +++ b/test/LegacyCallHandler-test.ts @@ -52,6 +52,7 @@ import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-uti import { SdkContextClass } from "../src/contexts/SDKContext"; import Modal from "../src/Modal"; import { createAudioContext } from "../src/audio/compat"; +import * as ManagedHybrid from "../src/widgets/ManagedHybrid"; jest.mock("../src/Modal"); @@ -315,6 +316,7 @@ describe("LegacyCallHandler", () => { document.body.removeChild(audioElement); SdkConfig.reset(); + jest.restoreAllMocks(); }); it("should look up the correct user and start a call in the room when a phone number is dialled", async () => { @@ -403,6 +405,13 @@ describe("LegacyCallHandler", () => { expect(callHandler.getCallForRoom(NATIVE_ROOM_CHARLIE)).toBe(fakeCall); }); + it("should place calls using managed hybrid widget if enabled", async () => { + const spy = jest.spyOn(ManagedHybrid, "addManagedHybridWidget"); + jest.spyOn(ManagedHybrid, "isManagedHybridWidgetEnabled").mockReturnValue(true); + await callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); + expect(spy).toHaveBeenCalledWith(MatrixClientPeg.safeGet().getRoom(NATIVE_ROOM_ALICE)); + }); + describe("when listening to a voice broadcast", () => { let voiceBroadcastPlayback: VoiceBroadcastPlayback; diff --git a/test/Modal-test.ts b/test/Modal-test.ts index d7630bd2da..6114446743 100644 --- a/test/Modal-test.ts +++ b/test/Modal-test.ts @@ -16,6 +16,7 @@ limitations under the License. import Modal from "../src/Modal"; import QuestionDialog from "../src/components/views/dialogs/QuestionDialog"; +import defaultDispatcher from "../src/dispatcher/dispatcher"; describe("Modal", () => { test("forceCloseAllModals should close all open modals", () => { @@ -29,4 +30,28 @@ describe("Modal", () => { Modal.forceCloseAllModals(); expect(Modal.hasDialogs()).toBe(false); }); + + test("open modals should be closed on logout", () => { + const modal1OnFinished = jest.fn(); + const modal2OnFinished = jest.fn(); + + Modal.createDialog(QuestionDialog, { + title: "Test dialog 1", + description: "This is a test dialog", + button: "Word", + onFinished: modal1OnFinished, + }); + + Modal.createDialog(QuestionDialog, { + title: "Test dialog 2", + description: "This is a test dialog", + button: "Word", + onFinished: modal2OnFinished, + }); + + defaultDispatcher.dispatch({ action: "logout" }, true); + + expect(modal1OnFinished).toHaveBeenCalled(); + expect(modal2OnFinished).toHaveBeenCalled(); + }); }); diff --git a/test/SecurityManager-test.ts b/test/SecurityManager-test.ts index 15d1eb1dec..13d5f2f63f 100644 --- a/test/SecurityManager-test.ts +++ b/test/SecurityManager-test.ts @@ -31,7 +31,7 @@ describe("SecurityManager", () => { bootstrapSecretStorage: () => {}, } as unknown as CryptoApi; const client = stubClient(); - mocked(client.hasSecretStorageKey).mockResolvedValue(true); + client.secretStorage.hasKey = jest.fn().mockResolvedValue(true); mocked(client.getCrypto).mockReturnValue(crypto); // When I run accessSecretStorage @@ -48,7 +48,7 @@ describe("SecurityManager", () => { it("throws if crypto is unavailable", async () => { // Given a client with no crypto const client = stubClient(); - mocked(client.hasSecretStorageKey).mockResolvedValue(true); + client.secretStorage.hasKey = jest.fn().mockResolvedValue(true); mocked(client.getCrypto).mockReturnValue(undefined); // When I run accessSecretStorage diff --git a/test/accessibility/LandmarkNavigation-test.tsx b/test/accessibility/LandmarkNavigation-test.tsx new file mode 100644 index 0000000000..65d5315b3d --- /dev/null +++ b/test/accessibility/LandmarkNavigation-test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; + +import { Landmark, LandmarkNavigation } from "../../src/accessibility/LandmarkNavigation"; +import defaultDispatcher from "../../src/dispatcher/dispatcher"; + +describe("KeyboardLandmarkUtils", () => { + it("Landmarks are cycled through correctly without an opened room", () => { + render( +
+
+ SPACE_BUTTON +
+
+ ROOM_SEARCH +
+
+ ROOM_TILE +
+
+ HOME_PAGE +
+
, + ); + // ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> HOME <-> ACTIVE_SPACE_BUTTON + // ACTIVE_SPACE_BUTTON -> ROOM_SEARCH + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON); + expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus(); + + // ROOM_SEARCH -> ROOM_LIST + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH); + expect(screen.getByTestId("mx_RoomTile")).toHaveFocus(); + + // ROOM_LIST -> HOME_PAGE + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST); + expect(screen.getByTestId("mx_HomePage")).toHaveFocus(); + + // HOME_PAGE -> ACTIVE_SPACE_BUTTON + LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME); + expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus(); + + // HOME_PAGE <- ACTIVE_SPACE_BUTTON + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON, true); + expect(screen.getByTestId("mx_HomePage")).toHaveFocus(); + + // ROOM_LIST <- HOME_PAGE + LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME, true); + expect(screen.getByTestId("mx_RoomTile")).toHaveFocus(); + + // ROOM_SEARCH <- ROOM_LIST + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST, true); + expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus(); + + // ACTIVE_SPACE_BUTTON <- ROOM_SEARCH + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH, true); + expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus(); + }); + + it("Landmarks are cycled through correctly with an opened room", async () => { + const callback = jest.fn(); + defaultDispatcher.register(callback); + render( +
+
+ SPACE_BUTTON +
+
+ ROOM_SEARCH +
+
+ ROOM_TILE +
+
+ ROOM +
+ COMPOSER +
+
+
, + ); + // ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER <-> ACTIVE_SPACE_BUTTON + // ACTIVE_SPACE_BUTTON -> ROOM_SEARCH + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON); + expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus(); + + // ROOM_SEARCH -> ROOM_LIST + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH); + expect(screen.getByTestId("mx_RoomTile_selected")).toHaveFocus(); + + // ROOM_LIST -> MESSAGE_COMPOSER + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + + // MESSAGE_COMPOSER -> ACTIVE_SPACE_BUTTON + LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME); + expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus(); + + // MESSAGE_COMPOSER <- ACTIVE_SPACE_BUTTON + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON, true); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(2)); + + // ROOM_LIST <- MESSAGE_COMPOSER + LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME, true); + expect(screen.getByTestId("mx_RoomTile_selected")).toHaveFocus(); + + // ROOM_SEARCH <- ROOM_LIST + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST, true); + expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus(); + + // ACTIVE_SPACE_BUTTON <- ROOM_SEARCH + LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH, true); + expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus(); + }); +}); diff --git a/test/components/structures/FilePanel-test.tsx b/test/components/structures/FilePanel-test.tsx new file mode 100644 index 0000000000..2b53c9c86c --- /dev/null +++ b/test/components/structures/FilePanel-test.tsx @@ -0,0 +1,58 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { EventTimelineSet, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; +import { screen, render, waitFor } from "@testing-library/react"; +import { mocked } from "jest-mock"; + +import FilePanel from "../../../src/components/structures/FilePanel"; +import ResizeNotifier from "../../../src/utils/ResizeNotifier"; +import { stubClient } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; + +jest.mock("matrix-js-sdk/src/matrix", () => ({ + ...jest.requireActual("matrix-js-sdk/src/matrix"), + TimelineWindow: jest.fn().mockReturnValue({ + load: jest.fn().mockResolvedValue(null), + getEvents: jest.fn().mockReturnValue([]), + canPaginate: jest.fn().mockReturnValue(false), + }), +})); + +describe("FilePanel", () => { + beforeEach(() => { + stubClient(); + }); + + it("renders empty state", async () => { + const cli = MatrixClientPeg.safeGet(); + const room = new Room("!room:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + const timelineSet = new EventTimelineSet(room); + room.getOrCreateFilteredTimelineSet = jest.fn().mockReturnValue(timelineSet); + mocked(cli.getRoom).mockReturnValue(room); + + const { asFragment } = render( + , + ); + await waitFor(() => { + expect(screen.getByText("No files visible in this room")).toBeInTheDocument(); + }); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index eb070b7afc..a97b6ef5e3 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -524,35 +524,6 @@ describe("RoomView", () => { expect(asFragment()).toMatchSnapshot(); }); - describe("Peeking", () => { - beforeEach(() => { - // Make room peekable - room.currentState.setStateEvents([ - new MatrixEvent({ - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "world_readable", - }, - room_id: room.roomId, - }), - ]); - }); - - it("should show forget room button for non-guests", async () => { - mocked(cli.isGuest).mockReturnValue(false); - await mountRoomView(); - - expect(screen.getByLabelText("Forget room")).toBeInTheDocument(); - }); - - it("should not show forget room button for guests", async () => { - mocked(cli.isGuest).mockReturnValue(true); - await mountRoomView(); - expect(screen.queryByLabelText("Forget room")).not.toBeInTheDocument(); - }); - }); - describe("knock rooms", () => { const client = createTestClient(); diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index 19122e4cce..f66f58ed84 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -43,44 +43,21 @@ describe("ThreadPanel", () => { describe("Header", () => { it("expect that All filter for ThreadPanelHeader properly renders Show: All threads", () => { const { asFragment } = render( - undefined} - />, + undefined} />, ); expect(asFragment()).toMatchSnapshot(); }); it("expect that My filter for ThreadPanelHeader properly renders Show: My threads", () => { const { asFragment } = render( - undefined} - />, - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it("matches snapshot when no threads", () => { - const { asFragment } = render( - undefined} - />, + undefined} />, ); expect(asFragment()).toMatchSnapshot(); }); it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => { const { container } = render( - undefined} - />, + undefined} />, ); const found = container.querySelector(".mx_ThreadPanel_dropdown"); expect(found).toBeTruthy(); @@ -91,11 +68,7 @@ describe("ThreadPanel", () => { it("expect that ThreadPanelHeader has the correct option selected in the context menu", () => { const { container } = render( - undefined} - />, + undefined} />, ); fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")!); const found = screen.queryAllByRole("menuitemradio"); @@ -118,11 +91,7 @@ describe("ThreadPanel", () => { const { container } = render( - undefined} - /> + undefined} /> , ); @@ -136,11 +105,7 @@ describe("ThreadPanel", () => { const mockClient = createTestClient(); const { container } = render( - undefined} - /> + undefined} /> , ); fireEvent.click(getByRole(container, "button", { name: "Mark all as read" })); diff --git a/test/components/structures/__snapshots__/FilePanel-test.tsx.snap b/test/components/structures/__snapshots__/FilePanel-test.tsx.snap new file mode 100644 index 0000000000..87ffc5da2e --- /dev/null +++ b/test/components/structures/__snapshots__/FilePanel-test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FilePanel renders empty state 1`] = ` + +
+
+
+ +
+
+
+
+
+

+ No files visible in this room +

+

+ Attach files from chat or just drag and drop them anywhere in a room. +

+
+
+
+
+ +`; diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 5134769908..569fe3640c 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -6,45 +6,140 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 class="mx_RoomView mx_RoomView--local" >
-
+ + u +
+ + @user:example.com + +
+
+ +
+ + + + +
+
+
+
+ + u + u
+ 2
-
-
- @user:example.com -
-
-
-
+ + u +
+ + @user:example.com + +
+
+ +
+ + + + +
+
+
+
+ + u + u
+ 2
-
-
- @user:example.com -
-
-
-
+ + u +
+ + @user:example.com + +
+
+ +
+ + + + +
+
+
+
+ + u + u
+ 2
-
-
- @user:example.com -
-
-
-
+ + u +
+ + @user:example.com + +
+
+ +
+ + + + +
+
+
+
+ + u + u
+ 2
- -
- -
-
- @user:example.com -
-
-
-

- Threads -

`; - -exports[`ThreadPanel Header matches snapshot when no threads 1`] = ` - -
-

- Threads -

-
-
-`; diff --git a/test/components/views/elements/SyntaxHighlight-test.tsx b/test/components/views/elements/SyntaxHighlight-test.tsx index 2f8c751fd7..3c59c6df46 100644 --- a/test/components/views/elements/SyntaxHighlight-test.tsx +++ b/test/components/views/elements/SyntaxHighlight-test.tsx @@ -35,6 +35,6 @@ describe("", () => { await waitFor(() => expect(container.querySelector(`.language-${lang}`)).toBeTruthy()); const [_lang, opts] = mock.mock.lastCall!; - expect((opts as HighlightOptions)["language"]).toBe(lang); + expect((opts as unknown as HighlightOptions)["language"]).toBe(lang); }); }); diff --git a/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap index b344e3cd58..2eab478930 100644 --- a/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -13,33 +13,36 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] = class="mx_BaseCard_header" >
+

+ Example 1 +

+ +
{ expect(container).toMatchSnapshot(); }); - it(`Should display "The sender has blocked you from receiving this message"`, () => { + it(`Should display "The sender has blocked you from receiving this message"`, async () => { // When - const event = mkEvent({ - type: "m.room.message", - room: "myfakeroom", - user: "myfakeuser", - content: { - msgtype: "m.bad.encrypted", - }, - event: true, + const event = await mkDecryptionFailureMatrixEvent({ + code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE, + msg: "withheld", + roomId: "myfakeroom", + sender: "myfakeuser", }); - jest.spyOn(event, "isEncryptedDisabledForUnverifiedDevices", "get").mockReturnValue(true); + const { container } = customRender(event); // Then diff --git a/test/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap b/test/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap index c0096b6467..22e44fd16a 100644 --- a/test/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap @@ -5,7 +5,7 @@ exports[`DecryptionFailureBody Should display "The sender has blocked you from r
- The sender has blocked you from receiving this message + The sender has blocked you from receiving this message because your device is unverified
`; diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index bc314e9e32..3b36468786 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -56,6 +56,9 @@ import { clearAllModals, flushPromises } from "../../../test-utils"; import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog"; import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../src/settings/UIFeature"; +import { Action } from "../../../../src/dispatcher/actions"; +import ShareDialog from "../../../../src/components/views/dialogs/ShareDialog"; +import BulkRedactDialog from "../../../../src/components/views/dialogs/BulkRedactDialog"; jest.mock("../../../../src/utils/direct-messages", () => ({ ...jest.requireActual("../../../../src/utils/direct-messages"), @@ -302,15 +305,6 @@ describe("", () => { expect(screen.queryByTestId("space-header")).not.toBeInTheDocument(); }); - it("renders space header when room is a space room", () => { - const spaceRoom = { - ...mockRoom, - isSpaceRoom: jest.fn().mockReturnValue(true), - }; - renderComponent({ room: spaceRoom }); - expect(screen.getByTestId("space-header")).toBeInTheDocument(); - }); - it("renders encryption info panel without pending verification", () => { renderComponent({ phase: RightPanelPhases.EncryptionPanel, room: mockRoom }); expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument(); @@ -332,7 +326,7 @@ describe("", () => { , ); - screen.getByRole("button", { name: "Message" }); + screen.getByRole("button", { name: "Send message" }); }); it("hides the message button if the visibility customisation hides all create room features", () => { @@ -351,6 +345,64 @@ describe("", () => { }, ); }); + + describe("Ignore", () => { + const member = new RoomMember(defaultRoomId, defaultUserId); + + it("shows block button when member userId does not match client userId", () => { + // call to client.getUserId returns undefined, which will not match member.userId + renderComponent(); + + expect(screen.getByRole("button", { name: "Ignore" })).toBeInTheDocument(); + }); + + it("shows a modal before ignoring the user", async () => { + const originalCreateDialog = Modal.createDialog; + const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({ + finished: Promise.resolve([true]), + close: () => {}, + })); + + try { + mockClient.getIgnoredUsers.mockReturnValue([]); + renderComponent(); + + await userEvent.click(screen.getByRole("button", { name: "Ignore" })); + expect(modalSpy).toHaveBeenCalled(); + expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]); + } finally { + Modal.createDialog = originalCreateDialog; + } + }); + + it("cancels ignoring the user", async () => { + const originalCreateDialog = Modal.createDialog; + const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({ + finished: Promise.resolve([false]), + close: () => {}, + })); + + try { + mockClient.getIgnoredUsers.mockReturnValue([]); + renderComponent(); + + await userEvent.click(screen.getByRole("button", { name: "Ignore" })); + expect(modalSpy).toHaveBeenCalled(); + expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled(); + } finally { + Modal.createDialog = originalCreateDialog; + } + }); + + it("unignores the user", async () => { + mockClient.isUserIgnored.mockReturnValue(true); + mockClient.getIgnoredUsers.mockReturnValue([member.userId]); + renderComponent(); + + await userEvent.click(screen.getByRole("button", { name: "Unignore" })); + expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]); + }); + }); }); describe("with crypto enabled", () => { @@ -810,7 +862,7 @@ describe("", () => { describe("", () => { const member = new RoomMember(defaultRoomId, defaultUserId); - const defaultProps = { member, isIgnored: false, canInvite: false, isSpace: false }; + const defaultProps = { member, canInvite: false, isSpace: false }; const renderComponent = (props = {}) => { const Wrapper = (wrapperProps = {}) => { @@ -837,9 +889,13 @@ describe("", () => { inviteSpy.mockRestore(); }); - it("always shows share user button", () => { + it("always shows share user button and clicking it should produce a ShareDialog", async () => { + const spy = jest.spyOn(Modal, "createDialog"); + renderComponent(); - expect(screen.getByRole("button", { name: /share link to user/i })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Share profile" })); + + expect(spy).toHaveBeenCalledWith(ShareDialog, { target: defaultProps.member }); }); it("does not show ignore or direct message buttons when member userId matches client userId", () => { @@ -851,20 +907,31 @@ describe("", () => { expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument(); }); - it("shows ignore, direct message and mention buttons when member userId does not match client userId", () => { + it("shows direct message and mention buttons when member userId does not match client userId", () => { // call to client.getUserId returns undefined, which will not match member.userId renderComponent(); - expect(screen.getByRole("button", { name: /ignore/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /message/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /mention/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send message" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mention" })).toBeInTheDocument(); + }); + + it("mention button fires ComposerInsert Action", async () => { + renderComponent(); + + const button = screen.getByRole("button", { name: "Mention" }); + await userEvent.click(button); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ComposerInsert, + timelineRenderingType: "Room", + userId: "@user:example.com", + }); }); it("when call to client.getRoom is null, does not show read receipt button", () => { mockClient.getRoom.mockReturnValueOnce(null); renderComponent(); - expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Jump to read receipt" })).not.toBeInTheDocument(); }); it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, does not show read receipt button", () => { @@ -872,7 +939,7 @@ describe("", () => { mockClient.getRoom.mockReturnValueOnce(mockRoom); renderComponent(); - expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Jump to read receipt" })).not.toBeInTheDocument(); }); it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => { @@ -880,7 +947,7 @@ describe("", () => { mockClient.getRoom.mockReturnValueOnce(mockRoom); renderComponent(); - expect(screen.getByRole("button", { name: /jump to read receipt/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Jump to read receipt" })).toBeInTheDocument(); }); it("clicking the read receipt button calls dispatch with correct event_id", async () => { @@ -889,7 +956,7 @@ describe("", () => { mockClient.getRoom.mockReturnValue(mockRoom); renderComponent(); - const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i }); + const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); expect(readReceiptButton).toBeInTheDocument(); await userEvent.click(readReceiptButton); @@ -913,7 +980,7 @@ describe("", () => { mockClient.getRoom.mockReturnValue(mockRoom); renderComponent(); - const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i }); + const readReceiptButton = screen.getByRole("button", { name: "Jump to read receipt" }); expect(readReceiptButton).toBeInTheDocument(); await userEvent.click(readReceiptButton); @@ -973,52 +1040,6 @@ describe("", () => { }); }); - it("shows a modal before ignoring the user", async () => { - const originalCreateDialog = Modal.createDialog; - const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({ - finished: Promise.resolve([true]), - close: () => {}, - })); - - try { - mockClient.getIgnoredUsers.mockReturnValue([]); - renderComponent({ isIgnored: false }); - - await userEvent.click(screen.getByRole("button", { name: "Ignore" })); - expect(modalSpy).toHaveBeenCalled(); - expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]); - } finally { - Modal.createDialog = originalCreateDialog; - } - }); - - it("cancels ignoring the user", async () => { - const originalCreateDialog = Modal.createDialog; - const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({ - finished: Promise.resolve([false]), - close: () => {}, - })); - - try { - mockClient.getIgnoredUsers.mockReturnValue([]); - renderComponent({ isIgnored: false }); - - await userEvent.click(screen.getByRole("button", { name: "Ignore" })); - expect(modalSpy).toHaveBeenCalled(); - expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled(); - } finally { - Modal.createDialog = originalCreateDialog; - } - }); - - it("unignores the user", async () => { - mockClient.getIgnoredUsers.mockReturnValue([member.userId]); - renderComponent({ isIgnored: true }); - - await userEvent.click(screen.getByRole("button", { name: "Unignore" })); - expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]); - }); - it.each([ ["for a RoomMember", member, member.getMxcAvatarUrl()], ["for a User", defaultUser, defaultUser.avatarUrl], @@ -1029,10 +1050,10 @@ describe("", () => { mocked(startDmOnFirstMessage).mockReturnValue(deferred.promise); renderComponent({ member }); - await userEvent.click(screen.getByText("Message")); + await userEvent.click(screen.getByRole("button", { name: "Send message" })); // Checking the attribute, because the button is a DIV and toBeDisabled() does not work. - expect(screen.getByText("Message")).toHaveAttribute("disabled"); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [ new DirectoryMember({ @@ -1048,7 +1069,7 @@ describe("", () => { }); // Checking the attribute, because the button is a DIV and toBeDisabled() does not work. - expect(screen.getByText("Message")).not.toHaveAttribute("disabled"); + expect(screen.getByRole("button", { name: "Send message" })).not.toBeDisabled(); }, ); }); @@ -1405,10 +1426,30 @@ describe("", () => { renderComponent({ member: defaultMemberWithPowerLevel }); - expect(screen.getByRole("heading", { name: /admin tools/i })).toBeInTheDocument(); - expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument(); - expect(screen.getByText(/ban from room/i)).toBeInTheDocument(); - expect(screen.getByText(/remove recent messages/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Disinvite from room" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ban from room" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove messages" })).toBeInTheDocument(); + }); + + it("should show BulkRedactDialog upon clicking the Remove messages button", async () => { + const spy = jest.spyOn(Modal, "createDialog"); + + mockClient.getRoom.mockReturnValue(mockRoom); + mockClient.getUserId.mockReturnValue("@arbitraryId:server"); + const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!); + mockMeMember.powerLevel = 51; // defaults to 50 + const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember; + mockRoom.getMember.mockImplementation((userId) => + userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel, + ); + + renderComponent({ member: defaultMemberWithPowerLevel }); + await userEvent.click(screen.getByRole("button", { name: "Remove messages" })); + + expect(spy).toHaveBeenCalledWith( + BulkRedactDialog, + expect.objectContaining({ member: defaultMemberWithPowerLevel }), + ); }); it("returns mute toggle button if conditions met", () => { @@ -1450,10 +1491,9 @@ describe("", () => { isUpdating: true, }); - const button = screen.getByText(/mute/i); + const button = screen.getByRole("button", { name: "Mute" }); expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute("disabled"); - expect(button).toHaveAttribute("aria-disabled", "true"); + expect(button).toBeDisabled(); }); it("should not show mute button for one's own member", () => { diff --git a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap index 8f8322d44d..392e1da949 100644 --- a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap @@ -71,7 +71,7 @@ exports[` has button to edit topic when expanded 1`] = `

with crypto enabled renders 1`] = ` data-testid="avatar-img" data-type="round" role="button" - style="--cpd-avatar-size: 230.39999999999998px;" + style="--cpd-avatar-size: 120px;" > u @@ -117,44 +126,51 @@ exports[` with crypto enabled renders 1`] = `
-
-

- - @user:example.com - -

-
-
- customUserIdentifier -
-
- Unknown + @user:example.com
+ +
+ Unknown
+

+

+ customUserIdentifier +
+
+

-

+

Security -

+

Messages in this room are not end-to-end encrypted.

@@ -192,32 +208,100 @@ exports[` with crypto enabled renders 1`] = `
-

- Options -

-
+ + +
+
+
+ + +
@@ -232,16 +316,25 @@ exports[` with crypto enabled should render a deactivate button for
-
+ Profile +

+
with crypto enabled should render a deactivate button for data-testid="avatar-img" data-type="round" role="button" - style="--cpd-avatar-size: 230.39999999999998px;" + style="--cpd-avatar-size: 120px;" > u @@ -272,44 +365,51 @@ exports[` with crypto enabled should render a deactivate button for
-
-

- - @user:example.com - -

-
-
- customUserIdentifier -
-
- Unknown + @user:example.com
+ +
+ Unknown
+

+

+ customUserIdentifier +
+
+

-

+

Security -

+

Messages in this room are not end-to-end encrypted.

@@ -347,50 +447,134 @@ exports[` with crypto enabled should render a deactivate button for
-

- Options -

-
+ +
+ + +
-

- Admin Tools -

-
-
+ + + +
+
+
diff --git a/test/components/views/rooms/MemberList-test.tsx b/test/components/views/rooms/MemberList-test.tsx index 648fa71230..87806e5a85 100644 --- a/test/components/views/rooms/MemberList-test.tsx +++ b/test/components/views/rooms/MemberList-test.tsx @@ -19,7 +19,6 @@ import React from "react"; import { act, fireEvent, render, RenderResult, screen } from "@testing-library/react"; import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; -import { compare } from "matrix-js-sdk/src/utils"; import { mocked, MockedObject } from "jest-mock"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -145,7 +144,8 @@ describe("MemberList", () => { if (!groupChange) { const nameA = memberA.name[0] === "@" ? memberA.name.slice(1) : memberA.name; const nameB = memberB.name[0] === "@" ? memberB.name.slice(1) : memberB.name; - const nameCompare = compare(nameB, nameA); + const collator = new Intl.Collator(); + const nameCompare = collator.compare(nameB, nameA); console.log("Comparing name"); expect(nameCompare).toBeGreaterThanOrEqual(0); } else { diff --git a/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap b/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap index 81a243e75a..0f0600a7b3 100644 --- a/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap +++ b/test/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap @@ -3,7 +3,7 @@ exports[` renders button with an unread marker when room is unread 1`] = `
`; -exports[` Manage integrations should render manage integrations sections 1`] = ` -
diff --git a/test/components/views/settings/tabs/user/__snapshots__/SecurityUserSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/SecurityUserSettingsTab-test.tsx.snap index 7e2940026f..4ccca2a02c 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/SecurityUserSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/SecurityUserSettingsTab-test.tsx.snap @@ -8,6 +8,58 @@ exports[` renders security section 1`] = `
+
-
-
-

- Encryption -

-
-
-
- -
-
-
-
-
- Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices. -
-
-
diff --git a/test/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap b/test/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap index f623e75f21..14133f22ab 100644 --- a/test/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap @@ -234,7 +234,7 @@ exports[` should show all activated MetaSpaces in the correct orde aria-expanded="true" aria-haspopup="dialog" aria-label="Threads" - class="_icon-button_rijzz_17 mx_ThreadsActivityCentreButton" + class="_icon-button_bh2qc_17 mx_ThreadsActivityCentreButton" role="button" style="--cpd-icon-button-size: 32px;" tabindex="0" diff --git a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap index 1d72be5473..1e1ed87e6c 100644 --- a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap @@ -150,7 +150,7 @@ exports[`ThreadsActivityCentre should close the release announcement when the TA aria-expanded="true" aria-haspopup="menu" aria-label="Threads" - class="_icon-button_rijzz_17 mx_ThreadsActivityCentreButton" + class="_icon-button_bh2qc_17 mx_ThreadsActivityCentreButton" data-state="open" id="radix-2" role="button" @@ -437,7 +437,7 @@ exports[`ThreadsActivityCentre should render the release announcement 1`] = ` aria-expanded="true" aria-haspopup="dialog" aria-label="Threads" - class="_icon-button_rijzz_17 mx_ThreadsActivityCentreButton" + class="_icon-button_bh2qc_17 mx_ThreadsActivityCentreButton" role="button" style="--cpd-icon-button-size: 32px;" tabindex="0" diff --git a/test/integrations/IntegrationManagers-test.ts b/test/integrations/IntegrationManagers-test.ts new file mode 100644 index 0000000000..db95ab435b --- /dev/null +++ b/test/integrations/IntegrationManagers-test.ts @@ -0,0 +1,70 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import { IntegrationManagers } from "../../src/integrations/IntegrationManagers"; +import { stubClient } from "../test-utils"; + +describe("IntegrationManagers", () => { + let client: MatrixClient; + let intMgrs: IntegrationManagers; + + beforeEach(() => { + client = stubClient(); + mocked(client).getAccountData.mockReturnValue({ + getContent: jest.fn().mockReturnValue({ + foo: { + id: "foo", + content: { + type: "m.integration_manager", + url: "http://foo/ui", + data: { + api_url: "http://foo/api", + }, + }, + }, + bar: { + id: "bar", + content: { + type: "m.integration_manager", + url: "http://bar/ui", + data: { + api_url: "http://bar/api", + }, + }, + }, + }), + } as unknown as MatrixEvent); + + intMgrs = new IntegrationManagers(); + intMgrs.startWatching(); + }); + + afterEach(() => { + intMgrs.stopWatching(); + }); + + describe("getOrderedManagers", () => { + it("should return integration managers in alphabetical order", () => { + const orderedManagers = intMgrs.getOrderedManagers(); + + expect(orderedManagers[0].id).toBe("bar"); + expect(orderedManagers[1].id).toBe("foo"); + }); + }); +}); diff --git a/test/stores/ReleaseAnnouncementStore-test.tsx b/test/stores/ReleaseAnnouncementStore-test.tsx index 77e79cd545..b9490b0d64 100644 --- a/test/stores/ReleaseAnnouncementStore-test.tsx +++ b/test/stores/ReleaseAnnouncementStore-test.tsx @@ -99,14 +99,18 @@ describe("ReleaseAnnouncementStore", () => { // Sanity check expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("threadsActivityCentre"); - const promise = listenReleaseAnnouncementChanged(); + let promise = listenReleaseAnnouncementChanged(); + await releaseAnnouncementStore.nextReleaseAnnouncement(); + + expect(await promise).toBe("newRoomHeader"); + expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("newRoomHeader"); + + promise = listenReleaseAnnouncementChanged(); await releaseAnnouncementStore.nextReleaseAnnouncement(); - // Currently there is only one feature, so the next feature should be null expect(await promise).toBeNull(); - expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull(); const secondStore = new ReleaseAnnouncementStore(); - // The TAC release announcement has been viewed, so it should be updated in the store account + // All the release announcements have been viewed, so it should be updated in the store account // The release announcement viewing states should be share among all instances (devices in the same account) expect(secondStore.getReleaseAnnouncement()).toBeNull(); }); @@ -118,8 +122,7 @@ describe("ReleaseAnnouncementStore", () => { const promise = listenReleaseAnnouncementChanged(); await secondStore.nextReleaseAnnouncement(); - // Currently there is only one feature, so the next feature should be null - expect(await promise).toBeNull(); - expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull(); + expect(await promise).toBe("newRoomHeader"); + expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("newRoomHeader"); }); }); diff --git a/test/stores/WidgetLayoutStore-test.ts b/test/stores/WidgetLayoutStore-test.ts index ddffe66ae9..ee4ebcc6d0 100644 --- a/test/stores/WidgetLayoutStore-test.ts +++ b/test/stores/WidgetLayoutStore-test.ts @@ -25,25 +25,29 @@ import SettingsStore from "../../src/settings/SettingsStore"; // setup test env values const roomId = "!room:server"; -const mockRoom = { - roomId: roomId, - currentState: { - getStateEvents: (_l, _x) => { - return { - getId: () => "$layoutEventId", - getContent: () => null, - }; - }, - }, -}; describe("WidgetLayoutStore", () => { let client: MatrixClient; let store: WidgetLayoutStore; let roomUpdateListener: (event: string) => void; let mockApps: IApp[]; + let mockRoom: Room; + let layoutEventContent: Record | null; beforeEach(() => { + layoutEventContent = null; + mockRoom = { + roomId: roomId, + currentState: { + getStateEvents: (_l, _x) => { + return { + getId: () => "$layoutEventId", + getContent: () => layoutEventContent, + }; + }, + }, + }; + mockApps = [ { roomId: roomId, id: "1" }, { roomId: roomId, id: "2" }, @@ -57,6 +61,8 @@ describe("WidgetLayoutStore", () => { off: jest.fn(), getApps: () => mockApps, } as unknown as WidgetStore); + + SettingsStore.reset(); }); beforeAll(() => { @@ -85,6 +91,22 @@ describe("WidgetLayoutStore", () => { expect(store.getContainerHeight(mockRoom, Container.Top)).toBeNull(); }); + it("ordering of top container widgets should be consistent even if no index specified", async () => { + layoutEventContent = { + widgets: { + "1": { + container: "top", + }, + "2": { + container: "top", + }, + }, + }; + + store.recalculateRoom(mockRoom); + expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([mockApps[0], mockApps[1]]); + }); + it("add three widgets to top container", async () => { store.recalculateRoom(mockRoom); store.moveToContainer(mockRoom, mockApps[0], Container.Top); @@ -156,9 +178,14 @@ describe("WidgetLayoutStore", () => { await store.start(); expect(roomUpdateListener).toHaveBeenCalled(); - expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([mockApps[0]]); + expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]); expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]); - expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([mockApps[1], mockApps[2], mockApps[3]]); + expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([ + mockApps[0], + mockApps[1], + mockApps[2], + mockApps[3], + ]); }); it("should clear the layout and emit an update if there are no longer apps in the room", () => { @@ -238,21 +265,15 @@ describe("WidgetLayoutStore", () => { "widgets": { "1": { "container": "top", - "height": 23, - "index": 2, - "width": 64, + "height": undefined, + "index": 0, + "width": 100, }, "2": { - "container": "top", - "height": 23, - "index": 0, - "width": 10, + "container": "right", }, "3": { - "container": "top", - "height": 23, - "index": 1, - "width": 26, + "container": "right", }, "4": { "container": "right", diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index 6e51d4ae98..b0ec31044a 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -95,10 +95,13 @@ describe("StopGapWidgetDriver", () => { "org.matrix.msc2762.timeline:!1:example.org", "org.matrix.msc2762.send.event:org.matrix.rageshake_request", "org.matrix.msc2762.receive.event:org.matrix.rageshake_request", + "org.matrix.msc2762.receive.state_event:m.room.create", "org.matrix.msc2762.receive.state_event:m.room.member", "org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call", "org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@alice:example.org", "org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call.member", + `org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#_@alice:example.org_${client.deviceId}`, + `org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@alice:example.org_${client.deviceId}`, "org.matrix.msc3819.send.to_device:m.call.invite", "org.matrix.msc3819.receive.to_device:m.call.invite", "org.matrix.msc3819.send.to_device:m.call.candidates", diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 9b28a3077a..856d9b17b7 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -116,7 +116,6 @@ export function createTestClient(): MatrixClient { bootstrapCrossSigning: jest.fn(), hasSecretStorageKey: jest.fn(), getKeyBackupVersion: jest.fn(), - checkOwnCrossSigningTrust: jest.fn(), secretStorage: { get: jest.fn(), @@ -275,6 +274,7 @@ export function createTestClient(): MatrixClient { matrixRTC: createStubMatrixRTC(), isFallbackICEServerAllowed: jest.fn().mockReturnValue(false), getAuthIssuer: jest.fn(), + getOrCreateFilter: jest.fn(), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client); diff --git a/test/theme-test.ts b/test/theme-test.ts index fdba6d0d18..b375a75301 100644 --- a/test/theme-test.ts +++ b/test/theme-test.ts @@ -15,9 +15,13 @@ limitations under the License. */ import SettingsStore from "../src/settings/SettingsStore"; -import { enumerateThemes, setTheme } from "../src/theme"; +import { enumerateThemes, getOrderedThemes, setTheme } from "../src/theme"; describe("theme", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + describe("setTheme", () => { let lightTheme: HTMLStyleElement; let darkTheme: HTMLStyleElement; @@ -48,7 +52,6 @@ describe("theme", () => { }); afterEach(() => { - jest.restoreAllMocks(); jest.useRealTimers(); }); @@ -162,4 +165,16 @@ describe("theme", () => { }); }); }); + + describe("getOrderedThemes", () => { + it("should return a list of themes in the correct order", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue([{ name: "Zebra Striped" }, { name: "Apple Green" }]); + expect(getOrderedThemes()).toEqual([ + { id: "light", name: "Light" }, + { id: "dark", name: "Dark" }, + { id: "custom-Apple Green", name: "Apple Green" }, + { id: "custom-Zebra Striped", name: "Zebra Striped" }, + ]); + }); + }); }); diff --git a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index aca4e162c8..3958005c5b 100644 --- a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -12,77 +12,73 @@ exports[`HTMLExport should export 1`] = ` Exported Data - -
-
-
-
-
-
-
-
- ! -
-
-
-
- !myroom:example.org -
-
-
-
-
- -
-
-
-
-
-
    +
    +
    +
    +
    +
    +
    +
    +
    + ! +
    +
    +
    +
    -
    - ! -

    !myroom:example.org

    -

    created this room.

    This is the start of export of !myroom:example.org. Exported by @userId:matrix.org at 11/17/2022.

    -
    -

    + !myroom:example.org +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
      + ! +

      !myroom:example.org

      +

      created this room.

      This is the start of export of !myroom:example.org. Exported by @userId:matrix.org at 11/17/2022.

      +
      +

      +
      +
    1. @user49:example.com
      Message #49
    2. @user48:example.com
      Message #48
    3. @user47:example.com
      Message #47
    4. @user46:example.com
      Message #46
    5. @user45:example.com
      Message #45
    6. @user44:example.com
      Message #44
    7. @user43:example.com
      Message #43
    8. @user42:example.com
      Message #42
    9. @user41:example.com
      Message #41
    10. @user40:example.com
      Message #40
    11. @user39:example.com
      Message #39
    12. @user38:example.com
      Message #38
    13. @user37:example.com
      Message #37
    14. @user36:example.com
      Message #36
    15. @user35:example.com
      Message #35
    16. @user34:example.com
      Message #34
    17. @user33:example.com
      Message #33
    18. @user32:example.com
      Message #32
    19. @user31:example.com
      Message #31
    20. @user30:example.com
      Message #30
    21. @user29:example.com
      Message #29
    22. @user28:example.com
      Message #28
    23. @user27:example.com
      Message #27
    24. @user26:example.com
      Message #26
    25. @user25:example.com
      Message #25
    26. @user24:example.com
      Message #24
    27. @user23:example.com
      Message #23
    28. @user22:example.com
      Message #22
    29. @user21:example.com
      Message #21
    30. @user20:example.com
      Message #20
    31. @user19:example.com
      Message #19
    32. @user18:example.com
      Message #18
    33. @user17:example.com
      Message #17
    34. @user16:example.com
      Message #16
    35. @user15:example.com
      Message #15
    36. @user14:example.com
      Message #14
    37. @user13:example.com
      Message #13
    38. @user12:example.com
      Message #12
    39. @user11:example.com
      Message #11
    40. @user10:example.com
      Message #10
    41. @user9:example.com
      Message #9
    42. @user8:example.com
      Message #8
    43. @user7:example.com
      Message #7
    44. @user6:example.com
      Message #6
    45. @user5:example.com
      Message #5
    46. @user4:example.com
      Message #4
    47. @user3:example.com
      Message #3
    48. @user2:example.com
      Message #2
    49. @user1:example.com
      Message #1
    50. @user0:example.com
      Message #0
    51. +
    -
  1. @user49:example.com
    Message #49
  2. @user48:example.com
    Message #48
  3. @user47:example.com
    Message #47
  4. @user46:example.com
    Message #46
  5. @user45:example.com
    Message #45
  6. @user44:example.com
    Message #44
  7. @user43:example.com
    Message #43
  8. @user42:example.com
    Message #42
  9. @user41:example.com
    Message #41
  10. @user40:example.com
    Message #40
  11. @user39:example.com
    Message #39
  12. @user38:example.com
    Message #38
  13. @user37:example.com
    Message #37
  14. @user36:example.com
    Message #36
  15. @user35:example.com
    Message #35
  16. @user34:example.com
    Message #34
  17. @user33:example.com
    Message #33
  18. @user32:example.com
    Message #32
  19. @user31:example.com
    Message #31
  20. @user30:example.com
    Message #30
  21. @user29:example.com
    Message #29
  22. @user28:example.com
    Message #28
  23. @user27:example.com
    Message #27
  24. @user26:example.com
    Message #26
  25. @user25:example.com
    Message #25
  26. @user24:example.com
    Message #24
  27. @user23:example.com
    Message #23
  28. @user22:example.com
    Message #22
  29. @user21:example.com
    Message #21
  30. @user20:example.com
    Message #20
  31. @user19:example.com
    Message #19
  32. @user18:example.com
    Message #18
  33. @user17:example.com
    Message #17
  34. @user16:example.com
    Message #16
  35. @user15:example.com
    Message #15
  36. @user14:example.com
    Message #14
  37. @user13:example.com
    Message #13
  38. @user12:example.com
    Message #12
  39. @user11:example.com
    Message #11
  40. @user10:example.com
    Message #10
  41. @user9:example.com
    Message #9
  42. @user8:example.com
    Message #8
  43. @user7:example.com
    Message #7
  44. @user6:example.com
    Message #6
  45. @user5:example.com
    Message #5
  46. @user4:example.com
    Message #4
  47. @user3:example.com
    Message #3
  48. @user2:example.com
    Message #2
  49. @user1:example.com
    Message #1
  50. @user0:example.com
    Message #0
  51. -
+
+
+
+
+
+
-
-
-
-
-
+ +
-
- -
-
" diff --git a/test/widgets/ManagedHybrid-test.ts b/test/widgets/ManagedHybrid-test.ts index b91db09dc1..05093ed0d4 100644 --- a/test/widgets/ManagedHybrid-test.ts +++ b/test/widgets/ManagedHybrid-test.ts @@ -14,38 +14,91 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isManagedHybridWidgetEnabled } from "../../src/widgets/ManagedHybrid"; -import DMRoomMap from "../../src/utils/DMRoomMap"; +import { Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import fetchMock from "fetch-mock-jest"; + +import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from "../../src/widgets/ManagedHybrid"; import { stubClient } from "../test-utils"; import SdkConfig from "../../src/SdkConfig"; +import WidgetUtils from "../../src/utils/WidgetUtils"; +import { WidgetLayoutStore } from "../../src/stores/widgets/WidgetLayoutStore"; + +jest.mock("../../src/utils/room/getJoinedNonFunctionalMembers", () => ({ + getJoinedNonFunctionalMembers: jest.fn().mockReturnValue([1, 2]), +})); describe("isManagedHybridWidgetEnabled", () => { - let dmRoomMap: DMRoomMap; + let room: Room; beforeEach(() => { - stubClient(); - dmRoomMap = { - getUserIdForRoomId: jest.fn().mockReturnValue("@user:server"), - } as unknown as DMRoomMap; - DMRoomMap.setShared(dmRoomMap); + const client = stubClient(); + room = new Room("!room:server", client, client.getSafeUserId()); }); it("should return false if widget_build_url is unset", () => { - expect(isManagedHybridWidgetEnabled("!room:server")).toBeFalsy(); + expect(isManagedHybridWidgetEnabled(room)).toBeFalsy(); }); - it("should return true for DMs when widget_build_url_ignore_dm is unset", () => { + it("should return true for 1-1 rooms when widget_build_url_ignore_dm is unset", () => { SdkConfig.put({ widget_build_url: "https://url", }); - expect(isManagedHybridWidgetEnabled("!room:server")).toBeTruthy(); + expect(isManagedHybridWidgetEnabled(room)).toBeTruthy(); }); - it("should return false for DMs when widget_build_url_ignore_dm is true", () => { + it("should return false for 1-1 rooms when widget_build_url_ignore_dm is true", () => { SdkConfig.put({ widget_build_url: "https://url", widget_build_url_ignore_dm: true, }); - expect(isManagedHybridWidgetEnabled("!room:server")).toBeFalsy(); + expect(isManagedHybridWidgetEnabled(room)).toBeFalsy(); + }); +}); + +describe("addManagedHybridWidget", () => { + let room: Room; + + beforeEach(() => { + const client = stubClient(); + room = new Room("!room:server", client, client.getSafeUserId()); + }); + + it("should noop if user lacks permission", async () => { + const logSpy = jest.spyOn(logger, "error").mockImplementation(); + jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(false); + + fetchMock.mockClear(); + await addManagedHybridWidget(room); + expect(logSpy).toHaveBeenCalledWith("User not allowed to modify widgets in !room:server"); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + + it("should noop if no widget_build_url", async () => { + jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); + + fetchMock.mockClear(); + await addManagedHybridWidget(room); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + + it("should add the widget successfully", async () => { + fetchMock.get("https://widget-build-url/?roomId=!room:server", { + widget_id: "WIDGET_ID", + widget: { key: "value" }, + }); + jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); + jest.spyOn(WidgetLayoutStore.instance, "canCopyLayoutToRoom").mockReturnValue(true); + const setRoomWidgetContentSpy = jest.spyOn(WidgetUtils, "setRoomWidgetContent").mockResolvedValue(); + SdkConfig.put({ + widget_build_url: "https://widget-build-url", + }); + + await addManagedHybridWidget(room); + expect(fetchMock).toHaveBeenCalledWith("https://widget-build-url?roomId=!room:server"); + expect(setRoomWidgetContentSpy).toHaveBeenCalledWith(room.client, room.roomId, "WIDGET_ID", { + "key": "value", + "io.element.managed_hybrid": true, + }); }); }); diff --git a/yarn.lock b/yarn.lock index 24295924f6..64cb0c1f8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55,7 +55,7 @@ "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3" chokidar "^3.4.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.24.7": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== @@ -63,7 +63,7 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" -"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.22.13": +"@babel/code-frame@^7.10.4": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA== @@ -79,14 +79,6 @@ "@babel/highlight" "^7.22.13" chalk "^2.4.2" -"@babel/code-frame@^7.23.5": - version "7.24.2" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" - integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== - dependencies: - "@babel/highlight" "^7.24.2" - picocolors "^1.0.0" - "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.5", "@babel/compat-data@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" @@ -160,17 +152,7 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" -"@babel/generator@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.3.tgz#86e6e83d95903fbe7613f448613b8b319f330a8e" - integrity sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg== - dependencies: - "@babel/types" "^7.23.3" - "@jridgewell/gen-mapping" "^0.3.2" - "@jridgewell/trace-mapping" "^0.3.17" - jsesc "^2.5.1" - -"@babel/generator@^7.24.7": +"@babel/generator@^7.23.3", "@babel/generator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== @@ -252,27 +234,14 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" -"@babel/helper-environment-visitor@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" - integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== - -"@babel/helper-environment-visitor@^7.24.7": +"@babel/helper-environment-visitor@^7.22.20", "@babel/helper-environment-visitor@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" integrity sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ== dependencies: "@babel/types" "^7.24.7" -"@babel/helper-function-name@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" - integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== - dependencies: - "@babel/template" "^7.22.15" - "@babel/types" "^7.23.0" - -"@babel/helper-function-name@^7.24.7": +"@babel/helper-function-name@^7.23.0", "@babel/helper-function-name@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz#75f1e1725742f39ac6584ee0b16d94513da38dd2" integrity sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA== @@ -280,14 +249,7 @@ "@babel/template" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/helper-hoist-variables@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" - integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-hoist-variables@^7.24.7": +"@babel/helper-hoist-variables@^7.22.5", "@babel/helper-hoist-variables@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz#b4ede1cde2fd89436397f30dc9376ee06b0f25ee" integrity sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ== @@ -392,36 +354,19 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/helper-split-export-declaration@^7.22.6": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz#b9a67f06a46b0b339323617c8c6213b9055a78b6" - integrity sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q== - dependencies: - "@babel/types" "^7.24.5" - -"@babel/helper-split-export-declaration@^7.24.7": +"@babel/helper-split-export-declaration@^7.22.6", "@babel/helper-split-export-declaration@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" integrity sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA== dependencies: "@babel/types" "^7.24.7" -"@babel/helper-string-parser@^7.22.5": - version "7.23.4" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" - integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ== - -"@babel/helper-string-parser@^7.24.1", "@babel/helper-string-parser@^7.24.7": +"@babel/helper-string-parser@^7.22.5", "@babel/helper-string-parser@^7.24.1", "@babel/helper-string-parser@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== -"@babel/helper-validator-identifier@^7.22.20": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" - integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== - -"@babel/helper-validator-identifier@^7.24.5", "@babel/helper-validator-identifier@^7.24.7": +"@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.24.5", "@babel/helper-validator-identifier@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== @@ -467,17 +412,7 @@ chalk "^2.4.2" js-tokens "^4.0.0" -"@babel/highlight@^7.23.4": - version "7.24.2" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.2.tgz#3f539503efc83d3c59080a10e6634306e0370d26" - integrity sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA== - dependencies: - "@babel/helper-validator-identifier" "^7.22.20" - chalk "^2.4.2" - js-tokens "^4.0.0" - picocolors "^1.0.0" - -"@babel/highlight@^7.24.2", "@babel/highlight@^7.24.7": +"@babel/highlight@^7.23.4", "@babel/highlight@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== @@ -487,7 +422,7 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.14.7", "@babel/parser@^7.18.5", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.16", "@babel/parser@^7.23.3", "@babel/parser@^7.24.0", "@babel/parser@^7.24.7": +"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.14.7", "@babel/parser@^7.18.5", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.16", "@babel/parser@^7.23.3", "@babel/parser@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== @@ -1283,23 +1218,21 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.22.15": - version "7.24.0" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" - integrity sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA== +"@babel/runtime@^7.13.10": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" + integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== dependencies: - "@babel/code-frame" "^7.23.5" - "@babel/parser" "^7.24.0" - "@babel/types" "^7.24.0" + regenerator-runtime "^0.14.0" -"@babel/template@^7.24.7": +"@babel/template@^7.22.15", "@babel/template@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig== @@ -1317,23 +1250,7 @@ "@babel/parser" "^7.22.15" "@babel/types" "^7.22.15" -"@babel/traverse@^7.18.5", "@babel/traverse@^7.22.15", "@babel/traverse@^7.22.20": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.3.tgz#26ee5f252e725aa7aca3474aa5b324eaf7908b5b" - integrity sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ== - dependencies: - "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.23.3" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.3" - "@babel/types" "^7.23.3" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/traverse@^7.24.7": +"@babel/traverse@^7.18.5", "@babel/traverse@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== @@ -1349,6 +1266,22 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.22.15", "@babel/traverse@^7.22.20": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.3.tgz#26ee5f252e725aa7aca3474aa5b324eaf7908b5b" + integrity sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ== + dependencies: + "@babel/code-frame" "^7.22.13" + "@babel/generator" "^7.23.3" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/parser" "^7.23.3" + "@babel/types" "^7.23.3" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.3.3": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" @@ -1358,7 +1291,7 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" -"@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.23.0", "@babel/types@^7.24.0", "@babel/types@^7.24.5": +"@babel/types@^7.22.15", "@babel/types@^7.22.19": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.5.tgz#7661930afc638a5383eb0c4aee59b74f38db84d7" integrity sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ== @@ -1367,7 +1300,7 @@ "@babel/helper-validator-identifier" "^7.24.5" to-fast-properties "^2.0.0" -"@babel/types@^7.22.5", "@babel/types@^7.24.7", "@babel/types@^7.4.4": +"@babel/types@^7.23.0", "@babel/types@^7.23.3", "@babel/types@^7.24.0", "@babel/types@^7.24.5", "@babel/types@^7.24.7", "@babel/types@^7.4.4": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== @@ -1376,15 +1309,6 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" -"@babel/types@^7.23.3": - version "7.23.3" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.3.tgz#d5ea892c07f2ec371ac704420f4dcdb07b5f9598" - integrity sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw== - dependencies: - "@babel/helper-string-parser" "^7.22.5" - "@babel/helper-validator-identifier" "^7.22.20" - to-fast-properties "^2.0.0" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1439,9 +1363,9 @@ eslint-visitor-keys "^3.3.0" "@eslint-community/regexpp@^4.10.0": - version "4.10.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.1.tgz#361461e5cb3845d874e61731c11cfedd664d83a0" - integrity sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA== + version "4.11.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae" + integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A== "@eslint-community/regexpp@^4.6.1": version "4.8.0" @@ -1483,20 +1407,20 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== -"@floating-ui/core@^1.0.0": - version "1.6.3" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.3.tgz#5e7bb92843f47fd1d8dcb9b3cc3c243aaed54f95" - integrity sha512-1ZpCvYf788/ZXOhRQGFxnYQOVgeU+pi0i+d0Ow34La7qjIXETi6RNswGVKkA6KcDO8/+Ysu2E/CeUmmeEBDvTg== +"@floating-ui/core@^1.6.0": + version "1.6.4" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.4.tgz#0140cf5091c8dee602bff9da5ab330840ff91df6" + integrity sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA== dependencies: - "@floating-ui/utils" "^0.2.3" + "@floating-ui/utils" "^0.2.4" "@floating-ui/dom@^1.0.0": - version "1.6.6" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.6.tgz#be54c1ab2d19112ad323e63dbeb08185fed0ffd3" - integrity sha512-qiTYajAnh3P+38kECeffMSQgbvXty2VB6rS+42iWR4FPIlZjLK84E9qtLnMTLIpPz2znD/TaFqaiavMUrS+Hcw== + version "1.6.7" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.7.tgz#85d22f731fcc5b209db504478fb1df5116a83015" + integrity sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng== dependencies: - "@floating-ui/core" "^1.0.0" - "@floating-ui/utils" "^0.2.3" + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.4" "@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.0.8": version "2.1.1" @@ -1514,10 +1438,10 @@ "@floating-ui/utils" "^0.2.0" tabbable "^6.0.0" -"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.3.tgz#506fcc73f730affd093044cb2956c31ba6431545" - integrity sha512-XGndio0l5/Gvd6CLIABvsav9HHezgDFFhDfHk1bvLfr9ni8dojqLSvBbotJEjmIwNHL7vK4QzBJTdBRoB+c1ww== +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.4.tgz#1d459cee5031893a08a0e064c406ad2130cced7c" + integrity sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA== "@humanwhocodes/config-array@^0.11.14": version "0.11.14" @@ -1856,10 +1780,10 @@ resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== -"@matrix-org/analytics-events@^0.23.0": - version "0.23.1" - resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.23.1.tgz#206224f63e64b8cd892f704964204bce433bd189" - integrity sha512-+DuK5F313bZfO0jCsP7X3u0FQ09IM9Ujc9Zf//XoxzGThG9pvSYsEyNXQO7kUeQHwXlOobtVg1QcP172kN+h/g== +"@matrix-org/analytics-events@^0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.24.0.tgz#21a64537ac975b18e1eb13d9fd0bdc7d448a6039" + integrity sha512-3FDdtqZ+5cMqVffWjFNOIQ7RDFN6XS11kqdtN2ps8uvq5ce8gT0yXQvK37WeKWKZZ5QAKeoMzGhud+lsVcb1xg== "@matrix-org/emojibase-bindings@^1.1.2": version "1.1.3" @@ -1962,11 +1886,11 @@ integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== "@playwright/test@^1.40.1": - version "1.45.0" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.45.0.tgz#790a66165a46466c0d7099dd260881802f5aba7e" - integrity sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw== + version "1.45.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.45.2.tgz#e1b8512e20916720de1c5f5e89a362a252ea78ca" + integrity sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ== dependencies: - playwright "1.45.0" + playwright "1.45.2" "@radix-ui/primitive@1.0.1": version "1.0.1" @@ -2195,6 +2119,14 @@ dependencies: "@radix-ui/react-slot" "1.1.0" +"@radix-ui/react-progress@^1.0.3": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.1.0.tgz#28c267885ec154fc557ec7a66cb462787312f7e2" + integrity sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg== + dependencies: + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-roving-focus@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz#b30c59daf7e714c748805bfe11c76f96caaac35e" @@ -2233,9 +2165,9 @@ "@radix-ui/react-compose-refs" "1.1.0" "@radix-ui/react-tooltip@^1.0.6": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz#1807386562015c49b3e83d938910dd47f8cc6175" - integrity sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ== + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.2.tgz#c42db2ffd7dcc6ff3d65407c8cb70490288f518d" + integrity sha512-9XRsLwe6Yb9B/tlnYCPVUd/TFS4J7HuOZW345DCeC6vKIxQGMZdx21RK4VoZauPD5frgkXTYVS5y90L+3YBn4w== dependencies: "@radix-ui/primitive" "1.1.0" "@radix-ui/react-compose-refs" "1.1.0" @@ -2300,76 +2232,76 @@ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== -"@sentry-internal/browser-utils@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.12.0.tgz#ddea4b1ed1ee798beccf71b5b531928f7bdfd082" - integrity sha512-h7HRqED15Qa+DRt8iZGna24Z331nglgjPzdFn4+u+jvnZrehUjH0vjsfuj7qhwSUNZu8Rxi1ZlUYFURjLDTKCA== +"@sentry-internal/browser-utils@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.16.0.tgz#182931f169a586dde50cf255237b129aad00dde7" + integrity sha512-40lzNy5F6dUFCN85AGThBxHPQLSwoNhZM2hWqhAR5rZ3Yed0uBaKlm4aNJCeeUB9l4kd0sH0In+i9Nqu6TGKrw== dependencies: - "@sentry/core" "8.12.0" - "@sentry/types" "8.12.0" - "@sentry/utils" "8.12.0" + "@sentry/core" "8.16.0" + "@sentry/types" "8.16.0" + "@sentry/utils" "8.16.0" -"@sentry-internal/feedback@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.12.0.tgz#c4ba284ad7ab8a611f5cde4f705df2888c686ae0" - integrity sha512-PvQ14wVOPmzRdYdmXD791CqERZZC4jZa5hnyBKBuF6ZpifIQ4Uk7spPu6ZO+Ympx3GtRlpYjk4dbjHyNSfYTwA== +"@sentry-internal/feedback@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.16.0.tgz#dc8a3b807a37d0df136e62937e87ac23ce2ce6a8" + integrity sha512-BmRazZKl6iiVSg6eybUNOI1ve4eZqYpJYjkX48Jedn+7iZg7z12MNYl6IWPFBcN+sg+clf4wiKDr/SYS0yNemQ== dependencies: - "@sentry/core" "8.12.0" - "@sentry/types" "8.12.0" - "@sentry/utils" "8.12.0" + "@sentry/core" "8.16.0" + "@sentry/types" "8.16.0" + "@sentry/utils" "8.16.0" -"@sentry-internal/replay-canvas@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.12.0.tgz#b3f473ff3000f9151cdfcd26e1cf07ce8e3d60b4" - integrity sha512-0slfHZ3TD3MKeBu5NEGuKuecxStX23gts5L3mGFJd/zwsd04A31fhVmo6agIkxnZbOU4GPX/7HPWIeevkvy3ig== +"@sentry-internal/replay-canvas@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.16.0.tgz#c6501dd9f7e5dac1399978cc9e2797eb281a8f70" + integrity sha512-Bjh6pCDLZIPAPU2dNvJfI7BQV16rsRtYcylJgkGamjf8IcaBu7r/Whsvt1q34xO29xc0ISlp+0xG+YAdN1690Q== dependencies: - "@sentry-internal/replay" "8.12.0" - "@sentry/core" "8.12.0" - "@sentry/types" "8.12.0" - "@sentry/utils" "8.12.0" + "@sentry-internal/replay" "8.16.0" + "@sentry/core" "8.16.0" + "@sentry/types" "8.16.0" + "@sentry/utils" "8.16.0" -"@sentry-internal/replay@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.12.0.tgz#c518139ea6805dcc3d47c050d9857486166e6e21" - integrity sha512-TJceMtzRnY3SCvt3nFDu9rlT00Le7SaV2RL3D7SyDuijvJbWvIw3DRk7yutpF8c9YKO9j6FMa4NlkCJ+YAnnKQ== +"@sentry-internal/replay@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.16.0.tgz#5bf564d7293d4fb4993327567e9ad12079ceb951" + integrity sha512-JT/wmYU2JPtl8Ldl9oml/25Yz6C5wG+SpylDeUx4mPh728E/iI9vesIc2652J/0xots/DZXe4K6K5nYjdFtEcQ== dependencies: - "@sentry-internal/browser-utils" "8.12.0" - "@sentry/core" "8.12.0" - "@sentry/types" "8.12.0" - "@sentry/utils" "8.12.0" + "@sentry-internal/browser-utils" "8.16.0" + "@sentry/core" "8.16.0" + "@sentry/types" "8.16.0" + "@sentry/utils" "8.16.0" "@sentry/browser@^8.0.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.12.0.tgz#12dba1c7c54d74bba6830e6625f851d55c56bce8" - integrity sha512-H82dmr7KQWoS2DQc5dJko5wNepltcEro1EM4mBeL2YmVbNRtoZzD3HQTpbxJJuFsTvEMZevvez5HFlpUgxmIwQ== + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.16.0.tgz#af9b7b7556198d6de03cbc41b7abb5a16ecfc342" + integrity sha512-8Fxmk2aFWRixi2IKixiJR10Du34yb13HYr2iRw1haPKb5ZKa6CFA+XAnSzwpPZxO0RSHuPQR06YNkXaQ8fRAQQ== dependencies: - "@sentry-internal/browser-utils" "8.12.0" - "@sentry-internal/feedback" "8.12.0" - "@sentry-internal/replay" "8.12.0" - "@sentry-internal/replay-canvas" "8.12.0" - "@sentry/core" "8.12.0" - "@sentry/types" "8.12.0" - "@sentry/utils" "8.12.0" + "@sentry-internal/browser-utils" "8.16.0" + "@sentry-internal/feedback" "8.16.0" + "@sentry-internal/replay" "8.16.0" + "@sentry-internal/replay-canvas" "8.16.0" + "@sentry/core" "8.16.0" + "@sentry/types" "8.16.0" + "@sentry/utils" "8.16.0" -"@sentry/core@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.12.0.tgz#e1cbb5c0494db708ade84295f40360250709fd81" - integrity sha512-y+5Hlf/E45nj2adJy4aUCNBefQbyWIX66Z9bOM6JjnVB0hxCm5H0sYqrFKldYqaeZx6/Q2cgAcGs61krUxNerQ== +"@sentry/core@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.16.0.tgz#cf2f4e572240983ec7e9fa083cc1ffce3147f20b" + integrity sha512-l9mQgm5OqnykvZMh6PmJ/9ygW4qLyEFop+pQH/uM5zQCZQvEa7rvAd9QXKHdbVKq1CxJa/nJiByc8wPWxsftGQ== dependencies: - "@sentry/types" "8.12.0" - "@sentry/utils" "8.12.0" + "@sentry/types" "8.16.0" + "@sentry/utils" "8.16.0" -"@sentry/types@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.12.0.tgz#a14608eddec270c994d86a06408c0d3e5b11f1f2" - integrity sha512-pKuW64IjgcklWAOHzPJ02Ej480hyL25TLnYCAfl2TDMrYc+N0bbbH1N7ZxqJpTSVK9IxZPY/t2TRxpQBiyPEcg== +"@sentry/types@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.16.0.tgz#a9ae39cffd50a0bdba0556a1596fb135d035cf26" + integrity sha512-cIRsn7gWGVaWHgCniBWA0N8PNwzDYibhjyjPRTMxUjuZCT37i7zxByKKmd9u4TpRIJ64MyirNyM0O6T0A26fpg== -"@sentry/utils@8.12.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.12.0.tgz#be6a6514a034a04bb8feb5556a90ac2b83aa9310" - integrity sha512-pwYMoOmexz3vsNSOJGPvD2qwp/fsPcr8mkFk67wMM37Y+30KQ8pF4Aq1cc+HBRIn1tKmenzFDPTczSdVPFxm3Q== +"@sentry/utils@8.16.0": + version "8.16.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.16.0.tgz#5d1c9fb6cd562660b507c6647e6437282bef939a" + integrity sha512-tltCf2DVzz5TiYjxu/Rxbc9Qmm04893MFshV97jOTBcQeO2AAZBEl5rAoTCv1P08y7Yg+KiVwCx9Zj2x5U80/g== dependencies: - "@sentry/types" "8.12.0" + "@sentry/types" "8.16.0" "@sinclair/typebox@^0.27.8": version "0.27.8" @@ -2552,6 +2484,11 @@ resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.4.tgz#e3e331b7e0d5496873d417839f3b2bbcf555bb73" integrity sha512-aqBg5oAGo/qh/+wxUfuMadDu2WO0MEWOblyzwaM1Ske2xilUxBfgPqapAFVAfrVTDMVwa0UMarzGot8m64IAzA== +"@types/css-tree@^2.3.8": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@types/css-tree/-/css-tree-2.3.8.tgz#0eabc115e45051b2f7abe51ee1531074b234ed19" + integrity sha512-zABG3nI2UENsx7AQv63tI5/ptoAG/7kQR1H0OvG+WTWYHOR5pfAT3cGgC8SdyCrgX/TTxJBZNmx82IjCXs1juQ== + "@types/diff-match-patch@^1.0.32": version "1.0.36" resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af" @@ -2689,9 +2626,9 @@ integrity sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ== "@types/lodash@^4.14.168": - version "4.17.5" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.5.tgz#e6c29b58e66995d57cd170ce3e2a61926d55ee04" - integrity sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw== + version "4.17.7" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" + integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== "@types/mapbox__point-geometry@*", "@types/mapbox__point-geometry@^0.1.2": version "0.1.4" @@ -2908,29 +2845,29 @@ "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^7.0.0": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.14.1.tgz#90e2f76a5930d553ede124e1f541a39b4417465e" - integrity sha512-aAJd6bIf2vvQRjUG3ZkNXkmBpN+J7Wd0mfQiiVCJMu9Z5GcZZdcc0j8XwN/BM97Fl7e3SkTXODSk4VehUv7CGw== + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.0.tgz#b3563927341eca15124a18c6f94215f779f5c02a" + integrity sha512-py1miT6iQpJcs1BiJjm54AMzeuMPBSPuKPlnT8HlfudbcS5rYeX5jajpLf3mrdRh9dA/Ec2FVUY0ifeVNDIhZw== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "7.14.1" - "@typescript-eslint/type-utils" "7.14.1" - "@typescript-eslint/utils" "7.14.1" - "@typescript-eslint/visitor-keys" "7.14.1" + "@typescript-eslint/scope-manager" "7.16.0" + "@typescript-eslint/type-utils" "7.16.0" + "@typescript-eslint/utils" "7.16.0" + "@typescript-eslint/visitor-keys" "7.16.0" graphemer "^1.4.0" ignore "^5.3.1" natural-compare "^1.4.0" ts-api-utils "^1.3.0" "@typescript-eslint/parser@^7.0.0": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.14.1.tgz#13d97f357aed3c5719f259a6cc3d1a1f065d3692" - integrity sha512-8lKUOebNLcR0D7RvlcloOacTOWzOqemWEWkKSVpMZVF/XVcwjPR+3MD08QzbW9TCGJ+DwIc6zUSGZ9vd8cO1IA== + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.16.0.tgz#53fae8112f8c912024aea7b499cf7374487af6d8" + integrity sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw== dependencies: - "@typescript-eslint/scope-manager" "7.14.1" - "@typescript-eslint/types" "7.14.1" - "@typescript-eslint/typescript-estree" "7.14.1" - "@typescript-eslint/visitor-keys" "7.14.1" + "@typescript-eslint/scope-manager" "7.16.0" + "@typescript-eslint/types" "7.16.0" + "@typescript-eslint/typescript-estree" "7.16.0" + "@typescript-eslint/visitor-keys" "7.16.0" debug "^4.3.4" "@typescript-eslint/scope-manager@7.13.0": @@ -2941,21 +2878,21 @@ "@typescript-eslint/types" "7.13.0" "@typescript-eslint/visitor-keys" "7.13.0" -"@typescript-eslint/scope-manager@7.14.1": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.14.1.tgz#63de7a577bc6fe8ee6e412a5b85499f654b93ee5" - integrity sha512-gPrFSsoYcsffYXTOZ+hT7fyJr95rdVe4kGVX1ps/dJ+DfmlnjFN/GcMxXcVkeHDKqsq6uAcVaQaIi3cFffmAbA== +"@typescript-eslint/scope-manager@7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz#eb0757af5720c9c53c8010d7a0355ae27e17b7e5" + integrity sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw== dependencies: - "@typescript-eslint/types" "7.14.1" - "@typescript-eslint/visitor-keys" "7.14.1" + "@typescript-eslint/types" "7.16.0" + "@typescript-eslint/visitor-keys" "7.16.0" -"@typescript-eslint/type-utils@7.14.1": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.14.1.tgz#c183f2f28c4c8578eb80aebc4ac9ace400160af6" - integrity sha512-/MzmgNd3nnbDbOi3LfasXWWe292+iuo+umJ0bCCMCPc1jLO/z2BQmWUUUXvXLbrQey/JgzdF/OV+I5bzEGwJkQ== +"@typescript-eslint/type-utils@7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.16.0.tgz#ec52b1932b8fb44a15a3e20208e0bd49d0b6bd00" + integrity sha512-j0fuUswUjDHfqV/UdW6mLtOQQseORqfdmoBNDFOqs9rvNVR2e+cmu6zJu/Ku4SDuqiJko6YnhwcL8x45r8Oqxg== dependencies: - "@typescript-eslint/typescript-estree" "7.14.1" - "@typescript-eslint/utils" "7.14.1" + "@typescript-eslint/typescript-estree" "7.16.0" + "@typescript-eslint/utils" "7.16.0" debug "^4.3.4" ts-api-utils "^1.3.0" @@ -2964,10 +2901,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.13.0.tgz#0cca95edf1f1fdb0cfe1bb875e121b49617477c5" integrity sha512-QWuwm9wcGMAuTsxP+qz6LBBd3Uq8I5Nv8xb0mk54jmNoCyDspnMvVsOxI6IsMmway5d1S9Su2+sCKv1st2l6eA== -"@typescript-eslint/types@7.14.1": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.14.1.tgz#a43a540dbe5df7f2a11269683d777fc50b4350aa" - integrity sha512-mL7zNEOQybo5R3AavY+Am7KLv8BorIv7HCYS5rKoNZKQD9tsfGUpO4KdAn3sSUvTiS4PQkr2+K0KJbxj8H9NDg== +"@typescript-eslint/types@7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.16.0.tgz#60a19d7e7a6b1caa2c06fac860829d162a036ed2" + integrity sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw== "@typescript-eslint/typescript-estree@7.13.0": version "7.13.0" @@ -2983,13 +2920,13 @@ semver "^7.6.0" ts-api-utils "^1.3.0" -"@typescript-eslint/typescript-estree@7.14.1": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.14.1.tgz#ba7c9bac8744487749d19569e254d057754a1575" - integrity sha512-k5d0VuxViE2ulIO6FbxxSZaxqDVUyMbXcidC8rHvii0I56XZPv8cq+EhMns+d/EVIL41sMXqRbK3D10Oza1bbA== +"@typescript-eslint/typescript-estree@7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz#98ac779d526fab2a781e5619c9250f3e33867c09" + integrity sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw== dependencies: - "@typescript-eslint/types" "7.14.1" - "@typescript-eslint/visitor-keys" "7.14.1" + "@typescript-eslint/types" "7.16.0" + "@typescript-eslint/visitor-keys" "7.16.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -2997,15 +2934,15 @@ semver "^7.6.0" ts-api-utils "^1.3.0" -"@typescript-eslint/utils@7.14.1": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.14.1.tgz#3307b8226f99103dca2133d0ebcae38419d82c9d" - integrity sha512-CMmVVELns3nak3cpJhZosDkm63n+DwBlDX8g0k4QUa9BMnF+lH2lr3d130M1Zt1xxmB3LLk3NV7KQCq86ZBBhQ== +"@typescript-eslint/utils@7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.16.0.tgz#b38dc0ce1778e8182e227c98d91d3418449aa17f" + integrity sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA== dependencies: "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "7.14.1" - "@typescript-eslint/types" "7.14.1" - "@typescript-eslint/typescript-estree" "7.14.1" + "@typescript-eslint/scope-manager" "7.16.0" + "@typescript-eslint/types" "7.16.0" + "@typescript-eslint/typescript-estree" "7.16.0" "@typescript-eslint/utils@^6.0.0 || ^7.0.0": version "7.13.0" @@ -3025,12 +2962,12 @@ "@typescript-eslint/types" "7.13.0" eslint-visitor-keys "^3.4.3" -"@typescript-eslint/visitor-keys@7.14.1": - version "7.14.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.14.1.tgz#cc79b5ea154aea734b2a13b983670749f5742274" - integrity sha512-Crb+F75U1JAEtBeQGxSKwI60hZmmzaqA3z9sYsVm8X7W5cwLEm5bRe0/uXS6+MR/y8CVpKSR/ontIAIEPFcEkA== +"@typescript-eslint/visitor-keys@7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz#a1d99fa7a3787962d6e0efd436575ef840e23b06" + integrity sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg== dependencies: - "@typescript-eslint/types" "7.14.1" + "@typescript-eslint/types" "7.16.0" eslint-visitor-keys "^3.4.3" "@ungap/structured-clone@^1.2.0": @@ -3039,22 +2976,23 @@ integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== "@vector-im/compound-design-tokens@^1.2.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.3.0.tgz#1d04f006a9e56b920432095d08d7c84c0933ebc7" - integrity sha512-RXcyEAdxNzekMhVuvxtLPt9zb6yT2N+5cnb2Hul9zwRiF7+XEHpD36+IF6V0QOXk2pkN0wOr3jCvc9eOWOq9SQ== + version "1.5.0" + resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.5.0.tgz#6c8ed8eb0ddbb1fd8f8e6025d66b856dee8b5677" + integrity sha512-G1EvLJ2lyWjd2esKqlJjQl7KXrCfQNKZUdtW68y2aQi8EvVMOpVvCNXGf0HwRmdXGGy2FhBIOufVTgx39I7juw== dependencies: svg2vectordrawable "^2.9.1" -"@vector-im/compound-web@^5.2.3": - version "5.2.3" - resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-5.2.3.tgz#feab8ae7623cfaa243b9be69325e1696bfa1a09c" - integrity sha512-KU5vAgNIFBzRHfCRK5dGAhxjrfkrUXeOYzDUNc2QjEnqGaUR3RM4c53sw0Ga1oHbOeAWoUGId+ptH3ewPdUTAQ== +"@vector-im/compound-web@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-5.4.0.tgz#b95262197199c11931a8c6f5269514eb9461f187" + integrity sha512-+EPbr8HzlGEWSePEcPs2iQEBnjXvHGWK177SKF8IO2C7Z2Ygddxa2VTQ7oqtrUfgT+NB5IBTLyXV4Nx7FLgmMA== dependencies: "@floating-ui/react" "^0.26.9" "@floating-ui/react-dom" "^2.0.8" "@radix-ui/react-context-menu" "^2.1.5" "@radix-ui/react-dropdown-menu" "^2.0.6" "@radix-ui/react-form" "^0.0.3" + "@radix-ui/react-progress" "^1.0.3" "@radix-ui/react-separator" "^1.0.3" "@radix-ui/react-slot" "^1.0.2" "@radix-ui/react-tooltip" "^1.0.6" @@ -5103,10 +5041,10 @@ file-saver@^2.0.5: resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== -filesize@10.1.2: - version "10.1.2" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.2.tgz#33bb71c5c134102499f1bc36e6f2863137f6cb0c" - integrity sha512-Dx770ai81ohflojxhU+oG+Z2QGvKdYxgEr9OSA8UVrqhwNHjfH9A8f5NKfg83fEH8ZFA5N5llJo5T3PIoZ4CRA== +filesize@10.1.4: + version "10.1.4" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.4.tgz#184f256063a201f08b6e6b3cc47d21b60f5b8d89" + integrity sha512-ryBwPIIeErmxgPnm6cbESAzXjuEFubs+yKYLBZvg3CaiNcmkJChoOGcBSrZ6IwkMwPABwPpVXE6IlNdGJJrvEg== fill-range@^7.1.1: version "7.1.1" @@ -5206,9 +5144,9 @@ foreachasync@^3.0.0: integrity sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw== foreground-child@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" - integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + version "3.2.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" + integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA== dependencies: cross-spawn "^7.0.0" signal-exit "^4.0.1" @@ -5360,16 +5298,17 @@ glob-to-regexp@^0.4.0, glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^10.3.7: - version "10.3.15" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.15.tgz#e72bc61bc3038c90605f5dd48543dc67aaf3b50d" - integrity sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw== +glob@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.0.tgz#6031df0d7b65eaa1ccb9b29b5ced16cea658e77e" + integrity sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g== dependencies: foreground-child "^3.1.0" - jackspeak "^2.3.6" - minimatch "^9.0.1" - minipass "^7.0.4" - path-scurry "^1.11.0" + jackspeak "^4.0.1" + minimatch "^10.0.0" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" glob@^7.1.3, glob@^7.1.4, glob@^7.2.0: version "7.2.3" @@ -5510,9 +5449,9 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: function-bind "^1.1.2" highlight.js@^11.3.1: - version "11.9.0" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0" - integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw== + version "11.10.0" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92" + integrity sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ== hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" @@ -6049,10 +5988,10 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -jackspeak@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" - integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== +jackspeak@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.0.1.tgz#9fca4ce961af6083e259c376e9e3541431f5287b" + integrity sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: @@ -6592,9 +6531,9 @@ jwt-decode@4.0.0, jwt-decode@^4.0.0: integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== katex@^0.16.0: - version "0.16.10" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185" - integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA== + version "0.16.11" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" + integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== dependencies: commander "^8.3.0" @@ -6770,10 +6709,10 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lru-cache@^10.2.0: - version "10.2.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.2.tgz#48206bc114c1252940c41b25b41af5b545aca878" - integrity sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ== +lru-cache@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.0.tgz#15d93a196f189034d7166caf9fe55e7384c98a21" + integrity sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA== lru-cache@^5.1.1: version "5.1.1" @@ -6895,9 +6834,9 @@ matrix-js-sdk@34.1.0: uuid "10" matrix-web-i18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-3.2.1.tgz#34e6b66bec71a52fddbe72db56d9e35dabbaff59" - integrity sha512-pBklE6Q6mAwG6N3Qtpu/e+qX0XuWEdrs4SZ+QmYJWfyLNtKAB6XcSpE5m7aBW/+11ejg8ua8Q5bNcDV2b7C9lg== + version "3.3.0" + resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-3.3.0.tgz#a9f9d87d18ef96f75171883abbf201952cbfbe22" + integrity sha512-bJPJrBGrCdslkf2wMVHWyZlAEx9zSKnOsJ9rILaaEy195yyNLpXrYoyRIXEk8YWsdwtaK1ImE+r/Gh43J/I4ow== dependencies: "@babel/parser" "^7.18.5" "@babel/traverse" "^7.18.5" @@ -7012,6 +6951,13 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== +minimatch@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" + integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== + dependencies: + brace-expansion "^2.0.1" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -7019,10 +6965,10 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^9.0.1, minimatch@^9.0.4: - version "9.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" - integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: brace-expansion "^2.0.1" @@ -7031,10 +6977,10 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8, minimist@~1. resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.4: - version "7.1.1" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.1.tgz#f7f85aff59aa22f110b20e27692465cf3bf89481" - integrity sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA== +minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== mkdirp@1.0.4, mkdirp@^1.0.4: version "1.0.4" @@ -7335,6 +7281,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + pako@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" @@ -7409,13 +7360,13 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.11.0: - version "1.11.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" - integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== dependencies: - lru-cache "^10.2.0" - minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + lru-cache "^11.0.0" + minipass "^7.1.2" path-to-regexp@0.1.7: version "0.1.7" @@ -7474,17 +7425,17 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -playwright-core@1.45.0: - version "1.45.0" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.45.0.tgz#5741a670b7c9060ce06852c0051d84736fb94edc" - integrity sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ== +playwright-core@1.45.2, playwright-core@^1.45.1: + version "1.45.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.45.2.tgz#c8b8b7f66eda47fb2bd24e5435c92d1163022df8" + integrity sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw== -playwright@1.45.0: - version "1.45.0" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.45.0.tgz#400c709c64438690f13705cb9c88ef93089c5c27" - integrity sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA== +playwright@1.45.2: + version "1.45.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.45.2.tgz#21082072120a2c8a7e3bbb2792e81e8aa367b7a7" + integrity sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g== dependencies: - playwright-core "1.45.0" + playwright-core "1.45.2" optionalDependencies: fsevents "2.3.2" @@ -7566,10 +7517,10 @@ postcss@^8.4.38: picocolors "^1.0.0" source-map-js "^1.2.0" -posthog-js@1.141.3: - version "1.141.3" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.141.3.tgz#c0b78e62567b5de15e400254905d2b72544e3618" - integrity sha512-LZ+I6wJS82yX/SZVaK20V2WV4MEfB2G9fT2ZJoWlzwN5L3wsbpmjD9F2dVW818deBV3ms1w0Ho7rnlJtBGHx2g== +posthog-js@1.145.0: + version "1.145.0" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.145.0.tgz#5159459f02988b74407a1dd2b19469c422b31feb" + integrity sha512-LQdH6S2Ks3mnCI0q9aD5SZS0Uujc/90nuJuEeGDeGkWkVkYOSQJt4n0UHrIWEsZdmIKZf0a6OIBhTmO+yUiY3w== dependencies: fflate "^0.4.8" preact "^10.19.3" @@ -8125,12 +8076,12 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^5.0.0: - version "5.0.7" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.7.tgz#27bddf202e7d89cb2e0381656380d1734a854a74" - integrity sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg== +rimraf@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.0.0.tgz#503bb3d9283272384c121792d40e7ee3ab763cde" + integrity sha512-u+yqhM92LW+89cxUQK0SRyvXYQmyuKHx0jkx4W7KfwLGLqJnQM5031Uv1trE4gB9XEXBM/s6MxKlfW95IidqaA== dependencies: - glob "^10.3.7" + glob "^11.0.0" run-parallel@^1.1.9: version "1.2.0" @@ -9038,10 +8989,10 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" -typescript@5.5.2: - version "5.5.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.2.tgz#c26f023cb0054e657ce04f72583ea2d85f8d0507" - integrity sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew== +typescript@5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.3.tgz#e1b0a3c394190838a0b168e771b0ad56a0af0faa" + integrity sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ== ua-parser-js@^1.0.2: version "1.0.38"