Show user status in timeline (#32991)

* Use other branch

* All the changes that got lost

* Fix merge

* Ensure emoji can only be one character long

* Fixup labs feature

* Remove redundant check

* Update snapshot

* update snapshot

* add snapshot

* unpin

* fix pnpm lock

* undo pn[m lockfile changes altogether

as we shouldn't actually need any afaik

* update snpahot for changed IDs

* Snapshot update

* Snapshot update

* There is now another section

* more snapshots

* more snapshot

* More snapshots

* oh come on snapshots

* actual snapshot update

* Fix sonar issues

* just update the thing manually

* [screams internally]

* Update snapshot

* test for useUserStatus

* Make useUserStatus actually truncate

* Split out slash command to its own file

& add test

* Remove irrelevant comment

* doc

* Comment on non-obvious error message

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
This commit is contained in:
Will Hunt
2026-04-29 08:14:22 +00:00
committed by GitHub
co-authored by David Baker
parent 4c3cb0754b
commit 4bee845010
24 changed files with 523 additions and 22 deletions
+3
View File
@@ -297,6 +297,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.lazyLoadMembers = true; opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.threadSupport = true; opts.threadSupport = true;
if (SettingsStore.getValue("feature_user_status")) {
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"];
}
if (SettingsStore.getValue("feature_sliding_sync")) { if (SettingsStore.getValue("feature_sliding_sync")) {
throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported"); throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported");
@@ -13,6 +13,7 @@ import { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@eleme
import { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel"; import { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import { useUserStatus } from "../../../hooks/useUserStatus";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@@ -27,6 +28,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
userId: sender, userId: sender,
member: mxEvent.sender, member: mxEvent.sender,
}); });
const userStatus = useUserStatus(sender);
const disambiguatedProfileVM = useCreateAutoDisposedViewModel( const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
() => () =>
@@ -37,9 +39,13 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
colored: true, colored: true,
emphasizeDisplayName: true, emphasizeDisplayName: true,
withTooltip, withTooltip,
userStatus,
}), }),
); );
useEffect(() => {
disambiguatedProfileVM.setUserStatus(userStatus);
}, [disambiguatedProfileVM, userStatus]);
useEffect(() => { useEffect(() => {
disambiguatedProfileVM.setMember(sender ?? "", member); disambiguatedProfileVM.setMember(sender ?? "", member);
}, [disambiguatedProfileVM, member, sender]); }, [disambiguatedProfileVM, member, sender]);
+98
View File
@@ -0,0 +1,98 @@
/**
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.
*/
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 { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "./useEventEmitter";
import { useFeatureEnabled } from "./useSettings";
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.
*
* @param userId The ID of the user whose status is being fetched.
* @returns The user's status, or undefined if not available.
*/
export function useUserStatus(userId: string | undefined): UserStatus | undefined {
const isEnabled = useFeatureEnabled("feature_user_status");
const matrixClient = useMatrixClientContext();
const [rawUserStatus, setRawUserStatus] = useState<unknown>();
useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => {
if (syncedUserId !== userId) {
return;
}
if (syncProfile["org.matrix.msc4426.status"]) {
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
}
});
useEffect(() => {
(async () => {
if (!isEnabled) {
return;
}
if (!userId) {
setRawUserStatus(undefined);
return;
}
if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) {
setRawUserStatus(undefined);
return;
}
try {
const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.status");
setRawUserStatus(result);
} catch (ex) {
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
setRawUserStatus(undefined);
} else {
logger.warn(`Failed to get userStatus for ${userId}`, ex);
}
}
})();
}, [isEnabled, userId, matrixClient]);
if (!isEnabled) {
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)}`,
};
}
+13
View File
@@ -1541,6 +1541,11 @@
"experimental_section": "Early previews", "experimental_section": "Early previews",
"extended_profiles_msc_support": "Requires your server to support MSC4133", "extended_profiles_msc_support": "Requires your server to support MSC4133",
"feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call", "feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call",
"feature_user_status": {
"description": "Enables being able to see and set a current status.",
"display_name": "User status",
"required_msc_support": "Requires MSC4429 (Profile Updates for Legacy Sync)"
},
"feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.", "feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.",
"group_calls": "New group call experience", "group_calls": "New group call experience",
"group_developer": "Developer", "group_developer": "Developer",
@@ -3148,6 +3153,14 @@
"server_error_detail": "Server unavailable, overloaded, or something else went wrong.", "server_error_detail": "Server unavailable, overloaded, or something else went wrong.",
"shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message", "shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message",
"spoiler": "Sends the given message as a spoiler", "spoiler": "Sends the given message as a spoiler",
"status": {
"description": "Set your current status",
"no_args": "No arguments provided. You should supply an emoij and an optional text component.",
"no_emoji": "You did not provide an emoji",
"no_text": "You did not provide any status text",
"too_long_emoji": "The first argument must be an emoji",
"too_long_text": "The text you provided was too long."
},
"tableflip": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message", "tableflip": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message",
"topic": "Gets or sets the room topic", "topic": "Gets or sets the room topic",
"topic_none": "This room has no topic.", "topic_none": "This room has no topic.",
+26
View File
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd. Copyright 2024, 2025 New Vector Ltd.
Copyright 2018-2024 The Matrix.org Foundation C.I.C. Copyright 2018-2024 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston Copyright 2017 Travis Ralston
@@ -228,6 +229,7 @@ export interface Settings {
"feature_ask_to_join": IFeature; "feature_ask_to_join": IFeature;
"feature_notifications": IFeature; "feature_notifications": IFeature;
"feature_msc4362_encrypted_state_events": IFeature; "feature_msc4362_encrypted_state_events": IFeature;
"feature_user_status": IFeature;
// These are in the feature namespace but aren't actually features // These are in the feature namespace but aren't actually features
"feature_hidebold": IBaseSetting<boolean>; "feature_hidebold": IBaseSetting<boolean>;
@@ -789,6 +791,30 @@ export const SETTINGS: Settings = {
shouldWarn: true, shouldWarn: true,
default: false, default: false,
}, },
"feature_user_status": {
isFeature: true,
labsGroup: LabGroup.Profile,
displayName: _td("labs|feature_user_status|display_name"),
description: _td("labs|feature_user_status|description"),
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
supportedLevelsAreOrdered: true,
controller: new ServerSupportUnstableFeatureController(
"feature_user_status",
defaultWatchManager,
[["org.matrix.msc4429"], ["org.matrix.msc4429.stable"]],
undefined,
_td("labs|feature_user_status|required_msc_support"),
false,
// We have to assume it's available during early startup because of a race:
// The feature is used to enable extra sync filters during MatrixClient setup
// and we can't check for serverside support until the client has finished setting up.
// Once the client has setup, (so by the time the user actually opens the labs menu) we can
// enforce proper checks.
true,
true,
),
default: false,
},
"useCompactLayout": { "useCompactLayout": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
displayName: _td("settings|preferences|compact_modern"), displayName: _td("settings|preferences|compact_modern"),
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C. Copyright 2023 The Matrix.org Foundation C.I.C.
@@ -12,6 +13,7 @@ import { type WatchManager } from "../WatchManager";
import SettingsStore from "../SettingsStore"; import SettingsStore from "../SettingsStore";
import { type SettingKey } from "../Settings.tsx"; import { type SettingKey } from "../Settings.tsx";
import { _t } from "../../languageHandler.tsx"; import { _t } from "../../languageHandler.tsx";
import PlatformPeg from "../../PlatformPeg.ts";
/** /**
* Disables a given setting if the server unstable feature it requires is not supported * Disables a given setting if the server unstable feature it requires is not supported
@@ -28,6 +30,9 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
* @param unstableFeatureGroups - If any one of the feature groups is satisfied, * @param unstableFeatureGroups - If any one of the feature groups is satisfied,
* then the setting is considered enabled. A feature group is satisfied if all of * then the setting is considered enabled. A feature group is satisfied if all of
* the features in the group are supported (all features in a group are required). * the features in the group are supported (all features in a group are required).
* @param defaultEnabled - If we haven't been able to check for support yet, should
* this feature be enabled or disabled (default).
* @param forceReload - Should the client force reload.
*/ */
public constructor( public constructor(
private readonly settingName: SettingKey, private readonly settingName: SettingKey,
@@ -36,12 +41,23 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
private readonly stableVersion?: string, private readonly stableVersion?: string,
private readonly disabledMessage?: TranslationKey, private readonly disabledMessage?: TranslationKey,
private readonly forcedValue: any = false, private readonly forcedValue: any = false,
private readonly defaultEnabled = false,
private readonly forceReload = false,
) { ) {
super(); super();
} }
public onChange(): void {
if (this.forceReload) {
PlatformPeg.get()?.reload();
}
}
public get disabled(): boolean { public get disabled(): boolean {
return !this.enabled; if (this.enabled !== undefined) {
return !this.enabled;
}
return !this.defaultEnabled;
} }
public set disabled(newDisabledValue: boolean) { public set disabled(newDisabledValue: boolean) {
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
@@ -62,6 +63,7 @@ import { goto, join } from "./join";
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog"; import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
import upgraderoom from "./upgraderoom/upgraderoom"; import upgraderoom from "./upgraderoom/upgraderoom";
import { emoticon } from "./emoticon"; import { emoticon } from "./emoticon";
import { statusCommand } from "./status";
export { CommandCategories, Command }; export { CommandCategories, Command };
@@ -819,6 +821,7 @@ export const Commands = [
}, },
renderingTypes: [TimelineRenderingType.Room], renderingTypes: [TimelineRenderingType.Room],
}), }),
statusCommand,
// Command definitions for autocompletion ONLY: // Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
+51
View File
@@ -0,0 +1,51 @@
/*
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.
*/
import { _td } from "@element-hq/web-shared-components";
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";
export const statusCommand = new Command({
command: "status",
args: "<emoji> <text>",
description: _td("slash_command|status|description"),
isEnabled: () => SettingsStore.getValue("feature_user_status"),
runFn: function (cli, _roomId, _threadId, args) {
if (!args) {
return reject(new UserFriendlyError("slash_command|status|no_args"));
}
const [emojiText, text] = splitAtFirstSpace(args);
if (!emojiText) {
return reject(new UserFriendlyError("slash_command|status|no_emoji"));
}
if (!text) {
return reject(new UserFriendlyError("slash_command|status|no_text"));
}
const [emoji, additionalSegment] = [...new Intl.Segmenter().segment(emojiText)];
if (additionalSegment) {
// This is "too long" in that it's more than one grapheme, so the error we give is
// that it's "not an emoji".
return reject(new UserFriendlyError("slash_command|status|too_long_emoji"));
}
if (!userStatusTextWithinMaxLength(text)) {
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
}
return success(
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
emoji: emoji.segment,
text,
}),
);
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
});
@@ -15,6 +15,7 @@ import { type MouseEvent } from "react";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { getUserNameColorClass } from "../../../../utils/FormattingUtils"; import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
import UserIdentifier from "../../../../customisations/UserIdentifier"; import UserIdentifier from "../../../../customisations/UserIdentifier";
import type { UserStatus } from "../../../../hooks/useUserStatus";
/** /**
* Information about a member for disambiguation purposes. * Information about a member for disambiguation purposes.
@@ -46,6 +47,10 @@ export interface DisambiguatedProfileViewModelProps {
* The member information for disambiguation. * The member information for disambiguation.
*/ */
member?: MemberInfo | null; member?: MemberInfo | null;
/**
* The user's present status.
*/
userStatus?: UserStatus;
/** /**
* The fallback name to use if the member's display name is not available. * The fallback name to use if the member's display name is not available.
*/ */
@@ -62,6 +67,7 @@ export interface DisambiguatedProfileViewModelProps {
* Whether to show a tooltip with additional information. * Whether to show a tooltip with additional information.
*/ */
withTooltip?: boolean; withTooltip?: boolean;
/** /**
* Optional click handler for the profile. * Optional click handler for the profile.
*/ */
@@ -79,7 +85,7 @@ export class DisambiguatedProfileViewModel
private static readonly computeSnapshot = ( private static readonly computeSnapshot = (
props: DisambiguatedProfileViewModelProps, props: DisambiguatedProfileViewModelProps,
): DisambiguatedProfileViewSnapshot => { ): DisambiguatedProfileViewSnapshot => {
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip } = props; const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, userStatus } = props;
// Compute display name // Compute display name
const displayName = member?.rawDisplayName || fallbackName; const displayName = member?.rawDisplayName || fallbackName;
@@ -122,11 +128,15 @@ export class DisambiguatedProfileViewModel
displayIdentifier, displayIdentifier,
title, title,
emphasizeDisplayName, emphasizeDisplayName,
userStatus,
}; };
}; };
public constructor(props: DisambiguatedProfileViewModelProps) { public constructor(props: DisambiguatedProfileViewModelProps) {
super(props, DisambiguatedProfileViewModel.computeSnapshot(props)); super(props, DisambiguatedProfileViewModel.computeSnapshot(props));
this.snapshot.merge({
userStatus: props.userStatus,
});
} }
public setMember(fallbackName: string, member?: MemberInfo | null): void { public setMember(fallbackName: string, member?: MemberInfo | null): void {
@@ -136,6 +146,13 @@ export class DisambiguatedProfileViewModel
this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props)); this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props));
} }
public setUserStatus(userStatus?: UserStatus): void {
this.props.userStatus = userStatus;
this.snapshot.merge({
userStatus,
});
}
public onClick = (evt: MouseEvent<HTMLDivElement>): void => { public onClick = (evt: MouseEvent<HTMLDivElement>): void => {
this.props.onClick?.(evt); this.props.onClick?.(evt);
}; };
@@ -0,0 +1,62 @@
/*
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.
*/
import { stubClient } from "../test-utils";
import { statusCommand } from "../../src/slash-commands/status";
import { UserFriendlyError } from "../../src/languageHandler";
describe("/status", () => {
const roomId = "!room:example.com";
let client: ReturnType<typeof stubClient>;
beforeEach(() => {
client = stubClient();
client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined);
});
function run(args?: string) {
return statusCommand.run(client, roomId, null, args);
}
it("should reject if no args provided", () => {
const result = run(undefined);
expect(result.error).toBeInstanceOf(UserFriendlyError);
expect((result.error as UserFriendlyError).message).toBe(
"No arguments provided. You should supply an emoij and an optional text component.",
);
});
it("should reject if no text is provided after the emoji", () => {
const result = run("🎉");
expect(result.error).toBeInstanceOf(UserFriendlyError);
expect((result.error as UserFriendlyError).message).toBe("You did not provide any status text");
});
it("should reject if the emoji field has more than one grapheme segment", () => {
const result = run("ab hello");
expect(result.error).toBeInstanceOf(UserFriendlyError);
expect((result.error as UserFriendlyError).message).toBe("The first argument must be an emoji");
});
it("should reject if the status text exceeds the maximum byte length", () => {
const longText = "a".repeat(257);
const result = run(`🎉 ${longText}`);
expect(result.error).toBeInstanceOf(UserFriendlyError);
expect((result.error as UserFriendlyError).message).toBe("The text you provided was too long.");
});
it("should set the extended profile property on success", async () => {
const result = run("🎉 Having a great day");
expect(result.error).toBeUndefined();
await result.promise;
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
emoji: "🎉",
text: "Having a great day",
});
});
});
@@ -31,12 +31,12 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines
u u
</span> </span>
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
@userId:matrix.org @userId:matrix.org
@@ -35,7 +35,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
class="mx_MemberTileView_name" class="mx_MemberTileView_name"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
title="@userId:matrix.org (@userId:matrix.org)" title="@userId:matrix.org (@userId:matrix.org)"
@@ -111,7 +111,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
class="mx_MemberTileView_name" class="mx_MemberTileView_name"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
title="@userId:matrix.org (@userId:matrix.org)" title="@userId:matrix.org (@userId:matrix.org)"
@@ -187,7 +187,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
class="mx_MemberTileView_name" class="mx_MemberTileView_name"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
title="@userId:matrix.org (@userId:matrix.org)" title="@userId:matrix.org (@userId:matrix.org)"
@@ -71,12 +71,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
Alice Alice
@@ -165,12 +165,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
Alice Alice
@@ -262,12 +262,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
class="mx_MessageTimestamp" class="mx_MessageTimestamp"
/> />
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
Alice Alice
@@ -48,6 +48,6 @@ describe("<LabsUserSettingsTab />", () => {
// non-beta labs section // non-beta labs section
expect(screen.getByText("Early previews")).toBeInTheDocument(); expect(screen.getByText("Early previews")).toBeInTheDocument();
const labsSections = container.getElementsByClassName("mx_SettingsSubsection"); const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
expect(labsSections).toHaveLength(8); expect(labsSections).toHaveLength(9);
}); });
}); });
@@ -214,12 +214,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
@userId:matrix.org @userId:matrix.org
@@ -308,12 +308,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
@userId:matrix.org @userId:matrix.org
@@ -405,12 +405,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
class="mx_MessageTimestamp" class="mx_MessageTimestamp"
/> />
<div <div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8" class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button" role="button"
tabindex="0" tabindex="0"
> >
<span <span
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName" class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
dir="auto" dir="auto"
> >
@userId:matrix.org @userId:matrix.org
@@ -0,0 +1,155 @@
/*
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.
*/
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 { getMockClientWithEventEmitter, mockClientMethodsUser, mockClientMethodsServer } from "../../test-utils";
import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider";
import SettingsStore from "../../../src/settings/SettingsStore";
const userId = "@alice:example.com";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsServer(),
getCrypto: jest.fn().mockReturnValue(null),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
getExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
});
function render(uid: string | undefined = userId) {
return renderHook(() => useUserStatus(uid), {
wrapper: ({ children }) => (
<MatrixClientContextProvider client={client}>{children}</MatrixClientContextProvider>
),
});
}
describe("userStatusTextWithinMaxLength", () => {
it("returns true for short text", () => {
expect(userStatusTextWithinMaxLength("on a horse")).toBe(true);
});
it("returns false for text exceeding 256 bytes", () => {
expect(userStatusTextWithinMaxLength("a".repeat(257))).toBe(false);
});
it("returns true for text exactly 256 bytes", () => {
expect(userStatusTextWithinMaxLength("a".repeat(256))).toBe(true);
});
});
describe("useUserStatus", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name): any => {
if (name === "feature_user_status") return true;
});
client.doesServerSupportExtendedProfiles.mockResolvedValue(true);
client.getExtendedProfileProperty.mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns undefined when feature is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const { result } = render();
expect(result.current).toBeUndefined();
});
it("returns undefined when userId is undefined", async () => {
const { result } = render(undefined);
expect(result.current).toBeUndefined();
});
it("returns undefined when server does not support extended profiles", async () => {
client.doesServerSupportExtendedProfiles.mockResolvedValue(false);
const { result } = render();
expect(result.current).toBeUndefined();
});
it("returns undefined when status property is not set", async () => {
client.getExtendedProfileProperty.mockResolvedValue(undefined);
const { result } = render();
await waitFor(() =>
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(userId, "org.matrix.msc4426.status"),
);
expect(result.current).toBeUndefined();
});
it("returns undefined when status is not an object", async () => {
client.getExtendedProfileProperty.mockResolvedValue("not an object");
const { result } = render();
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
expect(result.current).toBeUndefined();
});
it("returns undefined when emoji is missing", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ text: "on a horse" });
const { result } = render();
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
expect(result.current).toBeUndefined();
});
it("returns undefined when text is missing", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎" });
const { result } = render();
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
expect(result.current).toBeUndefined();
});
it("returns the user status when valid", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
});
it("truncates text that exceeds 256 bytes", async () => {
const longText = "a".repeat(257);
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: longText });
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: `${"a".repeat(256)}` }));
});
it("returns undefined when M_NOT_FOUND error is thrown", async () => {
const error = new Error();
client.getExtendedProfileProperty.mockRejectedValue(error);
const { result } = render();
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
expect(result.current).toBeUndefined();
});
it("updates status when UserProfileUpdate event is emitted", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
// Simulate a profile update event
client.emit(ClientEvent.UserProfileUpdate, userId, {
"org.matrix.msc4426.status": { emoji: "😵", text: "off a horse" },
});
await waitFor(() => expect(result.current).toEqual({ emoji: "😵", text: "off a horse" }));
});
it("ignores UserProfileUpdate events for different users", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
client.emit(ClientEvent.UserProfileUpdate, "@bob:example.com", {
"org.matrix.msc4426.status": { emoji: "🤷", text: "unrelated status" },
});
// Should still have original status
expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" });
});
});
File diff suppressed because one or more lines are too long
+5
View File
@@ -131,5 +131,10 @@ joining a room.
Replaces the legacy notification settings with a new one to manage push rules. Replaces the legacy notification settings with a new one to manage push rules.
## User status (`feature_user_status`)
Enables setting a status message in your profile and to be able to view other's statuses.
Requires [MSC4429](https://github.com/matrix-org/matrix-spec-proposals/pull/4429) and [MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426).
**Warning** This feature has options which are not backwards compatible, disabling **Warning** This feature has options which are not backwards compatible, disabling
it may have unintended consequences. it may have unintended consequences.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -24,4 +24,8 @@
font-size: var(--cpd-font-size-body-sm); font-size: var(--cpd-font-size-body-sm);
margin-inline-start: 5px; margin-inline-start: 5px;
} }
.userStatus {
margin-inline-start: 5px;
}
} }
@@ -40,6 +40,10 @@ const meta = {
displayIdentifier: { control: "text" }, displayIdentifier: { control: "text" },
title: { control: "text" }, title: { control: "text" },
emphasizeDisplayName: { control: "boolean" }, emphasizeDisplayName: { control: "boolean" },
userStatus: {
status: { control: "string" },
emoji: { control: "string" },
},
}, },
args: { args: {
displayName: "Alice", displayName: "Alice",
@@ -82,6 +86,17 @@ export const WithTooltip: Story = {
}, },
}; };
export const WithUserStatus: Story = {
args: {
displayName: "Eve",
title: "Eve (@eve:matrix.org)",
userStatus: {
emoji: "🏝️",
text: "On holiday",
},
},
};
export const FullExample: Story = { export const FullExample: Story = {
args: { args: {
displayName: "Eve", displayName: "Eve",
@@ -89,5 +104,9 @@ export const FullExample: Story = {
colorClass: "mx_Username_color5", colorClass: "mx_Username_color5",
title: "Eve (@eve:matrix.org)", title: "Eve (@eve:matrix.org)",
emphasizeDisplayName: true, emphasizeDisplayName: true,
userStatus: {
emoji: "🏝️",
text: "On holiday",
},
}, },
}; };
@@ -7,6 +7,7 @@
import React, { type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react"; import React, { type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Text, Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./DisambiguatedProfile.module.css"; import styles from "./DisambiguatedProfile.module.css";
@@ -38,6 +39,14 @@ export interface DisambiguatedProfileViewSnapshot {
* Whether to emphasize the display name with additional styling. * Whether to emphasize the display name with additional styling.
*/ */
emphasizeDisplayName?: boolean; emphasizeDisplayName?: boolean;
/**
* User status message
*/
userStatus?: {
emoji: string;
text: string;
};
} }
/** /**
@@ -80,7 +89,9 @@ interface DisambiguatedProfileViewProps {
* ``` * ```
*/ */
export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element { export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName } = useViewModel(vm); const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, userStatus } = useViewModel(vm);
const userStatusEmoji = userStatus && [...new Intl.Segmenter().segment(userStatus.emoji)][0]?.segment;
const displayNameClasses = classNames(colorClass, { const displayNameClasses = classNames(colorClass, {
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName, [styles.disambiguatedProfile_displayName]: emphasizeDisplayName,
@@ -115,6 +126,13 @@ export function DisambiguatedProfileView({ vm, className }: Readonly<Disambiguat
{displayIdentifier} {displayIdentifier}
</span> </span>
)} )}
{userStatus && (
<Tooltip description={userStatus.text}>
<Text as="span" size="md" className={styles.userStatus}>
{userStatusEmoji}
</Text>
</Tooltip>
)}
</div> </div>
); );
} }
@@ -36,6 +36,11 @@ exports[`DisambiguatedProfileView > renders the full example 1`] = `
> >
@eve:matrix.org @eve:matrix.org
</span> </span>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 DisambiguatedProfile-module_userStatus"
>
🏝️
</span>
</div> </div>
</div> </div>
`; `;