Make EventTileViewModel consume pure render data (#33805)

This commit is contained in:
rbondesson
2026-06-11 10:05:43 +02:00
committed by GitHub
parent 8a21fdb127
commit a8f9c75fc1
5 changed files with 163 additions and 127 deletions
@@ -17,7 +17,8 @@ import React, {
type ReactNode,
} from "react";
import {
type EventStatus,
EventStatus,
EventType,
type MatrixEvent,
MatrixEventEvent,
type Relations,
@@ -690,6 +691,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
});
};
private readonly getAvatarMember = (): RoomMember | null => {
if (this.props.mxEvent.getContent().third_party_invite) {
return this.props.mxEvent.target;
}
return this.props.mxEvent.sender;
};
private readonly onReactionsCreated = (relationType: string, eventType: string): void => {
if (!isEventTileReactionRelation(relationType, eventType)) {
return;
@@ -852,11 +861,25 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const isEditing = !!this.props.editState;
const eventType = this.props.mxEvent.getType();
const msgtype = this.props.mxEvent.getContent().msgtype;
const isSending =
this.props.eventSendStatus === EventStatus.SENDING ||
this.props.eventSendStatus === EventStatus.QUEUED ||
this.props.eventSendStatus === EventStatus.ENCRYPTING;
return {
event: {
mxEvent: this.props.mxEvent,
eventSendStatus: this.props.eventSendStatus,
eventType,
msgtype,
eventTs: this.props.mxEvent.getTs(),
eventId: this.props.mxEvent.getId() ?? undefined,
isLocalEcho: !!this.props.mxEvent.status,
isSending,
ariaLive: this.props.eventSendStatus === null ? undefined : "off",
isRoomCreate: eventType === EventType.RoomCreate,
isCallInvite: eventType === EventType.CallInvite,
isRtcNotification: eventType === EventType.RTCNotification,
isEditing,
isEncryptionFailure,
forExport: this.props.forExport,
@@ -974,9 +997,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const scrollToken = eventTileRenderState.root.scrollToken;
const rootRenderState = { tileClasses, tileAriaLive, scrollToken };
const avatar = (
<EventTileAvatarAdapter mxEvent={this.props.mxEvent} senderSnapshot={eventTileSnapshot.sender} />
);
const avatarMember = this.getAvatarMember();
const avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />;
const sender = (
<EventTileSenderAdapter
mxEvent={this.props.mxEvent}
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
import MemberAvatar from "../../avatars/MemberAvatar";
import SenderProfile from "../../messages/SenderProfile";
@@ -16,8 +16,8 @@ import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeli
* Props for the {@link EventTileAvatarAdapter} component.
*/
interface EventTileAvatarAdapterProps {
/** Matrix event whose sender avatar is being rendered. */
mxEvent: MatrixEvent;
/** Room member whose avatar is being rendered. */
avatarMember: RoomMember | null;
/** Snapshot of the sender identity state for this tile. */
senderSnapshot: EventTileSenderSnapshot;
}
@@ -26,22 +26,22 @@ interface EventTileAvatarAdapterProps {
* Renders the sender avatar for an event tile.
*/
export function EventTileAvatarAdapter({
mxEvent,
avatarMember,
senderSnapshot,
}: Readonly<EventTileAvatarAdapterProps>): JSX.Element | null {
const { avatarSize } = senderSnapshot.profileState;
if (!mxEvent.sender || avatarSize === null) {
if (!avatarMember || avatarSize === null) {
return null;
}
return (
<div className="mx_EventTile_avatar">
<MemberAvatar
member={senderSnapshot.avatarMember}
member={avatarMember}
size={avatarSize}
viewUserOnClick={senderSnapshot.viewUserOnClick}
forceHistorical={mxEvent.getType() === EventType.RoomMember}
forceHistorical={senderSnapshot.forceHistoricalAvatar}
/>
</div>
);
@@ -5,8 +5,6 @@ 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 { EventStatus, EventType, type MatrixEvent, MsgType, type RoomMember } from "matrix-js-sdk/src/matrix";
import { ElementCallEventType } from "../../../../call-types";
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
import { Layout } from "../../../../settings/enums/Layout";
@@ -16,19 +14,17 @@ import { Layout } from "../../../../settings/enums/Layout";
* Keep this module free of React lifecycle, DOM access, dispatch, and MatrixClientPeg lookups.
*/
/** Whether the event send status represents a pending send state. */
export function isSendingStatus(eventSendStatus?: EventStatus): boolean {
return [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.ENCRYPTING].includes(eventSendStatus!);
}
/** The aria-live setting used by EventTile for the current send status. */
export function getAriaLive(eventSendStatus?: EventStatus | null): "off" | undefined {
return eventSendStatus === null ? undefined : "off";
/** Inputs for the stable scroll token derivation. */
export interface ScrollTokenInput {
/** The event identifier, when available. */
eventId?: string;
/** Whether the event is a local echo. */
isLocalEcho: boolean;
}
/** The stable scroll token for a non-local-echo event. */
export function getScrollToken(mxEvent: MatrixEvent): string | undefined {
return mxEvent.status ? undefined : mxEvent.getId();
export function getScrollToken({ eventId, isLocalEcho }: ScrollTokenInput): string | undefined {
return isLocalEcho ? undefined : eventId;
}
/** Whether EventTile should render as a continuation in the current layout/rendering mode. */
@@ -65,11 +61,16 @@ export function getEventTileLineClassState({
eventType,
msgtype,
}: EventTileLineClassState): Record<string, boolean> {
const roomMessageEventType = "m.room.message";
const stickerEventType = "m.sticker";
const imageMsgtype = "m.image";
const emoteMsgtype = "m.emote";
return {
mx_EventTile_mediaLine: isProbablyMedia,
mx_EventTile_image: eventType === EventType.RoomMessage && msgtype === MsgType.Image,
mx_EventTile_sticker: eventType === EventType.Sticker,
mx_EventTile_emote: eventType === EventType.RoomMessage && msgtype === MsgType.Emote,
mx_EventTile_image: eventType === roomMessageEventType && msgtype === imageMsgtype,
mx_EventTile_sticker: eventType === stickerEventType,
mx_EventTile_emote: eventType === roomMessageEventType && msgtype === emoteMsgtype,
};
}
@@ -89,6 +90,12 @@ export interface EventTileSenderProfileStateInput {
isBubbleMessage: boolean;
/** The current timeline layout. */
layout?: Layout;
/** Whether the event is a room create event. */
isRoomCreate: boolean;
/** Whether the event is a call invite. */
isCallInvite: boolean;
/** Whether the event is an RTC notification. */
isRtcNotification: boolean;
}
/** EventTile avatar and sender profile display state. */
@@ -108,6 +115,9 @@ export function getEventTileSenderProfileState({
eventType,
isBubbleMessage,
layout,
isRoomCreate,
isCallInvite,
isRtcNotification,
}: EventTileSenderProfileStateInput): EventTileSenderProfileState {
if (isRenderingNotification) {
return { avatarSize: "24px", needsSenderProfile: true };
@@ -124,7 +134,7 @@ export function getEventTileSenderProfileState({
return { avatarSize: "32px", needsSenderProfile: true };
}
if (eventType === EventType.RoomCreate || isBubbleMessage) {
if (isRoomCreate || isBubbleMessage) {
return { avatarSize: null, needsSenderProfile: false };
}
@@ -134,9 +144,9 @@ export function getEventTileSenderProfileState({
if (
(continuation && timelineRenderingType !== TimelineRenderingType.File) ||
eventType === EventType.CallInvite ||
isCallInvite ||
ElementCallEventType.matches(eventType) ||
eventType === EventType.RTCNotification
isRtcNotification
) {
return { avatarSize: null, needsSenderProfile: false };
}
@@ -148,15 +158,6 @@ export function getEventTileSenderProfileState({
return { avatarSize: "30px", needsSenderProfile: true };
}
/** The room member whose avatar should render for the EventTile. */
export function getEventTileAvatarMember(mxEvent: MatrixEvent): RoomMember | null {
if (mxEvent.getContent().third_party_invite) {
return mxEvent.target;
}
return mxEvent.sender;
}
/** Whether clicking the avatar should open the user profile. */
export function getShouldViewUserOnClick(
inhibitInteraction: boolean | undefined,
@@ -239,8 +240,8 @@ export function getShouldShowMessageActionBar({
export interface ShouldShowTimestampInput {
/** The event origin timestamp. */
eventTs: number;
/** The Matrix event type for event-type timestamp derivation. */
eventType: string;
/** Whether the event is an RTC notification. */
isRtcNotification: boolean;
/** Whether timestamp rendering is disabled. */
hideTimestamp?: boolean;
/** Whether timestamps should always show. */
@@ -260,7 +261,7 @@ export interface ShouldShowTimestampInput {
/** Whether EventTile should render the message timestamp. */
export function getShouldShowTimestamp({
eventTs,
eventType,
isRtcNotification,
hideTimestamp,
alwaysShowTimestamps,
last,
@@ -271,7 +272,7 @@ export function getShouldShowTimestamp({
}: ShouldShowTimestampInput): boolean {
return (
!!eventTs &&
eventType !== EventType.RTCNotification &&
!isRtcNotification &&
!hideTimestamp &&
(alwaysShowTimestamps || last || hover || focusWithin || actionBarFocused || hasContextMenu)
);
@@ -420,6 +421,8 @@ export interface EventTileClassState {
isContinuation?: boolean;
/** The Matrix event type for event-type class derivation. */
eventType: string;
/** Whether the event is a call invite. */
isCallInvite: boolean;
/** Whether the tile is the last event in the timeline. */
isLast?: boolean;
/** Whether the tile is the last event in its section. */
@@ -455,6 +458,7 @@ export function getEventTileClassState({
isSelected,
isContinuation,
eventType,
isCallInvite,
isLast,
isLastInSection,
isContextual,
@@ -478,14 +482,13 @@ export function getEventTileClassState({
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: isHighlighted,
mx_EventTile_selected: isSelected,
mx_EventTile_continuation:
isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType),
mx_EventTile_continuation: isContinuation || isCallInvite || ElementCallEventType.matches(eventType),
mx_EventTile_last: isLast,
mx_EventTile_lastInSection: isLastInSection,
mx_EventTile_contextual: isContextual,
mx_EventTile_actionBarFocused: isActionBarFocused,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === MsgType.Emote,
mx_EventTile_emote: msgtype === "m.emote",
mx_EventTile_noSender: hideSender,
mx_EventTile_clamp: timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification,
mx_EventTile_noBubble: noBubbleEvent,
@@ -5,15 +5,12 @@ 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 EventStatus, type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { BaseViewModel } from "@element-hq/web-shared-components";
import {
type EventTileSenderProfileState,
type FooterDisplayState,
getAriaLive,
getEventTileAvatarMember,
getEventTileClassState,
getEventTileLineClassState,
getEventTileSenderProfileState,
@@ -27,7 +24,6 @@ import {
getShouldShowTimestamp,
getShouldViewUserOnClick,
getTimestampDisplayState,
isSendingStatus,
type SenderProfileMode,
type TimestampDisplayState,
} from "./EventTileDerivedState";
@@ -54,10 +50,26 @@ import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reacti
/** Event-level inputs for deriving the EventTile snapshot. */
export interface EventTileEventInput {
/** The Matrix event rendered by the tile. */
mxEvent: MatrixEvent;
/** The event send status supplied by EventTile. */
eventSendStatus?: EventStatus | null;
/** The event type rendered by the tile. */
eventType: string;
/** The Matrix message type rendered by the tile. */
msgtype?: string;
/** The event origin timestamp. */
eventTs: number;
/** The stable event identifier, when available. */
eventId?: string;
/** Whether the event is a local echo. */
isLocalEcho: boolean;
/** Whether the event is in a pending send state. */
isSending: boolean;
/** Whether EventTile should announce updates in an aria-live region. */
ariaLive?: "off";
/** Whether the event is a room create event. */
isRoomCreate: boolean;
/** Whether the event is a call invite. */
isCallInvite: boolean;
/** Whether the event is an RTC notification. */
isRtcNotification: boolean;
/** Whether the event is currently being edited. */
isEditing: boolean;
/** Whether the event failed decryption. */
@@ -196,12 +208,12 @@ export interface EventTileLineSnapshot {
export interface EventTileSenderSnapshot {
/** EventTile avatar and sender profile display state. */
profileState: EventTileSenderProfileState;
/** The room member whose avatar should render. */
avatarMember: RoomMember | null;
/** Whether clicking the avatar should open the user profile. */
viewUserOnClick: boolean;
/** SenderProfile rendering mode. */
profileMode: SenderProfileMode;
/** Whether the avatar should use historical room member details. */
forceHistoricalAvatar: boolean;
}
/** Action bar state derived for the EventTile snapshot. */
@@ -459,15 +471,12 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
/** Creates an EventTile view model snapshot. */
public static createSnapshot(props: EventTileViewModelProps): EventTileViewModelSnapshot {
const { event, display, interaction, sender, timestamp, footer } = props;
const eventType = event.mxEvent.getType();
const msgtype = event.mxEvent.getContent().msgtype;
const isSending = isSendingStatus(event.eventSendStatus ?? undefined);
const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout);
const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification;
const eventSnapshot: EventTileEventSnapshot = {
eventType,
msgtype,
isSending,
eventType: event.eventType,
msgtype: event.msgtype,
isSending: event.isSending,
isEditing: event.isEditing,
isContinuation,
isRenderingNotification,
@@ -477,13 +486,16 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
isInfoMessage: display.isInfoMessage,
timelineRenderingType: display.timelineRenderingType,
continuation: display.continuation,
eventType,
eventType: event.eventType,
isBubbleMessage: display.isBubbleMessage,
layout: display.layout,
isRoomCreate: event.isRoomCreate,
isCallInvite: event.isCallInvite,
isRtcNotification: event.isRtcNotification,
});
const showTimestamp = getShouldShowTimestamp({
eventTs: event.mxEvent.getTs(),
eventType,
eventTs: event.eventTs,
isRtcNotification: event.isRtcNotification,
hideTimestamp: timestamp.hideTimestamp,
alwaysShowTimestamps: timestamp.alwaysShowTimestamps,
last: display.isLast,
@@ -494,23 +506,27 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
});
const timestampValue = getEventTileTimestamp({
timelineRenderingType: display.timelineRenderingType,
eventTs: event.mxEvent.getTs(),
eventTs: event.eventTs,
threadReplyEventTs: timestamp.threadReplyEventTs,
});
return {
event: eventSnapshot,
root: {
ariaLive: getAriaLive(event.eventSendStatus),
scrollToken: getScrollToken(event.mxEvent),
ariaLive: event.ariaLive,
scrollToken: getScrollToken({
eventId: event.eventId,
isLocalEcho: event.isLocalEcho,
}),
classState: EventTileViewModel.getClassState({
event,
display,
interaction,
sender,
eventType,
msgtype,
isSending,
eventType: event.eventType,
msgtype: event.msgtype,
isSending: event.isSending,
isCallInvite: event.isCallInvite,
isContinuation,
isRenderingNotification,
}),
@@ -518,13 +534,12 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
line: {
classState: getEventTileLineClassState({
isProbablyMedia: display.isProbablyMedia,
eventType,
msgtype,
eventType: event.eventType,
msgtype: event.msgtype,
}),
},
sender: {
profileState: senderProfileState,
avatarMember: getEventTileAvatarMember(event.mxEvent),
viewUserOnClick: getShouldViewUserOnClick(
interaction.inhibitInteraction,
display.timelineRenderingType,
@@ -534,6 +549,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
hideSender: sender.hideSender,
timelineRenderingType: display.timelineRenderingType,
}),
forceHistoricalAvatar: event.eventType === "m.room.member",
},
actionBar: {
show: getShouldShowMessageActionBar({
@@ -580,6 +596,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
eventType,
msgtype,
isSending,
isCallInvite,
isContinuation,
isRenderingNotification,
}: {
@@ -590,6 +607,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
eventType: string;
msgtype?: string;
isSending: boolean;
isCallInvite: boolean;
isContinuation?: boolean;
isRenderingNotification: boolean;
}): ReturnType<typeof getEventTileClassState> {
@@ -605,6 +623,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
isSelected: display.isSelected,
isContinuation,
eventType,
isCallInvite,
isLast: display.isLast,
isLastInSection: display.isLastInSection,
isContextual: display.isContextual,
@@ -5,9 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
import { EventStatus, EventType, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { Layout } from "../../../src/settings/enums/Layout";
import {
@@ -16,8 +13,6 @@ import {
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
describe("EventTileViewModel", () => {
const roomId = "!room:example.org";
const userId = "@alice:example.org";
type EventTileViewModelPropsOverrides = {
event?: Partial<EventTileViewModelProps["event"]>;
display?: Partial<EventTileViewModelProps["display"]>;
@@ -27,34 +22,22 @@ describe("EventTileViewModel", () => {
footer?: Partial<EventTileViewModelProps["footer"]>;
};
function makeMessageEvent({
type = EventType.RoomMessage,
content = { msgtype: MsgType.Text, body: "Hello" },
ts = 123,
status,
}: {
type?: string;
content?: Record<string, unknown>;
ts?: number;
status?: EventStatus;
} = {}): MatrixEvent {
return mkEvent({
event: true,
type,
room: roomId,
user: userId,
content,
ts,
status,
});
}
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): EventTileViewModelProps {
return {
event: {
mxEvent: makeMessageEvent(),
eventType: "m.room.message",
msgtype: "m.text",
eventTs: 123,
eventId: "$event",
isLocalEcho: false,
isSending: false,
ariaLive: "off",
isRoomCreate: false,
isCallInvite: false,
isRtcNotification: false,
isEditing: false,
isEncryptionFailure: false,
forExport: false,
...overrides.event,
},
display: {
@@ -94,14 +77,12 @@ describe("EventTileViewModel", () => {
};
}
it("derives sending, aria-live, and local echo scroll state", () => {
const mxEvent = makeMessageEvent({ status: EventStatus.SENDING });
it("derives sending, aria-live, and scroll state from plain event data", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
mxEvent,
eventSendStatus: EventStatus.SENDING,
isSending: true,
isLocalEcho: true,
},
}),
);
@@ -112,14 +93,25 @@ describe("EventTileViewModel", () => {
expect(snapshot.root.classState.mx_EventTile_sending).toBe(true);
});
it("derives render-ready root and line state", () => {
const mxEvent = makeMessageEvent({ status: EventStatus.SENDING });
it("derives a scroll token for non-local-echo events", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
eventId: "$remote-event",
isLocalEcho: false,
},
}),
);
expect(snapshot.root.scrollToken).toBe("$remote-event");
});
it("derives render-ready root and line state", () => {
const renderState = EventTileViewModel.createRenderState(
makeProps({
event: {
mxEvent,
eventSendStatus: EventStatus.SENDING,
isSending: true,
isLocalEcho: true,
},
display: {
isHighlighted: true,
@@ -269,9 +261,7 @@ describe("EventTileViewModel", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
mxEvent: makeMessageEvent({
content: { msgtype: MsgType.Image, body: "image" },
}),
msgtype: "m.image",
},
display: {
isProbablyMedia: true,
@@ -317,6 +307,18 @@ describe("EventTileViewModel", () => {
expect(snapshot.sender.viewUserOnClick).toBe(true);
});
it("marks room member avatars as historical", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
eventType: "m.room.member",
},
}),
);
expect(snapshot.sender.forceHistoricalAvatar).toBe(true);
});
it("derives action bar visibility from interaction state", () => {
const hoverSnapshot = EventTileViewModel.createSnapshot(makeProps({ interaction: { hover: true } }));
const contextMenuSnapshot = EventTileViewModel.createSnapshot(
@@ -356,10 +358,7 @@ describe("EventTileViewModel", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
mxEvent: makeMessageEvent({
type: EventType.RTCNotification,
content: {},
}),
isRtcNotification: true,
},
interaction: {
hover: true,
@@ -479,17 +478,10 @@ describe("EventTileViewModel", () => {
});
it("does not initialize timestamp child view models for events without an origin timestamp", () => {
const mxEvent = new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
sender: userId,
content: { msgtype: MsgType.Text, body: "Hello" },
event_id: "$event",
});
const vm = new EventTileViewModel(
makeProps({
event: {
mxEvent,
eventTs: 0,
},
timestamp: {
hideTimestamp: true,