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 10:01:34 +01:00
committed by GitHub
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 { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
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 { useTypedEventEmitter } from "./useEventEmitter";
import { useFeatureEnabled } from "./useSettings";
import { type UserStatus, validateUserStatus } from "../utils/userStatus";
import { validateUserStatus } from "../utils/userStatus";
const logger = rootLogger.getChild("useUserStatus");
@@ -32,9 +33,8 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine
if (syncedUserId !== userId) {
return;
}
if (syncProfile["org.matrix.msc4426.status"]) {
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
}
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
});
useEffect(() => {
(async () => {
+2 -2
View File
@@ -12,7 +12,7 @@ import SettingsStore from "../settings/SettingsStore";
import { reject, success } from "./utils";
import { UserFriendlyError } from "../languageHandler";
import { TimelineRenderingType } from "../contexts/RoomContext";
import { userStatusTextWithinMaxLength } from "../utils/userStatus";
import { setUserStatus, userStatusTextWithinMaxLength } from "../utils/userStatus";
export const statusCommand = new Command({
command: "status",
@@ -40,7 +40,7 @@ export const statusCommand = new Command({
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
}
return success(
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
setUserStatus(cli, {
emoji: emoji.segment,
text,
}),
+2 -1
View File
@@ -16,6 +16,7 @@ import {
ClientEvent,
} from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash";
import { type UserStatus } from "@element-hq/web-shared-components";
import { type ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
@@ -24,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler";
import { mediaFromMxc } from "../customisations/Media";
import SettingsStore from "../settings/SettingsStore";
import { type UserStatus, validateUserStatus } from "../utils/userStatus";
import { validateUserStatus } from "../utils/userStatus";
interface IState {
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.
*/
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 {
emoji: string;
text: string;
}
// MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8,
// so we truncate anything longer than that.
const MAX_STATUS_TEXT_BYTES = 256;
export function userStatusTextWithinMaxLength(text: string): boolean {
const textEncoder = new TextEncoder();
@@ -34,3 +34,14 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
: `${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 { logger } from "matrix-js-sdk/src/logger";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
@@ -18,6 +19,7 @@ import { shouldShowFeedback } from "../../utils/Feedback";
import { getHomePageUrl } from "../../utils/pages";
import SdkConfig from "../../SdkConfig";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { clearUserStatus } from "../../utils/userStatus";
// Matches maximum size of an avatar in the UserMenu
const AVATAR_PX = 88;
@@ -42,7 +44,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
expanded: !isPanelCollapsed,
manageAccountHref: accountManagementEndpoint,
showAvatar: isAuthenticated,
statusEmoji: OwnProfileStore.instance.userStatus?.emoji,
userStatus: OwnProfileStore.instance.userStatus,
actions: {
createAccount: !isAuthenticated,
signIn: !isAuthenticated,
@@ -57,7 +59,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
public constructor(
private readonly dispatcher: MatrixDispatcher,
client: MatrixClient,
private readonly client: MatrixClient,
isPanelCollapsed: boolean,
accountManagementEndpoint?: string,
) {
@@ -73,8 +75,8 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
public readonly recalculateProfile = (): void => {
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
const statusEmoji = OwnProfileStore.instance.userStatus?.emoji;
this.snapshot.merge({ displayName, avatarUrl, statusEmoji });
const userStatus = OwnProfileStore.instance.userStatus;
this.snapshot.merge({ displayName, avatarUrl, userStatus });
};
public readonly setOpen = (isOpen: boolean): void => {
@@ -128,4 +130,11 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
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 DisambiguatedProfileViewSnapshot,
type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface,
type UserStatus,
} from "@element-hq/web-shared-components";
import { type MouseEvent } from "react";
import { _t } from "../../../../languageHandler";
import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
import UserIdentifier from "../../../../customisations/UserIdentifier";
import { type UserStatus } from "../../../../utils/userStatus";
/**
* Information about a member for disambiguation purposes.
@@ -16,7 +16,6 @@ describe("/status", () => {
beforeEach(() => {
client = stubClient();
client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined);
});
function run(args?: string) {
+1
View File
@@ -360,6 +360,7 @@ export function createTestClient(): MatrixClient {
deleteRoomTag: jest.fn().mockResolvedValue({}),
setRoomTag: jest.fn().mockResolvedValue({}),
getExtendedProfileProperty: jest.fn(),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
} as unknown as MatrixClient;
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"
>
<div
class="_wrapper_11z86_8 mx_UserMenu"
class="_wrapper_bh5eg_8 mx_UserMenu"
>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="_triggerButton_11z86_80"
class="_triggerButton_bh5eg_107"
data-state="closed"
id="radix-react-use-id-1"
type="button"
>
<div
class="_avatarWrapper_11z86_57"
class="_avatarWrapper_bh5eg_57"
>
<span
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(),
...mockClientMethodsServer(),
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
});
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 () => {
client.isGuest.mockReturnValue(true);
const dispatcherSpy = jest.fn();
@@ -17,8 +17,8 @@ exports[`UserMenuViewModel should generate a menu options for a guest 1`] = `
"manageAccountHref": undefined,
"open": true,
"showAvatar": false,
"statusEmoji": undefined,
"userId": "@alice:domain",
"userStatus": undefined,
}
`;
@@ -39,7 +39,7 @@ exports[`UserMenuViewModel should generate a menu options for a logged in client
"manageAccountHref": undefined,
"open": true,
"showAvatar": true,
"statusEmoji": undefined,
"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": {
"user_menu": {
"clear_status": "Clear",
"create_an_account": "Create an account",
"got_an_account": "Got an 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/i18n/I18nApi";
export * from "./core/utils/linkify";
export type * from "./core/userStatus.ts";
// MVVM
export * from "./core/viewmodel";
@@ -60,7 +60,7 @@
overflow: visible;
}
.statusEmoji {
.iconStatusEmoji {
position: absolute;
width: 20px;
height: 20px;
@@ -77,6 +77,33 @@
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 {
border: none;
background: none;
@@ -24,6 +24,7 @@ const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
openHomePage: fn(),
openSecurity: fn(),
openSettings: fn(),
clearStatus: fn(),
});
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 = {
args: {
open: true,
@@ -83,18 +99,7 @@ export const Open: Story = {
* 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,
},
],
rules: MENU_OPEN_A11Y_RULES,
},
},
},
@@ -150,18 +155,7 @@ export const GuestOpen: Story = {
* 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,
},
],
rules: MENU_OPEN_A11Y_RULES,
},
},
},
@@ -170,26 +164,28 @@ export const GuestOpen: Story = {
tags: ["!dev", "!autodocs"],
};
export const WithStatusEmoji: Story = {
export const WithStatus: Story = {
args: {
statusEmoji: "🐹",
userStatus: {
emoji: "🐹",
text: "On the wheel",
},
},
};
export const AllOptions: Story = {
export const WithStatusOpen: 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>,
open: true,
userStatus: {
emoji: "🐹",
text: "On the wheel",
},
},
parameters: {
a11y: {
config: {
rules: MENU_OPEN_A11Y_RULES,
},
},
},
};
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
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", () => {
it("renders a button", async () => {
@@ -29,7 +29,7 @@ describe("UserMenu", () => {
expect(container).toMatchSnapshot();
});
it("renders the status emoji when set", async () => {
const { container } = render(<WithStatusEmoji />);
const { container } = render(<WithStatus />);
expect(container).toMatchSnapshot();
});
it("renders a menu", async () => {
@@ -50,4 +50,8 @@ describe("UserMenu", () => {
const { baseElement } = render(<NoAvatar />);
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 { 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 {
ChatProblemIcon,
DevicesIcon,
@@ -14,12 +14,15 @@ import {
LockIcon,
PopOutIcon,
SettingsIcon,
CloseIcon,
} 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";
import { type UserStatus } from "../../core/userStatus";
import { _t } from "../..";
export interface UserMenuViewSnapshot {
/**
@@ -51,9 +54,9 @@ export interface UserMenuViewSnapshot {
*/
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.
*/
@@ -101,6 +104,10 @@ export declare interface UserMenuViewActions {
* Called to open the settings dialog.
*/
openSettings: () => void;
/**
* Called when the user clicks the button to clear theirt status.
*/
clearStatus: () => void;
}
export type UserMenuViewProps = {
@@ -111,15 +118,40 @@ export type UserMenuViewProps = {
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 {
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, statusEmoji } =
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, userStatus } =
useViewModel(vm);
const { translate: _t } = useI18n();
const trigger = (
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
<div className={styles.avatarWrapper}>
<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>
</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">
{displayName}
</Text>
{userStatus && <StatusButton status={userStatus} clearStatus={vm.clearStatus} />}
<Text data-testid="userId" size="md" as="span" type="body">
{userId}
</Text>
@@ -845,6 +845,62 @@ exports[`UserMenu > renders an open menu 1`] = `
</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`] = `
<div>
<div
@@ -923,11 +979,11 @@ exports[`UserMenu > renders the status emoji when set 1`] = `
width="36px"
/>
</span>
<span
class="UserMenu-module_statusEmoji"
<div
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
>
🐹
</span>
</div>
</div>
</button>
<span