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>
This commit is contained in:
Will Hunt 2026-05-06 09:34:36 +01:00 committed by GitHub
parent bbd2d81a08
commit d4f419d1b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 1231 additions and 874 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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", () => {

View File

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

View File

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

View File

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

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

View File

@ -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",
}
`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

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

View File

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

View File

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

View File

@ -0,0 +1,178 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type 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>,
},
};

View File

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

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

View File

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

View 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";