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 09:14:22 +01:00 committed by GitHub
parent 4c3cb0754b
commit 4bee845010
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 523 additions and 22 deletions

View File

@ -297,6 +297,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.threadSupport = true;
if (SettingsStore.getValue("feature_user_status")) {
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"];
}
if (SettingsStore.getValue("feature_sliding_sync")) {
throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported");

View File

@ -13,6 +13,7 @@ import { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@eleme
import { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import { useUserStatus } from "../../../hooks/useUserStatus";
interface IProps {
mxEvent: MatrixEvent;
@ -27,6 +28,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
userId: sender,
member: mxEvent.sender,
});
const userStatus = useUserStatus(sender);
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
() =>
@ -37,9 +39,13 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
colored: true,
emphasizeDisplayName: true,
withTooltip,
userStatus,
}),
);
useEffect(() => {
disambiguatedProfileVM.setUserStatus(userStatus);
}, [disambiguatedProfileVM, userStatus]);
useEffect(() => {
disambiguatedProfileVM.setMember(sender ?? "", member);
}, [disambiguatedProfileVM, member, sender]);

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)}`,
};
}

View File

@ -1541,6 +1541,11 @@
"experimental_section": "Early previews",
"extended_profiles_msc_support": "Requires your server to support MSC4133",
"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.",
"group_calls": "New group call experience",
"group_developer": "Developer",
@ -3148,6 +3153,14 @@
"server_error_detail": "Server unavailable, overloaded, or something else went wrong.",
"shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message",
"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",
"topic": "Gets or sets the room topic",
"topic_none": "This room has no topic.",

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2018-2024 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston
@ -228,6 +229,7 @@ export interface Settings {
"feature_ask_to_join": IFeature;
"feature_notifications": IFeature;
"feature_msc4362_encrypted_state_events": IFeature;
"feature_user_status": IFeature;
// These are in the feature namespace but aren't actually features
"feature_hidebold": IBaseSetting<boolean>;
@ -789,6 +791,30 @@ export const SETTINGS: Settings = {
shouldWarn: true,
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": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
displayName: _td("settings|preferences|compact_modern"),

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
@ -12,6 +13,7 @@ import { type WatchManager } from "../WatchManager";
import SettingsStore from "../SettingsStore";
import { type SettingKey } from "../Settings.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
@ -28,6 +30,9 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
* @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
* 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(
private readonly settingName: SettingKey,
@ -36,12 +41,23 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
private readonly stableVersion?: string,
private readonly disabledMessage?: TranslationKey,
private readonly forcedValue: any = false,
private readonly defaultEnabled = false,
private readonly forceReload = false,
) {
super();
}
public onChange(): void {
if (this.forceReload) {
PlatformPeg.get()?.reload();
}
}
public get disabled(): boolean {
return !this.enabled;
if (this.enabled !== undefined) {
return !this.enabled;
}
return !this.defaultEnabled;
}
public set disabled(newDisabledValue: boolean) {

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
@ -62,6 +63,7 @@ import { goto, join } from "./join";
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
import upgraderoom from "./upgraderoom/upgraderoom";
import { emoticon } from "./emoticon";
import { statusCommand } from "./status";
export { CommandCategories, Command };
@ -819,6 +821,7 @@ export const Commands = [
},
renderingTypes: [TimelineRenderingType.Room],
}),
statusCommand,
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes

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],
});

View File

@ -15,6 +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";
/**
* Information about a member for disambiguation purposes.
@ -46,6 +47,10 @@ export interface DisambiguatedProfileViewModelProps {
* The member information for disambiguation.
*/
member?: MemberInfo | null;
/**
* The user's present status.
*/
userStatus?: UserStatus;
/**
* 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.
*/
withTooltip?: boolean;
/**
* Optional click handler for the profile.
*/
@ -79,7 +85,7 @@ export class DisambiguatedProfileViewModel
private static readonly computeSnapshot = (
props: DisambiguatedProfileViewModelProps,
): DisambiguatedProfileViewSnapshot => {
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip } = props;
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, userStatus } = props;
// Compute display name
const displayName = member?.rawDisplayName || fallbackName;
@ -122,11 +128,15 @@ export class DisambiguatedProfileViewModel
displayIdentifier,
title,
emphasizeDisplayName,
userStatus,
};
};
public constructor(props: DisambiguatedProfileViewModelProps) {
super(props, DisambiguatedProfileViewModel.computeSnapshot(props));
this.snapshot.merge({
userStatus: props.userStatus,
});
}
public setMember(fallbackName: string, member?: MemberInfo | null): void {
@ -136,6 +146,13 @@ export class DisambiguatedProfileViewModel
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 => {
this.props.onClick?.(evt);
};

View File

@ -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",
});
});
});

View File

@ -31,12 +31,12 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines
u
</span>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
@userId:matrix.org

View File

@ -35,7 +35,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
class="mx_MemberTileView_name"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
title="@userId:matrix.org (@userId:matrix.org)"
@ -111,7 +111,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
class="mx_MemberTileView_name"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
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"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
title="@userId:matrix.org (@userId:matrix.org)"

View File

@ -71,12 +71,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
Alice
@ -165,12 +165,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
Alice
@ -262,12 +262,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
class="mx_MessageTimestamp"
/>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
Alice

View File

@ -48,6 +48,6 @@ describe("<LabsUserSettingsTab />", () => {
// non-beta labs section
expect(screen.getByText("Early previews")).toBeInTheDocument();
const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
expect(labsSections).toHaveLength(8);
expect(labsSections).toHaveLength(9);
});
});

View File

@ -214,12 +214,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
@userId:matrix.org
@ -308,12 +308,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
@userId:matrix.org
@ -405,12 +405,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
class="mx_MessageTimestamp"
/>
<div
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
role="button"
tabindex="0"
>
<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"
>
@userId:matrix.org

View File

@ -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

View File

@ -131,5 +131,10 @@ joining a room.
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
it may have unintended consequences.

View File

@ -24,4 +24,8 @@
font-size: var(--cpd-font-size-body-sm);
margin-inline-start: 5px;
}
.userStatus {
margin-inline-start: 5px;
}
}

View File

@ -40,6 +40,10 @@ const meta = {
displayIdentifier: { control: "text" },
title: { control: "text" },
emphasizeDisplayName: { control: "boolean" },
userStatus: {
status: { control: "string" },
emoji: { control: "string" },
},
},
args: {
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 = {
args: {
displayName: "Eve",
@ -89,5 +104,9 @@ export const FullExample: Story = {
colorClass: "mx_Username_color5",
title: "Eve (@eve:matrix.org)",
emphasizeDisplayName: true,
userStatus: {
emoji: "🏝️",
text: "On holiday",
},
},
};

View File

@ -7,6 +7,7 @@
import React, { type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
import classNames from "classnames";
import { Text, Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./DisambiguatedProfile.module.css";
@ -38,6 +39,14 @@ export interface DisambiguatedProfileViewSnapshot {
* Whether to emphasize the display name with additional styling.
*/
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 {
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, {
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName,
@ -115,6 +126,13 @@ export function DisambiguatedProfileView({ vm, className }: Readonly<Disambiguat
{displayIdentifier}
</span>
)}
{userStatus && (
<Tooltip description={userStatus.text}>
<Text as="span" size="md" className={styles.userStatus}>
{userStatusEmoji}
</Text>
</Tooltip>
)}
</div>
);
}

View File

@ -36,6 +36,11 @@ exports[`DisambiguatedProfileView > renders the full example 1`] = `
>
@eve:matrix.org
</span>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 DisambiguatedProfile-module_userStatus"
>
🏝️
</span>
</div>
</div>
`;