Move EventTile to shared components - #2 (#33828)

* 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:
rbondesson
2026-07-27 09:26:06 +00:00
committed by GitHub
parent c82bddf1b0
commit 4decd338fb
15 changed files with 477 additions and 67 deletions
@@ -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}
/>
);
@@ -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({