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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]) });
|
||||
};
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user