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