From bb07f84e412c0df9f26ee49bc13408861e2019b0 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 4 Jun 2026 16:15:19 +0100 Subject: [PATCH] Add user status on user profile icon (#33653) * 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 --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- apps/web/src/hooks/useUserStatus.ts | 33 +- apps/web/src/slash-commands/status.ts | 2 +- apps/web/src/stores/OwnProfileStore.ts | 33 ++ apps/web/src/utils/userStatus.ts | 36 ++ .../src/viewmodels/menus/UserMenuViewModel.ts | 4 +- .../DisambiguatedProfileViewModel.ts | 2 +- apps/web/test/test-utils/test-utils.ts | 1 + .../__snapshots__/SpacePanel-test.tsx.snap | 26 +- .../unit-tests/hooks/useUserStatus-test.tsx | 3 +- .../unit-tests/stores/OwnProfileStore-test.ts | 60 +++- .../UserMenuViewModel-test.ts.snap | 2 + .../with-status-emoji-auto.png | Bin 0 -> 22073 bytes .../src/menus/UserMenu/UserMenu.module.css | 23 ++ .../src/menus/UserMenu/UserMenu.stories.tsx | 6 + .../src/menus/UserMenu/UserMenu.test.tsx | 6 +- .../src/menus/UserMenu/UserMenu.tsx | 12 +- .../__snapshots__/UserMenu.test.tsx.snap | 318 +++++++++++------- 17 files changed, 398 insertions(+), 169 deletions(-) create mode 100644 apps/web/src/utils/userStatus.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/with-status-emoji-auto.png diff --git a/apps/web/src/hooks/useUserStatus.ts b/apps/web/src/hooks/useUserStatus.ts index e73679c9b4..39c86f897c 100644 --- a/apps/web/src/hooks/useUserStatus.ts +++ b/apps/web/src/hooks/useUserStatus.ts @@ -12,21 +12,10 @@ import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useTypedEventEmitter } from "./useEventEmitter"; import { useFeatureEnabled } from "./useSettings"; +import { type UserStatus, validateUserStatus } from "../utils/userStatus"; const logger = rootLogger.getChild("useUserStatus"); -export interface UserStatus { - emoji: string; - text: string; -} - -const MAX_STATUS_TEXT_BYTES = 256; - -export function userStatusTextWithinMaxLength(text: string): boolean { - const textEncoder = new TextEncoder(); - return textEncoder.encode(text).length <= MAX_STATUS_TEXT_BYTES; -} - /** * Hook to get the MSC4426 user status for a given user ID. Returns undefined if the feature is disabled, * the user does not have a status, or if there was an error fetching the status. @@ -76,23 +65,5 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine return; } - if (typeof rawUserStatus !== "object" || rawUserStatus === null) { - logger.warn(`value of "org.matrix.msc4426.status" was not an object for ${userId}`); - return; - } - if ("emoji" in rawUserStatus === false || typeof rawUserStatus.emoji !== "string" || !rawUserStatus.emoji) { - logger.warn(`"emoji" property was not a valid string for ${userId}`); - return; - } - if ("text" in rawUserStatus === false || typeof rawUserStatus.text !== "string" || !rawUserStatus.text) { - logger.warn(`"text" property was not a valid string for ${userId}`); - return; - } - - return { - emoji: rawUserStatus.emoji, - text: userStatusTextWithinMaxLength(rawUserStatus.text) - ? rawUserStatus.text - : `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`, - }; + return validateUserStatus(rawUserStatus); } diff --git a/apps/web/src/slash-commands/status.ts b/apps/web/src/slash-commands/status.ts index 7a1bf36d94..e52e1e5c2e 100644 --- a/apps/web/src/slash-commands/status.ts +++ b/apps/web/src/slash-commands/status.ts @@ -11,8 +11,8 @@ import { Command, CommandCategories, splitAtFirstSpace } from "./SlashCommands"; import SettingsStore from "../settings/SettingsStore"; import { reject, success } from "./utils"; import { UserFriendlyError } from "../languageHandler"; -import { userStatusTextWithinMaxLength } from "../hooks/useUserStatus"; import { TimelineRenderingType } from "../contexts/RoomContext"; +import { userStatusTextWithinMaxLength } from "../utils/userStatus"; export const statusCommand = new Command({ command: "status", diff --git a/apps/web/src/stores/OwnProfileStore.ts b/apps/web/src/stores/OwnProfileStore.ts index b2d35a390f..788e8b81cb 100644 --- a/apps/web/src/stores/OwnProfileStore.ts +++ b/apps/web/src/stores/OwnProfileStore.ts @@ -13,6 +13,7 @@ import { type User, UserEvent, EventType, + ClientEvent, } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -22,11 +23,14 @@ import defaultDispatcher from "../dispatcher/dispatcher"; 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"; interface IState { displayName?: string; avatarUrl?: string; fetchedAt?: number; + userStatus?: UserStatus; } const KEY_DISPLAY_NAME = "mx_profile_displayname"; @@ -81,6 +85,10 @@ export class OwnProfileStore extends AsyncStoreWithClient { return this.state.avatarUrl || null; } + public get userStatus(): UserStatus | undefined { + return this.state.userStatus; + } + /** * Gets the user's avatar as an HTTP URL of the given size. If the user's * avatar is not present, this returns null. @@ -105,6 +113,9 @@ export class OwnProfileStore extends AsyncStoreWithClient { this.monitoredUser.removeListener(UserEvent.AvatarUrl, this.onProfileUpdate); } this.matrixClient?.removeListener(RoomStateEvent.Events, this.onStateEvents); + if (SettingsStore.getValue("feature_user_status")) { + this.matrixClient?.removeListener(ClientEvent.UserProfileUpdate, this.onExtendedProfileUpdate); + } await this.reset({}); } @@ -117,11 +128,16 @@ export class OwnProfileStore extends AsyncStoreWithClient { this.monitoredUser.on(UserEvent.AvatarUrl, this.onProfileUpdate); } + if (SettingsStore.getValue("feature_user_status")) { + this.matrixClient.on(ClientEvent.UserProfileUpdate, this.onExtendedProfileUpdate); + } + // We also have to listen for membership events for ourselves as the above User events // are fired only with presence, which matrix.org (and many others) has disabled. this.matrixClient.on(RoomStateEvent.Events, this.onStateEvents); await this.onProfileUpdate(); // trigger an initial update + await this.refreshUserStatus(); // trigger an update for the user status } protected async onAction(payload: ActionPayload): Promise { @@ -175,6 +191,23 @@ export class OwnProfileStore extends AsyncStoreWithClient { { trailing: true, leading: true }, ); + private onExtendedProfileUpdate = async (syncedUserId: string): Promise => { + if (syncedUserId === this.matrixClient?.getSafeUserId()) { + await this.refreshUserStatus(); + } + }; + + private refreshUserStatus = async (): Promise => { + if (!this.matrixClient) return; + if (!SettingsStore.getValue("feature_user_status")) return; + + const rawUserStatus = await this.matrixClient.getExtendedProfileProperty( + this.matrixClient.getSafeUserId(), + "org.matrix.msc4426.status", + ); + await this.updateState({ userStatus: validateUserStatus(rawUserStatus) }); + }; + private onStateEvents = async (ev: MatrixEvent): Promise => { const myUserId = MatrixClientPeg.safeGet().getUserId(); if (ev.getType() === EventType.RoomMember && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts new file mode 100644 index 0000000000..66a4a6f3cb --- /dev/null +++ b/apps/web/src/utils/userStatus.ts @@ -0,0 +1,36 @@ +/* +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. +*/ + +const MAX_STATUS_TEXT_BYTES = 256; + +export interface UserStatus { + emoji: string; + text: string; +} + +export function userStatusTextWithinMaxLength(text: string): boolean { + const textEncoder = new TextEncoder(); + return textEncoder.encode(text).length <= MAX_STATUS_TEXT_BYTES; +} + +export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefined { + if (typeof rawUserStatus !== "object" || rawUserStatus === null) { + return undefined; + } + if ("emoji" in rawUserStatus === false || typeof rawUserStatus.emoji !== "string" || !rawUserStatus.emoji) { + return undefined; + } + if ("text" in rawUserStatus === false || typeof rawUserStatus.text !== "string" || !rawUserStatus.text) { + return undefined; + } + return { + emoji: rawUserStatus.emoji, + text: userStatusTextWithinMaxLength(rawUserStatus.text) + ? rawUserStatus.text + : `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`, + }; +} diff --git a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts index 0de9d80e21..a97ee5a4ab 100644 --- a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts +++ b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts @@ -42,6 +42,7 @@ export class UserMenuViewModel extends BaseViewModel { const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined; - this.snapshot.merge({ displayName, avatarUrl }); + const statusEmoji = OwnProfileStore.instance.userStatus?.emoji; + this.snapshot.merge({ displayName, avatarUrl, statusEmoji }); }; public readonly setOpen = (isOpen: boolean): void => { 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 858c8110c7..ec7cc97702 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts @@ -15,7 +15,7 @@ import { type MouseEvent } from "react"; import { _t } from "../../../../languageHandler"; import { getUserNameColorClass } from "../../../../utils/FormattingUtils"; import UserIdentifier from "../../../../customisations/UserIdentifier"; -import type { UserStatus } from "../../../../hooks/useUserStatus"; +import { type UserStatus } from "../../../../utils/userStatus"; /** * Information about a member for disambiguation purposes. diff --git a/apps/web/test/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index 456a1e79b5..b0e6dccce2 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -359,6 +359,7 @@ export function createTestClient(): MatrixClient { sendTextMessage: jest.fn(), deleteRoomTag: jest.fn().mockResolvedValue({}), setRoomTag: jest.fn().mockResolvedValue({}), + getExtendedProfileProperty: jest.fn(), } 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 a5ef90234d..8acad258f2 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,27 +7,31 @@ exports[` should show all activated MetaSpaces in the correct orde class="mx_SpacePanel collapsed newUi" >
{ let client: MockedObject; @@ -76,4 +77,61 @@ describe("OwnProfileStore", () => { expect(ownProfileStore.displayName).toBe(client.getSafeUserId()); expect(ownProfileStore.avatarMxc).toBeNull(); }); + + describe("userStatus", () => { + beforeEach(() => { + client.getProfileInfo.mockResolvedValue({}); + }); + + it("should be undefined if the feature is disabled", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + await ownProfileStore.start(); + + expect(ownProfileStore.userStatus).toBeUndefined(); + }); + + it("should be undefined if no status is set", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + client.getExtendedProfileProperty.mockResolvedValue(undefined); + await ownProfileStore.start(); + + expect(ownProfileStore.userStatus).toBeUndefined(); + }); + + it("should fetch and expose the user status on start", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🏝️", text: "On a tropical holiday" }); + await ownProfileStore.start(); + + expect(client.getExtendedProfileProperty).toHaveBeenCalledWith( + client.getSafeUserId(), + "org.matrix.msc4426.status", + ); + expect(ownProfileStore.userStatus).toEqual({ emoji: "🏝️", text: "On a tropical holiday" }); + }); + + it("should update the user status when a UserProfileUpdate event is received for the current user", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🏝️", text: "On a tropical holiday" }); + await ownProfileStore.start(); + + client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🏡", text: "Back home" }); + client.emit(ClientEvent.UserProfileUpdate, client.getSafeUserId(), null); + await new Promise(process.nextTick); + + expect(ownProfileStore.userStatus).toEqual({ emoji: "🏡", text: "Back home" }); + }); + + it("should not update the user status when a UserProfileUpdate event is received for another user", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🏝️", text: "On a tropical holiday" }); + await ownProfileStore.start(); + + client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐭", text: "Is a mouse" }); + client.emit(ClientEvent.UserProfileUpdate, "@other:example.com", null); + await new Promise(process.nextTick); + + expect(ownProfileStore.userStatus).toEqual({ emoji: "🏝️", text: "On a tropical holiday" }); + }); + }); }); diff --git a/apps/web/test/viewmodels/menus/__snapshots__/UserMenuViewModel-test.ts.snap b/apps/web/test/viewmodels/menus/__snapshots__/UserMenuViewModel-test.ts.snap index 928f7ce97e..8d38b6e0b6 100644 --- a/apps/web/test/viewmodels/menus/__snapshots__/UserMenuViewModel-test.ts.snap +++ b/apps/web/test/viewmodels/menus/__snapshots__/UserMenuViewModel-test.ts.snap @@ -17,6 +17,7 @@ exports[`UserMenuViewModel should generate a menu options for a guest 1`] = ` "manageAccountHref": undefined, "open": true, "showAvatar": false, + "statusEmoji": undefined, "userId": "@alice:domain", } `; @@ -38,6 +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", } `; diff --git a/packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/with-status-emoji-auto.png b/packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/with-status-emoji-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..3bacbd91136827fca5db050833fd733d06ede90e GIT binary patch literal 22073 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 { it("renders a button", async () => { @@ -28,6 +28,10 @@ describe("UserMenu", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); + it("renders the status emoji when set", async () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); it("renders a menu", async () => { const { baseElement, getByRole } = render(); await userEvent.click(getByRole("button")); diff --git a/packages/shared-components/src/menus/UserMenu/UserMenu.tsx b/packages/shared-components/src/menus/UserMenu/UserMenu.tsx index 76dc98f4a5..e345612407 100644 --- a/packages/shared-components/src/menus/UserMenu/UserMenu.tsx +++ b/packages/shared-components/src/menus/UserMenu/UserMenu.tsx @@ -50,6 +50,10 @@ export interface UserMenuViewSnapshot { * Account management URL if the user is using OIDC. */ manageAccountHref?: string; + /** + * The user status emoji to display, or undefined for no icon. + */ + statusEmoji?: string; /** * A set of actions that the user can perform from the menu. */ @@ -108,11 +112,15 @@ export type UserMenuViewProps = { }; export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element { - const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar } = useViewModel(vm); + const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, statusEmoji } = + useViewModel(vm); const { translate: _t } = useI18n(); const trigger = ( ); 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 eff408e660..5dda1f6ca3 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 @@ -14,25 +14,29 @@ exports[`UserMenu > renders a button 1`] = ` id="radix-react-use-id-1" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + +
renders a button with a longer name 1`] = ` id="radix-react-use-id-1" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + + renders a guest menu 1`] = ` id="radix-react-use-id-1" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + + renders a menu 1`] = ` id="radix-react-use-id-1" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + + renders a menu without an avatar 1`] = ` id="radix-react-use-id-2" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + + renders an open menu 1`] = ` id="radix-react-use-id-2" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + + renders condensed view 1`] = ` id="radix-react-use-id-1" type="button" > - - - + role="img" + style="--cpd-avatar-size: 36px;" + > + + + `; + +exports[`UserMenu > renders the status emoji when set 1`] = ` +
+
+ + + Sally Sanderson + +
+
+`;