Add user status to autocomplete suggestion (#34241)

* Add user status to autocomplete suggestion

* Add test for PillCompletion titleIcon

* Convert to vitest

* Add test for UserStatusIcon

* Switch the user status icon view to be a view model based component

* Screenshots

* Add .catch

* Add test for UserProvider
This commit is contained in:
David Baker
2026-07-14 13:47:24 +00:00
committed by GitHub
parent 56bdadca12
commit c09f572fa7
12 changed files with 369 additions and 1 deletions
@@ -0,0 +1,24 @@
/*
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.
*/
// @vitest-environment happy-dom
import React from "react";
import { render, screen } from "test-utils-rtl";
import { describe, it, expect } from "vitest";
import { PillCompletion } from "./Components";
describe("PillCompletion", () => {
it("renders the titleIcon immediately after the title", () => {
render(<PillCompletion title="Alice" titleIcon={<span>💡</span>} description="@alice:example.org" />);
const title = screen.getByText("Alice");
const icon = screen.getByText("💡");
expect(title.nextElementSibling).toBe(icon);
});
});
+4
View File
@@ -50,6 +50,8 @@ export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element =
};
interface IPillCompletionProps extends ITextualCompletionProps {
/** An icon displayed after the title */
titleIcon?: React.ReactNode;
children?: React.ReactNode;
}
@@ -60,6 +62,7 @@ export const PillCompletion = (props: IPillCompletionProps): JSX.Element => {
description,
className,
children,
titleIcon,
"aria-selected": ariaSelectedAttribute,
ref,
...restProps
@@ -74,6 +77,7 @@ export const PillCompletion = (props: IPillCompletionProps): JSX.Element => {
>
{children}
<span className="mx_Autocomplete_Completion_title">{title}</span>
{titleIcon}
<span className="mx_Autocomplete_Completion_subtitle">{subtitle}</span>
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
@@ -0,0 +1,38 @@
/*
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.
*/
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import UserProvider from "./UserProvider";
import { makeUserPermalink } from "../utils/permalinks/Permalinks";
import { mkRoom, mkRoomMember, stubClient } from "../../test/test-utils";
describe("UserProvider", () => {
it("suggests a room member whose id matches the query", async () => {
const client = stubClient();
const room = mkRoom(client, "!room:e.com");
const alice = mkRoomMember(room.roomId, "@alice:e.com");
room.getJoinedMembers.mockReturnValue([alice]);
const userProvider = new UserProvider(room);
const completions = await userProvider.getCompletions("@ali", { beginning: true, start: 0, end: 4 });
expect(completions).toStrictEqual([
{
completion: alice.rawDisplayName,
completionId: alice.userId,
type: "user",
suffix: ": ",
href: makeUserPermalink(alice.userId),
component: expect.anything(),
range: { start: 0, end: 4 },
},
]);
});
});
+19 -1
View File
@@ -21,10 +21,12 @@ import {
type IRoomTimelineData,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { UserStatusIconView } from "@element-hq/web-shared-components";
import { MatrixClientPeg } from "../MatrixClientPeg";
import QueryMatcher from "./QueryMatcher";
import { PillCompletion } from "./Components";
import { UserStatusIconViewModel } from "../viewmodels/status/UserStatusIconViewModel";
import AutocompleteProvider from "./AutocompleteProvider";
import { _t } from "../languageHandler";
import { makeUserPermalink } from "../utils/permalinks/Permalinks";
@@ -43,6 +45,7 @@ export default class UserProvider extends AutocompleteProvider {
public matcher: QueryMatcher<RoomMember>;
public users?: RoomMember[];
public room: Room;
private statusViewModels = new Map<string, UserStatusIconViewModel>();
public constructor(room: Room, renderingType?: TimelineRenderingType) {
super({
@@ -64,6 +67,17 @@ export default class UserProvider extends AutocompleteProvider {
public destroy(): void {
MatrixClientPeg.get()?.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
for (const vm of this.statusViewModels.values()) vm.dispose();
this.statusViewModels.clear();
}
private getStatusViewModel(userId: string): UserStatusIconViewModel {
let vm = this.statusViewModels.get(userId);
if (!vm) {
vm = new UserStatusIconViewModel({ userId, matrixClient: MatrixClientPeg.safeGet() });
this.statusViewModels.set(userId, vm);
}
return vm;
}
private onRoomTimeline = (
@@ -127,7 +141,11 @@ export default class UserProvider extends AutocompleteProvider {
suffix: selection.beginning && range!.start === 0 ? ": " : " ",
href: makeUserPermalink(user.userId),
component: (
<PillCompletion title={displayName} description={description ?? undefined}>
<PillCompletion
title={displayName}
titleIcon={<UserStatusIconView vm={this.getStatusViewModel(user.userId)} />}
description={description ?? undefined}
>
<MemberAvatar member={user} size="24px" />
</PillCompletion>
),
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
/*
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 { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { waitFor } from "test-utils-rtl";
import { vi, describe, it, expect, beforeEach, afterEach, type MockedObject } from "vitest";
import { getMockClientWithEventEmitter, mockClientMethodsServer, mockClientMethodsUser } from "test-utils";
import { UserStatusIconViewModel } from "./UserStatusIconViewModel";
import SettingsStore from "../../settings/SettingsStore";
const userId = "@alice:example.com";
describe("UserStatusIconViewModel", () => {
let client: MockedObject<MatrixClient>;
beforeEach(() => {
vi.spyOn(SettingsStore, "getValue").mockImplementation((name): any => {
if (name === "feature_user_status") return true;
});
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsServer(),
doesServerSupportExtendedProfiles: vi.fn().mockResolvedValue(true),
getExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("initialises with an undefined status", () => {
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
expect(vm.getSnapshot().status).toBeUndefined();
});
it("does not fetch a status when the feature is disabled", async () => {
vi.mocked(SettingsStore.getValue).mockReturnValue(false);
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
await waitFor(() => expect(client.doesServerSupportExtendedProfiles).not.toHaveBeenCalled());
expect(vm.getSnapshot().status).toBeUndefined();
});
it("fetches and populates the status on construction", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
await waitFor(() => expect(vm.getSnapshot().status).toEqual({ emoji: "🐎", text: "on a horse" }));
});
it("updates the status when a matching UserProfileUpdate event is emitted", async () => {
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
client.emit(ClientEvent.UserProfileUpdate, userId, {
"org.matrix.msc4426.status": { emoji: "😵", text: "off a horse" },
});
expect(vm.getSnapshot().status).toEqual({ emoji: "😵", text: "off a horse" });
});
it("ignores UserProfileUpdate events for other users", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
await waitFor(() => expect(vm.getSnapshot().status).toEqual({ emoji: "🐎", text: "on a horse" }));
client.emit(ClientEvent.UserProfileUpdate, "@bob:example.com", {
"org.matrix.msc4426.status": { emoji: "🤷", text: "unrelated status" },
});
expect(vm.getSnapshot().status).toEqual({ emoji: "🐎", text: "on a horse" });
});
it("stops listening for updates once disposed", async () => {
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
vm.dispose();
client.emit(ClientEvent.UserProfileUpdate, userId, {
"org.matrix.msc4426.status": { emoji: "😵", text: "off a horse" },
});
expect(vm.getSnapshot().status).toBeUndefined();
});
it("does not update the snapshot if disposed before the initial fetch resolves", async () => {
let resolveFetch: (value: unknown) => void = () => {};
client.getExtendedProfileProperty.mockReturnValue(
new Promise((resolve) => {
resolveFetch = resolve;
}),
);
const vm = new UserStatusIconViewModel({ userId, matrixClient: client });
vm.dispose();
resolveFetch({ emoji: "🐎", text: "on a horse" });
await Promise.resolve();
expect(vm.getSnapshot().status).toBeUndefined();
});
});
@@ -0,0 +1,54 @@
/*
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 { type MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
import { BaseViewModel, type UserStatusIconViewSnapshot } from "@element-hq/web-shared-components";
import { fetchUserStatus, validateUserStatus } from "../../utils/userStatus";
import SettingsStore from "../../settings/SettingsStore";
import { logger } from "matrix-js-sdk/src/logger";
export interface UserStatusIconViewModelProps {
/**
* The ID of the user whose status should be displayed.
*/
userId: string;
/**
* The Matrix client instance.
*/
matrixClient: MatrixClient;
}
export class UserStatusIconViewModel extends BaseViewModel<UserStatusIconViewSnapshot, UserStatusIconViewModelProps> {
public constructor(props: UserStatusIconViewModelProps) {
super(props, { status: undefined });
if (!SettingsStore.getValue("feature_user_status")) {
return;
}
this.disposables.trackListener(
props.matrixClient,
ClientEvent.UserProfileUpdate,
this.onUserProfileUpdate as (...args: unknown[]) => void,
);
fetchUserStatus(props.matrixClient, props.userId)
.then((status) => {
if (this.isDisposed) return;
this.snapshot.merge({ status });
})
.catch((err) => {
logger.warn("Failed to fetch user status:", err);
});
}
private onUserProfileUpdate = (syncedUserId: string, syncProfile: Record<string, unknown> | null): void => {
if (syncedUserId !== this.props.userId) return;
this.snapshot.merge({ status: validateUserStatus(syncProfile?.["org.matrix.msc4426.status"]) });
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -95,5 +95,6 @@ export * from "./core/utils/linkify";
export type * from "./core/userStatus.ts";
export * from "./status/SetStatusView";
export * from "./status/StatusTextView";
export * from "./status/UserStatusIconView";
// MVVM
export * from "./core/viewmodel";
@@ -0,0 +1,31 @@
/*
* 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 { type Meta, type StoryObj } from "@storybook/react-vite";
import { UserStatusIconView, type UserStatusIconViewModel } from "./UserStatusIconView";
import { MockViewModel } from "../core/viewmodel/MockViewModel";
const meta = {
title: "Status/UserStatusIconView",
component: UserStatusIconView,
tags: ["autodocs"],
args: {
vm: new MockViewModel({ status: { emoji: "🐎", text: "on a horse" } }) as UserStatusIconViewModel,
},
} satisfies Meta<typeof UserStatusIconView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NoStatus: Story = {
args: {
vm: new MockViewModel({ status: undefined }) as UserStatusIconViewModel,
},
};
@@ -0,0 +1,37 @@
/*
* 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 { composeStories } from "@storybook/react-vite";
import React from "react";
import { describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@test-utils";
import * as stories from "./UserStatusIconView.stories";
const { Default, NoStatus } = composeStories(stories);
describe("UserStatusIconView", () => {
it("renders nothing when the user has no status", () => {
const { container } = render(<NoStatus />);
expect(container).toBeEmptyDOMElement();
});
it("renders the status emoji", () => {
render(<Default />);
expect(screen.getByText("🐎")).toBeInTheDocument();
});
it("shows the status text in a tooltip on hover", async () => {
render(<Default />);
await userEvent.hover(screen.getByText("🐎"));
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("on a horse");
});
});
});
@@ -0,0 +1,48 @@
/*
* 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, { type JSX } from "react";
import { Text, Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../core/viewmodel";
import { type UserStatus } from "..";
/**
* Snapshot for the UserStatusIconView.
*/
export interface UserStatusIconViewSnapshot {
/**
* The user's status, or undefined if not available.
*/
status?: UserStatus;
}
/**
* The view model for UserStatusIconView.
*/
export type UserStatusIconViewModel = ViewModel<UserStatusIconViewSnapshot>;
interface UserStatusIconViewProps {
/**
* The view model for the user status icon.
*/
vm: UserStatusIconViewModel;
}
/**
* Displays the MSC4426 status emoji for a user, e.g. after their display name
* in the user mention autocomplete. Renders nothing if the user has no status.
*/
export function UserStatusIconView({ vm }: Readonly<UserStatusIconViewProps>): JSX.Element | null {
const { status } = useViewModel(vm);
if (!status) return null;
return (
<Tooltip description={status.text}>
<Text as="span">{status.emoji}</Text>
</Tooltip>
);
}