From 3ab233940c0798c6cdc92e0c4eae9e98e9594011 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 16 Jun 2026 10:01:34 +0100 Subject: [PATCH] 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> --- apps/web/src/hooks/useUserStatus.ts | 8 +- apps/web/src/slash-commands/status.ts | 4 +- apps/web/src/stores/OwnProfileStore.ts | 3 +- apps/web/src/utils/userStatus.ts | 21 +++-- .../src/viewmodels/menus/UserMenuViewModel.ts | 17 +++- .../DisambiguatedProfileViewModel.ts | 2 +- apps/web/test/slash-commands/status-test.ts | 1 - apps/web/test/test-utils/test-utils.ts | 1 + .../__snapshots__/SpacePanel-test.tsx.snap | 6 +- .../test/unit-tests/utils/userStatus-test.ts | 55 +++++++++++++ .../menus/UserMenuViewModel-test.ts | 10 +++ .../UserMenuViewModel-test.ts.snap | 4 +- .../UserMenu.stories.tsx/all-options-auto.png | Bin 19794 -> 0 bytes ...us-emoji-auto.png => with-status-auto.png} | Bin .../with-status-open-auto.png | Bin 0 -> 42302 bytes .../shared-components/src/core/userStatus.ts | 15 ++++ .../src/i18n/strings/en_EN.json | 1 + packages/shared-components/src/index.ts | 1 + .../src/menus/UserMenu/UserMenu.module.css | 29 ++++++- .../src/menus/UserMenu/UserMenu.stories.tsx | 76 +++++++++--------- .../src/menus/UserMenu/UserMenu.test.tsx | 8 +- .../src/menus/UserMenu/UserMenu.tsx | 43 ++++++++-- .../__snapshots__/UserMenu.test.tsx.snap | 62 +++++++++++++- 23 files changed, 293 insertions(+), 74 deletions(-) create mode 100644 apps/web/test/unit-tests/utils/userStatus-test.ts delete mode 100644 packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/all-options-auto.png rename packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/{with-status-emoji-auto.png => with-status-auto.png} (100%) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/with-status-open-auto.png create mode 100644 packages/shared-components/src/core/userStatus.ts diff --git a/apps/web/src/hooks/useUserStatus.ts b/apps/web/src/hooks/useUserStatus.ts index 39c86f897c..131e43f9ae 100644 --- a/apps/web/src/hooks/useUserStatus.ts +++ b/apps/web/src/hooks/useUserStatus.ts @@ -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 () => { diff --git a/apps/web/src/slash-commands/status.ts b/apps/web/src/slash-commands/status.ts index e52e1e5c2e..e19c73ec9d 100644 --- a/apps/web/src/slash-commands/status.ts +++ b/apps/web/src/slash-commands/status.ts @@ -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, }), diff --git a/apps/web/src/stores/OwnProfileStore.ts b/apps/web/src/stores/OwnProfileStore.ts index 788e8b81cb..a1a2d8ebe6 100644 --- a/apps/web/src/stores/OwnProfileStore.ts +++ b/apps/web/src/stores/OwnProfileStore.ts @@ -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; diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index 66a4a6f3cb..6020b6bf89 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -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 { + return client.setExtendedProfileProperty("org.matrix.msc4426.status", { + emoji: userStatus.emoji, + text: userStatus.text, + }); +} + +export function clearUserStatus(client: MatrixClient): Promise { + return client.setExtendedProfileProperty("org.matrix.msc4426.status", null); +} diff --git a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts index a97ee5a4ab..ee021d3f46 100644 --- a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts +++ b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts @@ -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 { 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 { + this.setOpen(false); + clearUserStatus(this.client).catch((err) => { + logger.warn("Failed to clear user status", err); + }); + }; } diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts index ec7cc97702..76a9939087 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts @@ -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. diff --git a/apps/web/test/slash-commands/status-test.ts b/apps/web/test/slash-commands/status-test.ts index 2f81af17c6..e2ff04d03f 100644 --- a/apps/web/test/slash-commands/status-test.ts +++ b/apps/web/test/slash-commands/status-test.ts @@ -16,7 +16,6 @@ describe("/status", () => { beforeEach(() => { client = stubClient(); - client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined); }); function run(args?: string) { diff --git a/apps/web/test/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index b0e6dccce2..dd3800f766 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -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); diff --git a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap index 8f3449cee6..c2ba1f8a10 100644 --- a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap @@ -7,19 +7,19 @@ exports[` should show all activated MetaSpaces in the correct orde class="mx_SpacePanel collapsed newUi" >
); @@ -143,6 +175,7 @@ export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {displayName} + {userStatus && } {userId} diff --git a/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap b/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap index f1de919ab3..c23fe6619b 100644 --- a/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap +++ b/packages/shared-components/src/menus/UserMenu/__snapshots__/UserMenu.test.tsx.snap @@ -845,6 +845,62 @@ exports[`UserMenu > renders an open menu 1`] = ` `; +exports[`UserMenu > renders an open menu with user status 1`] = ` + +`; + exports[`UserMenu > renders condensed view 1`] = `
renders the status emoji when set 1`] = ` width="36px" /> - 🐹 - +