diff --git a/packages/shared-components/.storybook/preview.css b/packages/shared-components/.storybook/preview.css index 9f49585937..eb1772154e 100644 --- a/packages/shared-components/.storybook/preview.css +++ b/packages/shared-components/.storybook/preview.css @@ -8,3 +8,29 @@ Please see LICENSE files in the repository root for full details. .docs-story { background: var(--cpd-color-bg-canvas-default); } + +/* Username color classes - these are defined in the main app's _common.pcss + but need to be available in Storybook for components that use colorClass */ +.mx_Username_color1 { + color: var(--cpd-color-text-decorative-1); +} + +.mx_Username_color2 { + color: var(--cpd-color-text-decorative-2); +} + +.mx_Username_color3 { + color: var(--cpd-color-text-decorative-3); +} + +.mx_Username_color4 { + color: var(--cpd-color-text-decorative-4); +} + +.mx_Username_color5 { + color: var(--cpd-color-text-decorative-5); +} + +.mx_Username_color6 { + color: var(--cpd-color-text-decorative-6); +} diff --git a/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/default-auto.png new file mode 100644 index 0000000000..4f21429863 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/emphasized-auto.png b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/emphasized-auto.png new file mode 100644 index 0000000000..8741845a45 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/emphasized-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png new file mode 100644 index 0000000000..19129fa1ac Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-color-class-auto.png b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-color-class-auto.png new file mode 100644 index 0000000000..c8cc5574ee Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-color-class-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-mxid-auto.png b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-mxid-auto.png new file mode 100644 index 0000000000..4758b37a5b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-mxid-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-tooltip-auto.png new file mode 100644 index 0000000000..d00a53f88e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-tooltip-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 9c51cf74d2..4a0a971dfd 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -22,6 +22,7 @@ export * from "./message-body/TimelineSeparator/"; export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; export * from "./room/RoomStatusBar"; +export * from "./profile/DisambiguatedProfile"; export * from "./room/HistoryVisibilityBadge"; export * from "./rich-list/RichItem"; export * from "./rich-list/RichList"; diff --git a/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.module.css b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.module.css new file mode 100644 index 0000000000..5733e1c71c --- /dev/null +++ b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.module.css @@ -0,0 +1,27 @@ +/* + * 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. + */ + +.disambiguatedProfile { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + + .disambiguatedProfile_displayName { + font: var(--cpd-font-body-md-semibold); + letter-spacing: var(--cpd-letter-spacing-body-md); + margin-inline-end: 0; + /* keeps the height in check, important for the bubble apperance */ + line-height: 1; + } + + .disambiguatedProfile_mxid { + color: var(--cpd-color-text-secondary); + font-size: var(--cpd-font-size-body-sm); + margin-inline-start: 5px; + } +} diff --git a/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx new file mode 100644 index 0000000000..1f9128d3e0 --- /dev/null +++ b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.stories.tsx @@ -0,0 +1,83 @@ +/* + * 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 { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { + DisambiguatedProfileView, + type DisambiguatedProfileViewSnapshot, + type DisambiguatedProfileViewActions, +} from "./DisambiguatedProfileView"; +import { useMockedViewModel } from "../../viewmodel"; + +type DisambiguatedProfileProps = DisambiguatedProfileViewSnapshot & DisambiguatedProfileViewActions; + +const DisambiguatedProfileViewWrapper = ({ onClick, ...rest }: DisambiguatedProfileProps): JSX.Element => { + const vm = useMockedViewModel(rest, { onClick }); + return ; +}; + +export default { + title: "Profile/DisambiguatedProfile", + component: DisambiguatedProfileViewWrapper, + tags: ["autodocs"], + argTypes: { + displayName: { control: "text" }, + colorClass: { control: "text" }, + className: { control: "text" }, + displayIdentifier: { control: "text" }, + title: { control: "text" }, + emphasizeDisplayName: { control: "boolean" }, + }, + args: { + displayName: "Alice", + emphasizeDisplayName: true, + onClick: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = Template.bind({}); + +export const WithMxid = Template.bind({}); +WithMxid.args = { + displayName: "Alice", + displayIdentifier: "@alice:example.org", + colorClass: "mx_Username_color1", +}; + +export const WithColorClass = Template.bind({}); +WithColorClass.args = { + displayName: "Bob", + colorClass: "mx_Username_color3", +}; + +export const Emphasized = Template.bind({}); +Emphasized.args = { + displayName: "Charlie", + emphasizeDisplayName: true, +}; + +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + displayName: "Diana", + title: "Diana (@diana:example.org)", +}; + +export const FullExample = Template.bind({}); +FullExample.args = { + displayName: "Eve", + displayIdentifier: "@eve:matrix.org", + colorClass: "mx_Username_color5", + title: "Eve (@eve:matrix.org)", + emphasizeDisplayName: true, +}; diff --git a/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.test.tsx b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.test.tsx new file mode 100644 index 0000000000..0b05a1e0ad --- /dev/null +++ b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfile.test.tsx @@ -0,0 +1,212 @@ +/* + * 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 userEvent from "@testing-library/user-event"; +import { createEvent, fireEvent } from "@testing-library/dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@test-utils"; + +import * as stories from "./DisambiguatedProfile.stories"; +import { + DisambiguatedProfileView, + type DisambiguatedProfileViewActions, + type DisambiguatedProfileViewSnapshot, +} from "./DisambiguatedProfileView"; +import { MockViewModel } from "../../viewmodel/MockViewModel"; + +const { Default, WithMxid, WithColorClass, Emphasized, WithTooltip, FullExample } = composeStories(stories); + +describe("DisambiguatedProfileView", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with MXID for disambiguation", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with color class", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with emphasized display name", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with tooltip", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the full example", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + class DisambiguatedProfileViewModel + extends MockViewModel + implements DisambiguatedProfileViewActions + { + public onClick?: DisambiguatedProfileViewActions["onClick"]; + + public constructor(snapshot: DisambiguatedProfileViewSnapshot, actions: DisambiguatedProfileViewActions = {}) { + super(snapshot); + this.onClick = actions.onClick; + } + } + + const getProfileContainer = (displayName: string): HTMLDivElement => { + const profileContainer = screen.getByText(displayName).closest("div"); + if (!profileContainer) { + throw new Error("Expected profile container to exist"); + } + return profileContainer; + }; + + it("should display the display name", () => { + const vm = new DisambiguatedProfileViewModel({ + displayName: "Eve", + }); + + render(); + expect(screen.getByText("Eve")).toBeInTheDocument(); + }); + + it("should display the MXID when provided", () => { + const vm = new DisambiguatedProfileViewModel({ + displayName: "Test User", + displayIdentifier: "@test:example.org", + }); + + render(); + expect(screen.getByText("@test:example.org")).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + const vm = new DisambiguatedProfileViewModel( + { + displayName: "Clickable User", + }, + { onClick }, + ); + + render(); + await user.click(screen.getByText("Clickable User")); + expect(onClick).toHaveBeenCalled(); + }); + + it("should set button semantics when onClick is provided", () => { + const vm = new DisambiguatedProfileViewModel( + { + displayName: "Keyboard User", + }, + { onClick: vi.fn() }, + ); + + render(); + const profileContainer = getProfileContainer("Keyboard User"); + expect(profileContainer).toHaveAttribute("role", "button"); + expect(profileContainer).toHaveAttribute("tabIndex", "0"); + }); + + it("should call onClick on keyboard activation keys", () => { + const onClick = vi.fn(); + const vm = new DisambiguatedProfileViewModel( + { + displayName: "Keyboard User", + }, + { onClick }, + ); + + render(); + const profileContainer = getProfileContainer("Keyboard User"); + + const enterEvent = createEvent.keyDown(profileContainer, { key: "Enter" }); + fireEvent(profileContainer, enterEvent); + + const spaceEvent = createEvent.keyDown(profileContainer, { key: " " }); + fireEvent(profileContainer, spaceEvent); + + expect(enterEvent.defaultPrevented).toBe(true); + expect(spaceEvent.defaultPrevented).toBe(true); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it("should not call onClick for non-activation keys", () => { + const onClick = vi.fn(); + const vm = new DisambiguatedProfileViewModel( + { + displayName: "Keyboard User", + }, + { onClick }, + ); + + render(); + const profileContainer = getProfileContainer("Keyboard User"); + + fireEvent.keyDown(profileContainer, { key: "Escape" }); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("should not set button semantics when onClick is not provided", () => { + const vm = new DisambiguatedProfileViewModel({ + displayName: "Static User", + }); + + render(); + const profileContainer = getProfileContainer("Static User"); + expect(profileContainer).not.toHaveAttribute("role"); + expect(profileContainer).not.toHaveAttribute("tabIndex"); + }); + + it("should display tooltip title when provided", () => { + const vm = new DisambiguatedProfileViewModel({ + displayName: "User With Tooltip", + title: "User With Tooltip (@user:example.org)", + }); + + render(); + expect(screen.getByText("User With Tooltip").closest("div")).toHaveAttribute( + "title", + "User With Tooltip (@user:example.org)", + ); + }); + + it("should apply color class when provided", () => { + const vm = new DisambiguatedProfileViewModel({ + displayName: "Colored User", + colorClass: "mx_Username_color3", + }); + + render(); + const displayNameElement = screen.getByText("Colored User"); + expect(displayNameElement).toHaveClass("mx_Username_color3"); + }); + + it("should apply emphasis styling when emphasizeDisplayName is true", () => { + const vm = new DisambiguatedProfileViewModel({ + displayName: "Emphasized User", + emphasizeDisplayName: true, + }); + + render(); + const displayNameElement = screen.getByText("Emphasized User"); + expect(displayNameElement).toHaveClass("mx_DisambiguatedProfile_displayName"); + }); +}); diff --git a/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfileView.tsx b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfileView.tsx new file mode 100644 index 0000000000..df5128b2d8 --- /dev/null +++ b/packages/shared-components/src/profile/DisambiguatedProfile/DisambiguatedProfileView.tsx @@ -0,0 +1,118 @@ +/* + * 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, type KeyboardEventHandler, type MouseEventHandler } from "react"; +import classNames from "classnames"; + +import { type ViewModel, useViewModel } from "../../viewmodel"; +import styles from "./DisambiguatedProfile.module.css"; + +/** + * The snapshot representing the current state of the DisambiguatedProfile. + */ +export interface DisambiguatedProfileViewSnapshot { + /** + * The display name to show. + */ + displayName: string; + /** + * The CSS class for coloring the display name (e.g., "mx_Username_color1"). + * Undefined if coloring is not enabled. + */ + colorClass?: string; + /** + * The CSS class name. + */ + className?: string; + /** + * The formatted user identifier to display when disambiguation is needed. + * Undefined if disambiguation is not required. + */ + displayIdentifier?: string; + /** + * The tooltip title text (pre-translated). + * Undefined if tooltip is not enabled. + */ + title?: string; + /** + * Whether to emphasize the display name with additional styling. + */ + emphasizeDisplayName?: boolean; +} + +/** + * Actions that can be performed on the DisambiguatedProfile. + */ +export interface DisambiguatedProfileViewActions { + /** + * Optional click handler for the profile. + */ + onClick?: MouseEventHandler; +} + +/** + * The view model for DisambiguatedProfileView. + */ +export type DisambiguatedProfileViewModel = ViewModel & + DisambiguatedProfileViewActions; + +interface DisambiguatedProfileViewProps { + /** + * The view model for the disambiguated profile. + */ + vm: DisambiguatedProfileViewModel; +} + +/** + * A component to display a user's profile with optional disambiguation. + * Shows the display name and optionally the MXID when disambiguation is needed + * (e.g., when multiple users have the same display name). + * + * @example + * ```tsx + * + * ``` + */ +export function DisambiguatedProfileView({ vm }: Readonly): JSX.Element { + const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, className } = useViewModel(vm); + + const displayNameClasses = classNames(colorClass, { + [styles.disambiguatedProfile_displayName]: emphasizeDisplayName, + mx_DisambiguatedProfile_displayName: emphasizeDisplayName, + }); + + // Handle keyboard interaction for accessibility if onClick is provided + const handleKeyDown: KeyboardEventHandler | undefined = vm.onClick + ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + vm.onClick?.(event as unknown as React.MouseEvent); + } + } + : undefined; + + return ( +
+ + {displayName} + + {/* mx_DisambiguatedProfile_mxid is required for PCSS selectors like .mx_MemberTileView .mx_DisambiguatedProfile_mxid */} + {displayIdentifier && ( + + {displayIdentifier} + + )} +
+ ); +} diff --git a/packages/shared-components/src/profile/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap b/packages/shared-components/src/profile/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap new file mode 100644 index 0000000000..be12923128 --- /dev/null +++ b/packages/shared-components/src/profile/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap @@ -0,0 +1,115 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DisambiguatedProfileView > renders the default state 1`] = ` +
+
+ + Alice + +
+
+`; + +exports[`DisambiguatedProfileView > renders the full example 1`] = ` +
+
+ + Eve + + + @eve:matrix.org + +
+
+`; + +exports[`DisambiguatedProfileView > renders with MXID for disambiguation 1`] = ` +
+
+ + Alice + + + @alice:example.org + +
+
+`; + +exports[`DisambiguatedProfileView > renders with color class 1`] = ` +
+
+ + Bob + +
+
+`; + +exports[`DisambiguatedProfileView > renders with emphasized display name 1`] = ` +
+
+ + Charlie + +
+
+`; + +exports[`DisambiguatedProfileView > renders with tooltip 1`] = ` +
+
+ + Diana + +
+
+`; diff --git a/packages/shared-components/src/profile/DisambiguatedProfile/index.tsx b/packages/shared-components/src/profile/DisambiguatedProfile/index.tsx new file mode 100644 index 0000000000..3089ec37d4 --- /dev/null +++ b/packages/shared-components/src/profile/DisambiguatedProfile/index.tsx @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export { + DisambiguatedProfileView, + type DisambiguatedProfileViewModel, + type DisambiguatedProfileViewSnapshot, + type DisambiguatedProfileViewActions, +} from "./DisambiguatedProfileView"; diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 415bcd1845..3754bcfb62 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -38,7 +38,7 @@ test.describe("Editing", () => { const editLastMessage = async (page: Page, edit: string) => { const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); await eventTile.hover(); - await eventTile.getByRole("button", { name: "Edit" }).click(); + await eventTile.getByRole("button", { name: "Edit", exact: true }).click(); const textbox = page.getByRole("textbox", { name: "Edit message" }); await textbox.fill(edit); @@ -284,7 +284,7 @@ test.describe("Editing", () => { await expect(tile.getByText("Message", { exact: true })).toBeVisible(); const line = tile.locator(".mx_EventTile_line"); await line.hover(); - await line.getByRole("button", { name: "Edit" }).click(); + await line.getByRole("button", { name: "Edit", exact: true }).click(); await expect(axe).toHaveNoViolations(); const editComposer = page.getByRole("textbox", { name: "Edit message" }); await editComposer.pressSequentially("Foo"); diff --git a/src/components/views/messages/DisambiguatedProfile.tsx b/src/components/views/messages/DisambiguatedProfile.tsx deleted file mode 100644 index 660a832d37..0000000000 --- a/src/components/views/messages/DisambiguatedProfile.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. -Copyright 2021 Ε imon Brandner - -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 classNames from "classnames"; - -import { _t } from "../../../languageHandler"; -import { getUserNameColorClass } from "../../../utils/FormattingUtils"; -import UserIdentifier from "../../../customisations/UserIdentifier"; - -interface MemberInfo { - userId: string; - roomId: string; - rawDisplayName?: string; - disambiguate: boolean; -} - -interface IProps { - member?: MemberInfo | null; - fallbackName: string; - onClick?(): void; - colored?: boolean; - emphasizeDisplayName?: boolean; - withTooltip?: boolean; -} - -export default class DisambiguatedProfile extends React.Component { - public render(): React.ReactNode { - const { fallbackName, member, colored, emphasizeDisplayName, withTooltip, onClick } = this.props; - const rawDisplayName = member?.rawDisplayName || fallbackName; - const mxid = member?.userId; - - let colorClass: string | undefined; - if (colored) { - colorClass = getUserNameColorClass(mxid ?? ""); - } - - let mxidElement; - let title: string | undefined; - - if (mxid) { - const identifier = - UserIdentifier.getDisplayUserIdentifier?.(mxid, { - withDisplayName: true, - roomId: member.roomId, - }) ?? mxid; - if (member?.disambiguate) { - mxidElement = {identifier}; - } - title = _t("timeline|disambiguated_profile", { - displayName: rawDisplayName, - matrixId: identifier, - }); - } - - const displayNameClasses = classNames(colorClass, { - mx_DisambiguatedProfile_displayName: emphasizeDisplayName, - }); - - return ( -
- - {rawDisplayName} - - {mxidElement} -
- ); - } -} diff --git a/src/components/views/messages/SenderProfile.tsx b/src/components/views/messages/SenderProfile.tsx index 2e51c1b50a..82b4523fb7 100644 --- a/src/components/views/messages/SenderProfile.tsx +++ b/src/components/views/messages/SenderProfile.tsx @@ -7,10 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useEffect } from "react"; import { type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; +import { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@element-hq/web-shared-components"; -import DisambiguatedProfile from "./DisambiguatedProfile"; +import { DisambiguatedProfileViewModel } from "../../../viewmodels/profile/DisambiguatedProfileViewModel"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; interface IProps { @@ -20,20 +21,31 @@ interface IProps { } export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps): JSX.Element { + const sender = mxEvent.getSender(); + const member = useRoomMemberProfile({ - userId: mxEvent.getSender(), + userId: sender, member: mxEvent.sender, }); + const disambiguatedProfileVM = useCreateAutoDisposedViewModel( + () => + new DisambiguatedProfileViewModel({ + fallbackName: sender ?? "", + onClick, + member, + colored: true, + emphasizeDisplayName: true, + withTooltip, + className: "mx_DisambiguatedProfile", + }), + ); + + useEffect(() => { + disambiguatedProfileVM.setMember(sender ?? "", member); + }, [disambiguatedProfileVM, member, sender]); return mxEvent.getContent().msgtype !== MsgType.Emote ? ( - + ) : ( <> ); diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index 34c85af7b1..f8dbd02829 100644 --- a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useEffect } from "react"; +import { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@element-hq/web-shared-components"; -import DisambiguatedProfile from "../../../messages/DisambiguatedProfile"; import { type RoomMember } from "../../../../../models/rooms/RoomMember"; import { useMemberTileViewModel } from "../../../../viewmodels/memberlist/tiles/MemberTileViewModel"; import { E2EIconView } from "./common/E2EIconView"; @@ -17,6 +17,7 @@ import { _t } from "../../../../../languageHandler"; import { MemberTileView } from "./common/MemberTileView"; import { InvitedIconView } from "./common/InvitedIconView"; import { type MemberWithSeparator } from "../../../../viewmodels/memberlist/MemberListViewModel"; +import { DisambiguatedProfileViewModel } from "../../../../../viewmodels/profile/DisambiguatedProfileViewModel"; interface IProps { /** @@ -46,7 +47,19 @@ export function RoomMemberTileView(props: IProps): JSX.Element { /> ); const name = vm.name; - const nameJSX = ; + const disambiguatedProfileVM = useCreateAutoDisposedViewModel( + () => + new DisambiguatedProfileViewModel({ + fallbackName: name, + member, + withTooltip: true, + className: "mx_DisambiguatedProfile", + }), + ); + useEffect(() => { + disambiguatedProfileVM.setMember(name, member); + }, [disambiguatedProfileVM, member, name]); + const nameJSX = ; const presenceState = member.presenceState; let presenceJSX: JSX.Element | undefined; diff --git a/src/viewmodels/profile/DisambiguatedProfileViewModel.ts b/src/viewmodels/profile/DisambiguatedProfileViewModel.ts new file mode 100644 index 0000000000..807ae20522 --- /dev/null +++ b/src/viewmodels/profile/DisambiguatedProfileViewModel.ts @@ -0,0 +1,147 @@ +/* + * 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 { + BaseViewModel, + type DisambiguatedProfileViewActions, + type DisambiguatedProfileViewSnapshot, + type DisambiguatedProfileViewModel as DisambiguatedProfileViewModelInterface, +} from "@element-hq/web-shared-components"; +import { type MouseEvent } from "react"; + +import { _t } from "../../languageHandler"; +import { getUserNameColorClass } from "../../utils/FormattingUtils"; +import UserIdentifier from "../../customisations/UserIdentifier"; + +/** + * Information about a member for disambiguation purposes. + */ +interface MemberInfo { + /** + * The user's Matrix ID. + */ + userId: string; + /** + * The room ID context for disambiguation. + */ + roomId: string; + /** + * The raw display name of the user, if available. + */ + rawDisplayName?: string; + /** + * Whether the user is set to have disambiguation name. + */ + disambiguate: boolean; +} + +/** + * Props for the DisambiguatedProfileViewModel. + */ +export interface DisambiguatedProfileViewModelProps { + /** + * The member information for disambiguation. + */ + member?: MemberInfo | null; + /** + * The fallback name to use if the member's display name is not available. + */ + fallbackName: string; + /** + * Whether to apply color styling to the display name. + */ + colored?: boolean; + /** + * Whether to emphasize the display name. + */ + emphasizeDisplayName?: boolean; + /** + * Whether to show a tooltip with additional information. + */ + withTooltip?: boolean; + /** + * Optional click handler for the profile. + */ + onClick?: DisambiguatedProfileViewActions["onClick"]; + /** + * Optional CSS class name to apply to the profile. + */ + className?: string; +} + +/** + * ViewModel for the disambiguated profile, providing the current state of the component. + * It computes pre-rendered values for the View including color classes, display identifiers, and tooltips. + */ +export class DisambiguatedProfileViewModel + extends BaseViewModel + implements DisambiguatedProfileViewModelInterface +{ + private static readonly computeSnapshot = ( + props: DisambiguatedProfileViewModelProps, + ): DisambiguatedProfileViewSnapshot => { + const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, className } = props; + + // Compute display name + const displayName = member?.rawDisplayName || fallbackName; + const mxid = member?.userId; + + // Compute color class if coloring is enabled + let colorClass: string | undefined; + if (colored && mxid) { + colorClass = getUserNameColorClass(mxid); + } + + // Compute display identifier for disambiguation + let displayIdentifier: string | undefined; + let title: string | undefined; + + if (mxid) { + const identifier = + UserIdentifier.getDisplayUserIdentifier?.(mxid, { + withDisplayName: true, + roomId: member?.roomId, + }) ?? mxid; + + // Only show identifier if disambiguation is needed + if (member?.disambiguate) { + displayIdentifier = identifier; + } + + // Compute tooltip title if enabled + if (withTooltip) { + title = _t("timeline|disambiguated_profile", { + displayName, + matrixId: identifier, + }); + } + } + + return { + displayName, + colorClass, + className, + displayIdentifier, + title, + emphasizeDisplayName, + }; + }; + + public constructor(props: DisambiguatedProfileViewModelProps) { + super(props, DisambiguatedProfileViewModel.computeSnapshot(props)); + } + + public setMember(fallbackName: string, member?: MemberInfo | null): void { + this.props.member = member; + this.props.fallbackName = fallbackName; + + this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props)); + } + + public onClick(evt: MouseEvent): void { + this.props.onClick?.(evt); + } +} diff --git a/test/unit-tests/components/views/dialogs/ForwardDialog-test.tsx b/test/unit-tests/components/views/dialogs/ForwardDialog-test.tsx index 683b7e1793..5bc614c12d 100644 --- a/test/unit-tests/components/views/dialogs/ForwardDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/ForwardDialog-test.tsx @@ -337,7 +337,7 @@ describe("ForwardDialog", () => { it("converts legacy location events to pin drop shares", async () => { const { container } = mountForwardDialog(legacyLocationEvent); - expect(container.querySelector(".mx_MLocationBody")).toBeTruthy(); + await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy()); sendToFirstRoom(container); // text and description from original event are removed @@ -364,7 +364,7 @@ describe("ForwardDialog", () => { it("removes personal information from static self location shares", async () => { const { container } = mountForwardDialog(modernLocationEvent); - expect(container.querySelector(".mx_MLocationBody")).toBeTruthy(); + await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy()); sendToFirstRoom(container); const timestamp = M_TIMESTAMP.findIn(modernLocationEvent.getContent())!; @@ -404,7 +404,7 @@ describe("ForwardDialog", () => { }; const { container } = mountForwardDialog(beaconEvent); - expect(container.querySelector(".mx_MLocationBody")).toBeTruthy(); + await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy()); sendToFirstRoom(container); @@ -414,7 +414,7 @@ describe("ForwardDialog", () => { it("forwards pin drop event", async () => { const { container } = mountForwardDialog(pinDropLocationEvent); - expect(container.querySelector(".mx_MLocationBody")).toBeTruthy(); + await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy()); sendToFirstRoom(container); diff --git a/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap index b22e49ae12..4f269d0818 100644 --- a/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap +++ b/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap @@ -31,10 +31,12 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines u
@userId:matrix.org diff --git a/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap index 4b5fbda763..4c5b4bcda5 100644 --- a/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap @@ -35,7 +35,9 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh class="mx_MemberTileView_name" >
should render 1`] = ` tabindex="-1" >
Alice @@ -210,10 +212,12 @@ exports[` should render 1`] = ` tabindex="-1" >
Alice @@ -352,10 +356,12 @@ exports[` should render 1`] = ` class="mx_MessageTimestamp" />
Alice diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap index cb9c51ad20..f78cf65ae3 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap @@ -214,10 +214,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` tabindex="-1" >
@userId:matrix.org @@ -353,10 +355,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` tabindex="-1" >
@userId:matrix.org @@ -495,10 +499,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` class="mx_MessageTimestamp" />
@userId:matrix.org diff --git a/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index f0903c5b16..115385f2e4 100644 --- a/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -57,7 +57,7 @@ exports[`HTMLExport should export 1`] = `

-
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx b/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx new file mode 100644 index 0000000000..270f3e66b4 --- /dev/null +++ b/test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx @@ -0,0 +1,158 @@ +/* + * 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 { DisambiguatedProfileViewModel } from "../../../src/viewmodels/profile/DisambiguatedProfileViewModel"; + +describe("DisambiguatedProfileViewModel", () => { + const member = { + userId: "@alice:example.org", + roomId: "!room:example.org", + rawDisplayName: "Alice", + disambiguate: true, + }; + const nonDisambiguatedMember = { + ...member, + disambiguate: false, + }; + + it("should return the snapshot from props", () => { + const vm = new DisambiguatedProfileViewModel({ + member, + fallbackName: "Fallback", + colored: true, + emphasizeDisplayName: true, + withTooltip: true, + }); + + expect(vm.getSnapshot()).toEqual({ + displayName: "Alice", + colorClass: "mx_Username_color3", + className: undefined, + displayIdentifier: "@alice:example.org", + title: "Alice (@alice:example.org)", + emphasizeDisplayName: true, + }); + }); + + it("should default member fields when member is null", () => { + const vm = new DisambiguatedProfileViewModel({ + member: null, + fallbackName: "Fallback", + }); + + expect(vm.getSnapshot()).toMatchObject({ + displayName: "Fallback", + colorClass: undefined, + className: undefined, + displayIdentifier: undefined, + title: undefined, + emphasizeDisplayName: undefined, + }); + }); + + it("should pass through className prop", () => { + const vm = new DisambiguatedProfileViewModel({ + member, + fallbackName: "Fallback", + className: "mx_DisambiguatedProfile", + }); + + expect(vm.getSnapshot().className).toBe("mx_DisambiguatedProfile"); + }); + + it("should delegate onClick without emitting a snapshot update", () => { + const onClick = jest.fn(); + const vm = new DisambiguatedProfileViewModel({ + member, + fallbackName: "Fallback", + onClick, + }); + const prevSnapshot = vm.getSnapshot(); + const subscriber = jest.fn(); + + vm.subscribe(subscriber); + onClick({} as never); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(subscriber).not.toHaveBeenCalled(); + expect(vm.getSnapshot()).toBe(prevSnapshot); + }); + + it("should emit snapshot update when fallbackName changes", () => { + const vm = new DisambiguatedProfileViewModel({ + member: null, + fallbackName: "Fallback", + }); + const subscriber = jest.fn(); + + vm.subscribe(subscriber); + vm.setMember("Updated"); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(vm.getSnapshot().displayName).toBe("Updated"); + }); + + it("should emit snapshot update when setMember is called even if fallbackName is unchanged", () => { + const vm = new DisambiguatedProfileViewModel({ + member: null, + fallbackName: "Fallback", + }); + const subscriber = jest.fn(); + + vm.subscribe(subscriber); + vm.setMember("Fallback"); + + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it("should compute tooltip title from constructor props when withTooltip is true", () => { + const vm = new DisambiguatedProfileViewModel({ + member, + fallbackName: "Fallback", + withTooltip: true, + }); + + expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)"); + }); + + it("should compute tooltip title even when disambiguation is not needed", () => { + const vm = new DisambiguatedProfileViewModel({ + member: nonDisambiguatedMember, + fallbackName: "Fallback", + withTooltip: true, + }); + + expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)"); + }); + + it("should emit snapshot update when member changes via setMember", () => { + const vm = new DisambiguatedProfileViewModel({ + member: null, + fallbackName: "Fallback", + }); + const subscriber = jest.fn(); + + vm.subscribe(subscriber); + vm.setMember("Fallback", member); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(vm.getSnapshot().displayName).toBe("Alice"); + }); + + it("should emit snapshot update when setMember is called with unchanged member", () => { + const vm = new DisambiguatedProfileViewModel({ + member, + fallbackName: "Fallback", + }); + const subscriber = jest.fn(); + + vm.subscribe(subscriber); + vm.setMember("Fallback", member); + + expect(subscriber).toHaveBeenCalledTimes(1); + }); +});