* Add pure sender render inputs to EventTileViewModel * Move sender/avatar normalization to EventTile * Split sender and avatar adapters into focused leaf components * Add tests for the sender and avatar adapters * Fix sonar issues * Make sender profile consume pure member info * Collapse sender adapter onto snapshot-driven props * Share current-member resolution for event and reply tiles * Fix Prettier issues * Keep settings preview on historical sender profiles * Update the proprty name to reflect the use of the event-time sender data instead of resolving the current room member * Fix Sonar issue * Tighten member to MemberInfo | null in the view model
This commit is contained in:
@@ -117,7 +117,14 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
|
||||
|
||||
return (
|
||||
<div className={className} role="presentation">
|
||||
<EventTile mxEvent={event} layout={this.props.layout} as="div" hideTimestamp inhibitInteraction />
|
||||
<EventTile
|
||||
mxEvent={event}
|
||||
layout={this.props.layout}
|
||||
as="div"
|
||||
hideTimestamp
|
||||
inhibitInteraction
|
||||
useEventSenderSnapshot
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,32 +8,42 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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 { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
||||
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
|
||||
import {
|
||||
DisambiguatedProfileViewModel,
|
||||
type MemberInfo,
|
||||
} from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
||||
import { useUserStatus } from "../../../hooks/useUserStatus";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
/** Stable sender ID for the profile. */
|
||||
senderId?: string;
|
||||
/** Plain member data used to resolve display name, identifier, and tooltip state. */
|
||||
member?: MemberInfo | null;
|
||||
/** Whether the message body renders as an emote. */
|
||||
isEmote: boolean;
|
||||
/** Invoked when the profile is clicked. */
|
||||
onClick?(this: void): void;
|
||||
/** Whether to show the disambiguation tooltip. */
|
||||
withTooltip?: boolean;
|
||||
}
|
||||
|
||||
export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps): JSX.Element {
|
||||
const sender = mxEvent.getSender();
|
||||
|
||||
const member = useRoomMemberProfile({
|
||||
userId: sender,
|
||||
member: mxEvent.sender,
|
||||
});
|
||||
const userStatus = useUserStatus(sender);
|
||||
/**
|
||||
* Renders the sender identity for message and timeline views from pure render data.
|
||||
*/
|
||||
export default function SenderProfile(props: IProps): JSX.Element {
|
||||
const senderId = props.senderId ?? props.member?.userId;
|
||||
const member = props.member;
|
||||
const userStatus = useUserStatus(senderId);
|
||||
const isEmote = props.isEmote;
|
||||
const onClick = props.onClick;
|
||||
const withTooltip = props.withTooltip;
|
||||
|
||||
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new DisambiguatedProfileViewModel({
|
||||
fallbackName: sender ?? "",
|
||||
fallbackName: senderId ?? "",
|
||||
onClick,
|
||||
member,
|
||||
colored: true,
|
||||
@@ -47,11 +57,12 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
|
||||
disambiguatedProfileVM.setUserStatus(userStatus);
|
||||
}, [disambiguatedProfileVM, userStatus]);
|
||||
useEffect(() => {
|
||||
disambiguatedProfileVM.setMember(sender ?? "", member);
|
||||
}, [disambiguatedProfileVM, member, sender]);
|
||||
return mxEvent.getContent().msgtype !== MsgType.Emote ? (
|
||||
<DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />
|
||||
) : (
|
||||
disambiguatedProfileVM.setMember(senderId ?? "", member);
|
||||
}, [disambiguatedProfileVM, member, senderId]);
|
||||
|
||||
return isEmote ? (
|
||||
<></>
|
||||
) : (
|
||||
<DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import React, {
|
||||
import {
|
||||
EventStatus,
|
||||
EventType,
|
||||
MsgType,
|
||||
type MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
type Relations,
|
||||
@@ -36,6 +37,7 @@ import ReplyChain from "../elements/ReplyChain";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
||||
@@ -68,8 +70,9 @@ import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
|
||||
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
|
||||
import { EventTileAvatarAdapter } from "./EventTile/EventTileAvatarAdapter";
|
||||
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
|
||||
import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter";
|
||||
import { EventTileSenderAdapter } from "./EventTile/EventTileSenderAdapter";
|
||||
import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter";
|
||||
import { EventTileFooter } from "./EventTile/EventTileFooter";
|
||||
import { EventTilePreviewBody } from "./EventTile/EventTilePreviewBody";
|
||||
@@ -109,6 +112,7 @@ import {
|
||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { resolveRoomMemberProfile, roomMemberToMemberInfo } from "../../../hooks/room/useRoomMemberProfile";
|
||||
import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
||||
import { shouldHighlightEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileHighlightState";
|
||||
import { shouldHideEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileVisibilityState";
|
||||
@@ -251,6 +255,9 @@ export interface EventTileProps {
|
||||
/** Whether the current user can see a message hidden from other users for moderation. */
|
||||
isSeeingThroughMessageHiddenForModeration?: boolean;
|
||||
|
||||
/** Whether sender profile rendering should use the event-time member snapshot. */
|
||||
useEventSenderSnapshot?: boolean;
|
||||
|
||||
/** Whether the timestamp should be hidden for preview rendering. */
|
||||
hideTimestamp?: boolean;
|
||||
/** Whether interactive controls inside the tile should be inhibited. */
|
||||
@@ -916,7 +923,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
inhibitInteraction: this.props.inhibitInteraction,
|
||||
},
|
||||
sender: {
|
||||
senderId: this.props.mxEvent.getSender() ?? undefined,
|
||||
member: roomMemberToMemberInfo(
|
||||
this.props.useEventSenderSnapshot
|
||||
? this.getAvatarMember()
|
||||
: resolveRoomMemberProfile({
|
||||
room: MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId() ?? ""),
|
||||
userId: this.props.mxEvent.getSender() ?? undefined,
|
||||
member: this.getAvatarMember(),
|
||||
useOnlyCurrentProfiles: SettingsStore.getValue("useOnlyCurrentProfiles"),
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
}),
|
||||
),
|
||||
hideSender: this.props.hideSender,
|
||||
isEmote: this.props.mxEvent.getContent().msgtype === MsgType.Emote,
|
||||
},
|
||||
timestamp: {
|
||||
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
|
||||
@@ -1001,8 +1021,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
const avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />;
|
||||
const sender = (
|
||||
<EventTileSenderAdapter
|
||||
mxEvent={this.props.mxEvent}
|
||||
senderSnapshot={eventTileSnapshot.sender}
|
||||
sender={eventTileSnapshot.sender}
|
||||
onSenderProfileClick={this.onSenderProfileClick}
|
||||
/>
|
||||
);
|
||||
|
||||
+1
-34
@@ -6,10 +6,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MemberAvatar from "../../avatars/MemberAvatar";
|
||||
import SenderProfile from "../../messages/SenderProfile";
|
||||
import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||
|
||||
/**
|
||||
@@ -46,35 +45,3 @@ export function EventTileAvatarAdapter({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the {@link EventTileSenderAdapter} component.
|
||||
*/
|
||||
interface EventTileSenderAdapterProps {
|
||||
/** Matrix event whose sender identity is being rendered. */
|
||||
mxEvent: MatrixEvent;
|
||||
/** Snapshot of the sender identity state for this tile. */
|
||||
senderSnapshot: EventTileSenderSnapshot;
|
||||
/** Invoked when the sender profile is clicked. */
|
||||
onSenderProfileClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sender identity display for an event tile.
|
||||
*/
|
||||
export function EventTileSenderAdapter({
|
||||
mxEvent,
|
||||
senderSnapshot,
|
||||
onSenderProfileClick,
|
||||
}: Readonly<EventTileSenderAdapterProps>): JSX.Element | null {
|
||||
switch (senderSnapshot.profileMode) {
|
||||
case "clickable":
|
||||
return <SenderProfile onClick={onSenderProfileClick} mxEvent={mxEvent} />;
|
||||
case "tooltip":
|
||||
return <SenderProfile mxEvent={mxEvent} withTooltip />;
|
||||
case "default":
|
||||
return <SenderProfile mxEvent={mxEvent} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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 SenderProfile from "../../messages/SenderProfile";
|
||||
import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||
|
||||
/**
|
||||
* Props for the {@link EventTileSenderAdapter} component.
|
||||
*/
|
||||
interface EventTileSenderAdapterProps {
|
||||
/** The sender identity state for this tile. */
|
||||
sender: EventTileSenderSnapshot;
|
||||
/** Invoked when the sender profile is clicked. */
|
||||
onSenderProfileClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sender identity display for an event tile.
|
||||
*/
|
||||
export function EventTileSenderAdapter({
|
||||
sender,
|
||||
onSenderProfileClick,
|
||||
}: Readonly<EventTileSenderAdapterProps>): JSX.Element | null {
|
||||
switch (sender.profileMode) {
|
||||
case "clickable":
|
||||
return (
|
||||
<SenderProfile
|
||||
onClick={onSenderProfileClick}
|
||||
senderId={sender.senderId}
|
||||
member={sender.member}
|
||||
isEmote={sender.isEmote}
|
||||
/>
|
||||
);
|
||||
case "tooltip":
|
||||
return (
|
||||
<SenderProfile senderId={sender.senderId} member={sender.member} isEmote={sender.isEmote} withTooltip />
|
||||
);
|
||||
case "default":
|
||||
return <SenderProfile senderId={sender.senderId} member={sender.member} isEmote={sender.isEmote} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { type GetRelationsForEvent } from "../rooms/EventTile";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { type IBodyProps } from "../messages/IBodyProps";
|
||||
import { FileBodyFactory, VideoBodyFactory, renderMBody } from "../messages/MBodyFactory";
|
||||
import { roomMemberToMemberInfo } from "../../../hooks/room/useRoomMemberProfile";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -125,7 +126,11 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
sender = (
|
||||
<div className="mx_ReplyTile_sender">
|
||||
<MemberAvatar member={mxEvent.sender} fallbackUserId={mxEvent.getSender()} size="16px" />
|
||||
<SenderProfile mxEvent={mxEvent} />
|
||||
<SenderProfile
|
||||
senderId={mxEvent.getSender() ?? undefined}
|
||||
member={roomMemberToMemberInfo(mxEvent.sender)}
|
||||
isEmote={msgType === MsgType.Emote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,65 @@ 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 { type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import { useSettingValue } from "../useSettings";
|
||||
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx";
|
||||
import { type MemberInfo } from "../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
||||
|
||||
/** Inputs for resolving the room member that should be rendered. */
|
||||
export interface RoomMemberProfileResolutionProps {
|
||||
/** Room used to look up the current member, if needed. */
|
||||
room?: Pick<Room, "getMember"> | null;
|
||||
/** Matrix user ID to resolve. */
|
||||
userId?: string;
|
||||
/** Historical member snapshot from the event, if available. */
|
||||
member?: RoomMember | null;
|
||||
/** Whether historical profiles should always be preserved. */
|
||||
forceHistorical?: boolean;
|
||||
/** Whether the current profile should be preferred. */
|
||||
useOnlyCurrentProfiles?: boolean;
|
||||
/** Timeline context used to force current profiles for thread surfaces. */
|
||||
timelineRenderingType?: TimelineRenderingType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the room member that should drive profile rendering.
|
||||
*/
|
||||
export function resolveRoomMemberProfile({
|
||||
room,
|
||||
userId,
|
||||
member: propMember,
|
||||
forceHistorical = false,
|
||||
useOnlyCurrentProfiles = false,
|
||||
timelineRenderingType = TimelineRenderingType.Room,
|
||||
}: RoomMemberProfileResolutionProps): RoomMember | undefined | null {
|
||||
const threadContexts = [TimelineRenderingType.ThreadsList, TimelineRenderingType.Thread];
|
||||
if ((!forceHistorical && useOnlyCurrentProfiles) || threadContexts.includes(timelineRenderingType)) {
|
||||
const currentMember = room?.getMember(userId ?? "");
|
||||
if (currentMember) return currentMember;
|
||||
}
|
||||
|
||||
return propMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a room member into the plain render data used by shared profile components.
|
||||
*/
|
||||
export function roomMemberToMemberInfo(member: RoomMember | undefined | null): MemberInfo | null {
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: member.userId,
|
||||
roomId: member.roomId,
|
||||
rawDisplayName: member.rawDisplayName,
|
||||
disambiguate: member.disambiguate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRoomMemberProfile({
|
||||
userId = "",
|
||||
@@ -26,13 +79,14 @@ export function useRoomMemberProfile({
|
||||
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
||||
|
||||
const member = useMemo(() => {
|
||||
const threadContexts = [TimelineRenderingType.ThreadsList, TimelineRenderingType.Thread];
|
||||
if ((!forceHistorical && useOnlyCurrentProfiles) || threadContexts.includes(context.timelineRenderingType)) {
|
||||
const currentMember = context.room?.getMember(userId);
|
||||
if (currentMember) return currentMember;
|
||||
}
|
||||
|
||||
return propMember;
|
||||
return resolveRoomMemberProfile({
|
||||
room: context.room,
|
||||
userId,
|
||||
member: propMember,
|
||||
forceHistorical,
|
||||
useOnlyCurrentProfiles,
|
||||
timelineRenderingType: context.timelineRenderingType,
|
||||
});
|
||||
}, [forceHistorical, propMember, context.room, context.timelineRenderingType, useOnlyCurrentProfiles, userId]);
|
||||
|
||||
return member;
|
||||
|
||||
@@ -20,7 +20,7 @@ import UserIdentifier from "../../../../customisations/UserIdentifier";
|
||||
/**
|
||||
* Information about a member for disambiguation purposes.
|
||||
*/
|
||||
interface MemberInfo {
|
||||
export interface MemberInfo {
|
||||
/**
|
||||
* The user's Matrix ID.
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type SenderProfileMode,
|
||||
type TimestampDisplayState,
|
||||
} from "./EventTileDerivedState";
|
||||
import { type MemberInfo } from "./DisambiguatedProfileViewModel";
|
||||
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||
import { type Layout } from "../../../../settings/enums/Layout";
|
||||
import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel";
|
||||
@@ -130,8 +131,14 @@ export interface EventTileInteractionInput {
|
||||
|
||||
/** Sender inputs for deriving the EventTile snapshot. */
|
||||
export interface EventTileSenderInput {
|
||||
/** The Matrix sender ID, when available. */
|
||||
senderId?: string;
|
||||
/** Plain member info for sender/profile rendering. */
|
||||
member: MemberInfo | null;
|
||||
/** Whether sender details should be hidden. */
|
||||
hideSender?: boolean;
|
||||
/** Whether the event body renders as an emote. */
|
||||
isEmote: boolean;
|
||||
}
|
||||
|
||||
/** Timestamp inputs for deriving the EventTile snapshot. */
|
||||
@@ -206,6 +213,10 @@ export interface EventTileLineSnapshot {
|
||||
|
||||
/** Sender state derived for the EventTile snapshot. */
|
||||
export interface EventTileSenderSnapshot {
|
||||
/** The Matrix sender ID, when available. */
|
||||
senderId?: string;
|
||||
/** Plain member info for sender/profile rendering. */
|
||||
member: MemberInfo | null;
|
||||
/** EventTile avatar and sender profile display state. */
|
||||
profileState: EventTileSenderProfileState;
|
||||
/** Whether clicking the avatar should open the user profile. */
|
||||
@@ -214,6 +225,8 @@ export interface EventTileSenderSnapshot {
|
||||
profileMode: SenderProfileMode;
|
||||
/** Whether the avatar should use historical room member details. */
|
||||
forceHistoricalAvatar: boolean;
|
||||
/** Whether the event body renders as an emote. */
|
||||
isEmote: boolean;
|
||||
}
|
||||
|
||||
/** Action bar state derived for the EventTile snapshot. */
|
||||
@@ -539,6 +552,8 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
||||
}),
|
||||
},
|
||||
sender: {
|
||||
senderId: sender.senderId,
|
||||
member: sender.member ?? null,
|
||||
profileState: senderProfileState,
|
||||
viewUserOnClick: getShouldViewUserOnClick(
|
||||
interaction.inhibitInteraction,
|
||||
@@ -550,6 +565,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
||||
timelineRenderingType: display.timelineRenderingType,
|
||||
}),
|
||||
forceHistoricalAvatar: event.eventType === "m.room.member",
|
||||
isEmote: sender.isEmote,
|
||||
},
|
||||
actionBar: {
|
||||
show: getShouldShowMessageActionBar({
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
RelationType,
|
||||
type Relations,
|
||||
Room,
|
||||
RoomMember,
|
||||
TweakName,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
@@ -518,7 +519,7 @@ describe("EventTile", () => {
|
||||
});
|
||||
|
||||
describe("sender and avatar rendering", () => {
|
||||
it("shows sender and avatar in room timelines", () => {
|
||||
it("keeps the sender/avatar composition in room timelines", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
|
||||
@@ -1060,6 +1061,25 @@ describe("EventTile", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the current room member when current profiles are enabled", async () => {
|
||||
const senderId = mxEvent.getSender()!;
|
||||
const currentMember = new RoomMember(room.roomId, senderId);
|
||||
currentMember.rawDisplayName = "Alan (away)";
|
||||
|
||||
jest.spyOn(room, "getMember").mockImplementation((userId) => (userId === senderId ? currentMember : null));
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(settingName) => settingName === "useOnlyCurrentProfiles",
|
||||
);
|
||||
|
||||
const { container } = getComponent();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile_displayName")).toHaveTextContent(
|
||||
"Alan (away)",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the tile error fallback when tile rendering throws", async () => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
jest.spyOn(EventTileFactory, "renderTile").mockImplementation(() => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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 from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MemberAvatar from "../../../../../src/components/views/avatars/MemberAvatar";
|
||||
import { EventTileAvatarAdapter } from "../../../../../src/components/views/rooms/EventTile/EventTileAvatarAdapter";
|
||||
import { type EventTileSenderSnapshot } from "../../../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||
|
||||
jest.mock("../../../../../src/components/views/avatars/MemberAvatar", () =>
|
||||
jest.fn(() => <div data-testid="member-avatar" />),
|
||||
);
|
||||
|
||||
function makeSenderSnapshot(overrides: Partial<EventTileSenderSnapshot> = {}): EventTileSenderSnapshot {
|
||||
return {
|
||||
senderId: "@alice:example.org",
|
||||
member: null,
|
||||
viewUserOnClick: true,
|
||||
profileMode: "clickable",
|
||||
forceHistoricalAvatar: false,
|
||||
isEmote: false,
|
||||
...overrides,
|
||||
profileState: {
|
||||
avatarSize: "30px",
|
||||
needsSenderProfile: true,
|
||||
...overrides.profileState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("EventTileAvatarAdapter", () => {
|
||||
const mockedMemberAvatar = mocked(MemberAvatar);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes render data through to MemberAvatar", () => {
|
||||
const avatarMember = new RoomMember("!room:example.org", "@alice:example.org");
|
||||
const senderSnapshot = makeSenderSnapshot({
|
||||
profileState: {
|
||||
avatarSize: "24px",
|
||||
needsSenderProfile: true,
|
||||
},
|
||||
viewUserOnClick: false,
|
||||
forceHistoricalAvatar: true,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={senderSnapshot} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
expect(mockedMemberAvatar.mock.calls[0][0]).toMatchObject({
|
||||
member: avatarMember,
|
||||
size: "24px",
|
||||
viewUserOnClick: false,
|
||||
forceHistorical: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when the avatar is not available", () => {
|
||||
const { container } = render(
|
||||
<EventTileAvatarAdapter avatarMember={null} senderSnapshot={makeSenderSnapshot()} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).toBeNull();
|
||||
expect(mockedMemberAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import SenderProfile from "../../../../../src/components/views/messages/SenderProfile";
|
||||
import { EventTileSenderAdapter } from "../../../../../src/components/views/rooms/EventTile/EventTileSenderAdapter";
|
||||
import { type MemberInfo } from "../../../../../src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
||||
import { type EventTileSenderSnapshot } from "../../../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/SenderProfile", () =>
|
||||
jest.fn(() => <div data-testid="sender-profile" />),
|
||||
);
|
||||
|
||||
function makeSenderSnapshot(overrides: Partial<EventTileSenderSnapshot> = {}): EventTileSenderSnapshot {
|
||||
return {
|
||||
senderId: "@alice:example.org",
|
||||
member: {
|
||||
userId: "@alice:example.org",
|
||||
roomId: "!room:example.org",
|
||||
rawDisplayName: "Alice",
|
||||
disambiguate: false,
|
||||
},
|
||||
viewUserOnClick: true,
|
||||
profileMode: "clickable",
|
||||
forceHistoricalAvatar: false,
|
||||
isEmote: false,
|
||||
...overrides,
|
||||
profileState: {
|
||||
avatarSize: "30px",
|
||||
needsSenderProfile: true,
|
||||
...overrides.profileState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("EventTileSenderAdapter", () => {
|
||||
const mockedSenderProfile = mocked(SenderProfile);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes clickable sender state to SenderProfile", () => {
|
||||
const onSenderProfileClick = jest.fn();
|
||||
const senderSnapshot = makeSenderSnapshot({ profileMode: "clickable" });
|
||||
const memberInfo: MemberInfo = senderSnapshot.member!;
|
||||
|
||||
render(<EventTileSenderAdapter sender={senderSnapshot} onSenderProfileClick={onSenderProfileClick} />);
|
||||
|
||||
expect(screen.getByTestId("sender-profile")).toBeInTheDocument();
|
||||
expect(mockedSenderProfile.mock.calls[0][0]).toMatchObject({
|
||||
senderId: "@alice:example.org",
|
||||
member: memberInfo,
|
||||
isEmote: false,
|
||||
onClick: onSenderProfileClick,
|
||||
});
|
||||
});
|
||||
|
||||
it("enables tooltip rendering for tooltip mode", () => {
|
||||
const senderSnapshot = makeSenderSnapshot({ profileMode: "tooltip" });
|
||||
|
||||
render(<EventTileSenderAdapter sender={senderSnapshot} onSenderProfileClick={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("sender-profile")).toBeInTheDocument();
|
||||
expect(mockedSenderProfile.mock.calls[0][0]).toMatchObject({
|
||||
senderId: "@alice:example.org",
|
||||
isEmote: false,
|
||||
withTooltip: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns nothing when the sender should be hidden", () => {
|
||||
const senderSnapshot = makeSenderSnapshot({ profileMode: "hidden" });
|
||||
|
||||
const { container } = render(
|
||||
<EventTileSenderAdapter sender={senderSnapshot} onSenderProfileClick={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockedSenderProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,10 @@ describe("ReplyTile", () => {
|
||||
jest.mocked(renderReplyTile).mockClear().mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders video replies with the video body", () => {
|
||||
const mxEvent = mkEvent({
|
||||
event: true,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
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 { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { resolveRoomMemberProfile, roomMemberToMemberInfo } from "../../../../src/hooks/room/useRoomMemberProfile";
|
||||
import { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
||||
|
||||
describe("resolveRoomMemberProfile", () => {
|
||||
it("prefers the current room member when current profiles are enabled", () => {
|
||||
const roomId = "!room:server";
|
||||
const userId = "@alice:server";
|
||||
const currentMember = new RoomMember(roomId, userId);
|
||||
currentMember.rawDisplayName = "Alan (away)";
|
||||
currentMember.disambiguate = false;
|
||||
const historicalMember = new RoomMember(roomId, userId);
|
||||
historicalMember.rawDisplayName = "Alan";
|
||||
historicalMember.disambiguate = false;
|
||||
const room = {
|
||||
getMember: jest.fn().mockReturnValue(currentMember),
|
||||
};
|
||||
|
||||
const resolved = resolveRoomMemberProfile({
|
||||
room,
|
||||
userId,
|
||||
member: historicalMember,
|
||||
useOnlyCurrentProfiles: true,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(currentMember);
|
||||
});
|
||||
|
||||
it("falls back to the historical member when current profiles are disabled", () => {
|
||||
const roomId = "!room:server";
|
||||
const userId = "@alice:server";
|
||||
const historicalMember = new RoomMember(roomId, userId);
|
||||
historicalMember.rawDisplayName = "Alan";
|
||||
historicalMember.disambiguate = false;
|
||||
|
||||
const resolved = resolveRoomMemberProfile({
|
||||
room: {
|
||||
getMember: jest.fn(),
|
||||
},
|
||||
userId,
|
||||
member: historicalMember,
|
||||
useOnlyCurrentProfiles: false,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(historicalMember);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomMemberToMemberInfo", () => {
|
||||
it("converts a room member into plain render data", () => {
|
||||
const member = new RoomMember("!room:server", "@alice:server");
|
||||
member.rawDisplayName = "Alan";
|
||||
member.disambiguate = true;
|
||||
|
||||
expect(roomMemberToMemberInfo(member)).toEqual({
|
||||
userId: "@alice:server",
|
||||
roomId: "!room:server",
|
||||
rawDisplayName: "Alan",
|
||||
disambiguate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for missing members", () => {
|
||||
expect(roomMemberToMemberInfo(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,9 @@ describe("EventTileViewModel", () => {
|
||||
...overrides.interaction,
|
||||
},
|
||||
sender: {
|
||||
senderId: "@alice:example.org",
|
||||
member: null,
|
||||
isEmote: false,
|
||||
...overrides.sender,
|
||||
},
|
||||
timestamp: {
|
||||
@@ -307,6 +310,20 @@ describe("EventTileViewModel", () => {
|
||||
expect(snapshot.sender.viewUserOnClick).toBe(true);
|
||||
});
|
||||
|
||||
it("carries pure sender render inputs", () => {
|
||||
const snapshot = EventTileViewModel.createSnapshot(
|
||||
makeProps({
|
||||
sender: {
|
||||
senderId: "@moderator:example.org",
|
||||
isEmote: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot.sender.senderId).toBe("@moderator:example.org");
|
||||
expect(snapshot.sender.isEmote).toBe(true);
|
||||
});
|
||||
|
||||
it("marks room member avatars as historical", () => {
|
||||
const snapshot = EventTileViewModel.createSnapshot(
|
||||
makeProps({
|
||||
|
||||
Reference in New Issue
Block a user