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 0000000000..3bacbd9113 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/menus/UserMenu/UserMenu.stories.tsx/with-status-emoji-auto.png differ diff --git a/packages/shared-components/src/menus/UserMenu/UserMenu.module.css b/packages/shared-components/src/menus/UserMenu/UserMenu.module.css index 67f59e2f39..2b0348aa98 100644 --- a/packages/shared-components/src/menus/UserMenu/UserMenu.module.css +++ b/packages/shared-components/src/menus/UserMenu/UserMenu.module.css @@ -54,6 +54,29 @@ } } +.avatarWrapper { + position: relative; + display: inline-flex; + overflow: visible; +} + +.statusEmoji { + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + /* bottom edges aligned with the avatar */ + bottom: 0; + /* center of circle on the right edge of the avatar */ + right: -10px; + background-color: var(--cpd-color-bg-canvas-default); +} + .triggerButton { border: none; background: none; diff --git a/packages/shared-components/src/menus/UserMenu/UserMenu.stories.tsx b/packages/shared-components/src/menus/UserMenu/UserMenu.stories.tsx index 79b7ad6350..d2c1d54016 100644 --- a/packages/shared-components/src/menus/UserMenu/UserMenu.stories.tsx +++ b/packages/shared-components/src/menus/UserMenu/UserMenu.stories.tsx @@ -170,6 +170,12 @@ export const GuestOpen: Story = { tags: ["!dev", "!autodocs"], }; +export const WithStatusEmoji: Story = { + args: { + statusEmoji: "🐹", + }, +}; + export const AllOptions: Story = { args: { displayName: "Alice", diff --git a/packages/shared-components/src/menus/UserMenu/UserMenu.test.tsx b/packages/shared-components/src/menus/UserMenu/UserMenu.test.tsx index f1eacc0f4e..2c12a2d88b 100644 --- a/packages/shared-components/src/menus/UserMenu/UserMenu.test.tsx +++ b/packages/shared-components/src/menus/UserMenu/UserMenu.test.tsx @@ -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 } = composeStories(stories); +const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatusEmoji } = composeStories(stories); describe("UserMenu", () => { 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 + +
+
+`;