Move EventTile to shared components - #4a (#34469)
* Add EventTile root properties to view model * Move render-related event reads into the vm * Remove duplicate root derivation in UnwrappedEventTile * Move data-has-reply derivation into EventTileViewModel * Move getEventDisplayInfo() and dependencies into EventTileViewModel * Add tests for improved coverage * Cleanup and comments * Fix: Replacement events have a fallback tile but must not show their own reply chain * Combine dependency and prop updates into one atomic VM update * Remove property which was never read and fix oxfmt issue
This commit is contained in:
@@ -18,8 +18,6 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
EventStatus,
|
EventStatus,
|
||||||
EventType,
|
|
||||||
MsgType,
|
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
type Relations,
|
type Relations,
|
||||||
@@ -50,19 +48,13 @@ import { type ComposerInsertPayload } from "../../../dispatcher/payloads/Compose
|
|||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import PlatformPeg from "../../../PlatformPeg";
|
import PlatformPeg from "../../../PlatformPeg";
|
||||||
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
|
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
|
||||||
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
import { copyPlaintext } from "../../../utils/strings";
|
import { copyPlaintext } from "../../../utils/strings";
|
||||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import {
|
import { isMessageEvent, renderTile, type EventTileTypeProps } from "../../../events/EventTileFactory";
|
||||||
haveRendererForEvent,
|
|
||||||
isMessageEvent,
|
|
||||||
renderTile,
|
|
||||||
type EventTileTypeProps,
|
|
||||||
} from "../../../events/EventTileFactory";
|
|
||||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||||
@@ -80,7 +72,9 @@ import { EventTileThreadInfo, EventTileThreadPanelSummary } from "./EventTile/Ev
|
|||||||
import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot";
|
import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot";
|
||||||
import {
|
import {
|
||||||
EventTileViewModel,
|
EventTileViewModel,
|
||||||
|
type EventTileRenderState,
|
||||||
type EventTileViewModelProps,
|
type EventTileViewModelProps,
|
||||||
|
type EventTileViewModelDependencies,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
import {
|
import {
|
||||||
getEventTileReceiptState,
|
getEventTileReceiptState,
|
||||||
@@ -91,7 +85,6 @@ import {
|
|||||||
getEventTileThreadState,
|
getEventTileThreadState,
|
||||||
type EventTileThreadState,
|
type EventTileThreadState,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState";
|
} from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState";
|
||||||
import { getEventTileReplyChainState } from "../../../viewmodels/room/timeline/event-tile/EventTileReplyChainState";
|
|
||||||
import {
|
import {
|
||||||
eventTileActionBarFocusChange,
|
eventTileActionBarFocusChange,
|
||||||
eventTileBlurWithin,
|
eventTileBlurWithin,
|
||||||
@@ -279,19 +272,12 @@ interface IState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EventTileRenderInputs {
|
interface EventTileRenderInputs {
|
||||||
displayInfo: ReturnType<typeof getEventDisplayInfo>;
|
|
||||||
hasPinnedMessageBadge: boolean;
|
hasPinnedMessageBadge: boolean;
|
||||||
hasReactionsRow: boolean;
|
hasReactionsRow: boolean;
|
||||||
threadState: EventTileThreadState;
|
threadState: EventTileThreadState;
|
||||||
isOwnEvent: boolean;
|
isOwnEvent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventTileRootRenderState {
|
|
||||||
tileClasses: string;
|
|
||||||
tileAriaLive?: "off";
|
|
||||||
scrollToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */
|
/** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */
|
||||||
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
|
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
|
||||||
private suppressReadReceiptAnimation: boolean;
|
private suppressReadReceiptAnimation: boolean;
|
||||||
@@ -332,7 +318,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.viewModel = new EventTileViewModel(this.createViewModelProps());
|
this.viewModel = new EventTileViewModel(this.createViewModelDependencies(), this.createViewModelProps());
|
||||||
|
|
||||||
this.e2eViewModel = new EventTileE2eViewModel({
|
this.e2eViewModel = new EventTileE2eViewModel({
|
||||||
cli: MatrixClientPeg.safeGet(),
|
cli: MatrixClientPeg.safeGet(),
|
||||||
@@ -795,19 +781,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createRootAttributes({
|
private createRootAttributes({
|
||||||
tileClasses,
|
className,
|
||||||
tileAriaLive,
|
ariaLive,
|
||||||
scrollToken,
|
scrollToken,
|
||||||
}: EventTileRootRenderState): Record<string, unknown> {
|
}: EventTileRenderState["root"]): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
"className": tileClasses,
|
"className": className,
|
||||||
"aria-live": tileAriaLive,
|
"aria-live": ariaLive,
|
||||||
"aria-atomic": true,
|
"aria-atomic": true,
|
||||||
"data-scroll-tokens": scrollToken,
|
"data-scroll-tokens": scrollToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private createInteractiveRootAttributes(rootRenderState: EventTileRootRenderState): Record<string, unknown> {
|
private createInteractiveRootAttributes(rootRenderState: EventTileRenderState["root"]): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
...this.createRootAttributes(rootRenderState),
|
...this.createRootAttributes(rootRenderState),
|
||||||
ref: this.ref,
|
ref: this.ref,
|
||||||
@@ -839,14 +825,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRenderInputs(
|
private createViewModelDependencies(): EventTileViewModelDependencies {
|
||||||
displayInfo = getEventDisplayInfo(
|
return {
|
||||||
MatrixClientPeg.safeGet(),
|
mxEvent: this.props.mxEvent,
|
||||||
this.props.mxEvent,
|
matrixClient: MatrixClientPeg.safeGet(),
|
||||||
this.context.showHiddenEvents,
|
showHiddenEvents: this.context.showHiddenEvents,
|
||||||
shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
|
hideEvent: shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
|
||||||
),
|
};
|
||||||
): EventTileRenderInputs {
|
}
|
||||||
|
|
||||||
|
private createRenderInputs(): EventTileRenderInputs {
|
||||||
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
|
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
|
||||||
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||||
const hasReactionsRow = !isRedacted;
|
const hasReactionsRow = !isRedacted;
|
||||||
@@ -855,7 +843,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
|
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayInfo,
|
|
||||||
hasPinnedMessageBadge,
|
hasPinnedMessageBadge,
|
||||||
hasReactionsRow,
|
hasReactionsRow,
|
||||||
threadState,
|
threadState,
|
||||||
@@ -864,12 +851,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createViewModelProps(inputs: EventTileRenderInputs = this.createRenderInputs()): EventTileViewModelProps {
|
private createViewModelProps(inputs: EventTileRenderInputs = this.createRenderInputs()): EventTileViewModelProps {
|
||||||
const { displayInfo, hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs;
|
const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs;
|
||||||
const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
|
const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
|
||||||
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
|
|
||||||
const isEditing = !!this.props.editState;
|
const isEditing = !!this.props.editState;
|
||||||
const eventType = this.props.mxEvent.getType();
|
|
||||||
const msgtype = this.props.mxEvent.getContent().msgtype;
|
|
||||||
const isSending =
|
const isSending =
|
||||||
this.props.eventSendStatus === EventStatus.SENDING ||
|
this.props.eventSendStatus === EventStatus.SENDING ||
|
||||||
this.props.eventSendStatus === EventStatus.QUEUED ||
|
this.props.eventSendStatus === EventStatus.QUEUED ||
|
||||||
@@ -877,18 +861,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
event: {
|
event: {
|
||||||
eventType,
|
|
||||||
msgtype,
|
|
||||||
eventTs: this.props.mxEvent.getTs(),
|
|
||||||
eventId: this.props.mxEvent.getId() ?? undefined,
|
|
||||||
isLocalEcho: !!this.props.mxEvent.status,
|
|
||||||
isSending,
|
isSending,
|
||||||
ariaLive: this.props.eventSendStatus === null ? undefined : "off",
|
ariaLive: this.props.eventSendStatus === null ? undefined : "off",
|
||||||
isRoomCreate: eventType === EventType.RoomCreate,
|
|
||||||
isCallInvite: eventType === EventType.CallInvite,
|
|
||||||
isRtcNotification: eventType === EventType.RTCNotification,
|
|
||||||
isEditing,
|
isEditing,
|
||||||
isEncryptionFailure,
|
|
||||||
forExport: this.props.forExport,
|
forExport: this.props.forExport,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
@@ -896,11 +871,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
layout: this.props.layout,
|
layout: this.props.layout,
|
||||||
continuation: this.props.continuation,
|
continuation: this.props.continuation,
|
||||||
isProbablyMedia,
|
isProbablyMedia,
|
||||||
isBubbleMessage: displayInfo.isBubbleMessage,
|
|
||||||
isLeftAlignedBubbleMessage: displayInfo.isLeftAlignedBubbleMessage,
|
|
||||||
isAlignedBetweenBubbles: displayInfo.isAlignedBetweenBubbles,
|
|
||||||
isInfoMessage: displayInfo.isInfoMessage,
|
|
||||||
noBubbleEvent: displayInfo.noBubbleEvent,
|
|
||||||
isTwelveHour: this.props.isTwelveHour,
|
isTwelveHour: this.props.isTwelveHour,
|
||||||
isHighlighted: shouldHighlightEventTile({
|
isHighlighted: shouldHighlightEventTile({
|
||||||
cli: MatrixClientPeg.safeGet(),
|
cli: MatrixClientPeg.safeGet(),
|
||||||
@@ -923,7 +893,6 @@ 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(
|
member: roomMemberToMemberInfo(
|
||||||
this.props.useEventSenderSnapshot
|
this.props.useEventSenderSnapshot
|
||||||
? this.getAvatarMember()
|
? this.getAvatarMember()
|
||||||
@@ -936,7 +905,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
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,
|
||||||
@@ -977,22 +945,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode {
|
public render(): ReactNode {
|
||||||
const eventType = this.props.mxEvent.getType();
|
|
||||||
const replacingEventId = this.props.mxEvent.replacingEventId();
|
|
||||||
|
|
||||||
const displayInfo = getEventDisplayInfo(
|
|
||||||
MatrixClientPeg.safeGet(),
|
|
||||||
this.props.mxEvent,
|
|
||||||
this.context.showHiddenEvents,
|
|
||||||
shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
|
|
||||||
);
|
|
||||||
const { hasRenderer, isSeeingThroughMessageHiddenForModeration } = displayInfo;
|
|
||||||
const { isQuoteExpanded } = this.state;
|
const { isQuoteExpanded } = this.state;
|
||||||
|
const renderInputs = this.createRenderInputs();
|
||||||
|
const { hasPinnedMessageBadge, hasReactionsRow, threadState } = renderInputs;
|
||||||
|
|
||||||
|
this.viewModel.setInputs(this.createViewModelDependencies(), this.createViewModelProps(renderInputs));
|
||||||
|
const eventTileRenderState = this.viewModel.getSnapshot();
|
||||||
|
const eventTileSnapshot = eventTileRenderState.snapshot;
|
||||||
|
|
||||||
// This shouldn't happen: the caller should check we support this type
|
// This shouldn't happen: the caller should check we support this type
|
||||||
// before trying to instantiate us
|
// before trying to instantiate us
|
||||||
if (!hasRenderer) {
|
if (!eventTileSnapshot.event.hasRenderer) {
|
||||||
const { mxEvent } = this.props;
|
logger.warn(
|
||||||
logger.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`);
|
`Event type not supported: type:${eventTileSnapshot.event.eventType} isState:${eventTileSnapshot.event.isState}`,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
|
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
|
||||||
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
|
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
|
||||||
@@ -1000,22 +966,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderInputs = this.createRenderInputs(displayInfo);
|
|
||||||
const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = renderInputs;
|
|
||||||
|
|
||||||
this.viewModel.setProps(this.createViewModelProps(renderInputs));
|
|
||||||
const eventTileRenderState = this.viewModel.getSnapshot();
|
|
||||||
const eventTileSnapshot = eventTileRenderState.snapshot;
|
|
||||||
|
|
||||||
const lineClasses = eventTileRenderState.line.className;
|
const lineClasses = eventTileRenderState.line.className;
|
||||||
const tileClasses = eventTileRenderState.root.className;
|
const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification;
|
||||||
const tileAriaLive = eventTileRenderState.root.ariaLive;
|
const isSeeingThroughMessageHiddenForModeration =
|
||||||
const isRenderingNotification = eventTileRenderState.root.isRenderingNotification;
|
eventTileSnapshot.event.isSeeingThroughMessageHiddenForModeration;
|
||||||
|
|
||||||
const permalink = this.getPermalink();
|
const permalink = this.getPermalink();
|
||||||
|
const rootRenderState = eventTileRenderState.root;
|
||||||
const scrollToken = eventTileRenderState.root.scrollToken;
|
|
||||||
const rootRenderState = { tileClasses, tileAriaLive, scrollToken };
|
|
||||||
|
|
||||||
const avatarMember = this.getAvatarMember();
|
const avatarMember = this.getAvatarMember();
|
||||||
const avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />;
|
const avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />;
|
||||||
@@ -1083,16 +1040,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const replyChainState = getEventTileReplyChainState({
|
|
||||||
mxEvent: this.props.mxEvent,
|
|
||||||
hasRenderer: haveRendererForEvent(
|
|
||||||
this.props.mxEvent,
|
|
||||||
MatrixClientPeg.safeGet(),
|
|
||||||
this.context.showHiddenEvents,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
let replyChain: JSX.Element | undefined;
|
let replyChain: JSX.Element | undefined;
|
||||||
if (replyChainState.shouldShowReplyChain) {
|
if (eventTileSnapshot.root.data.hasReply) {
|
||||||
replyChain = (
|
replyChain = (
|
||||||
<ReplyChain
|
<ReplyChain
|
||||||
parentEv={this.props.mxEvent}
|
parentEv={this.props.mxEvent}
|
||||||
@@ -1115,10 +1064,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
this.props.as || "li",
|
this.props.as || "li",
|
||||||
{
|
{
|
||||||
...this.createInteractiveRootAttributes(rootRenderState),
|
...this.createInteractiveRootAttributes(rootRenderState),
|
||||||
"data-has-reply": !!replyChain,
|
"data-has-reply": eventTileSnapshot.root.data.hasReply,
|
||||||
"data-layout": this.props.layout,
|
"data-layout": eventTileSnapshot.root.data.layout,
|
||||||
"data-self": isOwnEvent,
|
"data-self": eventTileSnapshot.root.data.isOwnEvent,
|
||||||
"data-event-id": this.props.mxEvent.getId(),
|
"data-event-id": eventTileSnapshot.root.data.eventId,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
|
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
|
||||||
@@ -1136,7 +1085,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{renderTile(
|
{renderTile(
|
||||||
TimelineRenderingType.Thread,
|
TimelineRenderingType.Thread,
|
||||||
this.createRenderTileProps({
|
this.createRenderTileProps({
|
||||||
replacingEventId,
|
replacingEventId: eventTileSnapshot.event.replacingEventId,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
permalinkCreator: this.props.permalinkCreator!,
|
permalinkCreator: this.props.permalinkCreator!,
|
||||||
}),
|
}),
|
||||||
@@ -1169,10 +1118,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{
|
{
|
||||||
...this.createInteractiveRootAttributes(rootRenderState),
|
...this.createInteractiveRootAttributes(rootRenderState),
|
||||||
"tabIndex": -1,
|
"tabIndex": -1,
|
||||||
"data-layout": this.props.layout,
|
"data-layout": eventTileSnapshot.root.data.layout,
|
||||||
"data-shape": this.context.timelineRenderingType,
|
"data-shape": eventTileSnapshot.root.data.shape,
|
||||||
"data-self": isOwnEvent,
|
"data-self": eventTileSnapshot.root.data.isOwnEvent,
|
||||||
"data-has-reply": !!replyChain,
|
"data-has-reply": eventTileSnapshot.root.data.hasReply,
|
||||||
"onClick": (ev: MouseEvent) => {
|
"onClick": (ev: MouseEvent) => {
|
||||||
const target = ev.currentTarget as HTMLElement;
|
const target = ev.currentTarget as HTMLElement;
|
||||||
let index = -1;
|
let index = -1;
|
||||||
@@ -1210,7 +1159,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{timestamp}
|
{timestamp}
|
||||||
<UnreadNotificationBadge
|
<UnreadNotificationBadge
|
||||||
room={room || undefined}
|
room={room || undefined}
|
||||||
threadId={this.props.mxEvent.getId()}
|
threadId={eventTileSnapshot.root.data.eventId}
|
||||||
forceDot={true}
|
forceDot={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1274,10 +1223,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{
|
{
|
||||||
...this.createInteractiveRootAttributes(rootRenderState),
|
...this.createInteractiveRootAttributes(rootRenderState),
|
||||||
"tabIndex": -1,
|
"tabIndex": -1,
|
||||||
"data-layout": this.props.layout,
|
"data-layout": eventTileSnapshot.root.data.layout,
|
||||||
"data-self": isOwnEvent,
|
"data-self": eventTileSnapshot.root.data.isOwnEvent,
|
||||||
"data-event-id": this.props.mxEvent.getId(),
|
"data-event-id": eventTileSnapshot.root.data.eventId,
|
||||||
"data-has-reply": !!replyChain,
|
"data-has-reply": eventTileSnapshot.root.data.hasReply,
|
||||||
},
|
},
|
||||||
<>
|
<>
|
||||||
{ircTimestamp}
|
{ircTimestamp}
|
||||||
@@ -1295,7 +1244,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{groupPadlock}
|
{groupPadlock}
|
||||||
{replyChain}
|
{replyChain}
|
||||||
{renderTile(
|
{renderTile(
|
||||||
this.context.timelineRenderingType,
|
eventTileSnapshot.root.data.shape,
|
||||||
this.createRenderTileProps({
|
this.createRenderTileProps({
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { BaseViewModel } from "@element-hq/web-shared-components";
|
import { BaseViewModel } from "@element-hq/web-shared-components";
|
||||||
|
import { EventType, MsgType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type EventTileSenderProfileState,
|
type EventTileSenderProfileState,
|
||||||
@@ -42,6 +43,9 @@ import {
|
|||||||
type E2eMessageSharedIconViewModelProps,
|
type E2eMessageSharedIconViewModelProps,
|
||||||
} from "./E2eMessageSharedIconViewModel";
|
} from "./E2eMessageSharedIconViewModel";
|
||||||
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
|
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
|
||||||
|
import { getEventTileReplyChainState } from "./EventTileReplyChainState";
|
||||||
|
import { getEventDisplayInfo } from "../../../../utils/EventRenderingUtils";
|
||||||
|
import { haveRendererForEvent } from "../../../../events/EventTileFactory";
|
||||||
import {
|
import {
|
||||||
ThreadListActionBarViewModel,
|
ThreadListActionBarViewModel,
|
||||||
type ThreadListActionBarViewModelProps,
|
type ThreadListActionBarViewModelProps,
|
||||||
@@ -51,6 +55,18 @@ import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reacti
|
|||||||
|
|
||||||
/** Event-level inputs for deriving the EventTile snapshot. */
|
/** Event-level inputs for deriving the EventTile snapshot. */
|
||||||
export interface EventTileEventInput {
|
export interface EventTileEventInput {
|
||||||
|
/** 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 currently being edited. */
|
||||||
|
isEditing: boolean;
|
||||||
|
/** Whether the tile is rendering for export. */
|
||||||
|
forExport?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event-level inputs after SDK data has been converted to pure values. */
|
||||||
|
export interface EventTileDerivedEventInput extends EventTileEventInput {
|
||||||
/** The event type rendered by the tile. */
|
/** The event type rendered by the tile. */
|
||||||
eventType: string;
|
eventType: string;
|
||||||
/** The Matrix message type rendered by the tile. */
|
/** The Matrix message type rendered by the tile. */
|
||||||
@@ -59,24 +75,26 @@ export interface EventTileEventInput {
|
|||||||
eventTs: number;
|
eventTs: number;
|
||||||
/** The stable event identifier, when available. */
|
/** The stable event identifier, when available. */
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
|
/** The event identifier replaced by this event, when available. */
|
||||||
|
replacingEventId?: string;
|
||||||
|
/** Whether the event is a state event. */
|
||||||
|
isState: boolean;
|
||||||
/** Whether the event is a local echo. */
|
/** Whether the event is a local echo. */
|
||||||
isLocalEcho: boolean;
|
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. */
|
/** Whether the event is a room create event. */
|
||||||
isRoomCreate: boolean;
|
isRoomCreate: boolean;
|
||||||
/** Whether the event is a call invite. */
|
/** Whether the event is a call invite. */
|
||||||
isCallInvite: boolean;
|
isCallInvite: boolean;
|
||||||
/** Whether the event is an RTC notification. */
|
/** Whether the event is an RTC notification. */
|
||||||
isRtcNotification: boolean;
|
isRtcNotification: boolean;
|
||||||
/** Whether the event is currently being edited. */
|
|
||||||
isEditing: boolean;
|
|
||||||
/** Whether the event failed decryption. */
|
/** Whether the event failed decryption. */
|
||||||
isEncryptionFailure: boolean;
|
isEncryptionFailure: boolean;
|
||||||
/** Whether the tile is rendering for export. */
|
/** Whether a renderer is available for the event. */
|
||||||
forExport?: boolean;
|
hasRenderer: boolean;
|
||||||
|
/** Whether the event should be rendered through the moderation fallback. */
|
||||||
|
isSeeingThroughMessageHiddenForModeration: boolean;
|
||||||
|
/** Whether EventTile should render the reply chain. */
|
||||||
|
hasReplyChain: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display inputs for deriving the EventTile snapshot. */
|
/** Display inputs for deriving the EventTile snapshot. */
|
||||||
@@ -90,15 +108,15 @@ export interface EventTileDisplayInput {
|
|||||||
/** Whether the event body is likely to render media content. */
|
/** Whether the event body is likely to render media content. */
|
||||||
isProbablyMedia: boolean;
|
isProbablyMedia: boolean;
|
||||||
/** Whether the tile should use bubble container styling. */
|
/** Whether the tile should use bubble container styling. */
|
||||||
isBubbleMessage: boolean;
|
isBubbleMessage?: boolean;
|
||||||
/** Whether the bubble tile is left-aligned. */
|
/** Whether the bubble tile is left-aligned. */
|
||||||
isLeftAlignedBubbleMessage: boolean;
|
isLeftAlignedBubbleMessage?: boolean;
|
||||||
/** Whether the event is aligned between bubble columns. */
|
/** Whether the event is aligned between bubble columns. */
|
||||||
isAlignedBetweenBubbles: boolean;
|
isAlignedBetweenBubbles?: boolean;
|
||||||
/** Whether the event renders as an informational timeline item. */
|
/** Whether the event renders as an informational timeline item. */
|
||||||
isInfoMessage: boolean;
|
isInfoMessage?: boolean;
|
||||||
/** Whether bubble styling should be suppressed for this event. */
|
/** Whether bubble styling should be suppressed for this event. */
|
||||||
noBubbleEvent: boolean;
|
noBubbleEvent?: boolean;
|
||||||
/** Whether timestamps use twelve-hour formatting. */
|
/** Whether timestamps use twelve-hour formatting. */
|
||||||
isTwelveHour?: boolean;
|
isTwelveHour?: boolean;
|
||||||
/** Whether the event should be highlighted. */
|
/** Whether the event should be highlighted. */
|
||||||
@@ -113,6 +131,15 @@ export interface EventTileDisplayInput {
|
|||||||
isContextual?: boolean;
|
isContextual?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Event display inputs after renderer and event display information has been normalized. */
|
||||||
|
export interface EventTileDerivedDisplayInput extends EventTileDisplayInput {
|
||||||
|
isBubbleMessage: boolean;
|
||||||
|
isLeftAlignedBubbleMessage: boolean;
|
||||||
|
isAlignedBetweenBubbles: boolean;
|
||||||
|
isInfoMessage: boolean;
|
||||||
|
noBubbleEvent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Interaction inputs for deriving the EventTile snapshot. */
|
/** Interaction inputs for deriving the EventTile snapshot. */
|
||||||
export interface EventTileInteractionInput {
|
export interface EventTileInteractionInput {
|
||||||
/** Whether the tile is currently hovered. */
|
/** Whether the tile is currently hovered. */
|
||||||
@@ -138,7 +165,7 @@ export interface EventTileSenderInput {
|
|||||||
/** Whether sender details should be hidden. */
|
/** Whether sender details should be hidden. */
|
||||||
hideSender?: boolean;
|
hideSender?: boolean;
|
||||||
/** Whether the event body renders as an emote. */
|
/** Whether the event body renders as an emote. */
|
||||||
isEmote: boolean;
|
isEmote?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Timestamp inputs for deriving the EventTile snapshot. */
|
/** Timestamp inputs for deriving the EventTile snapshot. */
|
||||||
@@ -179,12 +206,44 @@ export interface EventTileViewModelProps {
|
|||||||
footer: EventTileFooterInput;
|
footer: EventTileFooterInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pure EventTile inputs after event and sender data has been normalized. */
|
||||||
|
export interface NormalizedEventTileViewModelProps {
|
||||||
|
event: EventTileDerivedEventInput;
|
||||||
|
display: EventTileDerivedDisplayInput;
|
||||||
|
interaction: EventTileInteractionInput;
|
||||||
|
sender: EventTileSenderInput;
|
||||||
|
timestamp: EventTileTimestampInput;
|
||||||
|
footer: EventTileFooterInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Application dependencies used by EventTileViewModel to derive render data. */
|
||||||
|
export interface EventTileViewModelDependencies {
|
||||||
|
/** The Matrix event being rendered. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Matrix client used to select the event renderer. */
|
||||||
|
matrixClient: MatrixClient;
|
||||||
|
/** Whether hidden events should use their fallback renderer. */
|
||||||
|
showHiddenEvents: boolean;
|
||||||
|
/** Whether the event is hidden by the current tile context. */
|
||||||
|
hideEvent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Event-level state derived for the EventTile snapshot. */
|
/** Event-level state derived for the EventTile snapshot. */
|
||||||
export interface EventTileEventSnapshot {
|
export interface EventTileEventSnapshot {
|
||||||
/** The Matrix event type. */
|
/** The Matrix event type. */
|
||||||
eventType: string;
|
eventType: string;
|
||||||
/** The Matrix message type. */
|
/** The Matrix message type. */
|
||||||
msgtype?: string;
|
msgtype?: string;
|
||||||
|
/** The stable event identifier, when available. */
|
||||||
|
eventId?: string;
|
||||||
|
/** The event identifier replaced by this event, when available. */
|
||||||
|
replacingEventId?: string;
|
||||||
|
/** Whether the event is a state event. */
|
||||||
|
isState: boolean;
|
||||||
|
/** The event origin timestamp. */
|
||||||
|
eventTs: number;
|
||||||
|
/** Whether the event is a local echo. */
|
||||||
|
isLocalEcho: boolean;
|
||||||
/** Whether the event is in a pending send state. */
|
/** Whether the event is in a pending send state. */
|
||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
/** Whether the event is currently being edited. */
|
/** Whether the event is currently being edited. */
|
||||||
@@ -193,6 +252,26 @@ export interface EventTileEventSnapshot {
|
|||||||
isContinuation?: boolean;
|
isContinuation?: boolean;
|
||||||
/** Whether the tile is rendering as a notification. */
|
/** Whether the tile is rendering as a notification. */
|
||||||
isRenderingNotification: boolean;
|
isRenderingNotification: boolean;
|
||||||
|
/** Whether the event failed decryption. */
|
||||||
|
isEncryptionFailure: boolean;
|
||||||
|
/** Whether a renderer is available for the event. */
|
||||||
|
hasRenderer: boolean;
|
||||||
|
/** Whether the event should be rendered through the moderation fallback. */
|
||||||
|
isSeeingThroughMessageHiddenForModeration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain data attributes rendered on the EventTile root element. */
|
||||||
|
export interface EventTileRootData {
|
||||||
|
/** The event identifier exposed through `data-event-id`. */
|
||||||
|
eventId?: string;
|
||||||
|
/** The configured tile layout exposed through `data-layout`. */
|
||||||
|
layout?: Layout;
|
||||||
|
/** The timeline rendering mode exposed through `data-shape`. */
|
||||||
|
shape: TimelineRenderingType;
|
||||||
|
/** Whether the event belongs to the current user, exposed through `data-self`. */
|
||||||
|
isOwnEvent: boolean;
|
||||||
|
/** Whether EventTile renders a reply chain, exposed through `data-has-reply`. */
|
||||||
|
hasReply: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Root state derived for the EventTile snapshot. */
|
/** Root state derived for the EventTile snapshot. */
|
||||||
@@ -201,6 +280,8 @@ export interface EventTileRootSnapshot {
|
|||||||
ariaLive?: "off";
|
ariaLive?: "off";
|
||||||
/** The stable scroll token for the event. */
|
/** The stable scroll token for the event. */
|
||||||
scrollToken?: string;
|
scrollToken?: string;
|
||||||
|
/** Plain data attributes used by the EventTile root element. */
|
||||||
|
data: EventTileRootData;
|
||||||
/** EventTile root CSS class flags. */
|
/** EventTile root CSS class flags. */
|
||||||
classState: ReturnType<typeof getEventTileClassState>;
|
classState: ReturnType<typeof getEventTileClassState>;
|
||||||
}
|
}
|
||||||
@@ -288,6 +369,8 @@ export interface EventTileRenderState {
|
|||||||
scrollToken?: string;
|
scrollToken?: string;
|
||||||
/** Whether the tile is rendering as a notification. */
|
/** Whether the tile is rendering as a notification. */
|
||||||
isRenderingNotification: boolean;
|
isRenderingNotification: boolean;
|
||||||
|
/** Plain data attributes used by the EventTile root element. */
|
||||||
|
data: EventTileRootData;
|
||||||
};
|
};
|
||||||
/** EventTile line render state. */
|
/** EventTile line render state. */
|
||||||
line: {
|
line: {
|
||||||
@@ -319,7 +402,11 @@ export interface EventTileRenderState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derives the current EventTile snapshot from component-owned inputs. */
|
/**
|
||||||
|
* Aggregate application-side render-state boundary for EventTile.
|
||||||
|
*
|
||||||
|
* SDK objects are converted to plain render data here before the existing render tree consumes it.
|
||||||
|
*/
|
||||||
export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
|
export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
|
||||||
private messageTimestampViewModel?: MessageTimestampViewModel;
|
private messageTimestampViewModel?: MessageTimestampViewModel;
|
||||||
private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
|
private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
|
||||||
@@ -331,16 +418,18 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
private actionBarViewModel?: EventTileActionBarViewModel;
|
private actionBarViewModel?: EventTileActionBarViewModel;
|
||||||
private reactionsRowViewModel?: ReactionsRowViewModel;
|
private reactionsRowViewModel?: ReactionsRowViewModel;
|
||||||
|
|
||||||
public constructor(props: EventTileViewModelProps) {
|
public constructor(dependencies: EventTileViewModelDependencies, props: EventTileViewModelProps) {
|
||||||
const initialRenderState = EventTileViewModel.createRenderState(props);
|
const normalizedProps = EventTileViewModel.normalizeDependencies(dependencies, props);
|
||||||
|
const initialRenderState = EventTileViewModel.createRenderState(normalizedProps);
|
||||||
|
|
||||||
super(props, initialRenderState);
|
super(normalizedProps, initialRenderState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates root EventTile inputs and refreshes the derived render state. */
|
/** Updates dependencies and root inputs together, emitting one consistent render state. */
|
||||||
public setProps(props: EventTileViewModelProps): void {
|
public setInputs(dependencies: EventTileViewModelDependencies, props: EventTileViewModelProps): void {
|
||||||
this.props = props;
|
const normalizedProps = EventTileViewModel.normalizeDependencies(dependencies, props);
|
||||||
this.snapshot.set(EventTileViewModel.createRenderState(props));
|
this.props = normalizedProps;
|
||||||
|
this.snapshot.set(EventTileViewModel.createRenderState(normalizedProps));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override dispose(): void {
|
public override dispose(): void {
|
||||||
@@ -447,7 +536,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Derives render-ready EventTile state from component-owned inputs. */
|
/** Derives render-ready EventTile state from component-owned inputs. */
|
||||||
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
|
public static createRenderState(props: NormalizedEventTileViewModelProps): EventTileRenderState {
|
||||||
const snapshot = EventTileViewModel.createSnapshot(props);
|
const snapshot = EventTileViewModel.createSnapshot(props);
|
||||||
const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout;
|
const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout;
|
||||||
const showPadlock = !props.display.isBubbleMessage;
|
const showPadlock = !props.display.isBubbleMessage;
|
||||||
@@ -459,6 +548,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
ariaLive: snapshot.root.ariaLive,
|
ariaLive: snapshot.root.ariaLive,
|
||||||
scrollToken: snapshot.root.scrollToken,
|
scrollToken: snapshot.root.scrollToken,
|
||||||
isRenderingNotification: snapshot.event.isRenderingNotification,
|
isRenderingNotification: snapshot.event.isRenderingNotification,
|
||||||
|
data: snapshot.root.data,
|
||||||
},
|
},
|
||||||
line: {
|
line: {
|
||||||
className: classNames("mx_EventTile_line", snapshot.line.classState),
|
className: classNames("mx_EventTile_line", snapshot.line.classState),
|
||||||
@@ -481,18 +571,78 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives pure inputs from application dependencies while keeping the VM's public props SDK-free.
|
||||||
|
*/
|
||||||
|
private static normalizeDependencies(
|
||||||
|
dependencies: EventTileViewModelDependencies,
|
||||||
|
props: EventTileViewModelProps,
|
||||||
|
): NormalizedEventTileViewModelProps {
|
||||||
|
const { mxEvent } = dependencies;
|
||||||
|
const eventType = mxEvent.getType();
|
||||||
|
const displayInfo = getEventDisplayInfo(
|
||||||
|
dependencies.matrixClient,
|
||||||
|
mxEvent,
|
||||||
|
dependencies.showHiddenEvents,
|
||||||
|
dependencies.hideEvent,
|
||||||
|
);
|
||||||
|
const replyChainState = getEventTileReplyChainState({
|
||||||
|
mxEvent,
|
||||||
|
// Replacement events have a fallback tile but must not show their own reply chain
|
||||||
|
hasRenderer: haveRendererForEvent(mxEvent, dependencies.matrixClient, dependencies.showHiddenEvents),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
event: {
|
||||||
|
...props.event,
|
||||||
|
eventType,
|
||||||
|
msgtype: mxEvent.getContent().msgtype,
|
||||||
|
eventTs: mxEvent.getTs(),
|
||||||
|
eventId: mxEvent.getId() ?? undefined,
|
||||||
|
replacingEventId: mxEvent.replacingEventId() ?? undefined,
|
||||||
|
isState: mxEvent.isState(),
|
||||||
|
isLocalEcho: !!mxEvent.status,
|
||||||
|
isRoomCreate: eventType === EventType.RoomCreate,
|
||||||
|
isCallInvite: eventType === EventType.CallInvite,
|
||||||
|
isRtcNotification: eventType === EventType.RTCNotification,
|
||||||
|
isEncryptionFailure: mxEvent.isDecryptionFailure(),
|
||||||
|
hasRenderer: displayInfo.hasRenderer,
|
||||||
|
isSeeingThroughMessageHiddenForModeration: displayInfo.isSeeingThroughMessageHiddenForModeration,
|
||||||
|
hasReplyChain: replyChainState.shouldShowReplyChain,
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
...props.display,
|
||||||
|
...displayInfo,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
...props.sender,
|
||||||
|
senderId: mxEvent.getSender() ?? undefined,
|
||||||
|
isEmote: mxEvent.getContent().msgtype === MsgType.Emote,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Creates an EventTile view model snapshot. */
|
/** Creates an EventTile view model snapshot. */
|
||||||
public static createSnapshot(props: EventTileViewModelProps): EventTileViewModelSnapshot {
|
public static createSnapshot(props: NormalizedEventTileViewModelProps): EventTileViewModelSnapshot {
|
||||||
const { event, display, interaction, sender, timestamp, footer } = props;
|
const { event, display, interaction, sender, timestamp, footer } = props;
|
||||||
const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout);
|
const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout);
|
||||||
const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification;
|
const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification;
|
||||||
const eventSnapshot: EventTileEventSnapshot = {
|
const eventSnapshot: EventTileEventSnapshot = {
|
||||||
eventType: event.eventType,
|
eventType: event.eventType,
|
||||||
msgtype: event.msgtype,
|
msgtype: event.msgtype,
|
||||||
|
eventId: event.eventId,
|
||||||
|
replacingEventId: event.replacingEventId,
|
||||||
|
isState: event.isState,
|
||||||
|
eventTs: event.eventTs,
|
||||||
|
isLocalEcho: event.isLocalEcho,
|
||||||
isSending: event.isSending,
|
isSending: event.isSending,
|
||||||
isEditing: event.isEditing,
|
isEditing: event.isEditing,
|
||||||
isContinuation,
|
isContinuation,
|
||||||
isRenderingNotification,
|
isRenderingNotification,
|
||||||
|
isEncryptionFailure: event.isEncryptionFailure,
|
||||||
|
hasRenderer: event.hasRenderer,
|
||||||
|
isSeeingThroughMessageHiddenForModeration: event.isSeeingThroughMessageHiddenForModeration,
|
||||||
};
|
};
|
||||||
const senderProfileState = getEventTileSenderProfileState({
|
const senderProfileState = getEventTileSenderProfileState({
|
||||||
isRenderingNotification,
|
isRenderingNotification,
|
||||||
@@ -531,6 +681,13 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
isLocalEcho: event.isLocalEcho,
|
isLocalEcho: event.isLocalEcho,
|
||||||
}),
|
}),
|
||||||
|
data: {
|
||||||
|
eventId: event.eventId,
|
||||||
|
layout: display.layout,
|
||||||
|
shape: display.timelineRenderingType,
|
||||||
|
isOwnEvent: footer.isOwnEvent,
|
||||||
|
hasReply: event.hasReplyChain,
|
||||||
|
},
|
||||||
classState: EventTileViewModel.getClassState({
|
classState: EventTileViewModel.getClassState({
|
||||||
event,
|
event,
|
||||||
display,
|
display,
|
||||||
@@ -565,7 +722,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,
|
isEmote: sender.isEmote ?? false,
|
||||||
},
|
},
|
||||||
actionBar: {
|
actionBar: {
|
||||||
show: getShouldShowMessageActionBar({
|
show: getShouldShowMessageActionBar({
|
||||||
@@ -616,8 +773,8 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
isContinuation,
|
isContinuation,
|
||||||
isRenderingNotification,
|
isRenderingNotification,
|
||||||
}: {
|
}: {
|
||||||
event: EventTileEventInput;
|
event: EventTileDerivedEventInput;
|
||||||
display: EventTileDisplayInput;
|
display: EventTileDerivedDisplayInput;
|
||||||
interaction: EventTileInteractionInput;
|
interaction: EventTileInteractionInput;
|
||||||
sender: EventTileSenderInput;
|
sender: EventTileSenderInput;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
|
|||||||
@@ -292,6 +292,15 @@ describe("EventTile", () => {
|
|||||||
expect(getTile(container)).toContainElement(getLine(container));
|
expect(getTile(container)).toContainElement(getLine(container));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the existing root and line markup", () => {
|
||||||
|
const { container } = getComponent();
|
||||||
|
const tile = getTile(container);
|
||||||
|
|
||||||
|
expect(tile.tagName).toBe("LI");
|
||||||
|
expect(tile).toContainElement(getLine(container));
|
||||||
|
expect(getLine(container)).toHaveClass("mx_EventTile_line");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not expose a scroll token for local echo events", () => {
|
it("does not expose a scroll token for local echo events", () => {
|
||||||
const localEcho = makeOwnMessage();
|
const localEcho = makeOwnMessage();
|
||||||
localEcho.setStatus(EventStatus.SENDING);
|
localEcho.setStatus(EventStatus.SENDING);
|
||||||
|
|||||||
@@ -7,14 +7,36 @@
|
|||||||
|
|
||||||
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
||||||
import { Layout } from "../../../src/settings/enums/Layout";
|
import { Layout } from "../../../src/settings/enums/Layout";
|
||||||
|
import { mkEvent, stubClient } from "../../test-utils";
|
||||||
import {
|
import {
|
||||||
EventTileViewModel,
|
EventTileViewModel,
|
||||||
|
type EventTileViewModelDependencies,
|
||||||
|
type NormalizedEventTileViewModelProps,
|
||||||
type EventTileViewModelProps,
|
type EventTileViewModelProps,
|
||||||
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
|
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
describe("EventTileViewModel", () => {
|
describe("EventTileViewModel", () => {
|
||||||
|
const matrixClient = stubClient();
|
||||||
|
|
||||||
|
const makeEvent = () =>
|
||||||
|
mkEvent({
|
||||||
|
event: true,
|
||||||
|
id: "$event",
|
||||||
|
room: "!room:example.org",
|
||||||
|
ts: 123,
|
||||||
|
type: "m.room.message",
|
||||||
|
user: "@alice:example.org",
|
||||||
|
content: { msgtype: "m.text" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeDependencies = (mxEvent = makeEvent()): EventTileViewModelDependencies => ({
|
||||||
|
mxEvent,
|
||||||
|
matrixClient,
|
||||||
|
showHiddenEvents: false,
|
||||||
|
});
|
||||||
|
|
||||||
type EventTileViewModelPropsOverrides = {
|
type EventTileViewModelPropsOverrides = {
|
||||||
event?: Partial<EventTileViewModelProps["event"]>;
|
event?: Partial<NormalizedEventTileViewModelProps["event"]>;
|
||||||
display?: Partial<EventTileViewModelProps["display"]>;
|
display?: Partial<EventTileViewModelProps["display"]>;
|
||||||
interaction?: Partial<EventTileViewModelProps["interaction"]>;
|
interaction?: Partial<EventTileViewModelProps["interaction"]>;
|
||||||
sender?: Partial<EventTileViewModelProps["sender"]>;
|
sender?: Partial<EventTileViewModelProps["sender"]>;
|
||||||
@@ -22,13 +44,15 @@ describe("EventTileViewModel", () => {
|
|||||||
footer?: Partial<EventTileViewModelProps["footer"]>;
|
footer?: Partial<EventTileViewModelProps["footer"]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): EventTileViewModelProps {
|
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): NormalizedEventTileViewModelProps {
|
||||||
return {
|
return {
|
||||||
event: {
|
event: {
|
||||||
eventType: "m.room.message",
|
eventType: "m.room.message",
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
eventTs: 123,
|
eventTs: 123,
|
||||||
eventId: "$event",
|
eventId: "$event",
|
||||||
|
isState: false,
|
||||||
|
hasReplyChain: false,
|
||||||
isLocalEcho: false,
|
isLocalEcho: false,
|
||||||
isSending: false,
|
isSending: false,
|
||||||
ariaLive: "off",
|
ariaLive: "off",
|
||||||
@@ -37,6 +61,8 @@ describe("EventTileViewModel", () => {
|
|||||||
isRtcNotification: false,
|
isRtcNotification: false,
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
isEncryptionFailure: false,
|
isEncryptionFailure: false,
|
||||||
|
hasRenderer: true,
|
||||||
|
isSeeingThroughMessageHiddenForModeration: false,
|
||||||
forExport: false,
|
forExport: false,
|
||||||
...overrides.event,
|
...overrides.event,
|
||||||
},
|
},
|
||||||
@@ -91,8 +117,21 @@ describe("EventTileViewModel", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(snapshot.event.isSending).toBe(true);
|
expect(snapshot.event.isSending).toBe(true);
|
||||||
|
expect(snapshot.event).toMatchObject({
|
||||||
|
eventId: "$event",
|
||||||
|
eventTs: 123,
|
||||||
|
isLocalEcho: true,
|
||||||
|
isEncryptionFailure: false,
|
||||||
|
});
|
||||||
expect(snapshot.root.ariaLive).toBe("off");
|
expect(snapshot.root.ariaLive).toBe("off");
|
||||||
expect(snapshot.root.scrollToken).toBeUndefined();
|
expect(snapshot.root.scrollToken).toBeUndefined();
|
||||||
|
expect(snapshot.root.data).toEqual({
|
||||||
|
eventId: "$event",
|
||||||
|
layout: Layout.Group,
|
||||||
|
shape: TimelineRenderingType.Room,
|
||||||
|
isOwnEvent: false,
|
||||||
|
hasReply: false,
|
||||||
|
});
|
||||||
expect(snapshot.root.classState.mx_EventTile_sending).toBe(true);
|
expect(snapshot.root.classState.mx_EventTile_sending).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -468,13 +507,13 @@ describe("EventTileViewModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates an instance snapshot when inputs change", () => {
|
it("updates an instance snapshot when inputs change", () => {
|
||||||
const vm = new EventTileViewModel(makeProps());
|
const vm = new EventTileViewModel(makeDependencies(), makeProps());
|
||||||
const listener = jest.fn();
|
const listener = jest.fn();
|
||||||
const unsubscribe = vm.subscribe(listener);
|
const unsubscribe = vm.subscribe(listener);
|
||||||
|
|
||||||
expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false);
|
expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false);
|
||||||
|
|
||||||
vm.setProps(makeProps({ interaction: { hover: true } }));
|
vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } }));
|
||||||
|
|
||||||
expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true);
|
expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true);
|
||||||
expect(listener).toHaveBeenCalled();
|
expect(listener).toHaveBeenCalled();
|
||||||
@@ -483,8 +522,162 @@ describe("EventTileViewModel", () => {
|
|||||||
vm.dispose();
|
vm.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits once when dependencies and inputs are updated together", () => {
|
||||||
|
const vm = new EventTileViewModel(makeDependencies(), makeProps());
|
||||||
|
const listener = jest.fn();
|
||||||
|
const unsubscribe = vm.subscribe(listener);
|
||||||
|
|
||||||
|
vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } }));
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
vm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes event and sender state from its SDK dependency", () => {
|
||||||
|
const event = mkEvent({
|
||||||
|
event: true,
|
||||||
|
id: "$member-event",
|
||||||
|
room: "!room:example.org",
|
||||||
|
ts: 456,
|
||||||
|
type: "m.room.member",
|
||||||
|
user: "@bob:example.org",
|
||||||
|
content: { membership: "join" },
|
||||||
|
});
|
||||||
|
const vm = new EventTileViewModel(
|
||||||
|
makeDependencies(event),
|
||||||
|
makeProps({
|
||||||
|
event: {
|
||||||
|
eventType: "m.room.message",
|
||||||
|
eventId: "$wrong-event",
|
||||||
|
eventTs: 123,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
senderId: "@wrong:example.org",
|
||||||
|
isEmote: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().snapshot.event).toMatchObject({
|
||||||
|
eventType: "m.room.member",
|
||||||
|
eventId: "$member-event",
|
||||||
|
eventTs: 456,
|
||||||
|
isState: true,
|
||||||
|
});
|
||||||
|
expect(vm.getSnapshot().snapshot.sender).toMatchObject({
|
||||||
|
senderId: "@bob:example.org",
|
||||||
|
forceHistoricalAvatar: true,
|
||||||
|
isEmote: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vm.setInputs(
|
||||||
|
makeDependencies(
|
||||||
|
mkEvent({
|
||||||
|
event: true,
|
||||||
|
id: "$updated-event",
|
||||||
|
room: "!room:example.org",
|
||||||
|
ts: 789,
|
||||||
|
type: "m.call.invite",
|
||||||
|
user: "@carol:example.org",
|
||||||
|
content: { msgtype: "m.call.invite" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
makeProps(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().snapshot.event).toMatchObject({
|
||||||
|
eventType: "m.call.invite",
|
||||||
|
eventId: "$updated-event",
|
||||||
|
eventTs: 789,
|
||||||
|
});
|
||||||
|
expect(vm.getSnapshot().snapshot.sender.senderId).toBe("@carol:example.org");
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes event identity, replacement, renderer, and decryption state", () => {
|
||||||
|
const event = makeEvent();
|
||||||
|
jest.spyOn(event, "replacingEventId").mockReturnValue("$replaced-event");
|
||||||
|
jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true);
|
||||||
|
|
||||||
|
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
|
||||||
|
const snapshot = vm.getSnapshot().snapshot;
|
||||||
|
|
||||||
|
expect(snapshot.event).toMatchObject({
|
||||||
|
eventType: "m.room.message",
|
||||||
|
eventId: "$event",
|
||||||
|
replacingEventId: "$replaced-event",
|
||||||
|
isEncryptionFailure: true,
|
||||||
|
hasRenderer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show a reply chain for replacement events", () => {
|
||||||
|
const event = mkEvent({
|
||||||
|
event: true,
|
||||||
|
id: "$replacement-event",
|
||||||
|
room: "!room:example.org",
|
||||||
|
ts: 123,
|
||||||
|
type: "m.room.message",
|
||||||
|
user: "@alice:example.org",
|
||||||
|
content: {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"m.relates_to": {
|
||||||
|
"rel_type": "m.replace",
|
||||||
|
"event_id": "$original-event",
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$parent-event",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().snapshot.root.data.hasReply).toBe(false);
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives an unavailable renderer for unsupported events", () => {
|
||||||
|
const event = mkEvent({
|
||||||
|
event: true,
|
||||||
|
room: "!room:example.org",
|
||||||
|
type: "org.example.unsupported",
|
||||||
|
user: "@alice:example.org",
|
||||||
|
content: {},
|
||||||
|
});
|
||||||
|
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false);
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates renderer state when dependencies change", () => {
|
||||||
|
const event = mkEvent({
|
||||||
|
event: true,
|
||||||
|
room: "!room:example.org",
|
||||||
|
type: "org.example.unsupported",
|
||||||
|
user: "@alice:example.org",
|
||||||
|
content: {},
|
||||||
|
});
|
||||||
|
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false);
|
||||||
|
|
||||||
|
vm.setInputs(makeDependencies(makeEvent()), makeProps());
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(true);
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("lazily owns timestamp child view models", () => {
|
it("lazily owns timestamp child view models", () => {
|
||||||
const vm = new EventTileViewModel(makeProps());
|
const vm = new EventTileViewModel(makeDependencies(), makeProps());
|
||||||
const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 });
|
const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 });
|
||||||
const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 });
|
const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 });
|
||||||
|
|
||||||
@@ -496,6 +689,7 @@ describe("EventTileViewModel", () => {
|
|||||||
|
|
||||||
it("does not initialize timestamp child view models for events without an origin timestamp", () => {
|
it("does not initialize timestamp child view models for events without an origin timestamp", () => {
|
||||||
const vm = new EventTileViewModel(
|
const vm = new EventTileViewModel(
|
||||||
|
makeDependencies(),
|
||||||
makeProps({
|
makeProps({
|
||||||
event: {
|
event: {
|
||||||
eventTs: 0,
|
eventTs: 0,
|
||||||
@@ -512,7 +706,7 @@ describe("EventTileViewModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("owns and updates the thread-list action bar child view model", () => {
|
it("owns and updates the thread-list action bar child view model", () => {
|
||||||
const vm = new EventTileViewModel(makeProps());
|
const vm = new EventTileViewModel(makeDependencies(), makeProps());
|
||||||
const onViewInRoomClick = jest.fn();
|
const onViewInRoomClick = jest.fn();
|
||||||
const onCopyLinkClick = jest.fn();
|
const onCopyLinkClick = jest.fn();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user