User status in user menu (#33797)

* Add user status on user profile icon

Currently unstyled & no tests

* Style the user status icon

* Update snapshot

for avatar wrapper

* More snapshot updates

* add if braces

* Split out user status functions

to avoid circular dep which has the weird effect of just breaking
jest's mocking.

* type imports

* Update imports

* Update snapshot

* Tests

* baseline image

* Just snapshot the component itself

* User status in user menu

first pass, unstyled

* fix export

* superstylin'

* snapshots

* Add story & test

* Snapshots

and re-use the repeated rules

* Tests

* Fix jest lcov projectRoot config

* Change tooltip text to just 'clear'

* Add comment & fix console warn

* Remove all options screenshot

as it's not really particularly useful as a preset story

* remove stale screenshot

* fix imports

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
David Baker
2026-06-16 09:01:34 +00:00
committed by GitHub
co-authored by Michael Telatynski
parent 44c540eca0
commit 3ab233940c
23 changed files with 293 additions and 74 deletions
+4 -4
View File
@@ -8,11 +8,12 @@ Please see LICENSE files in the repository root for full details.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix"; import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { type UserStatus } from "@element-hq/web-shared-components";
import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "./useEventEmitter"; import { useTypedEventEmitter } from "./useEventEmitter";
import { useFeatureEnabled } from "./useSettings"; import { useFeatureEnabled } from "./useSettings";
import { type UserStatus, validateUserStatus } from "../utils/userStatus"; import { validateUserStatus } from "../utils/userStatus";
const logger = rootLogger.getChild("useUserStatus"); const logger = rootLogger.getChild("useUserStatus");
@@ -32,9 +33,8 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine
if (syncedUserId !== userId) { if (syncedUserId !== userId) {
return; return;
} }
if (syncProfile["org.matrix.msc4426.status"]) {
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]); setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
}
}); });
useEffect(() => { useEffect(() => {
(async () => { (async () => {
+2 -2
View File
@@ -12,7 +12,7 @@ import SettingsStore from "../settings/SettingsStore";
import { reject, success } from "./utils"; import { reject, success } from "./utils";
import { UserFriendlyError } from "../languageHandler"; import { UserFriendlyError } from "../languageHandler";
import { TimelineRenderingType } from "../contexts/RoomContext"; import { TimelineRenderingType } from "../contexts/RoomContext";
import { userStatusTextWithinMaxLength } from "../utils/userStatus"; import { setUserStatus, userStatusTextWithinMaxLength } from "../utils/userStatus";
export const statusCommand = new Command({ export const statusCommand = new Command({
command: "status", command: "status",
@@ -40,7 +40,7 @@ export const statusCommand = new Command({
return reject(new UserFriendlyError("slash_command|status|too_long_text")); return reject(new UserFriendlyError("slash_command|status|too_long_text"));
} }
return success( return success(
cli.setExtendedProfileProperty("org.matrix.msc4426.status", { setUserStatus(cli, {
emoji: emoji.segment, emoji: emoji.segment,
text, text,
}), }),
+2 -1
View File
@@ -16,6 +16,7 @@ import {
ClientEvent, ClientEvent,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash"; import { throttle } from "lodash";
import { type UserStatus } from "@element-hq/web-shared-components";
import { type ActionPayload } from "../dispatcher/payloads"; import { type ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
@@ -24,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import { mediaFromMxc } from "../customisations/Media"; import { mediaFromMxc } from "../customisations/Media";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import { type UserStatus, validateUserStatus } from "../utils/userStatus"; import { validateUserStatus } from "../utils/userStatus";
interface IState { interface IState {
displayName?: string; displayName?: string;
+16 -5
View File
@@ -5,12 +5,12 @@ 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.
*/ */
const MAX_STATUS_TEXT_BYTES = 256; import { type UserStatus } from "@element-hq/web-shared-components";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
export interface UserStatus { // MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8,
emoji: string; // so we truncate anything longer than that.
text: string; const MAX_STATUS_TEXT_BYTES = 256;
}
export function userStatusTextWithinMaxLength(text: string): boolean { export function userStatusTextWithinMaxLength(text: string): boolean {
const textEncoder = new TextEncoder(); const textEncoder = new TextEncoder();
@@ -34,3 +34,14 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}`, : `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}`,
}; };
} }
export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", {
emoji: userStatus.emoji,
text: userStatus.text,
});
}
export function clearUserStatus(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
}
@@ -6,6 +6,7 @@
*/ */
import { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components"; import { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components";
import { logger } from "matrix-js-sdk/src/logger";
import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
@@ -18,6 +19,7 @@ import { shouldShowFeedback } from "../../utils/Feedback";
import { getHomePageUrl } from "../../utils/pages"; import { getHomePageUrl } from "../../utils/pages";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { clearUserStatus } from "../../utils/userStatus";
// Matches maximum size of an avatar in the UserMenu // Matches maximum size of an avatar in the UserMenu
const AVATAR_PX = 88; const AVATAR_PX = 88;
@@ -42,7 +44,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
expanded: !isPanelCollapsed, expanded: !isPanelCollapsed,
manageAccountHref: accountManagementEndpoint, manageAccountHref: accountManagementEndpoint,
showAvatar: isAuthenticated, showAvatar: isAuthenticated,
statusEmoji: OwnProfileStore.instance.userStatus?.emoji, userStatus: OwnProfileStore.instance.userStatus,
actions: { actions: {
createAccount: !isAuthenticated, createAccount: !isAuthenticated,
signIn: !isAuthenticated, signIn: !isAuthenticated,
@@ -57,7 +59,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
public constructor( public constructor(
private readonly dispatcher: MatrixDispatcher, private readonly dispatcher: MatrixDispatcher,
client: MatrixClient, private readonly client: MatrixClient,
isPanelCollapsed: boolean, isPanelCollapsed: boolean,
accountManagementEndpoint?: string, accountManagementEndpoint?: string,
) { ) {
@@ -73,8 +75,8 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
public readonly recalculateProfile = (): void => { public readonly recalculateProfile = (): void => {
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId; const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
const statusEmoji = OwnProfileStore.instance.userStatus?.emoji; const userStatus = OwnProfileStore.instance.userStatus;
this.snapshot.merge({ displayName, avatarUrl, statusEmoji }); this.snapshot.merge({ displayName, avatarUrl, userStatus });
}; };
public readonly setOpen = (isOpen: boolean): void => { public readonly setOpen = (isOpen: boolean): void => {
@@ -128,4 +130,11 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
action: Action.ViewUserSettings, action: Action.ViewUserSettings,
}); });
}; };
public readonly clearStatus = (): void => {
this.setOpen(false);
clearUserStatus(this.client).catch((err) => {
logger.warn("Failed to clear user status", err);
});
};
} }
@@ -9,13 +9,13 @@ import {
type DisambiguatedProfileViewActions, type DisambiguatedProfileViewActions,
type DisambiguatedProfileViewSnapshot, type DisambiguatedProfileViewSnapshot,
type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface, type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface,
type UserStatus,
} from "@element-hq/web-shared-components"; } from "@element-hq/web-shared-components";
import { type MouseEvent } from "react"; import { type MouseEvent } from "react";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { getUserNameColorClass } from "../../../../utils/FormattingUtils"; import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
import UserIdentifier from "../../../../customisations/UserIdentifier"; import UserIdentifier from "../../../../customisations/UserIdentifier";
import { type UserStatus } from "../../../../utils/userStatus";
/** /**
* Information about a member for disambiguation purposes. * Information about a member for disambiguation purposes.
@@ -16,7 +16,6 @@ describe("/status", () => {
beforeEach(() => { beforeEach(() => {
client = stubClient(); client = stubClient();
client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined);
}); });
function run(args?: string) { function run(args?: string) {
+1
View File
@@ -360,6 +360,7 @@ export function createTestClient(): MatrixClient {
deleteRoomTag: jest.fn().mockResolvedValue({}), deleteRoomTag: jest.fn().mockResolvedValue({}),
setRoomTag: jest.fn().mockResolvedValue({}), setRoomTag: jest.fn().mockResolvedValue({}),
getExtendedProfileProperty: jest.fn(), getExtendedProfileProperty: jest.fn(),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
} as unknown as MatrixClient; } as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client); client.reEmitter = new ReEmitter(client);
@@ -7,19 +7,19 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
class="mx_SpacePanel collapsed newUi" class="mx_SpacePanel collapsed newUi"
> >
<div <div
class="_wrapper_11z86_8 mx_UserMenu" class="_wrapper_bh5eg_8 mx_UserMenu"
> >
<button <button
aria-expanded="false" aria-expanded="false"
aria-haspopup="menu" aria-haspopup="menu"
aria-label="User menu" aria-label="User menu"
class="_triggerButton_11z86_80" class="_triggerButton_bh5eg_107"
data-state="closed" data-state="closed"
id="radix-react-use-id-1" id="radix-react-use-id-1"
type="button" type="button"
> >
<div <div
class="_avatarWrapper_11z86_57" class="_avatarWrapper_bh5eg_57"
> >
<span <span
aria-label="@test:test" aria-label="@test:test"
@@ -0,0 +1,55 @@
/*
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 MatrixClient } from "matrix-js-sdk/src/matrix";
import { clearUserStatus, setUserStatus, userStatusTextWithinMaxLength } from "../../../src/utils/userStatus";
import { stubClient } from "../../test-utils";
describe("userStatus utils", () => {
describe("userStatusTextWithinMaxLength", () => {
it("returns true for text within the max length", () => {
const text = "a".repeat(256);
expect(userStatusTextWithinMaxLength(text)).toBe(true);
});
it("returns false for text exceeding the max length", () => {
const text = "a".repeat(257);
expect(userStatusTextWithinMaxLength(text)).toBe(false);
});
});
describe("setUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("sets the user status with valid input", async () => {
setUserStatus(client, { emoji: "🐳", text: "Feeling a little blue" });
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
emoji: "🐳",
text: "Feeling a little blue",
});
});
});
describe("clearUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("clears the user status", async () => {
clearUserStatus(client);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
});
});
});
@@ -27,6 +27,7 @@ describe("UserMenuViewModel", () => {
...mockClientMethodsUser(), ...mockClientMethodsUser(),
...mockClientMethodsServer(), ...mockClientMethodsServer(),
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)), getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
}); });
SdkContextClass.instance.client = client; SdkContextClass.instance.client = client;
}); });
@@ -153,6 +154,15 @@ describe("UserMenuViewModel", () => {
); );
}); });
it("can clear a user status", async () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
vm.setOpen(true);
vm.clearStatus();
await waitFor(() =>
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null),
);
});
it("should be able to open the createAccount screen as a guest", async () => { it("should be able to open the createAccount screen as a guest", async () => {
client.isGuest.mockReturnValue(true); client.isGuest.mockReturnValue(true);
const dispatcherSpy = jest.fn(); const dispatcherSpy = jest.fn();
@@ -17,8 +17,8 @@ exports[`UserMenuViewModel should generate a menu options for a guest 1`] = `
"manageAccountHref": undefined, "manageAccountHref": undefined,
"open": true, "open": true,
"showAvatar": false, "showAvatar": false,
"statusEmoji": undefined,
"userId": "@alice:domain", "userId": "@alice:domain",
"userStatus": undefined,
} }
`; `;
@@ -39,7 +39,7 @@ exports[`UserMenuViewModel should generate a menu options for a logged in client
"manageAccountHref": undefined, "manageAccountHref": undefined,
"open": true, "open": true,
"showAvatar": true, "showAvatar": true,
"statusEmoji": undefined,
"userId": "@alice:domain", "userId": "@alice:domain",
"userStatus": undefined,
} }
`; `;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,15 @@
/*
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.
*/
/**
* A tuple of an emoji and string representing a user's MSC4426 status.
* The emoji should be a single grapheme cluster.
*/
export interface UserStatus {
emoji: string;
text: string;
}
@@ -53,6 +53,7 @@
}, },
"menus": { "menus": {
"user_menu": { "user_menu": {
"clear_status": "Clear",
"create_an_account": "Create an account", "create_an_account": "Create an account",
"got_an_account": "Got an account?", "got_an_account": "Got an account?",
"manage_account": "Manage account", "manage_account": "Manage account",
+1
View File
@@ -88,5 +88,6 @@ export * from "./core/utils/numbers";
export * from "./core/utils/FormattingUtils"; export * from "./core/utils/FormattingUtils";
export * from "./core/i18n/I18nApi"; export * from "./core/i18n/I18nApi";
export * from "./core/utils/linkify"; export * from "./core/utils/linkify";
export type * from "./core/userStatus.ts";
// MVVM // MVVM
export * from "./core/viewmodel"; export * from "./core/viewmodel";
@@ -60,7 +60,7 @@
overflow: visible; overflow: visible;
} }
.statusEmoji { .iconStatusEmoji {
position: absolute; position: absolute;
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -77,6 +77,33 @@
background-color: var(--cpd-color-bg-canvas-default); background-color: var(--cpd-color-bg-canvas-default);
} }
.statusButton {
border-radius: 30px;
background: var(--cpd-color-bg-subtle-secondary);
display: flex;
align-items: center;
padding-inline-start: var(--cpd-space-2x);
gap: 6px;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
.menuStatusEmoji {
flex-shrink: 0;
}
.menuStatusText {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
}
span {
font: var(--cpd-font-body-md-medium);
color: var(--cpd-color-text-secondary);
}
}
.triggerButton { .triggerButton {
border: none; border: none;
background: none; background: none;
@@ -24,6 +24,7 @@ const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
openHomePage: fn(), openHomePage: fn(),
openSecurity: fn(), openSecurity: fn(),
openSettings: fn(), openSettings: fn(),
clearStatus: fn(),
}); });
return <UserMenuView vm={vm} />; return <UserMenuView vm={vm} />;
}; };
@@ -67,6 +68,21 @@ export const LongerName: Story = {
}, },
}; };
// Rules needed for any story where the menu is open, for the reasons
// given in each rule.
const MENU_OPEN_A11Y_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,
},
];
export const Open: Story = { export const Open: Story = {
args: { args: {
open: true, open: true,
@@ -83,18 +99,7 @@ export const Open: Story = {
* to learn more. * to learn more.
*/ */
config: { config: {
rules: [ rules: MENU_OPEN_A11Y_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,
},
],
}, },
}, },
}, },
@@ -150,18 +155,7 @@ export const GuestOpen: Story = {
* to learn more. * to learn more.
*/ */
config: { config: {
rules: [ rules: MENU_OPEN_A11Y_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,
},
],
}, },
}, },
}, },
@@ -170,26 +164,28 @@ export const GuestOpen: Story = {
tags: ["!dev", "!autodocs"], tags: ["!dev", "!autodocs"],
}; };
export const WithStatusEmoji: Story = { export const WithStatus: Story = {
args: { args: {
statusEmoji: "🐹", userStatus: {
emoji: "🐹",
text: "On the wheel",
},
}, },
}; };
export const AllOptions: Story = { export const WithStatusOpen: Story = {
args: { args: {
displayName: "Alice", open: true,
userId: "@alice:example.org", userStatus: {
manageAccountHref: "#", emoji: "🐹",
showAvatar: true, text: "On the wheel",
actions: { },
createAccount: true, },
signIn: true, parameters: {
linkNewDevice: true, a11y: {
openSecurity: true, config: {
openHomePage: true, rules: MENU_OPEN_A11Y_RULES,
openFeedback: true, },
openSettings: true, },
} satisfies Record<keyof UserMenuViewSnapshot["actions"], true>,
}, },
}; };
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
import * as stories from "./UserMenu.stories.tsx"; import * as stories from "./UserMenu.stories.tsx";
const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatusEmoji } = composeStories(stories); const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatus, WithStatusOpen } = composeStories(stories);
describe("UserMenu", () => { describe("UserMenu", () => {
it("renders a button", async () => { it("renders a button", async () => {
@@ -29,7 +29,7 @@ describe("UserMenu", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders the status emoji when set", async () => { it("renders the status emoji when set", async () => {
const { container } = render(<WithStatusEmoji />); const { container } = render(<WithStatus />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders a menu", async () => { it("renders a menu", async () => {
@@ -50,4 +50,8 @@ describe("UserMenu", () => {
const { baseElement } = render(<NoAvatar />); const { baseElement } = render(<NoAvatar />);
expect(baseElement).toMatchSnapshot(); expect(baseElement).toMatchSnapshot();
}); });
it("renders an open menu with user status", async () => {
const { container } = render(<WithStatusOpen />);
expect(container).toMatchSnapshot();
});
}); });
@@ -6,7 +6,7 @@
*/ */
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web"; import { Avatar, Button, IconButton, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
import { import {
ChatProblemIcon, ChatProblemIcon,
DevicesIcon, DevicesIcon,
@@ -14,12 +14,15 @@ import {
LockIcon, LockIcon,
PopOutIcon, PopOutIcon,
SettingsIcon, SettingsIcon,
CloseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./UserMenu.module.css"; import styles from "./UserMenu.module.css";
import { useViewModel, type ViewModel } from "../../core/viewmodel"; import { useViewModel, type ViewModel } from "../../core/viewmodel";
import { useI18n } from "../../core/i18n/i18nContext"; import { useI18n } from "../../core/i18n/i18nContext";
import { type UserStatus } from "../../core/userStatus";
import { _t } from "../..";
export interface UserMenuViewSnapshot { export interface UserMenuViewSnapshot {
/** /**
@@ -51,9 +54,9 @@ export interface UserMenuViewSnapshot {
*/ */
manageAccountHref?: string; manageAccountHref?: string;
/** /**
* The user status emoji to display, or undefined for no icon. * The user status to display, or undefined for no icon / status.
*/ */
statusEmoji?: string; userStatus?: UserStatus;
/** /**
* A set of actions that the user can perform from the menu. * A set of actions that the user can perform from the menu.
*/ */
@@ -101,6 +104,10 @@ export declare interface UserMenuViewActions {
* Called to open the settings dialog. * Called to open the settings dialog.
*/ */
openSettings: () => void; openSettings: () => void;
/**
* Called when the user clicks the button to clear theirt status.
*/
clearStatus: () => void;
} }
export type UserMenuViewProps = { export type UserMenuViewProps = {
@@ -111,15 +118,40 @@ export type UserMenuViewProps = {
className?: string; className?: string;
}; };
function StatusButton({ status, clearStatus }: { status: UserStatus; clearStatus: () => void }): JSX.Element {
return (
<div className={styles.statusButton}>
<Text as="span" className={styles.menuStatusEmoji}>
{status.emoji}
</Text>
<Text as="span" className={styles.menuStatusText}>
{status.text}
</Text>
<IconButton
onClick={clearStatus}
aria-label={_t("menus|user_menu|clear_status")}
tooltip={_t("menus|user_menu|clear_status")}
size="28px"
>
<CloseIcon />
</IconButton>
</div>
);
}
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element { export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, statusEmoji } = const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, userStatus } =
useViewModel(vm); useViewModel(vm);
const { translate: _t } = useI18n(); const { translate: _t } = useI18n();
const trigger = ( const trigger = (
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}> <button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
<div className={styles.avatarWrapper}> <div className={styles.avatarWrapper}>
<Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} /> <Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} />
{statusEmoji && <span className={styles.statusEmoji}>{statusEmoji}</span>} {userStatus && (
<Text as="div" className={styles.iconStatusEmoji}>
{userStatus.emoji}
</Text>
)}
</div> </div>
</button> </button>
); );
@@ -143,6 +175,7 @@ export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element
<Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span"> <Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span">
{displayName} {displayName}
</Text> </Text>
{userStatus && <StatusButton status={userStatus} clearStatus={vm.clearStatus} />}
<Text data-testid="userId" size="md" as="span" type="body"> <Text data-testid="userId" size="md" as="span" type="body">
{userId} {userId}
</Text> </Text>
@@ -845,6 +845,62 @@ exports[`UserMenu > renders an open menu 1`] = `
</body> </body>
`; `;
exports[`UserMenu > renders an open menu with user status 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<div
class="UserMenu-module_wrapper"
>
<button
aria-controls="radix-react-use-id-1"
aria-expanded="true"
aria-haspopup="menu"
aria-label="User menu"
class="UserMenu-module_triggerButton"
data-state="open"
id="radix-react-use-id-2"
type="button"
>
<div
class="UserMenu-module_avatarWrapper"
>
<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>
<div
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
>
🐹
</div>
</div>
</button>
<span
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 UserMenu-module_displayName"
>
Sally Sanderson
</span>
</div>
</div>
`;
exports[`UserMenu > renders condensed view 1`] = ` exports[`UserMenu > renders condensed view 1`] = `
<div> <div>
<div <div
@@ -923,11 +979,11 @@ exports[`UserMenu > renders the status emoji when set 1`] = `
width="36px" width="36px"
/> />
</span> </span>
<span <div
class="UserMenu-module_statusEmoji" class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
> >
🐹 🐹
</span> </div>
</div> </div>
</button> </button>
<span <span