From c09f572fa7b6947112448e3dca3ce08d5430999d Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 Jul 2026 14:47:24 +0100 Subject: [PATCH] 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 --- apps/web/src/autocomplete/Components.test.tsx | 24 ++++ apps/web/src/autocomplete/Components.tsx | 4 + .../src/autocomplete/UserProvider.test.tsx | 38 ++++++ apps/web/src/autocomplete/UserProvider.tsx | 20 +++- .../status/UserStatusIconViewModel.test.ts | 113 ++++++++++++++++++ .../status/UserStatusIconViewModel.ts | 54 +++++++++ .../default-auto.png | Bin 0 -> 17249 bytes .../no-status-auto.png | Bin 0 -> 16380 bytes packages/shared-components/src/index.ts | 1 + .../src/status/UserStatusIconView.stories.tsx | 31 +++++ .../src/status/UserStatusIconView.test.tsx | 37 ++++++ .../src/status/UserStatusIconView.tsx | 48 ++++++++ 12 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/autocomplete/Components.test.tsx create mode 100644 apps/web/src/autocomplete/UserProvider.test.tsx create mode 100644 apps/web/src/viewmodels/status/UserStatusIconViewModel.test.ts create mode 100644 apps/web/src/viewmodels/status/UserStatusIconViewModel.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/no-status-auto.png create mode 100644 packages/shared-components/src/status/UserStatusIconView.stories.tsx create mode 100644 packages/shared-components/src/status/UserStatusIconView.test.tsx create mode 100644 packages/shared-components/src/status/UserStatusIconView.tsx diff --git a/apps/web/src/autocomplete/Components.test.tsx b/apps/web/src/autocomplete/Components.test.tsx new file mode 100644 index 0000000000..d858b8cba9 --- /dev/null +++ b/apps/web/src/autocomplete/Components.test.tsx @@ -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(💡} description="@alice:example.org" />); + + const title = screen.getByText("Alice"); + const icon = screen.getByText("💡"); + expect(title.nextElementSibling).toBe(icon); + }); +}); diff --git a/apps/web/src/autocomplete/Components.tsx b/apps/web/src/autocomplete/Components.tsx index f1493d0c23..0b59c911ea 100644 --- a/apps/web/src/autocomplete/Components.tsx +++ b/apps/web/src/autocomplete/Components.tsx @@ -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} {title} + {titleIcon} {subtitle} {description} diff --git a/apps/web/src/autocomplete/UserProvider.test.tsx b/apps/web/src/autocomplete/UserProvider.test.tsx new file mode 100644 index 0000000000..b1a102811b --- /dev/null +++ b/apps/web/src/autocomplete/UserProvider.test.tsx @@ -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 }, + }, + ]); + }); +}); diff --git a/apps/web/src/autocomplete/UserProvider.tsx b/apps/web/src/autocomplete/UserProvider.tsx index 39accdc8da..e6b8c46e99 100644 --- a/apps/web/src/autocomplete/UserProvider.tsx +++ b/apps/web/src/autocomplete/UserProvider.tsx @@ -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; public users?: RoomMember[]; public room: Room; + private statusViewModels = new Map(); 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: ( - + } + description={description ?? undefined} + > ), diff --git a/apps/web/src/viewmodels/status/UserStatusIconViewModel.test.ts b/apps/web/src/viewmodels/status/UserStatusIconViewModel.test.ts new file mode 100644 index 0000000000..21d60df4ad --- /dev/null +++ b/apps/web/src/viewmodels/status/UserStatusIconViewModel.test.ts @@ -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; + + 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(); + }); +}); diff --git a/apps/web/src/viewmodels/status/UserStatusIconViewModel.ts b/apps/web/src/viewmodels/status/UserStatusIconViewModel.ts new file mode 100644 index 0000000000..e33c5cfd2b --- /dev/null +++ b/apps/web/src/viewmodels/status/UserStatusIconViewModel.ts @@ -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 { + 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 | null): void => { + if (syncedUserId !== this.props.userId) return; + this.snapshot.merge({ status: validateUserStatus(syncProfile?.["org.matrix.msc4426.status"]) }); + }; +} diff --git a/packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..26facfe10bbd1cd5cf2d532f4c8202c5c020bc16 GIT binary patch literal 17249 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 D{-sWz literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/no-status-auto.png b/packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/no-status-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..17fe39394f561638fc5cf269603be3fafcbe35cd GIT binary patch literal 16380 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 Ro_InG>>t)Ir>}X@{{cu8Y-#`i literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 206e0015e8..ee637bb8d6 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/status/UserStatusIconView.stories.tsx b/packages/shared-components/src/status/UserStatusIconView.stories.tsx new file mode 100644 index 0000000000..30d9538873 --- /dev/null +++ b/packages/shared-components/src/status/UserStatusIconView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoStatus: Story = { + args: { + vm: new MockViewModel({ status: undefined }) as UserStatusIconViewModel, + }, +}; diff --git a/packages/shared-components/src/status/UserStatusIconView.test.tsx b/packages/shared-components/src/status/UserStatusIconView.test.tsx new file mode 100644 index 0000000000..5e04cb1c1c --- /dev/null +++ b/packages/shared-components/src/status/UserStatusIconView.test.tsx @@ -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(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the status emoji", () => { + render(); + expect(screen.getByText("🐎")).toBeInTheDocument(); + }); + + it("shows the status text in a tooltip on hover", async () => { + render(); + + await userEvent.hover(screen.getByText("🐎")); + await waitFor(() => { + expect(screen.getByRole("tooltip")).toHaveTextContent("on a horse"); + }); + }); +}); diff --git a/packages/shared-components/src/status/UserStatusIconView.tsx b/packages/shared-components/src/status/UserStatusIconView.tsx new file mode 100644 index 0000000000..6b1d724022 --- /dev/null +++ b/packages/shared-components/src/status/UserStatusIconView.tsx @@ -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; + +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): JSX.Element | null { + const { status } = useViewModel(vm); + if (!status) return null; + return ( + + {status.emoji} + + ); +}