diff --git a/apps/web/src/components/views/elements/EventTilePreview.tsx b/apps/web/src/components/views/elements/EventTilePreview.tsx index 79c482d278..f21cdfc53a 100644 --- a/apps/web/src/components/views/elements/EventTilePreview.tsx +++ b/apps/web/src/components/views/elements/EventTilePreview.tsx @@ -117,7 +117,14 @@ export default class EventTilePreview extends React.Component { return (
- +
); } diff --git a/apps/web/src/components/views/messages/SenderProfile.tsx b/apps/web/src/components/views/messages/SenderProfile.tsx index 70ab205cc1..8bea932891 100644 --- a/apps/web/src/components/views/messages/SenderProfile.tsx +++ b/apps/web/src/components/views/messages/SenderProfile.tsx @@ -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 ? ( - - ) : ( + disambiguatedProfileVM.setMember(senderId ?? "", member); + }, [disambiguatedProfileVM, member, senderId]); + + return isEmote ? ( <> + ) : ( + ); } diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 3aca86c382..11466bbb75 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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 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 const avatar = ; const sender = ( ); diff --git a/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileAvatarAdapter.tsx similarity index 54% rename from apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx rename to apps/web/src/components/views/rooms/EventTile/EventTileAvatarAdapter.tsx index fbbd7cf29d..1805da4365 100644 --- a/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/EventTileAvatarAdapter.tsx @@ -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({ ); } - -/** - * 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): JSX.Element | null { - switch (senderSnapshot.profileMode) { - case "clickable": - return ; - case "tooltip": - return ; - case "default": - return ; - default: - return null; - } -} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileSenderAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileSenderAdapter.tsx new file mode 100644 index 0000000000..9a4ecf1b9a --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileSenderAdapter.tsx @@ -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): JSX.Element | null { + switch (sender.profileMode) { + case "clickable": + return ( + + ); + case "tooltip": + return ( + + ); + case "default": + return ; + default: + return null; + } +} diff --git a/apps/web/src/components/views/rooms/ReplyTile.tsx b/apps/web/src/components/views/rooms/ReplyTile.tsx index 69bd2a4104..2a129a02eb 100644 --- a/apps/web/src/components/views/rooms/ReplyTile.tsx +++ b/apps/web/src/components/views/rooms/ReplyTile.tsx @@ -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 { sender = (
- +
); } diff --git a/apps/web/src/hooks/room/useRoomMemberProfile.ts b/apps/web/src/hooks/room/useRoomMemberProfile.ts index 5b1b4ae125..037ef582d9 100644 --- a/apps/web/src/hooks/room/useRoomMemberProfile.ts +++ b/apps/web/src/hooks/room/useRoomMemberProfile.ts @@ -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 | 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; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts index 76a9939087..5448d2a909 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts @@ -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. */ diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 271c952ab1..8128beb3f3 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -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 { }); 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(() => { diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTileAvatarAdapter-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTileAvatarAdapter-test.tsx new file mode 100644 index 0000000000..10d4c92336 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/rooms/EventTileAvatarAdapter-test.tsx @@ -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(() =>
), +); + +function makeSenderSnapshot(overrides: Partial = {}): 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( + , + ); + + 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( + , + ); + + expect(container.querySelector(".mx_EventTile_avatar")).toBeNull(); + expect(mockedMemberAvatar).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTileSenderAdapter-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTileSenderAdapter-test.tsx new file mode 100644 index 0000000000..1ee8fb6a9c --- /dev/null +++ b/apps/web/test/unit-tests/components/views/rooms/EventTileSenderAdapter-test.tsx @@ -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(() =>
), +); + +function makeSenderSnapshot(overrides: Partial = {}): 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(); + + 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(); + + 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( + , + ); + + expect(container).toBeEmptyDOMElement(); + expect(mockedSenderProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/rooms/ReplyTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/ReplyTile-test.tsx index 43d5912987..d10e20ff2a 100644 --- a/apps/web/test/unit-tests/components/views/rooms/ReplyTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/ReplyTile-test.tsx @@ -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, diff --git a/apps/web/test/unit-tests/hooks/room/useRoomMemberProfile-test.ts b/apps/web/test/unit-tests/hooks/room/useRoomMemberProfile-test.ts new file mode 100644 index 0000000000..3e8eb52f06 --- /dev/null +++ b/apps/web/test/unit-tests/hooks/room/useRoomMemberProfile-test.ts @@ -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(); + }); +}); diff --git a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts index 37e5dcc665..32a26d9938 100644 --- a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts +++ b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts @@ -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({