* 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 (
|
return (
|
||||||
<div className={className} role="presentation">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,32 +8,42 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { type JSX, useEffect } 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 { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
import {
|
||||||
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
|
DisambiguatedProfileViewModel,
|
||||||
|
type MemberInfo,
|
||||||
|
} from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
||||||
import { useUserStatus } from "../../../hooks/useUserStatus";
|
import { useUserStatus } from "../../../hooks/useUserStatus";
|
||||||
|
|
||||||
interface IProps {
|
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;
|
onClick?(this: void): void;
|
||||||
|
/** Whether to show the disambiguation tooltip. */
|
||||||
withTooltip?: boolean;
|
withTooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps): JSX.Element {
|
/**
|
||||||
const sender = mxEvent.getSender();
|
* Renders the sender identity for message and timeline views from pure render data.
|
||||||
|
*/
|
||||||
const member = useRoomMemberProfile({
|
export default function SenderProfile(props: IProps): JSX.Element {
|
||||||
userId: sender,
|
const senderId = props.senderId ?? props.member?.userId;
|
||||||
member: mxEvent.sender,
|
const member = props.member;
|
||||||
});
|
const userStatus = useUserStatus(senderId);
|
||||||
const userStatus = useUserStatus(sender);
|
const isEmote = props.isEmote;
|
||||||
|
const onClick = props.onClick;
|
||||||
|
const withTooltip = props.withTooltip;
|
||||||
|
|
||||||
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
|
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
|
||||||
() =>
|
() =>
|
||||||
new DisambiguatedProfileViewModel({
|
new DisambiguatedProfileViewModel({
|
||||||
fallbackName: sender ?? "",
|
fallbackName: senderId ?? "",
|
||||||
onClick,
|
onClick,
|
||||||
member,
|
member,
|
||||||
colored: true,
|
colored: true,
|
||||||
@@ -47,11 +57,12 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
|
|||||||
disambiguatedProfileVM.setUserStatus(userStatus);
|
disambiguatedProfileVM.setUserStatus(userStatus);
|
||||||
}, [disambiguatedProfileVM, userStatus]);
|
}, [disambiguatedProfileVM, userStatus]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
disambiguatedProfileVM.setMember(sender ?? "", member);
|
disambiguatedProfileVM.setMember(senderId ?? "", member);
|
||||||
}, [disambiguatedProfileVM, member, sender]);
|
}, [disambiguatedProfileVM, member, senderId]);
|
||||||
return mxEvent.getContent().msgtype !== MsgType.Emote ? (
|
|
||||||
<DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />
|
return isEmote ? (
|
||||||
) : (
|
|
||||||
<></>
|
<></>
|
||||||
|
) : (
|
||||||
|
<DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import React, {
|
|||||||
import {
|
import {
|
||||||
EventStatus,
|
EventStatus,
|
||||||
EventType,
|
EventType,
|
||||||
|
MsgType,
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
type Relations,
|
type Relations,
|
||||||
@@ -36,6 +37,7 @@ import ReplyChain from "../elements/ReplyChain";
|
|||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import { Layout } from "../../../settings/enums/Layout";
|
import { Layout } from "../../../settings/enums/Layout";
|
||||||
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import RoomAvatar from "../avatars/RoomAvatar";
|
import RoomAvatar from "../avatars/RoomAvatar";
|
||||||
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
||||||
@@ -68,8 +70,9 @@ import PinningUtils from "../../../utils/PinningUtils";
|
|||||||
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
|
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
|
||||||
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||||
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
|
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
|
||||||
|
import { EventTileAvatarAdapter } from "./EventTile/EventTileAvatarAdapter";
|
||||||
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
|
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
|
||||||
import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter";
|
import { EventTileSenderAdapter } from "./EventTile/EventTileSenderAdapter";
|
||||||
import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter";
|
import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter";
|
||||||
import { EventTileFooter } from "./EventTile/EventTileFooter";
|
import { EventTileFooter } from "./EventTile/EventTileFooter";
|
||||||
import { EventTilePreviewBody } from "./EventTile/EventTilePreviewBody";
|
import { EventTilePreviewBody } from "./EventTile/EventTilePreviewBody";
|
||||||
@@ -109,6 +112,7 @@ import {
|
|||||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||||
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
|
import { resolveRoomMemberProfile, roomMemberToMemberInfo } from "../../../hooks/room/useRoomMemberProfile";
|
||||||
import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
||||||
import { shouldHighlightEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileHighlightState";
|
import { shouldHighlightEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileHighlightState";
|
||||||
import { shouldHideEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileVisibilityState";
|
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. */
|
/** Whether the current user can see a message hidden from other users for moderation. */
|
||||||
isSeeingThroughMessageHiddenForModeration?: boolean;
|
isSeeingThroughMessageHiddenForModeration?: boolean;
|
||||||
|
|
||||||
|
/** Whether sender profile rendering should use the event-time member snapshot. */
|
||||||
|
useEventSenderSnapshot?: boolean;
|
||||||
|
|
||||||
/** Whether the timestamp should be hidden for preview rendering. */
|
/** Whether the timestamp should be hidden for preview rendering. */
|
||||||
hideTimestamp?: boolean;
|
hideTimestamp?: boolean;
|
||||||
/** Whether interactive controls inside the tile should be inhibited. */
|
/** 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,
|
inhibitInteraction: this.props.inhibitInteraction,
|
||||||
},
|
},
|
||||||
sender: {
|
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,
|
hideSender: this.props.hideSender,
|
||||||
|
isEmote: this.props.mxEvent.getContent().msgtype === MsgType.Emote,
|
||||||
},
|
},
|
||||||
timestamp: {
|
timestamp: {
|
||||||
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
|
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 avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />;
|
||||||
const sender = (
|
const sender = (
|
||||||
<EventTileSenderAdapter
|
<EventTileSenderAdapter
|
||||||
mxEvent={this.props.mxEvent}
|
sender={eventTileSnapshot.sender}
|
||||||
senderSnapshot={eventTileSnapshot.sender}
|
|
||||||
onSenderProfileClick={this.onSenderProfileClick}
|
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 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 MemberAvatar from "../../avatars/MemberAvatar";
|
||||||
import SenderProfile from "../../messages/SenderProfile";
|
|
||||||
import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,35 +45,3 @@ export function EventTileAvatarAdapter({
|
|||||||
</div>
|
</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 { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import { type IBodyProps } from "../messages/IBodyProps";
|
import { type IBodyProps } from "../messages/IBodyProps";
|
||||||
import { FileBodyFactory, VideoBodyFactory, renderMBody } from "../messages/MBodyFactory";
|
import { FileBodyFactory, VideoBodyFactory, renderMBody } from "../messages/MBodyFactory";
|
||||||
|
import { roomMemberToMemberInfo } from "../../../hooks/room/useRoomMemberProfile";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
@@ -125,7 +126,11 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
|||||||
sender = (
|
sender = (
|
||||||
<div className="mx_ReplyTile_sender">
|
<div className="mx_ReplyTile_sender">
|
||||||
<MemberAvatar member={mxEvent.sender} fallbackUserId={mxEvent.getSender()} size="16px" />
|
<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>
|
</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.
|
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 { useMemo } from "react";
|
||||||
|
|
||||||
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||||
import { useSettingValue } from "../useSettings";
|
import { useSettingValue } from "../useSettings";
|
||||||
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx";
|
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({
|
export function useRoomMemberProfile({
|
||||||
userId = "",
|
userId = "",
|
||||||
@@ -26,13 +79,14 @@ export function useRoomMemberProfile({
|
|||||||
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
||||||
|
|
||||||
const member = useMemo(() => {
|
const member = useMemo(() => {
|
||||||
const threadContexts = [TimelineRenderingType.ThreadsList, TimelineRenderingType.Thread];
|
return resolveRoomMemberProfile({
|
||||||
if ((!forceHistorical && useOnlyCurrentProfiles) || threadContexts.includes(context.timelineRenderingType)) {
|
room: context.room,
|
||||||
const currentMember = context.room?.getMember(userId);
|
userId,
|
||||||
if (currentMember) return currentMember;
|
member: propMember,
|
||||||
}
|
forceHistorical,
|
||||||
|
useOnlyCurrentProfiles,
|
||||||
return propMember;
|
timelineRenderingType: context.timelineRenderingType,
|
||||||
|
});
|
||||||
}, [forceHistorical, propMember, context.room, context.timelineRenderingType, useOnlyCurrentProfiles, userId]);
|
}, [forceHistorical, propMember, context.room, context.timelineRenderingType, useOnlyCurrentProfiles, userId]);
|
||||||
|
|
||||||
return member;
|
return member;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import UserIdentifier from "../../../../customisations/UserIdentifier";
|
|||||||
/**
|
/**
|
||||||
* Information about a member for disambiguation purposes.
|
* Information about a member for disambiguation purposes.
|
||||||
*/
|
*/
|
||||||
interface MemberInfo {
|
export interface MemberInfo {
|
||||||
/**
|
/**
|
||||||
* The user's Matrix ID.
|
* The user's Matrix ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type SenderProfileMode,
|
type SenderProfileMode,
|
||||||
type TimestampDisplayState,
|
type TimestampDisplayState,
|
||||||
} from "./EventTileDerivedState";
|
} from "./EventTileDerivedState";
|
||||||
|
import { type MemberInfo } from "./DisambiguatedProfileViewModel";
|
||||||
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||||
import { type Layout } from "../../../../settings/enums/Layout";
|
import { type Layout } from "../../../../settings/enums/Layout";
|
||||||
import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel";
|
import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel";
|
||||||
@@ -130,8 +131,14 @@ export interface EventTileInteractionInput {
|
|||||||
|
|
||||||
/** Sender inputs for deriving the EventTile snapshot. */
|
/** Sender inputs for deriving the EventTile snapshot. */
|
||||||
export interface EventTileSenderInput {
|
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. */
|
/** Whether sender details should be hidden. */
|
||||||
hideSender?: boolean;
|
hideSender?: boolean;
|
||||||
|
/** Whether the event body renders as an emote. */
|
||||||
|
isEmote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Timestamp inputs for deriving the EventTile snapshot. */
|
/** Timestamp inputs for deriving the EventTile snapshot. */
|
||||||
@@ -206,6 +213,10 @@ export interface EventTileLineSnapshot {
|
|||||||
|
|
||||||
/** Sender state derived for the EventTile snapshot. */
|
/** Sender state derived for the EventTile snapshot. */
|
||||||
export interface EventTileSenderSnapshot {
|
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. */
|
/** EventTile avatar and sender profile display state. */
|
||||||
profileState: EventTileSenderProfileState;
|
profileState: EventTileSenderProfileState;
|
||||||
/** Whether clicking the avatar should open the user profile. */
|
/** Whether clicking the avatar should open the user profile. */
|
||||||
@@ -214,6 +225,8 @@ export interface EventTileSenderSnapshot {
|
|||||||
profileMode: SenderProfileMode;
|
profileMode: SenderProfileMode;
|
||||||
/** Whether the avatar should use historical room member details. */
|
/** Whether the avatar should use historical room member details. */
|
||||||
forceHistoricalAvatar: boolean;
|
forceHistoricalAvatar: boolean;
|
||||||
|
/** Whether the event body renders as an emote. */
|
||||||
|
isEmote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Action bar state derived for the EventTile snapshot. */
|
/** Action bar state derived for the EventTile snapshot. */
|
||||||
@@ -539,6 +552,8 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
sender: {
|
sender: {
|
||||||
|
senderId: sender.senderId,
|
||||||
|
member: sender.member ?? null,
|
||||||
profileState: senderProfileState,
|
profileState: senderProfileState,
|
||||||
viewUserOnClick: getShouldViewUserOnClick(
|
viewUserOnClick: getShouldViewUserOnClick(
|
||||||
interaction.inhibitInteraction,
|
interaction.inhibitInteraction,
|
||||||
@@ -550,6 +565,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
timelineRenderingType: display.timelineRenderingType,
|
timelineRenderingType: display.timelineRenderingType,
|
||||||
}),
|
}),
|
||||||
forceHistoricalAvatar: event.eventType === "m.room.member",
|
forceHistoricalAvatar: event.eventType === "m.room.member",
|
||||||
|
isEmote: sender.isEmote,
|
||||||
},
|
},
|
||||||
actionBar: {
|
actionBar: {
|
||||||
show: getShouldShowMessageActionBar({
|
show: getShouldShowMessageActionBar({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
RelationType,
|
RelationType,
|
||||||
type Relations,
|
type Relations,
|
||||||
Room,
|
Room,
|
||||||
|
RoomMember,
|
||||||
TweakName,
|
TweakName,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import {
|
import {
|
||||||
@@ -518,7 +519,7 @@ describe("EventTile", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("sender and avatar rendering", () => {
|
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();
|
const { container } = getComponent();
|
||||||
|
|
||||||
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
|
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 () => {
|
it("renders the tile error fallback when tile rendering throws", async () => {
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
jest.spyOn(EventTileFactory, "renderTile").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);
|
jest.mocked(renderReplyTile).mockClear().mockReturnValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders video replies with the video body", () => {
|
it("renders video replies with the video body", () => {
|
||||||
const mxEvent = mkEvent({
|
const mxEvent = mkEvent({
|
||||||
event: true,
|
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,
|
...overrides.interaction,
|
||||||
},
|
},
|
||||||
sender: {
|
sender: {
|
||||||
|
senderId: "@alice:example.org",
|
||||||
|
member: null,
|
||||||
|
isEmote: false,
|
||||||
...overrides.sender,
|
...overrides.sender,
|
||||||
},
|
},
|
||||||
timestamp: {
|
timestamp: {
|
||||||
@@ -307,6 +310,20 @@ describe("EventTileViewModel", () => {
|
|||||||
expect(snapshot.sender.viewUserOnClick).toBe(true);
|
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", () => {
|
it("marks room member avatars as historical", () => {
|
||||||
const snapshot = EventTileViewModel.createSnapshot(
|
const snapshot = EventTileViewModel.createSnapshot(
|
||||||
makeProps({
|
makeProps({
|
||||||
|
|||||||
Reference in New Issue
Block a user