Refactor DisambiguatedProfile to shared-components (#31835)

* Refactoring of DisambiguatedProfile into shared components

* correct values and refactoring

* Add username color classes to Storybook and clean up DisambiguatedProfile stories

* Refactor DisambiguatedProfileView to use class component and enhance props structure

* Refactor DisambiguatedProfile components to use member object and enhance props structure

* Update copyright year to 2026 and adjust the tests to fit the correct memberinfro interface

* Add DisambiguatedProfileViewModel class

* Refactor DisambiguatedProfileViewModel to use member object and the rest of the props

* Refactor SenderProfile to use DisambiguatedProfileViewModel and update DisambiguatedProfile styles

* Refactor DisambiguatedProfileView to enhance  interface documentation

* Refactor DisambiguatedProfileView to use CSS modules for styling

* Updated css + tests to fit the new changes

* Update of the test snap to fit the current tests

* Adjusted RoomMemberTitleView and SenderProfile to use the new viewmodel, removed the old component.

* Implemented new viewmodel test for DisambiguatedProfileViewModel

* Update copyright text

* update css class names

* update to correct snapshot after css name changes.

* Apply suggestion from @florianduros

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Moved logic to viewmodel instead of having it in the view. Removed unessecery functions and css.

* removed unessecery file that I copied from root folder, this is no longed needed as I use the root file instead in the viewmodel

* Better Formatting

* Fix issues after merging develop

* FIxed issues with eslint

* Added Visible, non-interactive elements with click handlers must have at least one keyboard listener from eslint docs

* Updated snapshot the fit the latest update with eslint button requirment

* Update snapshot screens for new tests.

* Update tests to reflect snapshots

* Update snapshot due of outdated CSS module classes

* Add useEffect to call setProps on the DisambiguatedProfileViewModel
when props change, ensuring the view updates with the correct display
name. Update LayoutSwitcher snapshot for new CSS classes.

* Fix Playwright editing tests by adding exact match for Edit button selector
The DisambiguatedProfile refactoring added role="button" to the component,
causing the selector { name: "Edit" } to match both the user "Edith" and
the actual Edit button.

* Fix ForwardDialog location tests for async hook rendering The SenderProfile component now uses hooks that trigger async state updates.

* Fix SenderProfile useEffect to only update changeable props

* Added letter spacing

* Added ClassName prop

* Update snapshot

* Update letter-spacing

* Update snapshot screenshots

* Update Snapshots

* Update snapshot

* Removal of letter spacing to test CI

* Apply suggestion from @florianduros

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Added closing brackets + added back letter-spacing

* Update snapshots

* Update snapshot

* Update span to correctly apply to the CI tests, it wasn't possible to use classname as a prop

* Update snapshot

* Added comment to explain the span classNames

* DisambiguatedProfileViewModel.setProps to runtime-changing props

* replace DisambiguatedProfileViewModel setProps with explicit setters and update call sites

* Update Setters

* Prettier FIx

* Update Setters

* update DisambiguatedProfileViewModel setters and tests

* Update SenderProfile to show connect display name

* clone snapshot in setters to trigger reactive updates

* use snapshot.merge in DisambiguatedProfileViewModel setters

* emove duplicated logic in DisambiguatedProfileViewModel

* Change snapshot name

* Update viewmodel

* Updated Tests

* typo

* Update src/viewmodels/profile/DisambiguatedProfileViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Removal of unused function

* Update snapshots

* Update tests to pass coverage

* Update Eslint

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Zack
2026-02-11 15:31:06 +00:00
committed by GitHub
co-authored by Florian Duros
parent 7f31cf196f
commit 7e05552325
26 changed files with 983 additions and 113 deletions
@@ -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);
}
+1
View File
@@ -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";
@@ -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;
}
}
@@ -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 <DisambiguatedProfileView vm={vm} />;
};
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<typeof DisambiguatedProfileViewWrapper>;
const Template: StoryFn<typeof DisambiguatedProfileViewWrapper> = (args) => (
<DisambiguatedProfileViewWrapper {...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,
};
@@ -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(<Default />);
expect(container).toMatchSnapshot();
});
it("renders with MXID for disambiguation", () => {
const { container } = render(<WithMxid />);
expect(container).toMatchSnapshot();
});
it("renders with color class", () => {
const { container } = render(<WithColorClass />);
expect(container).toMatchSnapshot();
});
it("renders with emphasized display name", () => {
const { container } = render(<Emphasized />);
expect(container).toMatchSnapshot();
});
it("renders with tooltip", () => {
const { container } = render(<WithTooltip />);
expect(container).toMatchSnapshot();
});
it("renders the full example", () => {
const { container } = render(<FullExample />);
expect(container).toMatchSnapshot();
});
class DisambiguatedProfileViewModel
extends MockViewModel<DisambiguatedProfileViewSnapshot>
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(<DisambiguatedProfileView vm={vm} />);
expect(screen.getByText("Eve")).toBeInTheDocument();
});
it("should display the MXID when provided", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Test User",
displayIdentifier: "@test:example.org",
});
render(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
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(<DisambiguatedProfileView vm={vm} />);
const displayNameElement = screen.getByText("Emphasized User");
expect(displayNameElement).toHaveClass("mx_DisambiguatedProfile_displayName");
});
});
@@ -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<HTMLDivElement>;
}
/**
* The view model for DisambiguatedProfileView.
*/
export type DisambiguatedProfileViewModel = ViewModel<DisambiguatedProfileViewSnapshot> &
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
* <DisambiguatedProfileView vm={disambiguatedProfileViewModel} />
* ```
*/
export function DisambiguatedProfileView({ vm }: Readonly<DisambiguatedProfileViewProps>): 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<HTMLDivElement> | undefined = vm.onClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
vm.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}
}
: undefined;
return (
<div
className={classNames(className, styles.disambiguatedProfile)}
title={title}
onClick={vm.onClick}
onKeyDown={handleKeyDown}
role={vm.onClick ? "button" : undefined}
tabIndex={vm.onClick ? 0 : undefined}
>
<span className={displayNameClasses} dir="auto">
{displayName}
</span>
{/* mx_DisambiguatedProfile_mxid is required for PCSS selectors like .mx_MemberTileView .mx_DisambiguatedProfile_mxid */}
{displayIdentifier && (
<span className={classNames("mx_DisambiguatedProfile_mxid", styles.disambiguatedProfile_mxid)}>
{displayIdentifier}
</span>
)}
</div>
);
}
@@ -0,0 +1,115 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DisambiguatedProfileView > renders the default state 1`] = `
<div>
<div
class="disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders the full example 1`] = `
<div>
<div
class="disambiguatedProfile"
role="button"
tabindex="0"
title="Eve (@eve:matrix.org)"
>
<span
class="mx_Username_color5 disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Eve
</span>
<span
class="mx_DisambiguatedProfile_mxid disambiguatedProfile_mxid"
>
@eve:matrix.org
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with MXID for disambiguation 1`] = `
<div>
<div
class="disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="mx_Username_color1 disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
<span
class="mx_DisambiguatedProfile_mxid disambiguatedProfile_mxid"
>
@alice:example.org
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with color class 1`] = `
<div>
<div
class="disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="mx_Username_color3 disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Bob
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with emphasized display name 1`] = `
<div>
<div
class="disambiguatedProfile"
role="button"
tabindex="0"
>
<span
class="disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Charlie
</span>
</div>
</div>
`;
exports[`DisambiguatedProfileView > renders with tooltip 1`] = `
<div>
<div
class="disambiguatedProfile"
role="button"
tabindex="0"
title="Diana (@diana:example.org)"
>
<span
class="disambiguatedProfile_displayName mx_DisambiguatedProfile_displayName"
dir="auto"
>
Diana
</span>
</div>
</div>
`;
@@ -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";