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