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>
This commit is contained in:
David Baker
2026-06-04 16:15:19 +01:00
committed by GitHub
parent f65a53a174
commit bb07f84e41
17 changed files with 398 additions and 169 deletions
+2 -31
View File
@@ -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);
}
+1 -1
View File
@@ -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",
+33
View File
@@ -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<IState> {
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<IState> {
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<IState> {
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<void> {
@@ -175,6 +191,23 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
{ trailing: true, leading: true },
);
private onExtendedProfileUpdate = async (syncedUserId: string): Promise<void> => {
if (syncedUserId === this.matrixClient?.getSafeUserId()) {
await this.refreshUserStatus();
}
};
private refreshUserStatus = async (): Promise<void> => {
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<void> => {
const myUserId = MatrixClientPeg.safeGet().getUserId();
if (ev.getType() === EventType.RoomMember && ev.getSender() === myUserId && ev.getStateKey() === myUserId) {
+36
View File
@@ -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)}`,
};
}
@@ -42,6 +42,7 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
expanded: !isPanelCollapsed,
manageAccountHref: accountManagementEndpoint,
showAvatar: isAuthenticated,
statusEmoji: OwnProfileStore.instance.userStatus?.emoji,
actions: {
createAccount: !isAuthenticated,
signIn: !isAuthenticated,
@@ -72,7 +73,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;
this.snapshot.merge({ displayName, avatarUrl });
const statusEmoji = OwnProfileStore.instance.userStatus?.emoji;
this.snapshot.merge({ displayName, avatarUrl, statusEmoji });
};
public readonly setOpen = (isOpen: boolean): void => {
@@ -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.
+1
View File
@@ -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);
@@ -7,27 +7,31 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
class="mx_SpacePanel collapsed newUi"
>
<div
class="_wrapper_1yvpq_8 mx_UserMenu"
class="_wrapper_gc5p4_8 mx_UserMenu"
>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="_triggerButton_1yvpq_57"
class="_triggerButton_gc5p4_80"
data-state="closed"
id="radix-react-use-id-1"
type="button"
>
<span
aria-label="@test:test"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="5"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
<div
class="_avatarWrapper_gc5p4_57"
>
t
</span>
<span
aria-label="@test:test"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="5"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
>
t
</span>
</div>
</button>
</div>
<div
@@ -9,10 +9,11 @@ import React from "react";
import { renderHook, waitFor } from "jest-matrix-react";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import { useUserStatus, userStatusTextWithinMaxLength } from "../../../src/hooks/useUserStatus";
import { useUserStatus } from "../../../src/hooks/useUserStatus";
import { getMockClientWithEventEmitter, mockClientMethodsUser, mockClientMethodsServer } from "../../test-utils";
import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider";
import SettingsStore from "../../../src/settings/SettingsStore";
import { userStatusTextWithinMaxLength } from "../../../src/utils/userStatus";
const userId = "@alice:example.com";
@@ -6,12 +6,13 @@ 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.
*/
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { ClientEvent, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { type MockedObject, mocked } from "jest-mock";
import { stubClient } from "../../test-utils";
import { OwnProfileStore } from "../../../src/stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import SettingsStore from "../../../src/settings/SettingsStore";
describe("OwnProfileStore", () => {
let client: MockedObject<MatrixClient>;
@@ -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" });
});
});
});
@@ -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",
}
`;