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:
@@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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";
|
||||
@@ -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");
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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<IProps> {
|
||||
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 = <span className="mx_DisambiguatedProfile_mxid">{identifier}</span>;
|
||||
}
|
||||
title = _t("timeline|disambiguated_profile", {
|
||||
displayName: rawDisplayName,
|
||||
matrixId: identifier,
|
||||
});
|
||||
}
|
||||
|
||||
const displayNameClasses = classNames(colorClass, {
|
||||
mx_DisambiguatedProfile_displayName: emphasizeDisplayName,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_DisambiguatedProfile" title={withTooltip ? title : undefined} onClick={onClick}>
|
||||
<span className={displayNameClasses} dir="auto">
|
||||
{rawDisplayName}
|
||||
</span>
|
||||
{mxidElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ? (
|
||||
<DisambiguatedProfile
|
||||
fallbackName={mxEvent.getSender() ?? ""}
|
||||
onClick={onClick}
|
||||
member={member}
|
||||
colored={true}
|
||||
emphasizeDisplayName={true}
|
||||
withTooltip={withTooltip}
|
||||
/>
|
||||
<DisambiguatedProfileView vm={disambiguatedProfileVM} />
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
@@ -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 = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
|
||||
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new DisambiguatedProfileViewModel({
|
||||
fallbackName: name,
|
||||
member,
|
||||
withTooltip: true,
|
||||
className: "mx_DisambiguatedProfile",
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
disambiguatedProfileVM.setMember(name, member);
|
||||
}, [disambiguatedProfileVM, member, name]);
|
||||
const nameJSX = <DisambiguatedProfileView vm={disambiguatedProfileVM} />;
|
||||
|
||||
const presenceState = member.presenceState;
|
||||
let presenceJSX: JSX.Element | undefined;
|
||||
|
||||
147
src/viewmodels/profile/DisambiguatedProfileViewModel.ts
Normal file
147
src/viewmodels/profile/DisambiguatedProfileViewModel.ts
Normal file
@@ -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<DisambiguatedProfileViewSnapshot, DisambiguatedProfileViewModelProps>
|
||||
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<HTMLDivElement>): void {
|
||||
this.props.onClick?.(evt);
|
||||
}
|
||||
}
|
||||
@@ -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<number>(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);
|
||||
|
||||
|
||||
@@ -31,10 +31,12 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines
|
||||
u
|
||||
</span>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
|
||||
@@ -35,7 +35,9 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
||||
class="mx_MemberTileView_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
>
|
||||
<span
|
||||
@@ -109,7 +111,9 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
||||
class="mx_MemberTileView_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
>
|
||||
<span
|
||||
@@ -183,7 +187,9 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
||||
class="mx_MemberTileView_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -71,10 +71,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
Alice
|
||||
@@ -210,10 +212,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
Alice
|
||||
@@ -352,10 +356,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
class="mx_MessageTimestamp"
|
||||
/>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
Alice
|
||||
|
||||
@@ -214,10 +214,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
@@ -353,10 +355,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
@@ -495,10 +499,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
class="mx_MessageTimestamp"
|
||||
/>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
|
||||
File diff suppressed because one or more lines are too long
158
test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx
Normal file
158
test/viewmodels/profile/DisambiguatedProfileViewModel-test.tsx
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user