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
24 changed files with 523 additions and 22 deletions
@@ -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
@@ -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)"
@@ -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
@@ -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);
});
});
@@ -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
@@ -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