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.
|
* Tests for application startup with guest registration enabled on the server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Page } from "playwright-core";
|
||||||
import { expect, test } from "../../element-web-test";
|
import { expect, test } from "../../element-web-test";
|
||||||
|
|
||||||
test.use({
|
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 }) => {
|
test("Shows the welcome page by default", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Sign in" })).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 }) => {
|
test("Room link correctly loads a room view", async ({ page }) => {
|
||||||
await page.goto("/#/room/!room:id");
|
await page.goto("/#/room/!room:id");
|
||||||
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
|
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ test.describe("Logout tests", () => {
|
|||||||
await sendMessageInCurrentRoom(page, "Hello secret world");
|
await sendMessageInCurrentRoom(page, "Hello secret world");
|
||||||
|
|
||||||
const locator = await app.settings.openUserMenu();
|
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");
|
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ test.describe("Logout tests", () => {
|
|||||||
await sendMessageInCurrentRoom(page, "Hello secret world");
|
await sendMessageInCurrentRoom(page, "Hello secret world");
|
||||||
|
|
||||||
const locator = await app.settings.openUserMenu();
|
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");
|
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||||
|
|
||||||
@@ -54,7 +57,8 @@ test.describe("Logout tests", () => {
|
|||||||
await sendMessageInCurrentRoom(page, "Hello public world!");
|
await sendMessageInCurrentRoom(page, "Hello public world!");
|
||||||
|
|
||||||
const locator = await app.settings.openUserMenu();
|
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
|
// Should have logged out directly
|
||||||
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
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) {
|
export async function logOutOfElement(page: Page, discardKeys: boolean = false) {
|
||||||
await page.getByRole("button", { name: "User menu" }).click();
|
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) {
|
if (discardKeys) {
|
||||||
await page.getByRole("button", { name: "I don't want my encrypted messages" }).click();
|
await page.getByRole("button", { name: "I don't want my encrypted messages" }).click();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -328,8 +328,11 @@ test.describe("Room list", () => {
|
|||||||
const videoRoom = roomListView.getByRole("option", { name: "video room" });
|
const videoRoom = roomListView.getByRole("option", { name: "video room" });
|
||||||
await expect(videoRoom).toHaveAttribute("aria-selected", "true"); // wait for room list update
|
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
|
// 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");
|
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
|
// Allow the outstanding requests queue to settle before logging out
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click();
|
||||||
await page
|
await page.getByRole("button", { name: "Remove this device" }).click();
|
||||||
.locator(".mx_UserMenu_contextMenu")
|
|
||||||
.getByRole("menuitem", { name: "Remove this device" })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/#\/welcome$/);
|
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
|
// give a change for the outstanding requests queue to settle before logging out
|
||||||
await page.waitForTimeout(2000);
|
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\/$/);
|
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",
|
(request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "refresh_token",
|
||||||
);
|
);
|
||||||
const locator = await app.settings.openUserMenu();
|
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 revokeAccessTokenPromise;
|
||||||
await revokeRefreshTokenPromise;
|
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
|
// Allow the outstanding requests queue to settle before logging out
|
||||||
await page.waitForTimeout(2000);
|
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$/);
|
await expect(page).toHaveURL(/\/#\/welcome$/);
|
||||||
|
|
||||||
// Log in again
|
// 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 page.getByRole("button", { name: "User menu" }).click();
|
||||||
await expect(page.getByText(userId, { exact: true })).toBeVisible();
|
await expect(page.getByText(userId, { exact: true })).toBeVisible();
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
await page
|
await page.getByRole("menu", { name: "User menu" }).getByRole("menuitem", { name: "All settings" }).click();
|
||||||
.locator(".mx_UserMenu_contextMenu")
|
await page.getByRole("button", { name: "Remove this device" }).click();
|
||||||
.getByRole("menuitem", { name: "Remove this device" })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/#\/welcome$/);
|
await expect(page).toHaveURL(/\/#\/welcome$/);
|
||||||
|
|
||||||
// Log in again
|
// 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 expect(page.getByText(userId, { exact: true })).toBeVisible();
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
await page
|
await page
|
||||||
.locator(".mx_UserMenu_contextMenu")
|
.getByRole("menu", { name: "User menu" })
|
||||||
.getByRole("menuitem", { name: "Remove this device" })
|
.getByRole("menuitem", { name: "All settings" })
|
||||||
.click();
|
.click();
|
||||||
|
await page.getByRole("button", { name: "Remove this device" }).click();
|
||||||
await expect(page).toHaveURL(/\/#\/welcome$/);
|
await expect(page).toHaveURL(/\/#\/welcome$/);
|
||||||
|
|
||||||
// Log in again
|
// Log in again
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ test.describe("Account user settings tab", () => {
|
|||||||
await expect(accountPhoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
|
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
|
// Change the diaplay name to USER_NAME_NEW
|
||||||
const displayNameInput = uut
|
const displayNameInput = uut
|
||||||
.locator(".mx_SettingsTab .mx_UserProfileSettings")
|
.locator(".mx_SettingsTab .mx_UserProfileSettings")
|
||||||
@@ -140,7 +140,8 @@ test.describe("Account user settings tab", () => {
|
|||||||
await app.closeDialog();
|
await app.closeDialog();
|
||||||
|
|
||||||
// Assert the avatar's initial characters are set
|
// 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
|
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 spaceTree.getByRole("button", { name: "Expand" }).click();
|
||||||
await expect(page.locator(".mx_SpacePanel:not(.collapsed)")).toBeVisible(); // TODO: replace :not() selector
|
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" });
|
const item = page.locator(".mx_SpaceItem", { hasText: "Root Space" });
|
||||||
await expect(item).toBeVisible();
|
await expect(item).toBeVisible();
|
||||||
await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Page } from "playwright-core";
|
||||||
import { test, expect } from "../../element-web-test";
|
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.describe("User Menu", () => {
|
||||||
test.use({ displayName: "Jeff" });
|
test.use({ displayName: "Jeff" });
|
||||||
|
|
||||||
@@ -15,8 +25,8 @@ test.describe("User Menu", () => {
|
|||||||
await page.getByRole("button", { name: "User menu", exact: true }).click();
|
await page.getByRole("button", { name: "User menu", exact: true }).click();
|
||||||
const menu = page.getByRole("menu");
|
const menu = page.getByRole("menu");
|
||||||
|
|
||||||
await expect(menu.locator(".mx_UserMenu_contextMenu_displayName", { hasText: user.displayName })).toBeVisible();
|
await expect(menu.getByText(user.displayName)).toBeVisible();
|
||||||
await expect(menu.locator(".mx_UserMenu_contextMenu_userId", { hasText: user.userId })).toBeVisible();
|
await expect(menu.getByText(user.userId)).toBeVisible();
|
||||||
await expect(menu).toMatchScreenshot("user-menu.png");
|
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.
|
* Open the top left user menu, returning a Locator to the resulting context menu.
|
||||||
*/
|
*/
|
||||||
public async openUserMenu(): Promise<Locator> {
|
public async openUserMenu(): Promise<Locator> {
|
||||||
const locator = this.page.locator(".mx_ContextualMenu");
|
const locator = this.page.getByRole("menu", { name: "User menu" });
|
||||||
if (await locator.locator(".mx_UserMenu_contextMenu_header").isVisible()) return locator;
|
if (await locator.isVisible()) return locator;
|
||||||
await this.page.getByRole("button", { name: "User menu" }).click();
|
await this.page.getByRole("button", { name: "User menu" }).click();
|
||||||
await locator.waitFor();
|
await locator.waitFor();
|
||||||
return locator;
|
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/_ThreadsActivityCentre.pcss";
|
||||||
@import "./structures/_ToastContainer.pcss";
|
@import "./structures/_ToastContainer.pcss";
|
||||||
@import "./structures/_UploadBar.pcss";
|
@import "./structures/_UploadBar.pcss";
|
||||||
@import "./structures/_UserMenu.pcss";
|
|
||||||
@import "./structures/_ViewSource.pcss";
|
@import "./structures/_ViewSource.pcss";
|
||||||
@import "./structures/auth/_CompleteSecurity.pcss";
|
@import "./structures/auth/_CompleteSecurity.pcss";
|
||||||
@import "./structures/auth/_ConfirmSessionLockTheftView.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;
|
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 {
|
.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 {
|
.mx_ThemeChoicePanel_themeSelectors > .mx_StyledRadioButton input[type="radio"]:disabled + div {
|
||||||
border-color: $primary-content;
|
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 (
|
return (
|
||||||
<div className={`mx_ShareType_option-icon ${LocationShareType.Own}`}>
|
<div className={`mx_ShareType_option-icon ${LocationShareType.Own}`}>
|
||||||
<BaseAvatar
|
<BaseAvatar idName={userId} name={displayName} url={avatarUrl} size={avatarSize} />
|
||||||
idName={userId}
|
|
||||||
name={displayName}
|
|
||||||
url={avatarUrl}
|
|
||||||
size={avatarSize}
|
|
||||||
className="mx_UserMenu_userAvatar_BaseAvatar"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
|
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import React, {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useContext,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { DragDropContext, Draggable, Droppable, type DroppableProvidedProps } from "react-beautiful-dnd";
|
import { DragDropContext, Draggable, Droppable, type DroppableProvidedProps } from "react-beautiful-dnd";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { useCreateAutoDisposedViewModel, UserMenu } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { useContextMenu } from "../../structures/ContextMenu";
|
import { useContextMenu } from "../../structures/ContextMenu";
|
||||||
@@ -62,7 +65,6 @@ import { SettingLevel } from "../../../settings/SettingLevel";
|
|||||||
import UIStore from "../../../stores/UIStore";
|
import UIStore from "../../../stores/UIStore";
|
||||||
import QuickSettingsButton from "./QuickSettingsButton";
|
import QuickSettingsButton from "./QuickSettingsButton";
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
import UserMenu from "../../structures/UserMenu";
|
|
||||||
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
|
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
|
||||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||||
@@ -79,6 +81,9 @@ import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNav
|
|||||||
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
|
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
|
||||||
import { ModuleApi } from "../../../modules/Api.ts";
|
import { ModuleApi } from "../../../modules/Api.ts";
|
||||||
import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.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 useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
|
||||||
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
|
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
|
||||||
@@ -384,6 +389,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const SpacePanel: React.FC = () => {
|
const SpacePanel: React.FC = () => {
|
||||||
|
const client = useMatrixClientContext();
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
|
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -391,6 +397,7 @@ const SpacePanel: React.FC = () => {
|
|||||||
if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
|
if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
|
||||||
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
|
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
|
||||||
}, []);
|
}, []);
|
||||||
|
const sdkContext = useContext(SDKContext);
|
||||||
|
|
||||||
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
|
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
|
||||||
if (payload.action === Action.ToggleSpacePanel) {
|
if (payload.action === Action.ToggleSpacePanel) {
|
||||||
@@ -400,6 +407,26 @@ const SpacePanel: React.FC = () => {
|
|||||||
|
|
||||||
const newRoomListEnabled = useSettingValue("feature_new_room_list");
|
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 (
|
return (
|
||||||
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
|
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
|
||||||
{({ onKeyDownHandler, onDragEndHandler }) => (
|
{({ onKeyDownHandler, onDragEndHandler }) => (
|
||||||
@@ -438,23 +465,22 @@ const SpacePanel: React.FC = () => {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
aria-label={_t("common|spaces")}
|
aria-label={_t("common|spaces")}
|
||||||
>
|
>
|
||||||
<UserMenu isPanelCollapsed={isPanelCollapsed}>
|
<UserMenu vm={userMenuVm} />
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className={classNames("mx_SpacePanel_toggleCollapse", {
|
className={classNames("mx_SpacePanel_toggleCollapse", {
|
||||||
expanded: !isPanelCollapsed,
|
expanded: !isPanelCollapsed,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
|
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
|
||||||
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
|
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
|
||||||
caption={
|
caption={
|
||||||
<KeyboardShortcut
|
<KeyboardShortcut
|
||||||
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
|
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
|
||||||
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
|
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</UserMenu>
|
|
||||||
<Droppable droppableId="top-level-spaces">
|
<Droppable droppableId="top-level-spaces">
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
<InnerSpacePanel
|
<InnerSpacePanel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -194,5 +195,6 @@ export class SdkContextClass {
|
|||||||
|
|
||||||
public onLoggedOut(): void {
|
public onLoggedOut(): void {
|
||||||
this._UserProfilesStore = undefined;
|
this._UserProfilesStore = undefined;
|
||||||
|
this._OidcClientStore = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,7 @@
|
|||||||
"room_name": "Room %(name)s",
|
"room_name": "Room %(name)s",
|
||||||
"room_status_bar": "Room status bar",
|
"room_status_bar": "Room status bar",
|
||||||
"seek_bar_label": "Audio seek bar",
|
"seek_bar_label": "Audio seek bar",
|
||||||
"unread_messages": "Unread messages.",
|
"unread_messages": "Unread messages."
|
||||||
"user_menu": "User menu"
|
|
||||||
},
|
},
|
||||||
"a11y_jump_first_unread_room": "Jump to first unread room.",
|
"a11y_jump_first_unread_room": "Jump to first unread room.",
|
||||||
"action": {
|
"action": {
|
||||||
@@ -341,7 +340,6 @@
|
|||||||
"sign_in_instead_prompt": "Already have an account? <a>Sign in here</a>",
|
"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": "Sign In or Create Account",
|
||||||
"sign_in_or_register_description": "Use your account or create a new one to continue.",
|
"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",
|
"sign_in_with_sso": "Sign in with single sign-on",
|
||||||
"signing_in": "Signing In…",
|
"signing_in": "Signing In…",
|
||||||
"soft_logout": {
|
"soft_logout": {
|
||||||
@@ -3895,12 +3893,6 @@
|
|||||||
"verify_button": "Verify User",
|
"verify_button": "Verify User",
|
||||||
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
|
"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": {
|
"voip": {
|
||||||
"already_in_call": "Already in call",
|
"already_in_call": "Already in call",
|
||||||
"already_in_call_person": "You're already in a call with this person.",
|
"already_in_call_person": "You're already in a call with this person.",
|
||||||
|
|||||||
@@ -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 2024 New Vector Ltd.
|
||||||
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
|
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ describe("<LoggedInView />", () => {
|
|||||||
const userId = "@alice:domain.org";
|
const userId = "@alice:domain.org";
|
||||||
const mockClient = getMockClientWithEventEmitter({
|
const mockClient = getMockClientWithEventEmitter({
|
||||||
...mockClientMethodsUser(userId),
|
...mockClientMethodsUser(userId),
|
||||||
|
getClientWellKnown: jest.fn(),
|
||||||
getAccountData: jest.fn(),
|
getAccountData: jest.fn(),
|
||||||
getRoom: jest.fn(),
|
getRoom: jest.fn(),
|
||||||
getSyncState: jest.fn().mockReturnValue(null),
|
getSyncState: jest.fn().mockReturnValue(null),
|
||||||
@@ -104,6 +106,7 @@ describe("<LoggedInView />", () => {
|
|||||||
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
|
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
mockClient.pushProcessor = new PushProcessor(mockClient);
|
mockClient.pushProcessor = new PushProcessor(mockClient);
|
||||||
|
mockSdkContext.client = mockClient;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("synced push rules", () => {
|
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 2024 New Vector Ltd.
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
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 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 { mocked } from "jest-mock";
|
||||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
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 { type SpaceNotificationState } from "../../../../../src/stores/notifications/SpaceNotificationState";
|
||||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||||
import UnwrappedSpacePanel from "../../../../../src/components/views/spaces/SpacePanel";
|
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
|
// DND test utilities based on
|
||||||
// https://github.com/colinrobertbrooks/react-beautiful-dnd-test-utils/issues/18#issuecomment-1373388693
|
// https://github.com/colinrobertbrooks/react-beautiful-dnd-test-utils/issues/18#issuecomment-1373388693
|
||||||
@@ -110,6 +113,7 @@ describe("<SpacePanel />", () => {
|
|||||||
const mockClient = {
|
const mockClient = {
|
||||||
getUserId: jest.fn().mockReturnValue("@test:test"),
|
getUserId: jest.fn().mockReturnValue("@test:test"),
|
||||||
getSafeUserId: jest.fn().mockReturnValue("@test:test"),
|
getSafeUserId: jest.fn().mockReturnValue("@test:test"),
|
||||||
|
getClientWellKnown: jest.fn(),
|
||||||
mxcUrlToHttp: jest.fn(),
|
mxcUrlToHttp: jest.fn(),
|
||||||
getRoom: jest.fn(),
|
getRoom: jest.fn(),
|
||||||
isGuest: jest.fn(),
|
isGuest: jest.fn(),
|
||||||
@@ -125,6 +129,7 @@ describe("<SpacePanel />", () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||||
|
SdkContextClass.instance.client = mockClient;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -189,4 +194,13 @@ describe("<SpacePanel />", () => {
|
|||||||
|
|
||||||
expect(SpaceStore.instance.moveRootSpace).toHaveBeenCalledWith(0, 1);
|
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"
|
aria-label="Spaces"
|
||||||
class="mx_SpacePanel collapsed newUi"
|
class="mx_SpacePanel collapsed newUi"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
class="mx_UserMenu"
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="User menu"
|
||||||
|
class="_triggerButton_3x4xf_50"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-_r_0_"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div
|
<span
|
||||||
aria-expanded="false"
|
aria-label="@test:test"
|
||||||
aria-haspopup="true"
|
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
|
||||||
aria-label="User menu"
|
data-color="5"
|
||||||
class="mx_AccessibleButton mx_UserMenu_contextMenuButton"
|
data-type="round"
|
||||||
role="button"
|
role="img"
|
||||||
tabindex="0"
|
style="--cpd-avatar-size: 36px;"
|
||||||
>
|
>
|
||||||
<div
|
t
|
||||||
class="mx_UserMenu_userAvatar"
|
</span>
|
||||||
>
|
</button>
|
||||||
<span
|
<div
|
||||||
class="_avatar_va14e_8 mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar _avatar-imageless_va14e_55"
|
aria-label="Expand"
|
||||||
data-color="5"
|
class="mx_AccessibleButton mx_SpacePanel_toggleCollapse"
|
||||||
data-testid="avatar-img"
|
role="button"
|
||||||
data-type="round"
|
tabindex="0"
|
||||||
role="presentation"
|
>
|
||||||
style="--cpd-avatar-size: 32px;"
|
<svg
|
||||||
>
|
fill="currentColor"
|
||||||
t
|
height="1em"
|
||||||
</span>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
width="1em"
|
||||||
</div>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<div
|
|
||||||
aria-label="Expand"
|
|
||||||
class="mx_AccessibleButton mx_SpacePanel_toggleCollapse"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
fill="currentColor"
|
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"
|
||||||
height="1em"
|
/>
|
||||||
viewBox="0 0 24 24"
|
</svg>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
aria-label="Spaces"
|
aria-label="Spaces"
|
||||||
@@ -332,11 +325,11 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
|
|||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
aria-labelledby="_r_12_"
|
aria-labelledby="_r_14_"
|
||||||
class="_icon-button_1215g_8 mx_ThreadsActivityCentreButton"
|
class="_icon-button_1215g_8 mx_ThreadsActivityCentreButton"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-state="closed"
|
data-state="closed"
|
||||||
id="radix-_r_10_"
|
id="radix-_r_12_"
|
||||||
role="button"
|
role="button"
|
||||||
style="--cpd-icon-button-size: 32px;"
|
style="--cpd-icon-button-size: 32px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -364,7 +357,7 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
|
|||||||
<button
|
<button
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-label="Quick settings"
|
aria-label="Quick settings"
|
||||||
aria-labelledby="_r_17_"
|
aria-labelledby="_r_19_"
|
||||||
class="_icon-button_1215g_8 mx_QuickSettingsButton"
|
class="_icon-button_1215g_8 mx_QuickSettingsButton"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -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",
|
"open_dial_pad": "Open dial pad",
|
||||||
"separator_label": "Click or drag to expand"
|
"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": {
|
"notifications": {
|
||||||
"all_messages": "All messages",
|
"all_messages": "All messages",
|
||||||
"default_settings": "Match default settings",
|
"default_settings": "Match default settings",
|
||||||
@@ -238,6 +247,13 @@
|
|||||||
"view_image": "View image"
|
"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": {
|
"widget": {
|
||||||
"context_menu": {
|
"context_menu": {
|
||||||
"move_left": "Move left",
|
"move_left": "Move left",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
* Copyright 2025 New Vector Ltd.
|
* Copyright 2025 New Vector Ltd.
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
* 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 "./core/roving";
|
||||||
export * from "./room/composer/Banner";
|
export * from "./room/composer/Banner";
|
||||||
export * from "./crypto/SasEmoji";
|
export * from "./crypto/SasEmoji";
|
||||||
|
export * from "./menus/UserMenu";
|
||||||
export * from "./room/timeline/ReadMarker";
|
export * from "./room/timeline/ReadMarker";
|
||||||
export * from "./room/timeline/EventPresentation";
|
export * from "./room/timeline/EventPresentation";
|
||||||
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||