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 0000000000..26facfe10b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/default-auto.png differ 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 0000000000..17fe39394f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/status/UserStatusIconView.stories.tsx/no-status-auto.png differ 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} + + ); +}