diff --git a/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts b/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts index d5679bc017..a36e97ac45 100644 --- a/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts +++ b/apps/web/playwright/e2e/app-loading/guest-registration.spec.ts @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. * Tests for application startup with guest registration enabled on the server. */ +import type { Page } from "playwright-core"; import { expect, test } from "../../element-web-test"; test.use({ @@ -18,12 +19,28 @@ test.use({ }, }); +const screenshotOptions = (page?: Page) => ({ + // Hide the UserID + css: ` + span[data-testid="userId"] { + display: none !important; + } + `, +}); + test("Shows the welcome page by default", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible(); }); +test("Shows the user menu for guests", { tag: ["@screenshot"] }, async ({ page, app }) => { + await page.goto("/#/room/!room:id"); + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + const menu = await app.openUserMenu(); + await expect(menu).toMatchScreenshot("guest-menu.png", screenshotOptions(page)); +}); + test("Room link correctly loads a room view", async ({ page }) => { await page.goto("/#/room/!room:id"); await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); diff --git a/apps/web/playwright/e2e/crypto/logout.spec.ts b/apps/web/playwright/e2e/crypto/logout.spec.ts index 4eedca2942..6cf02f3408 100644 --- a/apps/web/playwright/e2e/crypto/logout.spec.ts +++ b/apps/web/playwright/e2e/crypto/logout.spec.ts @@ -23,7 +23,9 @@ test.describe("Logout tests", () => { await sendMessageInCurrentRoom(page, "Hello secret world"); const locator = await app.settings.openUserMenu(); - await locator.getByRole("menuitem", { name: "Remove this device", exact: true }).click(); + + await locator.getByRole("menuitem", { name: "All settings", exact: true }).click(); + await page.getByRole("button", { name: "Remove this device", exact: true }).click(); const currentDialogLocator = page.locator(".mx_Dialog"); @@ -41,7 +43,8 @@ test.describe("Logout tests", () => { await sendMessageInCurrentRoom(page, "Hello secret world"); const locator = await app.settings.openUserMenu(); - await locator.getByRole("menuitem", { name: "Remove this device", exact: true }).click(); + await locator.getByRole("menuitem", { name: "All settings", exact: true }).click(); + await page.getByRole("button", { name: "Remove this device", exact: true }).click(); const currentDialogLocator = page.locator(".mx_Dialog"); @@ -54,7 +57,8 @@ test.describe("Logout tests", () => { await sendMessageInCurrentRoom(page, "Hello public world!"); const locator = await app.settings.openUserMenu(); - await locator.getByRole("menuitem", { name: "Remove this device", exact: true }).click(); + await locator.getByRole("menuitem", { name: "All settings", exact: true }).click(); + await page.getByRole("button", { name: "Remove this device", exact: true }).click(); // Should have logged out directly await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); diff --git a/apps/web/playwright/e2e/crypto/utils.ts b/apps/web/playwright/e2e/crypto/utils.ts index ca51b7d9d8..f08863ccdb 100644 --- a/apps/web/playwright/e2e/crypto/utils.ts +++ b/apps/web/playwright/e2e/crypto/utils.ts @@ -252,7 +252,9 @@ export async function logIntoElementAndVerify(page: Page, credentials: Credentia */ export async function logOutOfElement(page: Page, discardKeys: boolean = false) { await page.getByRole("button", { name: "User menu" }).click(); - await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Remove this device" }).click(); + + await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click(); + await page.getByRole("button", { name: "Remove this device" }).click(); if (discardKeys) { await page.getByRole("button", { name: "I don't want my encrypted messages" }).click(); } else { diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index 66d905afb7..a782913f8b 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -328,8 +328,11 @@ test.describe("Room list", () => { const videoRoom = roomListView.getByRole("option", { name: "video room" }); await expect(videoRoom).toHaveAttribute("aria-selected", "true"); // wait for room list update + // Ensure we highlight the video + await videoRoom.click(); + // focus the user menu to avoid to have hover decoration - await page.getByRole("button", { name: "User menu" }).focus(); + await page.getByRole("button", { name: "User menu" }).hover(); await expect(videoRoom).toMatchScreenshot("room-list-item-video.png"); }); diff --git a/apps/web/playwright/e2e/login/login-consent.spec.ts b/apps/web/playwright/e2e/login/login-consent.spec.ts index 2dc33eaf21..23f10e39bb 100644 --- a/apps/web/playwright/e2e/login/login-consent.spec.ts +++ b/apps/web/playwright/e2e/login/login-consent.spec.ts @@ -340,11 +340,8 @@ test.describe("Login", () => { // Allow the outstanding requests queue to settle before logging out await page.waitForTimeout(2000); - - await page - .locator(".mx_UserMenu_contextMenu") - .getByRole("menuitem", { name: "Remove this device" }) - .click(); + await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click(); + await page.getByRole("button", { name: "Remove this device" }).click(); await expect(page).toHaveURL(/\/#\/welcome$/); }); }); diff --git a/apps/web/playwright/e2e/login/logout_redirect_url.spec.ts b/apps/web/playwright/e2e/login/logout_redirect_url.spec.ts index df0b8983d5..57596de0f4 100644 --- a/apps/web/playwright/e2e/login/logout_redirect_url.spec.ts +++ b/apps/web/playwright/e2e/login/logout_redirect_url.spec.ts @@ -28,8 +28,9 @@ test.describe("logout with logout_redirect_url", () => { // give a change for the outstanding requests queue to settle before logging out await page.waitForTimeout(2000); + await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click(); + await page.getByRole("button", { name: "Remove this device" }).click(); - await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Remove this device" }).click(); await expect(page).toHaveURL(/\/decoder-ring\/$/); }); }); diff --git a/apps/web/playwright/e2e/oidc/oidc-native.spec.ts b/apps/web/playwright/e2e/oidc/oidc-native.spec.ts index f81d6b71ff..21b79a1d13 100644 --- a/apps/web/playwright/e2e/oidc/oidc-native.spec.ts +++ b/apps/web/playwright/e2e/oidc/oidc-native.spec.ts @@ -74,7 +74,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { (request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "refresh_token", ); const locator = await app.settings.openUserMenu(); - await locator.getByRole("menuitem", { name: "Remove this device", exact: true }).click(); + await locator.getByRole("menuitem", { name: "All settings", exact: true }).click(); + await page.getByRole("button", { name: "Remove this device", exact: true }).click(); await revokeAccessTokenPromise; await revokeRefreshTokenPromise; }); @@ -122,7 +123,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Allow the outstanding requests queue to settle before logging out await page.waitForTimeout(2000); - await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Remove this device" }).click(); + await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click(); + await page.getByRole("button", { name: "Remove this device" }).click(); await expect(page).toHaveURL(/\/#\/welcome$/); // Log in again @@ -155,10 +157,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await page.getByRole("button", { name: "User menu" }).click(); await expect(page.getByText(userId, { exact: true })).toBeVisible(); await page.waitForTimeout(2000); - await page - .locator(".mx_UserMenu_contextMenu") - .getByRole("menuitem", { name: "Remove this device" }) - .click(); + await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click(); + await page.getByRole("button", { name: "Remove this device" }).click(); await expect(page).toHaveURL(/\/#\/welcome$/); // Log in again @@ -206,9 +206,10 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(page.getByText(userId, { exact: true })).toBeVisible(); await page.waitForTimeout(2000); await page - .locator(".mx_UserMenu_contextMenu") - .getByRole("menuitem", { name: "Remove this device" }) + .getByRole("menu", { name: "User menu" }) + .getByRole("menuitem", { name: "All settings" }) .click(); + await page.getByRole("button", { name: "Remove this device" }).click(); await expect(page).toHaveURL(/\/#\/welcome$/); // Log in again diff --git a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts index b3ec952b01..2090ebda0f 100644 --- a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts @@ -129,7 +129,7 @@ test.describe("Account user settings tab", () => { await expect(accountPhoneNumbers.getByRole("button", { name: "Add" })).toBeVisible(); }); - test("should support changing a display name", async ({ uut, page, app }) => { + test("should support changing a display name", async ({ uut, page, app, user }) => { // Change the diaplay name to USER_NAME_NEW const displayNameInput = uut .locator(".mx_SettingsTab .mx_UserProfileSettings") @@ -140,7 +140,8 @@ test.describe("Account user settings tab", () => { await app.closeDialog(); // Assert the avatar's initial characters are set - await expect(page.locator(".mx_UserMenu .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice + const menu = await app.openUserMenu(); + await expect(menu.getByRole("img", { name: user.userId }).getByText("A")).toBeVisible(); // Alice await expect(page.locator(".mx_RoomView_wrapper .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice }); diff --git a/apps/web/playwright/e2e/spaces/spaces.spec.ts b/apps/web/playwright/e2e/spaces/spaces.spec.ts index 173130fd29..48bb513800 100644 --- a/apps/web/playwright/e2e/spaces/spaces.spec.ts +++ b/apps/web/playwright/e2e/spaces/spaces.spec.ts @@ -318,6 +318,9 @@ test.describe("Spaces", () => { await spaceTree.getByRole("button", { name: "Expand" }).click(); await expect(page.locator(".mx_SpacePanel:not(.collapsed)")).toBeVisible(); // TODO: replace :not() selector + // focus the quick settings button to ensure the spaces aren't being hovered over for consistent screenshots + await page.getByRole("button", { name: "Quick settings" }).focus(); + const item = page.locator(".mx_SpaceItem", { hasText: "Root Space" }); await expect(item).toBeVisible(); await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible(); diff --git a/apps/web/playwright/e2e/user-menu/user-menu.spec.ts b/apps/web/playwright/e2e/user-menu/user-menu.spec.ts index 1f67aa0be6..cfc4757c15 100644 --- a/apps/web/playwright/e2e/user-menu/user-menu.spec.ts +++ b/apps/web/playwright/e2e/user-menu/user-menu.spec.ts @@ -6,8 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import type { Page } from "playwright-core"; import { test, expect } from "../../element-web-test"; +const screenshotOptions = (page?: Page) => ({ + // Hide the UserID + css: ` + span[data-testid="userId"] { + display: none !important; + } + `, +}); + test.describe("User Menu", () => { test.use({ displayName: "Jeff" }); @@ -15,8 +25,8 @@ test.describe("User Menu", () => { await page.getByRole("button", { name: "User menu", exact: true }).click(); const menu = page.getByRole("menu"); - await expect(menu.locator(".mx_UserMenu_contextMenu_displayName", { hasText: user.displayName })).toBeVisible(); - await expect(menu.locator(".mx_UserMenu_contextMenu_userId", { hasText: user.userId })).toBeVisible(); - await expect(menu).toMatchScreenshot("user-menu.png"); + await expect(menu.getByText(user.displayName)).toBeVisible(); + await expect(menu.getByText(user.userId)).toBeVisible(); + await expect(menu).toMatchScreenshot("user-menu.png", screenshotOptions(page)); }); }); diff --git a/apps/web/playwright/pages/settings.ts b/apps/web/playwright/pages/settings.ts index 0ea8c50348..c871bbeeaf 100644 --- a/apps/web/playwright/pages/settings.ts +++ b/apps/web/playwright/pages/settings.ts @@ -17,8 +17,8 @@ export class Settings { * Open the top left user menu, returning a Locator to the resulting context menu. */ public async openUserMenu(): Promise { - const locator = this.page.locator(".mx_ContextualMenu"); - if (await locator.locator(".mx_UserMenu_contextMenu_header").isVisible()) return locator; + const locator = this.page.getByRole("menu", { name: "User menu" }); + if (await locator.isVisible()) return locator; await this.page.getByRole("button", { name: "User menu" }).click(); await locator.waitFor(); return locator; diff --git a/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png b/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png new file mode 100644 index 0000000000..441dfefba3 Binary files /dev/null and b/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-default-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-default-linux.png index 531802ed1c..0c96750a50 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-default-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-default-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-fully-collapsed-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-fully-collapsed-linux.png index 3c63cdd84b..288db67450 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-fully-collapsed-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-collapse.spec.ts/room-list-collapse-fully-collapsed-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index 77345fe6fe..89cf0d7f64 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index 489c080f55..d0025d1fed 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 9e5528b3e5..74b18c088b 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index e5294b4392..47048b97b1 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png index 0a3580977e..f5c34f1c8a 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png index c90c4fc54f..5d36c31906 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png index 2a8ed886df..19e7689a64 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/window-custom-theme-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png index 4ca008798f..7b3063a2df 100644 Binary files a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png and b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png index b6c4e65be2..f187b3cab3 100644 Binary files a/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png index 005dae5da0..f3e182ae4d 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png index 005dae5da0..596a9c30b1 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png index 40f1243619..4952185cd1 100644 Binary files a/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png and b/apps/web/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png differ diff --git a/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png index d32108d254..64fc5585ec 100644 Binary files a/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png and b/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index a175838d57..1813d36d34 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -84,7 +84,6 @@ @import "./structures/_ThreadsActivityCentre.pcss"; @import "./structures/_ToastContainer.pcss"; @import "./structures/_UploadBar.pcss"; -@import "./structures/_UserMenu.pcss"; @import "./structures/_ViewSource.pcss"; @import "./structures/auth/_CompleteSecurity.pcss"; @import "./structures/auth/_ConfirmSessionLockTheftView.pcss"; diff --git a/apps/web/res/css/structures/_SpacePanel.pcss b/apps/web/res/css/structures/_SpacePanel.pcss index 5b6acf7596..a3c8b82701 100644 --- a/apps/web/res/css/structures/_SpacePanel.pcss +++ b/apps/web/res/css/structures/_SpacePanel.pcss @@ -339,26 +339,6 @@ Please see LICENSE files in the repository root for full details. mask-repeat: no-repeat; } } - - .mx_UserMenu { - padding-bottom: 12px; - border-bottom: 1px solid $separator; - margin: 12px 14px 4px 18px; - width: min-content; - max-width: 226px; - - /* Display the container and img here as block elements so they don't take - * up extra vertical space. - */ - .mx_UserMenu_userAvatar_BaseAvatar { - display: block; - } - } - - &.newUi .mx_UserMenu { - margin-top: var(--cpd-space-4x); - border-bottom: none; - } } .mx_SpacePanel_contextMenu { diff --git a/apps/web/res/css/structures/_UserMenu.pcss b/apps/web/res/css/structures/_UserMenu.pcss deleted file mode 100644 index b6c5b73e93..0000000000 --- a/apps/web/res/css/structures/_UserMenu.pcss +++ /dev/null @@ -1,126 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserMenu { - box-sizing: border-box; - display: flex; - align-items: center; - - .mx_AccessibleButton { - display: flex; - align-items: center; - - .mx_UserMenu_userAvatar { - position: relative; - - .mx_BaseAvatar { - pointer-events: none; /* makes the avatar non-draggable */ - } - } - } - - .mx_UserMenu_contextMenuButton { - width: 100%; - } - - .mx_UserMenu_name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-15px; - line-height: $font-24px; - margin-left: 10px; - } -} - -.mx_IconizedContextMenu { - &.mx_UserMenu_contextMenu { - width: 258px; - } -} - -.mx_UserMenu_contextMenu { - &.mx_IconizedContextMenu .mx_IconizedContextMenu_optionList_red { - .mx_AccessibleButton { - padding-top: 16px; - padding-bottom: 16px; - } - } - - .mx_UserMenu_contextMenu_header { - padding: 20px; - - /* Create a flexbox to organize the header a bit easier */ - display: flex; - align-items: center; - - .mx_UserMenu_contextMenu_name { - /* Create another flexbox of columns to handle large user IDs */ - display: flex; - flex-direction: column; - width: calc(100% - 40px); /* 40px = 32px theme button + 8px margin to theme button */ - - .mx_UserMenu_contextMenu_displayName, - .mx_UserMenu_contextMenu_userId { - font: var(--cpd-font-body-lg-regular); - - /* Automatically grow subelements to fit the container */ - flex: 1; - width: 100%; - - /* Ellipsize text overflow */ - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .mx_UserMenu_contextMenu_displayName { - font-weight: var(--cpd-font-weight-semibold); - } - } - - .mx_UserMenu_contextMenu_themeButton { - flex-shrink: 0; - margin-left: 8px; - - /* to make alignment easier, create flexbox for the image */ - display: flex; - align-items: center; - justify-content: center; - - /* For enhanced visibility under contrast control */ - outline: 1px solid transparent; - - /* Compound overrides to match transitional designs */ - padding: var(--cpd-space-2x); - svg { - width: 16px; - height: 16px; - } - } - - &.mx_UserMenu_contextMenu_guestPrompts { - padding-top: 0; - display: inline-block; - - > span { - font-weight: var(--cpd-font-weight-semibold); - display: block; - - & + span { - margin-top: 8px; - } - } - } - } - - .mx_IconizedContextMenu_icon svg { - color: $icon-button-color; - } -} diff --git a/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss index 483917fb0c..ce760ddcf0 100644 --- a/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/apps/web/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -82,10 +82,6 @@ $accent-1400: var(--cpd-color-green-1400); } } -.mx_UserMenu_contextMenu .mx_UserMenu_contextMenu_header .mx_UserMenu_contextMenu_themeButton { - background-color: $panel-actions !important; -} - .mx_ThemeChoicePanel_themeSelectors > .mx_StyledRadioButton input[type="radio"]:disabled + div { border-color: $primary-content; } diff --git a/apps/web/src/components/structures/UserMenu.tsx b/apps/web/src/components/structures/UserMenu.tsx deleted file mode 100644 index b3f20a07ed..0000000000 --- a/apps/web/src/components/structures/UserMenu.tsx +++ /dev/null @@ -1,441 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020, 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type JSX, createRef, type ReactNode, useMemo } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { - ChatSolidIcon, - HomeSolidIcon, - LockSolidIcon, - QrCodeIcon, - SettingsSolidIcon, - LeaveIcon, - NotificationsSolidIcon, - ThemeIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { IconButton } from "@vector-im/compound-web"; - -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import { type ActionPayload } from "../../dispatcher/payloads"; -import { Action } from "../../dispatcher/actions"; -import { _t } from "../../languageHandler"; -import { ChevronFace, ContextMenuButton, type MenuProps } from "./ContextMenu"; -import { UserTab } from "../views/dialogs/UserTab"; -import { type OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; -import FeedbackDialog from "../views/dialogs/FeedbackDialog"; -import Modal from "../../Modal"; -import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog"; -import SettingsStore from "../../settings/SettingsStore"; -import { findHighContrastTheme, isHighContrastTheme } from "../../theme"; -import { useRovingTabIndex } from "../../accessibility/RovingTabIndex"; -import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; -import SdkConfig from "../../SdkConfig"; -import { getHomePageUrl } from "../../utils/pages"; -import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import BaseAvatar from "../views/avatars/BaseAvatar"; -import { SettingLevel } from "../../settings/SettingLevel"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../views/context_menus/IconizedContextMenu"; -import { UIFeature } from "../../settings/UIFeature"; -import SpaceStore from "../../stores/spaces/SpaceStore"; -import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; -import UserIdentifierCustomisations from "../../customisations/UserIdentifier"; -import PosthogTrackers from "../../PosthogTrackers"; -import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; -import { SDKContext } from "../../contexts/SDKContext"; -import { shouldShowFeedback } from "../../utils/Feedback"; -import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher.ts"; -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter.ts"; - -interface IProps { - isPanelCollapsed: boolean; - children?: ReactNode; -} - -type PartialDOMRect = Pick; - -interface IState { - contextMenuPosition: PartialDOMRect | null; - selectedSpace?: Room | null; -} - -const toRightOf = (rect: PartialDOMRect): MenuProps => { - return { - left: rect.width + rect.left + 8, - top: rect.top, - chevronFace: ChevronFace.None, - }; -}; - -const below = (rect: PartialDOMRect): MenuProps => { - return { - left: rect.left, - top: rect.top + rect.height, - chevronFace: ChevronFace.None, - }; -}; - -const ThemeSwitchButton = (): JSX.Element => { - const [onFocus, isActive, ref] = useRovingTabIndex(); - const themeWatcher = useMemo(() => new ThemeWatcher(), []); - const [isHighContrast, isDark] = useTypedEventEmitterState( - themeWatcher, - ThemeWatcherEvent.Change, - (theme: string) => [isHighContrastTheme(theme), themeWatcher.isUserOnDarkTheme()], - ); - - const onSwitchThemeClick = (ev: ButtonEvent): void => { - ev.preventDefault(); - ev.stopPropagation(); - - PosthogTrackers.trackInteraction("WebUserMenuThemeToggleButton", ev); - - // Disable system theme matching if the user hits this button - SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); - - let newTheme = isDark ? "light" : "dark"; - if (isHighContrast) { - const hcTheme = findHighContrastTheme(newTheme); - if (hcTheme) { - newTheme = hcTheme; - } - } - SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab - themeWatcher.recheck(newTheme); - }; - - return ( - - - - ); -}; - -export default class UserMenu extends React.Component { - public static contextType = SDKContext; - declare public context: React.ContextType; - - private dispatcherRef?: string; - private themeWatcherRef?: string; - private readonly dndWatcherRef?: string; - private buttonRef = createRef(); - - public constructor(props: IProps) { - super(props); - - this.state = { - contextMenuPosition: null, - selectedSpace: SpaceStore.instance.activeSpaceRoom, - }; - } - - private get hasHomePage(): boolean { - return !!getHomePageUrl(SdkConfig.get(), this.context.client!); - } - - public componentDidMount(): void { - OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); - SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); - this.dispatcherRef = defaultDispatcher.register(this.onAction); - } - - public componentWillUnmount(): void { - SettingsStore.unwatchSetting(this.themeWatcherRef); - SettingsStore.unwatchSetting(this.dndWatcherRef); - defaultDispatcher.unregister(this.dispatcherRef); - OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); - SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); - } - - private onProfileUpdate = async (): Promise => { - // the store triggered an update, so force a layout update. We don't - // have any state to store here for that to magically happen. - this.forceUpdate(); - }; - - private onSelectedSpaceUpdate = async (): Promise => { - this.setState({ - selectedSpace: SpaceStore.instance.activeSpaceRoom, - }); - }; - - private onAction = (payload: ActionPayload): void => { - switch (payload.action) { - case Action.ToggleUserMenu: - if (this.state.contextMenuPosition) { - this.setState({ contextMenuPosition: null }); - } else { - if (this.buttonRef.current) this.buttonRef.current.click(); - } - break; - } - }; - - private onOpenMenuClick = (ev: ButtonEvent): void => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ contextMenuPosition: ev.currentTarget.getBoundingClientRect() }); - }; - - private onContextMenu = (ev: React.MouseEvent): void => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - contextMenuPosition: { - left: ev.clientX, - top: ev.clientY, - width: 20, - height: 0, - }, - }); - }; - - private onCloseMenu = (): void => { - this.setState({ contextMenuPosition: null }); - }; - - private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record): void => { - ev.preventDefault(); - ev.stopPropagation(); - - const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props }; - defaultDispatcher.dispatch(payload); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onProvideFeedback = (ev: ButtonEvent): void => { - ev.preventDefault(); - ev.stopPropagation(); - - Modal.createDialog(FeedbackDialog); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onSignOutClick = async (ev: ButtonEvent): Promise => { - ev.preventDefault(); - ev.stopPropagation(); - - if (await shouldShowLogoutDialog(MatrixClientPeg.safeGet())) { - Modal.createDialog(LogoutDialog); - } else { - defaultDispatcher.dispatch({ action: "logout" }); - } - - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onSignInClick = (): void => { - defaultDispatcher.dispatch({ action: "start_login" }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onRegisterClick = (): void => { - defaultDispatcher.dispatch({ action: "start_registration" }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onHomeClick = (ev: ButtonEvent): void => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({ action: Action.ViewHomePage }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private renderContextMenu = (): React.ReactNode => { - if (!this.state.contextMenuPosition) return null; - - let topSection: JSX.Element | undefined; - if (MatrixClientPeg.safeGet().isGuest()) { - topSection = ( -
- {_t( - "auth|sign_in_prompt", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} - {SettingsStore.getValue(UIFeature.Registration) - ? _t( - "auth|create_account_prompt", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - ) - : null} -
- ); - } - - let homeButton: JSX.Element | undefined; - if (this.hasHomePage) { - homeButton = ( - } - label={_t("common|home")} - onClick={this.onHomeClick} - /> - ); - } - - let feedbackButton: JSX.Element | undefined; - if (shouldShowFeedback()) { - feedbackButton = ( - } - label={_t("common|feedback")} - onClick={this.onProvideFeedback} - /> - ); - } - - const linkNewDeviceButton = ( - } - label={_t("user_menu|link_new_device")} - onClick={(e) => this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })} - /> - ); - - let primaryOptionList = ( - - {homeButton} - {linkNewDeviceButton} - } - label={_t("notifications|enable_prompt_toast_title")} - onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)} - /> - } - label={_t("room_settings|security|title")} - onClick={(e) => this.onSettingsOpen(e, UserTab.Security)} - /> - } - label={_t("user_menu|settings")} - onClick={(e) => this.onSettingsOpen(e)} - /> - {feedbackButton} - } - label={_t("action|sign_out")} - onClick={this.onSignOutClick} - /> - - ); - - if (MatrixClientPeg.safeGet().isGuest()) { - primaryOptionList = ( - - {homeButton} - } - label={_t("common|settings")} - onClick={(e) => this.onSettingsOpen(e)} - /> - {feedbackButton} - - ); - } - - const position = this.props.isPanelCollapsed - ? toRightOf(this.state.contextMenuPosition) - : below(this.state.contextMenuPosition); - - const userIdentifierString = UserIdentifierCustomisations.getDisplayUserIdentifier( - MatrixClientPeg.safeGet().getSafeUserId(), - { - withDisplayName: true, - }, - ); - - return ( - -
-
- - {OwnProfileStore.instance.displayName} - - - {userIdentifierString} - -
- - -
- {topSection} - {primaryOptionList} -
- ); - }; - - public render(): React.ReactNode { - const avatarSize = 32; // should match border-radius of the avatar - - const userId = MatrixClientPeg.safeGet().getSafeUserId(); - const displayName = OwnProfileStore.instance.displayName || userId; - const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); - - let name: JSX.Element | undefined; - if (!this.props.isPanelCollapsed) { - name =
{displayName}
; - } - - return ( -
- -
- -
- {name} - {this.renderContextMenu()} -
- - {this.props.children} -
- ); - } -} diff --git a/apps/web/src/components/views/location/ShareType.tsx b/apps/web/src/components/views/location/ShareType.tsx index 3e1585af47..d2cefbd1f5 100644 --- a/apps/web/src/components/views/location/ShareType.tsx +++ b/apps/web/src/components/views/location/ShareType.tsx @@ -28,13 +28,7 @@ const UserAvatar: React.FC = () => { return (
- +
); }; diff --git a/apps/web/src/components/views/spaces/SpacePanel.tsx b/apps/web/src/components/views/spaces/SpacePanel.tsx index 7a51cfe0c6..fd6e5ee01e 100644 --- a/apps/web/src/components/views/spaces/SpacePanel.tsx +++ b/apps/web/src/components/views/spaces/SpacePanel.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2021, 2022 The Matrix.org Foundation C.I.C. @@ -18,6 +19,7 @@ import React, { useLayoutEffect, useRef, useState, + useContext, } from "react"; import { DragDropContext, Draggable, Droppable, type DroppableProvidedProps } from "react-beautiful-dnd"; import classNames from "classnames"; @@ -31,6 +33,7 @@ import { PlusIcon, ChevronRightIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useCreateAutoDisposedViewModel, UserMenu } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import { useContextMenu } from "../../structures/ContextMenu"; @@ -62,7 +65,6 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import UIStore from "../../../stores/UIStore"; import QuickSettingsButton from "./QuickSettingsButton"; import { useSettingValue } from "../../../hooks/useSettings"; -import UserMenu from "../../structures/UserMenu"; import IndicatorScrollbar from "../../structures/IndicatorScrollbar"; import { useDispatcher } from "../../../hooks/useDispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -79,6 +81,9 @@ import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNav import { KeyboardShortcut } from "../settings/KeyboardShortcut"; import { ModuleApi } from "../../../modules/Api.ts"; import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts"; +import { UserMenuViewModel } from "../../../viewmodels/menus/UserMenuViewModel.ts"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; +import { SDKContext } from "../../../contexts/SDKContext.ts"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -384,6 +389,7 @@ const InnerSpacePanel = React.memo( ); const SpacePanel: React.FC = () => { + const client = useMatrixClientContext(); const [dragging, setDragging] = useState(false); const [isPanelCollapsed, setPanelCollapsed] = useState(true); const ref = useRef(null); @@ -391,6 +397,7 @@ const SpacePanel: React.FC = () => { if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current); return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel"); }, []); + const sdkContext = useContext(SDKContext); useDispatcher(defaultDispatcher, (payload: ActionPayload) => { if (payload.action === Action.ToggleSpacePanel) { @@ -400,6 +407,26 @@ const SpacePanel: React.FC = () => { const newRoomListEnabled = useSettingValue("feature_new_room_list"); + const userMenuVm = useCreateAutoDisposedViewModel( + () => + new UserMenuViewModel( + defaultDispatcher, + client, + isPanelCollapsed, + sdkContext.oidcClientStore.accountManagementEndpoint, + ), + ); + + useDispatcher(defaultDispatcher, (payload) => { + if (payload.action === Action.ToggleUserMenu) { + userMenuVm.setOpen(!userMenuVm.getSnapshot().open); + } + }); + + useEffect(() => { + userMenuVm.setExpanded(!isPanelCollapsed); + }, [userMenuVm, isPanelCollapsed]); + return ( {({ onKeyDownHandler, onDragEndHandler }) => ( @@ -438,23 +465,22 @@ const SpacePanel: React.FC = () => { ref={ref} aria-label={_t("common|spaces")} > - - setPanelCollapsed(!isPanelCollapsed)} - title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")} - caption={ - - } - > - - - + + setPanelCollapsed(!isPanelCollapsed)} + title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")} + caption={ + + } + > + + {(provided, snapshot) => ( Sign in here", "sign_in_or_register": "Sign In or Create Account", "sign_in_or_register_description": "Use your account or create a new one to continue.", - "sign_in_prompt": "Got an account? Sign in", "sign_in_with_sso": "Sign in with single sign-on", "signing_in": "Signing In…", "soft_logout": { @@ -3895,12 +3893,6 @@ "verify_button": "Verify User", "verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices." }, - "user_menu": { - "link_new_device": "Link new device", - "settings": "All settings", - "switch_theme_dark": "Switch to dark mode", - "switch_theme_light": "Switch to light mode" - }, "voip": { "already_in_call": "Already in call", "already_in_call_person": "You're already in a call with this person.", diff --git a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts new file mode 100644 index 0000000000..0de9d80e21 --- /dev/null +++ b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components"; + +import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; +import Modal from "../../Modal"; +import { Action } from "../../dispatcher/actions"; +import { UserTab } from "../../components/views/dialogs/UserTab"; +import FeedbackDialog from "../../components/views/dialogs/FeedbackDialog"; +import { shouldShowFeedback } from "../../utils/Feedback"; +import { getHomePageUrl } from "../../utils/pages"; +import SdkConfig from "../../SdkConfig"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; + +// Matches maximum size of an avatar in the UserMenu +const AVATAR_PX = 88; + +export class UserMenuViewModel extends BaseViewModel implements UserMenuViewActions { + private static computeSnapshot( + client: MatrixClient, + isPanelCollapsed: boolean, + accountManagementEndpoint?: string, + ): UserMenuSnapshot { + const hasHomePage = !!getHomePageUrl(SdkConfig.get(), client); + const isAuthenticated = !client.isGuest(); + const userId = client.getSafeUserId(); + const displayName = OwnProfileStore.instance.displayName || userId; + const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined; + + return { + open: false, + userId, + displayName, + avatarUrl, + expanded: !isPanelCollapsed, + manageAccountHref: accountManagementEndpoint, + showAvatar: isAuthenticated, + actions: { + createAccount: !isAuthenticated, + signIn: !isAuthenticated, + openHomePage: hasHomePage, + linkNewDevice: isAuthenticated, + openSecurity: isAuthenticated, + openFeedback: shouldShowFeedback(), + openSettings: true, + }, + }; + } + + public constructor( + private readonly dispatcher: MatrixDispatcher, + client: MatrixClient, + isPanelCollapsed: boolean, + accountManagementEndpoint?: string, + ) { + super(undefined, UserMenuViewModel.computeSnapshot(client, isPanelCollapsed, accountManagementEndpoint)); + OwnProfileStore.instance.on(UPDATE_EVENT, this.recalculateProfile); + } + + public dispose(): void { + OwnProfileStore.instance.off(UPDATE_EVENT, this.recalculateProfile); + super.dispose(); + } + + public readonly recalculateProfile = (): void => { + const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId; + const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined; + this.snapshot.merge({ displayName, avatarUrl }); + }; + + public readonly setOpen = (isOpen: boolean): void => { + this.snapshot.merge({ open: isOpen }); + }; + + public readonly setExpanded = (expanded: boolean): void => { + this.snapshot.merge({ expanded }); + }; + + public readonly createAccount = (): void => { + this.setOpen(false); + this.dispatcher.dispatch({ action: "start_registration" }); + }; + + public readonly signIn = (): void => { + this.setOpen(false); + this.dispatcher.dispatch({ action: "start_login" }); + }; + + public readonly openHomePage = (): void => { + this.setOpen(false); + this.dispatcher.dispatch({ action: Action.ViewHomePage }); + }; + + public readonly openFeedback = (): void => { + this.setOpen(false); + Modal.createDialog(FeedbackDialog); + }; + + public readonly linkNewDevice = (): void => { + this.setOpen(false); + this.dispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.SessionManager, + props: { showMsc4108QrCode: true }, + }); + }; + + public readonly openSecurity = (): void => { + this.setOpen(false); + this.dispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Security, + }); + }; + + public readonly openSettings = (): void => { + this.setOpen(false); + this.dispatcher.dispatch({ + action: Action.ViewUserSettings, + }); + }; +} diff --git a/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx b/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx index de4d0a2d58..3a3d5bc909 100644 --- a/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2015-2023 The Matrix.org Foundation C.I.C. @@ -64,6 +65,7 @@ describe("", () => { const userId = "@alice:domain.org"; const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), + getClientWellKnown: jest.fn(), getAccountData: jest.fn(), getRoom: jest.fn(), getSyncState: jest.fn().mockReturnValue(null), @@ -104,6 +106,7 @@ describe("", () => { mockClient.setPushRuleActions.mockReset().mockResolvedValue({}); // @ts-expect-error mockClient.pushProcessor = new PushProcessor(mockClient); + mockSdkContext.client = mockClient; }); describe("synced push rules", () => { diff --git a/apps/web/test/unit-tests/components/structures/UserMenu-test.tsx b/apps/web/test/unit-tests/components/structures/UserMenu-test.tsx deleted file mode 100644 index c023c42d39..0000000000 --- a/apps/web/test/unit-tests/components/structures/UserMenu-test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; -import { DEVICE_CODE_SCOPE, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; -import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; -import { mocked } from "jest-mock"; -import fetchMock from "@fetch-mock/jest"; - -import UnwrappedUserMenu from "../../../../src/components/structures/UserMenu"; -import { stubClient, wrapInSdkContext } from "../../../test-utils"; -import { TestSdkContext } from "../../TestSdkContext"; -import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; -import LogoutDialog from "../../../../src/components/views/dialogs/LogoutDialog"; -import Modal from "../../../../src/Modal"; -import { mockOpenIdConfiguration } from "../../../test-utils/oidc"; -import { Action } from "../../../../src/dispatcher/actions"; -import { UserTab } from "../../../../src/components/views/dialogs/UserTab"; -import SettingsStore from "../../../../src/settings/SettingsStore.ts"; - -describe("", () => { - let client: MatrixClient; - let sdkContext: TestSdkContext; - - beforeEach(() => { - sdkContext = new TestSdkContext(); - }); - - describe(" logout", () => { - beforeEach(() => { - client = stubClient(); - }); - - it("should logout directly if no crypto", async () => { - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - render(); - - mocked(client.getRooms).mockReturnValue([ - { - roomId: "!room0", - } as unknown as Room, - { - roomId: "!room1", - } as unknown as Room, - ]); - jest.spyOn(client, "getCrypto").mockReturnValue(undefined); - - const spy = jest.spyOn(defaultDispatcher, "dispatch"); - screen.getByRole("button", { name: /User menu/i }).click(); - (await screen.findByRole("menuitem", { name: /Remove this device/i })).click(); - await waitFor(() => { - expect(spy).toHaveBeenCalledWith({ action: "logout" }); - }); - }); - - it("should logout directly if no encrypted rooms", async () => { - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - render(); - - mocked(client.getRooms).mockReturnValue([ - { - roomId: "!room0", - } as unknown as Room, - { - roomId: "!room1", - } as unknown as Room, - ]); - const crypto = client.getCrypto()!; - - jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false); - - const spy = jest.spyOn(defaultDispatcher, "dispatch"); - screen.getByRole("button", { name: /User menu/i }).click(); - (await screen.findByRole("menuitem", { name: /Remove this device/i })).click(); - await waitFor(() => { - expect(spy).toHaveBeenCalledWith({ action: "logout" }); - }); - }); - - it("should show dialog if some encrypted rooms", async () => { - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - render(); - - mocked(client.getRooms).mockReturnValue([ - { - roomId: "!room0", - } as unknown as Room, - { - roomId: "!room1", - } as unknown as Room, - ]); - const crypto = client.getCrypto()!; - - jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockImplementation(async (roomId: string) => { - return roomId === "!room0"; - }); - - const spy = jest.spyOn(Modal, "createDialog"); - screen.getByRole("button", { name: /User menu/i }).click(); - (await screen.findByRole("menuitem", { name: /Remove this device/i })).click(); - - await waitFor(() => { - expect(spy).toHaveBeenCalledWith(LogoutDialog); - }); - }); - }); - - it("should render 'Link new device' button in OIDC native mode", async () => { - sdkContext.client = stubClient(); - const openIdMetadata = mockOpenIdConfiguration("https://issuer/"); - openIdMetadata.grant_types_supported.push(DEVICE_CODE_SCOPE); - fetchMock.get("https://issuer/.well-known/openid-configuration", openIdMetadata); - fetchMock.get("https://issuer/jwks", { - status: 200, - headers: { - "Content-Type": "application/json", - }, - keys: [], - }); - mocked(sdkContext.client.getVersions).mockResolvedValue({ - versions: [], - unstable_features: { - "org.matrix.msc4108": true, - }, - }); - mocked(sdkContext.client.waitForClientWellKnown).mockResolvedValue({}); - mocked(sdkContext.client.getCrypto).mockReturnValue({ - isCrossSigningReady: jest.fn().mockResolvedValue(true), - exportSecretsBundle: jest.fn().mockResolvedValue({}), - } as unknown as CryptoApi); - const spy = jest.spyOn(defaultDispatcher, "dispatch"); - - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - render(); - - screen.getByRole("button", { name: /User menu/i }).click(); - await expect(screen.findByText("Link new device")).resolves.toBeInTheDocument(); - - // Assert the QR code is shown directly - screen.getByRole("menuitem", { name: "Link new device" }).click(); - await waitFor(() => { - expect(spy).toHaveBeenCalledWith({ - action: Action.ViewUserSettings, - initialTabId: UserTab.SessionManager, - props: { showMsc4108QrCode: true }, - }); - }); - }); - - it("should toggle theme on switcher click", async () => { - sdkContext.client = stubClient(); - const spy = jest.spyOn(SettingsStore, "setValue"); - - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - render(); - - screen.getByRole("button", { name: /User menu/i }).click(); - - const themeSwitchButton = await screen.findByRole("button", { name: "Switch to dark mode" }); - expect(themeSwitchButton).toBeInTheDocument(); - - expect(spy).not.toHaveBeenCalled(); - fireEvent.click(themeSwitchButton); - expect(spy).toHaveBeenCalledWith("use_system_theme", null, "device", false); - expect(spy).toHaveBeenCalledWith("theme", null, "device", "dark"); - - fireEvent.click(themeSwitchButton); - expect(spy).toHaveBeenCalledWith("use_system_theme", null, "device", false); - expect(spy).toHaveBeenCalledWith("theme", null, "device", "light"); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/spaces/SpacePanel-test.tsx b/apps/web/test/unit-tests/components/views/spaces/SpacePanel-test.tsx index 496b160130..acb38a07b3 100644 --- a/apps/web/test/unit-tests/components/views/spaces/SpacePanel-test.tsx +++ b/apps/web/test/unit-tests/components/views/spaces/SpacePanel-test.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -7,7 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, screen, fireEvent, act, cleanup } from "jest-matrix-react"; +import { render, screen, fireEvent, act, cleanup, waitFor } from "jest-matrix-react"; import { mocked } from "jest-mock"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -22,6 +23,8 @@ import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { type SpaceNotificationState } from "../../../../../src/stores/notifications/SpaceNotificationState"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import UnwrappedSpacePanel from "../../../../../src/components/views/spaces/SpacePanel"; +import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../../src/dispatcher/actions"; // DND test utilities based on // https://github.com/colinrobertbrooks/react-beautiful-dnd-test-utils/issues/18#issuecomment-1373388693 @@ -110,6 +113,7 @@ describe("", () => { const mockClient = { getUserId: jest.fn().mockReturnValue("@test:test"), getSafeUserId: jest.fn().mockReturnValue("@test:test"), + getClientWellKnown: jest.fn(), mxcUrlToHttp: jest.fn(), getRoom: jest.fn(), isGuest: jest.fn(), @@ -125,6 +129,7 @@ describe("", () => { beforeAll(() => { jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient); + SdkContextClass.instance.client = mockClient; }); beforeEach(() => { @@ -189,4 +194,13 @@ describe("", () => { expect(SpaceStore.instance.moveRootSpace).toHaveBeenCalledWith(0, 1); }); + + it("should be able to open the user menu via dispatcher", async () => { + const { baseElement } = render(); + defaultDispatcher.dispatch({ action: Action.ToggleUserMenu }); + await waitFor(() => { + // Menu exists outside the component due to Portals, so select it manually. + expect(baseElement.querySelector("div[aria-label='User menu']")).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap index 7c433f4a42..a42da0cfb9 100644 --- a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap @@ -6,50 +6,43 @@ exports[` should show all activated MetaSpaces in the correct orde aria-label="Spaces" class="mx_SpacePanel collapsed newUi" > -