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 16:31:06 +01:00
committed by GitHub
parent 7f31cf196f
commit 7e05552325
26 changed files with 983 additions and 113 deletions
@@ -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>
);
}
}
+23 -11
View File
@@ -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;
@@ -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);
}
}