Refactor and redesign user menu (#32812)
* Initial quick settings menu * Total refactor * Quick design fixes. * Refactor to use a view model. * Remove unused strings * Apply label * Refactor naming * Fixup most tests * Remove specific theming for old user menu * prettier * Lots of cleanup * Allow overriding the menu classes * update snap * Oops translations * tidy * Cleanup guest flows. * Copyrights * Remove unused classname * Match guest view to designs * Add guest screenshots * Update guests * snapshot * Cleanup * fix import * Update tests * More sceenshot fixes * update collapsed * move statements to prevent flake * update snap * Kick it along * Click the room list * Fiddle with the room video list. * More screenshot adjustments * fix imports * fix another import * Update snaps * update snaps * Fix snap flakes * Refactor to move actions to view component, and callbacks to Actions * Cleanup * Cleanup * Cleanup * invert auth * More bits * fix * Change md buttons to sm * Try to assemble the snapshot component of the house of cards * Consistent newlines between tests * Update snapshot Not sure why this was like this, this seems consistet for a logged in user * Update snapshot again these seem sensible for a guest * Remove test I don't really understand why the thing it asserts matters, so I'm removing it for now. * Update snapshot * screenshot * Don't show profile picture for guests I'm not really sure what it meant for this interface to have a property with a default value, so I've removed it and added the property to the view model. * Show avatar in story * update snapshots for showAvatar * Update screenshots & hopefully make hover consistent in one * Use outline home icon --------- Co-authored-by: David Baker <dbkr@users.noreply.github.com>
@ -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 });
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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\/$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Locator> {
|
||||
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;
|
||||
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.7 KiB |
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
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 (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={onSwitchThemeClick}
|
||||
tooltip={isDark ? _t("user_menu|switch_theme_light") : _t("user_menu|switch_theme_dark")}
|
||||
size="32px"
|
||||
kind="secondary"
|
||||
>
|
||||
<ThemeIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
public static contextType = SDKContext;
|
||||
declare public context: React.ContextType<typeof SDKContext>;
|
||||
|
||||
private dispatcherRef?: string;
|
||||
private themeWatcherRef?: string;
|
||||
private readonly dndWatcherRef?: string;
|
||||
private buttonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
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<void> => {
|
||||
// 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<void> => {
|
||||
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<string, any>): 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<void> => {
|
||||
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<ViewHomePagePayload>({ 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 = (
|
||||
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
|
||||
{_t(
|
||||
"auth|sign_in_prompt",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onSignInClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)}
|
||||
{SettingsStore.getValue(UIFeature.Registration)
|
||||
? _t(
|
||||
"auth|create_account_prompt",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onRegisterClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let homeButton: JSX.Element | undefined;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<IconizedContextMenuOption
|
||||
icon={<HomeSolidIcon />}
|
||||
label={_t("common|home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let feedbackButton: JSX.Element | undefined;
|
||||
if (shouldShowFeedback()) {
|
||||
feedbackButton = (
|
||||
<IconizedContextMenuOption
|
||||
icon={<ChatSolidIcon />}
|
||||
label={_t("common|feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const linkNewDeviceButton = (
|
||||
<IconizedContextMenuOption
|
||||
icon={<QrCodeIcon />}
|
||||
label={_t("user_menu|link_new_device")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })}
|
||||
/>
|
||||
);
|
||||
|
||||
let primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
{linkNewDeviceButton}
|
||||
<IconizedContextMenuOption
|
||||
icon={<NotificationsSolidIcon />}
|
||||
label={_t("notifications|enable_prompt_toast_title")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
icon={<LockSolidIcon />}
|
||||
label={_t("room_settings|security|title")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
icon={<SettingsSolidIcon />}
|
||||
label={_t("user_menu|settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e)}
|
||||
/>
|
||||
{feedbackButton}
|
||||
<IconizedContextMenuOption
|
||||
className="mx_IconizedContextMenu_option_red"
|
||||
icon={<LeaveIcon />}
|
||||
label={_t("action|sign_out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
|
||||
if (MatrixClientPeg.safeGet().isGuest()) {
|
||||
primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
<IconizedContextMenuOption
|
||||
icon={<SettingsSolidIcon />}
|
||||
label={_t("common|settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e)}
|
||||
/>
|
||||
{feedbackButton}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
}
|
||||
|
||||
const position = this.props.isPanelCollapsed
|
||||
? toRightOf(this.state.contextMenuPosition)
|
||||
: below(this.state.contextMenuPosition);
|
||||
|
||||
const userIdentifierString = UserIdentifierCustomisations.getDisplayUserIdentifier(
|
||||
MatrixClientPeg.safeGet().getSafeUserId(),
|
||||
{
|
||||
withDisplayName: true,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<IconizedContextMenu {...position} onFinished={this.onCloseMenu} className="mx_UserMenu_contextMenu">
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{OwnProfileStore.instance.displayName}
|
||||
</span>
|
||||
<span className="mx_UserMenu_contextMenu_userId" title={userIdentifierString || ""}>
|
||||
{userIdentifierString}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ThemeSwitchButton />
|
||||
</div>
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
</IconizedContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
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 = <div className="mx_UserMenu_name">{displayName}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_UserMenu">
|
||||
<ContextMenuButton
|
||||
className="mx_UserMenu_contextMenuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
ref={this.buttonRef}
|
||||
label={_t("a11y|user_menu")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_UserMenu_userAvatar">
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
size={avatarSize + "px"}
|
||||
className="mx_UserMenu_userAvatar_BaseAvatar"
|
||||
/>
|
||||
</div>
|
||||
{name}
|
||||
{this.renderContextMenu()}
|
||||
</ContextMenuButton>
|
||||
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -28,13 +28,7 @@ const UserAvatar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className={`mx_ShareType_option-icon ${LocationShareType.Own}`}>
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
size={avatarSize}
|
||||
className="mx_UserMenu_userAvatar_BaseAvatar"
|
||||
/>
|
||||
<BaseAvatar idName={userId} name={displayName} url={avatarUrl} size={avatarSize} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
|
||||
@ -384,6 +389,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
|
||||
);
|
||||
|
||||
const SpacePanel: React.FC = () => {
|
||||
const client = useMatrixClientContext();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
|
||||
{({ onKeyDownHandler, onDragEndHandler }) => (
|
||||
@ -438,23 +465,22 @@ const SpacePanel: React.FC = () => {
|
||||
ref={ref}
|
||||
aria-label={_t("common|spaces")}
|
||||
>
|
||||
<UserMenu isPanelCollapsed={isPanelCollapsed}>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpacePanel_toggleCollapse", {
|
||||
expanded: !isPanelCollapsed,
|
||||
})}
|
||||
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
|
||||
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
|
||||
caption={
|
||||
<KeyboardShortcut
|
||||
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
|
||||
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</AccessibleButton>
|
||||
</UserMenu>
|
||||
<UserMenu vm={userMenuVm} />
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpacePanel_toggleCollapse", {
|
||||
expanded: !isPanelCollapsed,
|
||||
})}
|
||||
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
|
||||
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
|
||||
caption={
|
||||
<KeyboardShortcut
|
||||
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
|
||||
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</AccessibleButton>
|
||||
<Droppable droppableId="top-level-spaces">
|
||||
{(provided, snapshot) => (
|
||||
<InnerSpacePanel
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@ -194,5 +195,6 @@ export class SdkContextClass {
|
||||
|
||||
public onLoggedOut(): void {
|
||||
this._UserProfilesStore = undefined;
|
||||
this._OidcClientStore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,8 +15,7 @@
|
||||
"room_name": "Room %(name)s",
|
||||
"room_status_bar": "Room status bar",
|
||||
"seek_bar_label": "Audio seek bar",
|
||||
"unread_messages": "Unread messages.",
|
||||
"user_menu": "User menu"
|
||||
"unread_messages": "Unread messages."
|
||||
},
|
||||
"a11y_jump_first_unread_room": "Jump to first unread room.",
|
||||
"action": {
|
||||
@ -341,7 +340,6 @@
|
||||
"sign_in_instead_prompt": "Already have an account? <a>Sign in here</a>",
|
||||
"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? <a>Sign in</a>",
|
||||
"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.",
|
||||
|
||||
129
apps/web/src/viewmodels/menus/UserMenuViewModel.ts
Normal file
@ -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<UserMenuSnapshot, undefined> 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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("<LoggedInView />", () => {
|
||||
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("<LoggedInView />", () => {
|
||||
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
|
||||
// @ts-expect-error
|
||||
mockClient.pushProcessor = new PushProcessor(mockClient);
|
||||
mockSdkContext.client = mockClient;
|
||||
});
|
||||
|
||||
describe("synced push rules", () => {
|
||||
|
||||
@ -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("<UserMenu>", () => {
|
||||
let client: MatrixClient;
|
||||
let sdkContext: TestSdkContext;
|
||||
|
||||
beforeEach(() => {
|
||||
sdkContext = new TestSdkContext();
|
||||
});
|
||||
|
||||
describe("<UserMenu> logout", () => {
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
});
|
||||
|
||||
it("should logout directly if no crypto", async () => {
|
||||
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
|
||||
render(<UserMenu isPanelCollapsed={true} />);
|
||||
|
||||
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(<UserMenu isPanelCollapsed={true} />);
|
||||
|
||||
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(<UserMenu isPanelCollapsed={true} />);
|
||||
|
||||
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(<UserMenu isPanelCollapsed={true} />);
|
||||
|
||||
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(<UserMenu isPanelCollapsed={true} />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -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("<SpacePanel />", () => {
|
||||
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("<SpacePanel />", () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
SdkContextClass.instance.client = mockClient;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -189,4 +194,13 @@ describe("<SpacePanel />", () => {
|
||||
|
||||
expect(SpaceStore.instance.moveRootSpace).toHaveBeenCalledWith(0, 1);
|
||||
});
|
||||
|
||||
it("should be able to open the user menu via dispatcher", async () => {
|
||||
const { baseElement } = render(<SpacePanel />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,50 +6,43 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
|
||||
aria-label="Spaces"
|
||||
class="mx_SpacePanel collapsed newUi"
|
||||
>
|
||||
<div
|
||||
class="mx_UserMenu"
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="_triggerButton_3x4xf_50"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="User menu"
|
||||
class="mx_AccessibleButton mx_UserMenu_contextMenuButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
<span
|
||||
aria-label="@test:test"
|
||||
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
|
||||
data-color="5"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
<div
|
||||
class="mx_UserMenu_userAvatar"
|
||||
>
|
||||
<span
|
||||
class="_avatar_va14e_8 mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar _avatar-imageless_va14e_55"
|
||||
data-color="5"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="presentation"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
t
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Expand"
|
||||
class="mx_AccessibleButton mx_SpacePanel_toggleCollapse"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
t
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
aria-label="Expand"
|
||||
class="mx_AccessibleButton mx_SpacePanel_toggleCollapse"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
aria-label="Spaces"
|
||||
@ -332,11 +325,11 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Threads"
|
||||
aria-labelledby="_r_12_"
|
||||
aria-labelledby="_r_14_"
|
||||
class="_icon-button_1215g_8 mx_ThreadsActivityCentreButton"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-_r_10_"
|
||||
id="radix-_r_12_"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
@ -364,7 +357,7 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-label="Quick settings"
|
||||
aria-labelledby="_r_17_"
|
||||
aria-labelledby="_r_19_"
|
||||
class="_icon-button_1215g_8 mx_QuickSettingsButton"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
|
||||
183
apps/web/test/viewmodels/menus/UserMenuViewModel-test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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 { MatrixError, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import type { MockedObject } from "jest-mock";
|
||||
import { UserMenuViewModel } from "../../../src/viewmodels/menus/UserMenuViewModel";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsServer, mockClientMethodsUser } from "../../test-utils";
|
||||
import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
|
||||
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
||||
import SdkConfig from "../../../src/SdkConfig";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
|
||||
import Modal from "../../../src/Modal";
|
||||
import FeedbackDialog from "../../../src/components/views/dialogs/FeedbackDialog";
|
||||
|
||||
describe("UserMenuViewModel", () => {
|
||||
let dispatcher: MatrixDispatcher;
|
||||
let client: MockedObject<MatrixClient>;
|
||||
beforeEach(() => {
|
||||
dispatcher = new MatrixDispatcher();
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
...mockClientMethodsServer(),
|
||||
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
|
||||
});
|
||||
SdkContextClass.instance.client = client;
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
SdkConfig.reset();
|
||||
SdkContextClass.instance.onLoggedOut();
|
||||
SdkContextClass.instance.client = undefined;
|
||||
});
|
||||
|
||||
it("should generate a menu options for a logged in client", () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show a link for account management", async () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true, "https://example.org/");
|
||||
vm.setOpen(true);
|
||||
expect(vm.getSnapshot().manageAccountHref).toEqual("https://example.org/");
|
||||
});
|
||||
|
||||
it("should generate a menu options for a guest", () => {
|
||||
client.isGuest.mockReturnValue(true);
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should generate a menu options that include feedback", () => {
|
||||
SdkConfig.put({ bug_report_endpoint_url: "https://example.org" });
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
expect(vm.getSnapshot().actions.openFeedback).toEqual(true);
|
||||
});
|
||||
|
||||
it("should generate a menu options that includes a home page", () => {
|
||||
SdkConfig.put({ embedded_pages: { home_url: "https://example.org" } });
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
expect(vm.getSnapshot().actions.openHomePage).toEqual(true);
|
||||
});
|
||||
|
||||
it("can toggle menu", () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
expect(vm.getSnapshot().open).toEqual(true);
|
||||
vm.setOpen(false);
|
||||
expect(vm.getSnapshot().open).toEqual(false);
|
||||
});
|
||||
|
||||
it("can toggle expanded state", () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setExpanded(true);
|
||||
expect(vm.getSnapshot().expanded).toEqual(true);
|
||||
vm.setExpanded(false);
|
||||
expect(vm.getSnapshot().expanded).toEqual(false);
|
||||
});
|
||||
|
||||
it("can open the home menu", async () => {
|
||||
SdkConfig.put({ embedded_pages: { home_url: "https://example.org" } });
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
vm.setOpen(true);
|
||||
vm.openHomePage();
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewHomePage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can open the 'link new device' settings menu", async () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
vm.setOpen(true);
|
||||
vm.linkNewDevice();
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.SessionManager,
|
||||
props: { showMsc4108QrCode: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can open the 'security' settings menu", async () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
vm.setOpen(true);
|
||||
vm.openSecurity();
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Security,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can open the 'feedback' settings menu", async () => {
|
||||
jest.spyOn(Modal, "createDialog");
|
||||
SdkConfig.put({ bug_report_endpoint_url: "https://example.org" });
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
vm.setOpen(true);
|
||||
vm.openFeedback();
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(FeedbackDialog);
|
||||
});
|
||||
|
||||
it("can open the settings menu", async () => {
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
vm.setOpen(true);
|
||||
vm.openSettings();
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewUserSettings,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should be able to open the createAccount screen as a guest", async () => {
|
||||
client.isGuest.mockReturnValue(true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
vm.createAccount();
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: "start_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should be able to open the onSignIn screen as a guest", async () => {
|
||||
client.isGuest.mockReturnValue(true);
|
||||
const dispatcherSpy = jest.fn();
|
||||
dispatcher.register(dispatcherSpy);
|
||||
const vm = new UserMenuViewModel(dispatcher, client, true);
|
||||
vm.setOpen(true);
|
||||
vm.signIn();
|
||||
await waitFor(() =>
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
||||
action: "start_login",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,43 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`UserMenuViewModel should generate a menu options for a guest 1`] = `
|
||||
{
|
||||
"actions": {
|
||||
"createAccount": true,
|
||||
"linkNewDevice": false,
|
||||
"openFeedback": false,
|
||||
"openHomePage": false,
|
||||
"openSecurity": false,
|
||||
"openSettings": true,
|
||||
"signIn": true,
|
||||
},
|
||||
"avatarUrl": undefined,
|
||||
"displayName": "@alice:domain",
|
||||
"expanded": false,
|
||||
"manageAccountHref": undefined,
|
||||
"open": true,
|
||||
"showAvatar": false,
|
||||
"userId": "@alice:domain",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UserMenuViewModel should generate a menu options for a logged in client 1`] = `
|
||||
{
|
||||
"actions": {
|
||||
"createAccount": false,
|
||||
"linkNewDevice": true,
|
||||
"openFeedback": false,
|
||||
"openHomePage": false,
|
||||
"openSecurity": true,
|
||||
"openSettings": true,
|
||||
"signIn": false,
|
||||
},
|
||||
"avatarUrl": undefined,
|
||||
"displayName": "@alice:domain",
|
||||
"expanded": false,
|
||||
"manageAccountHref": undefined,
|
||||
"open": true,
|
||||
"showAvatar": true,
|
||||
"userId": "@alice:domain",
|
||||
}
|
||||
`;
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 50 KiB |
@ -48,6 +48,15 @@
|
||||
"open_dial_pad": "Open dial pad",
|
||||
"separator_label": "Click or drag to expand"
|
||||
},
|
||||
"menus": {
|
||||
"user_menu": {
|
||||
"create_an_account": "Create an account",
|
||||
"got_an_account": "Got an account?",
|
||||
"manage_account": "Manage account",
|
||||
"sign_in": "Sign in",
|
||||
"title": "User menu"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"all_messages": "All messages",
|
||||
"default_settings": "Match default settings",
|
||||
@ -238,6 +247,13 @@
|
||||
"view_image": "View image"
|
||||
}
|
||||
},
|
||||
"user_menu": {
|
||||
"link_new_device": "Link new device",
|
||||
"open_feedback": "Feedback",
|
||||
"open_home": "Home",
|
||||
"open_security": "Security & Privacy",
|
||||
"open_settings": "All settings"
|
||||
},
|
||||
"widget": {
|
||||
"context_menu": {
|
||||
"move_left": "Move left",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@ -13,6 +14,7 @@ export * from "./core/AvatarWithDetails";
|
||||
export * from "./core/roving";
|
||||
export * from "./room/composer/Banner";
|
||||
export * from "./crypto/SasEmoji";
|
||||
export * from "./menus/UserMenu";
|
||||
export * from "./room/timeline/ReadMarker";
|
||||
export * from "./room/timeline/EventPresentation";
|
||||
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.container {
|
||||
/* As per design, seperator should span whole width. */
|
||||
div[role="separator"] {
|
||||
margin-inline: 0;
|
||||
}
|
||||
max-width: 300px;
|
||||
/* Override menu defaults */
|
||||
padding-block: 0 !important;
|
||||
section.profile {
|
||||
margin: var(--cpd-space-8x) var(--cpd-space-6x);
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-2x);
|
||||
> * {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.displayname {
|
||||
text-wrap: balance;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.createAccount {
|
||||
margin-top: var(--cpd-space-1x);
|
||||
margin-bottom: var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
section.actions {
|
||||
margin: var(--cpd-space-2x) 0;
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-1x);
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
> button[data-kind="primary"] > svg {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.triggerButton {
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
color: var(--cpd-color-body-primary);
|
||||
gap: var(--cpd-space-2x);
|
||||
> span {
|
||||
height: fit-content;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
max-width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
margin-top: var(--cpd-space-3x);
|
||||
margin-bottom: var(--cpd-space-4x);
|
||||
margin-left: var(--cpd-space-3x);
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Meta, type StoryObj } from "@storybook/react-vite";
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { UserMenuView, type UserMenuViewSnapshot, type UserMenuViewActions } from "./UserMenu";
|
||||
import avatarUrl from "../../../static/element.png";
|
||||
import { useMockedViewModel } from "../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
|
||||
const vm = useMockedViewModel<UserMenuViewSnapshot, UserMenuViewActions>(snapshot, {
|
||||
setOpen: fn(),
|
||||
createAccount: fn(),
|
||||
signIn: fn(),
|
||||
linkNewDevice: fn(),
|
||||
openFeedback: fn(),
|
||||
openHomePage: fn(),
|
||||
openSecurity: fn(),
|
||||
openSettings: fn(),
|
||||
});
|
||||
return <UserMenuView vm={vm} />;
|
||||
};
|
||||
|
||||
const UserMenuWrapper = withViewDocs(UserMenuWrapperImpl, UserMenuView);
|
||||
|
||||
const meta = {
|
||||
title: "Menus/UserMenu",
|
||||
component: UserMenuWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
open: false,
|
||||
avatarUrl,
|
||||
displayName: "Sally Sanderson",
|
||||
userId: "@person-name:homeserver.com",
|
||||
manageAccountHref: "#",
|
||||
expanded: true,
|
||||
actions: {
|
||||
linkNewDevice: true,
|
||||
openSecurity: true,
|
||||
openFeedback: true,
|
||||
openSettings: true,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11583-3479&t=DwFpi7Zlq9uJr1SQ-0",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof UserMenuWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const LongerName: Story = {
|
||||
args: {
|
||||
displayName: "Sally Sanderson with a longer name",
|
||||
},
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
displayName: "Sally Sanderson with a longer name",
|
||||
userId: "@person-name:homeserver.com",
|
||||
expanded: true,
|
||||
showAvatar: true,
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
/*
|
||||
* Axe's context parameter
|
||||
* See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter
|
||||
* to learn more.
|
||||
*/
|
||||
config: {
|
||||
rules: [
|
||||
{
|
||||
// Menu contains a header which is invalid
|
||||
id: "aria-required-children",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
// Menu pops open by default
|
||||
id: "aria-hidden-focus",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Only used for playwright tests for the menu.
|
||||
// Steals focus if actually opened on the storybook page
|
||||
tags: ["!dev", "!autodocs"],
|
||||
};
|
||||
|
||||
export const Condensed: Story = {
|
||||
args: {
|
||||
displayName: "Sally Sanderson with a longer name",
|
||||
expanded: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Guest: Story = {
|
||||
args: {
|
||||
displayName: "Guest",
|
||||
userId: "@guest:attendees.example.org",
|
||||
manageAccountHref: undefined,
|
||||
showAvatar: false,
|
||||
actions: {
|
||||
createAccount: true,
|
||||
signIn: true,
|
||||
openHomePage: true,
|
||||
openFeedback: true,
|
||||
openSettings: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GuestOpen: Story = {
|
||||
args: {
|
||||
...Guest.args,
|
||||
open: true,
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
/*
|
||||
* Axe's context parameter
|
||||
* See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter
|
||||
* to learn more.
|
||||
*/
|
||||
config: {
|
||||
rules: [
|
||||
{
|
||||
// Menu contains a header which is invalid
|
||||
id: "aria-required-children",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
// Menu pops open by default
|
||||
id: "aria-hidden-focus",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Only used for playwright tests for the menu.
|
||||
// Steals focus if actually opened on the storybook page
|
||||
tags: ["!dev", "!autodocs"],
|
||||
};
|
||||
|
||||
export const AllOptions: Story = {
|
||||
args: {
|
||||
displayName: "Alice",
|
||||
userId: "@alice:example.org",
|
||||
manageAccountHref: "#",
|
||||
showAvatar: true,
|
||||
actions: {
|
||||
createAccount: true,
|
||||
signIn: true,
|
||||
linkNewDevice: true,
|
||||
openSecurity: true,
|
||||
openHomePage: true,
|
||||
openFeedback: true,
|
||||
openSettings: true,
|
||||
} satisfies Record<keyof UserMenuViewSnapshot["actions"], true>,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "@test-utils";
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./UserMenu.stories.tsx";
|
||||
|
||||
const { Default, LongerName, Condensed, Guest } = composeStories(stories);
|
||||
|
||||
describe("UserMenu", () => {
|
||||
it("renders a button", async () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a button with a longer name", async () => {
|
||||
const { container } = render(<LongerName />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders condensed view", async () => {
|
||||
const { container } = render(<Condensed />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a menu", async () => {
|
||||
const { baseElement, getByRole } = render(<Default />);
|
||||
await userEvent.click(getByRole("button"));
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
it("renders a guest menu", async () => {
|
||||
const { baseElement, getByRole } = render(<Guest />);
|
||||
await userEvent.click(getByRole("button"));
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
187
packages/shared-components/src/menus/UserMenu/UserMenu.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import {
|
||||
ChatProblemIcon,
|
||||
DevicesIcon,
|
||||
HomeIcon,
|
||||
LockIcon,
|
||||
PopOutIcon,
|
||||
SettingsIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./UserMenu.module.css";
|
||||
import { useViewModel, type ViewModel } from "../../core/viewmodel";
|
||||
import { useI18n } from "../../core/i18n/i18nContext";
|
||||
|
||||
export interface UserMenuViewSnapshot {
|
||||
/**
|
||||
* Is the menu open or closed.
|
||||
*/
|
||||
open: boolean;
|
||||
/**
|
||||
* Is the menu toggle expanded (avatar + displayname) or collapsed (avatar).
|
||||
*/
|
||||
expanded: boolean;
|
||||
/**
|
||||
* Avatar URL for the user, if one is set.
|
||||
*/
|
||||
avatarUrl?: string;
|
||||
/**
|
||||
* Should the avatar be visible.
|
||||
*/
|
||||
showAvatar?: boolean;
|
||||
/**
|
||||
* Display name for the user.
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* Matrix user ID for the user.
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* Account management URL if the user is using OIDC.
|
||||
*/
|
||||
manageAccountHref?: string;
|
||||
/**
|
||||
* A set of actions that the user can perform from the menu.
|
||||
*/
|
||||
actions: Partial<{
|
||||
createAccount: boolean;
|
||||
signIn: boolean;
|
||||
openHomePage: boolean;
|
||||
linkNewDevice: boolean;
|
||||
openSecurity: boolean;
|
||||
openFeedback: boolean;
|
||||
openSettings: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export declare interface UserMenuViewActions {
|
||||
/**
|
||||
* Called when the menu is opened or closed.
|
||||
*/
|
||||
setOpen: (open: boolean) => void;
|
||||
/**
|
||||
* Called to open the create new account view.
|
||||
*/
|
||||
createAccount: () => void;
|
||||
/**
|
||||
* Called to open the sign in view.
|
||||
*/
|
||||
signIn: () => void;
|
||||
/**
|
||||
* Called to change the view to the configured home page.
|
||||
*/
|
||||
openHomePage: () => void;
|
||||
/**
|
||||
* Called to open the link new device flow.
|
||||
*/
|
||||
linkNewDevice: () => void;
|
||||
/**
|
||||
* Called to open the security tab of the settings dialog.
|
||||
*/
|
||||
openSecurity: () => void;
|
||||
/**
|
||||
* Called to open the feedback dialog.
|
||||
*/
|
||||
openFeedback: () => void;
|
||||
/**
|
||||
* Called to open the settings dialog.
|
||||
*/
|
||||
openSettings: () => void;
|
||||
}
|
||||
|
||||
export type UserMenuViewProps = {
|
||||
vm: ViewModel<UserMenuViewSnapshot, UserMenuViewActions>;
|
||||
/**
|
||||
* Class name for the container
|
||||
*/
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
|
||||
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar } = useViewModel(vm);
|
||||
const { translate: _t } = useI18n();
|
||||
const trigger = (
|
||||
<button className={classNames(styles.triggerButton)} aria-label={_t("menus|user_menu|title")}>
|
||||
<Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} />
|
||||
{expanded && (
|
||||
<Text type="heading" size="sm" as="span" weight="semibold">
|
||||
{displayName}
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
showTitle={false}
|
||||
title={_t("menus|user_menu|title")}
|
||||
trigger={trigger}
|
||||
onOpenChange={vm.setOpen}
|
||||
align="start"
|
||||
side="bottom"
|
||||
className={classNames(styles.container, className)}
|
||||
>
|
||||
<section className={styles.profile}>
|
||||
{showAvatar && <Avatar id={userId} name={displayName} type="round" size="88px" src={avatarUrl} />}
|
||||
<Text className={styles.displayname} type="heading" size="md" weight="semibold" as="span">
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text data-testid="userId" size="md" as="span" type="body">
|
||||
{userId}
|
||||
</Text>
|
||||
{manageAccountHref && (
|
||||
<Button as="a" size="md" kind="tertiary" href={manageAccountHref} Icon={PopOutIcon}>
|
||||
{_t("menus|user_menu|manage_account")}
|
||||
</Button>
|
||||
)}
|
||||
{actions.createAccount && (
|
||||
<Button
|
||||
className={styles.createAccount}
|
||||
size="md"
|
||||
as="button"
|
||||
kind="primary"
|
||||
onClick={vm.createAccount}
|
||||
>
|
||||
{_t("menus|user_menu|create_an_account")}
|
||||
</Button>
|
||||
)}
|
||||
{actions.signIn && (
|
||||
<Text as="span" weight="medium">
|
||||
{_t("menus|user_menu|got_an_account")}
|
||||
<Link as="button" onClick={vm.signIn}>
|
||||
{_t("menus|user_menu|sign_in")}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</section>
|
||||
<Separator />
|
||||
<section className={styles.actions}>
|
||||
{actions.openHomePage && (
|
||||
<MenuItem Icon={HomeIcon} label={_t("user_menu|open_home")} onSelect={vm.openHomePage} />
|
||||
)}
|
||||
{actions.linkNewDevice && (
|
||||
<MenuItem Icon={DevicesIcon} label={_t("user_menu|link_new_device")} onSelect={vm.linkNewDevice} />
|
||||
)}
|
||||
{actions.openSecurity && (
|
||||
<MenuItem Icon={LockIcon} label={_t("user_menu|open_security")} onSelect={vm.openSecurity} />
|
||||
)}
|
||||
{actions.openFeedback && (
|
||||
<MenuItem Icon={ChatProblemIcon} label={_t("user_menu|open_feedback")} onSelect={vm.openFeedback} />
|
||||
)}
|
||||
{actions.openSettings && (
|
||||
<MenuItem Icon={SettingsIcon} label={_t("user_menu|open_settings")} onSelect={vm.openSettings} />
|
||||
)}
|
||||
</section>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`UserMenu > renders a button 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="UserMenu-module_triggerButton"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="@person-name:homeserver.com"
|
||||
class="_avatar_va14e_8"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_va14e_43"
|
||||
data-type="round"
|
||||
height="36px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="/static/element.png"
|
||||
width="36px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Sally Sanderson
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`UserMenu > renders a button with a longer name 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="UserMenu-module_triggerButton"
|
||||
data-state="closed"
|
||||
id="radix-_r_2_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="@person-name:homeserver.com"
|
||||
class="_avatar_va14e_8"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_va14e_43"
|
||||
data-type="round"
|
||||
height="36px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="/static/element.png"
|
||||
width="36px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Sally Sanderson with a longer name
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`UserMenu > renders a guest menu 1`] = `
|
||||
<body>
|
||||
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="UserMenu-module_triggerButton"
|
||||
data-state="closed"
|
||||
id="radix-_r_8_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="@guest:attendees.example.org"
|
||||
class="_avatar_va14e_8"
|
||||
data-color="4"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_va14e_43"
|
||||
data-type="round"
|
||||
height="36px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="/static/element.png"
|
||||
width="36px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Guest
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`UserMenu > renders a menu 1`] = `
|
||||
<body>
|
||||
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="UserMenu-module_triggerButton"
|
||||
data-state="closed"
|
||||
id="radix-_r_6_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="@person-name:homeserver.com"
|
||||
class="_avatar_va14e_8"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_va14e_43"
|
||||
data-type="round"
|
||||
height="36px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="/static/element.png"
|
||||
width="36px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Sally Sanderson
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`UserMenu > renders condensed view 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="UserMenu-module_triggerButton"
|
||||
data-state="closed"
|
||||
id="radix-_r_4_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="@person-name:homeserver.com"
|
||||
class="_avatar_va14e_8"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 36px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_va14e_43"
|
||||
data-type="round"
|
||||
height="36px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="/static/element.png"
|
||||
width="36px"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
13
packages/shared-components/src/menus/UserMenu/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
UserMenuView as UserMenu,
|
||||
type UserMenuViewProps,
|
||||
type UserMenuViewSnapshot as UserMenuSnapshot,
|
||||
type UserMenuViewActions,
|
||||
} from "./UserMenu";
|
||||