* Thin EventTile render state wiring * Move EventTile E2E verification into a view model * Derive EventTile E2E padlock slots in view model * Derive EventTile timestamp slots in render state * Derive EventTile footer slots in render state * Fix SonarCloud issues
This commit is contained in:
@@ -20,7 +20,6 @@ import React, {
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
type EventStatus,
|
||||
EventType,
|
||||
@@ -36,12 +35,6 @@ import {
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
||||
import {
|
||||
CryptoEvent,
|
||||
EventShieldColour,
|
||||
type EventShieldReason,
|
||||
type UserVerificationStatus,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { uniqueId, uniqBy } from "lodash";
|
||||
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
@@ -89,7 +82,6 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event
|
||||
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
|
||||
import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
||||
@@ -142,7 +134,7 @@ import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadLis
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||
import { getEventTileE2ePadlockViewState } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eState";
|
||||
import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
||||
|
||||
/** Relation lookup type retained for EventTile consumers. */
|
||||
export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||
@@ -292,18 +284,6 @@ export interface EventTileProps {
|
||||
interface IState {
|
||||
interaction: EventTileInteractionState;
|
||||
|
||||
/**
|
||||
* E2EE shield we should show for decryption problems.
|
||||
*
|
||||
* Note this will be `EventShieldColour.NONE` for all unencrypted events, **including those in encrypted rooms**.
|
||||
*/
|
||||
shieldColour: EventShieldColour;
|
||||
|
||||
/**
|
||||
* Reason code for the E2EE shield. `null` if `shieldColour` is `EventShieldColour.NONE`
|
||||
*/
|
||||
shieldReason: EventShieldReason | null;
|
||||
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions?: Relations | null | undefined;
|
||||
|
||||
@@ -318,6 +298,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
private isListeningForReceipts: boolean;
|
||||
private tile = createRef<IEventTileType>();
|
||||
private replyChain = createRef<ReplyChain>();
|
||||
private readonly e2eViewModel: EventTileE2eViewModel;
|
||||
private e2eViewModelSubscription?: () => void;
|
||||
|
||||
public readonly ref = createRef<HTMLElement>();
|
||||
|
||||
@@ -329,7 +311,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private unmounted = false;
|
||||
private readonly id = uniqueId();
|
||||
private staleHoverCheckActive = false;
|
||||
|
||||
@@ -344,15 +325,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
this.state = {
|
||||
interaction: initialEventTileInteractionState,
|
||||
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions: this.getReactions(),
|
||||
|
||||
thread,
|
||||
};
|
||||
|
||||
this.e2eViewModel = new EventTileE2eViewModel({
|
||||
cli: MatrixClientPeg.safeGet(),
|
||||
mxEvent: this.props.mxEvent,
|
||||
isRoomEncrypted: this.context.isRoomEncrypted,
|
||||
eventSendStatus: this.props.eventSendStatus,
|
||||
enableListeners: !this.props.forExport,
|
||||
});
|
||||
|
||||
// don't do RR animations until we are mounted
|
||||
this.suppressReadReceiptAnimation = true;
|
||||
|
||||
@@ -379,11 +365,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
this.suppressReadReceiptAnimation = false;
|
||||
this.e2eViewModelSubscription = this.e2eViewModel.subscribe(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
this.e2eViewModel.start();
|
||||
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
if (!this.props.forExport) {
|
||||
client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
|
||||
this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted);
|
||||
this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced);
|
||||
DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent);
|
||||
@@ -403,8 +392,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
|
||||
const room = client.getRoom(this.props.mxEvent.getRoomId());
|
||||
room?.on(ThreadEvent.New, this.onNewThread);
|
||||
|
||||
this.verifyEvent();
|
||||
}
|
||||
|
||||
private readonly updateThread = (thread: Thread): void => {
|
||||
@@ -423,7 +410,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
this.stopStaleHoverCheck();
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
|
||||
client.removeListener(RoomEvent.Receipt, this.onRoomReceipt);
|
||||
const room = client.getRoom(this.props.mxEvent.getRoomId());
|
||||
room?.off(ThreadEvent.New, this.onNewThread);
|
||||
@@ -435,11 +421,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
|
||||
}
|
||||
this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
|
||||
this.unmounted = false;
|
||||
this.e2eViewModelSubscription?.();
|
||||
this.e2eViewModelSubscription = undefined;
|
||||
this.e2eViewModel.dispose();
|
||||
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): void {
|
||||
public componentDidUpdate(_prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): void {
|
||||
// Some overlays, such as portalled tooltips, can interrupt the normal mouseleave path.
|
||||
// While hover is active, verify it against the browser's real :hover state on mouse movement.
|
||||
if (!prevState.interaction.hover && this.state.interaction.hover) {
|
||||
@@ -453,10 +441,12 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt);
|
||||
this.isListeningForReceipts = true;
|
||||
}
|
||||
// re-check the sender verification as outgoing events progress through the send process.
|
||||
if (prevProps.eventSendStatus !== this.props.eventSendStatus) {
|
||||
this.verifyEvent();
|
||||
}
|
||||
this.e2eViewModel.setProps({
|
||||
mxEvent: this.props.mxEvent,
|
||||
isRoomEncrypted: this.context.isRoomEncrypted,
|
||||
eventSendStatus: this.props.eventSendStatus,
|
||||
enableListeners: !this.props.forExport,
|
||||
});
|
||||
|
||||
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current);
|
||||
|
||||
@@ -571,53 +561,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
/** called when the event is decrypted after we show it.
|
||||
*/
|
||||
private readonly onDecrypted = (): void => {
|
||||
// we need to re-verify the sending device.
|
||||
this.verifyEvent();
|
||||
// E2E padlock verification is handled by EventTileE2eViewModel; this refreshes the rest of the tile body.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => {
|
||||
if (userId === this.props.mxEvent.getSender()) {
|
||||
this.verifyEvent();
|
||||
}
|
||||
};
|
||||
|
||||
/** called when the event is edited after we show it. */
|
||||
private readonly onReplaced = (): void => {
|
||||
// E2E padlock verification is handled by EventTileE2eViewModel; this refreshes the rest of the tile body.
|
||||
this.forceUpdate();
|
||||
// re-verify the event if it is replaced (the edit may not be verified)
|
||||
this.verifyEvent();
|
||||
};
|
||||
|
||||
private verifyEvent(): void {
|
||||
this.doVerifyEvent().catch((e) => {
|
||||
const event = this.props.mxEvent;
|
||||
logger.error(`Error getting encryption info on event ${event.getId()} in room ${event.getRoomId()}`, e);
|
||||
});
|
||||
}
|
||||
|
||||
private async doVerifyEvent(): Promise<void> {
|
||||
// if the event was edited, show the verification info for the edit, not
|
||||
// the original
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
|
||||
if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) {
|
||||
this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptionInfo =
|
||||
(await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null;
|
||||
if (this.unmounted) return;
|
||||
if (encryptionInfo === null) {
|
||||
// likely a decryption error
|
||||
this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason });
|
||||
}
|
||||
|
||||
private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean {
|
||||
const keysA = Object.keys(objA) as Array<keyof EventTileProps>;
|
||||
const keysB = Object.keys(objB) as Array<keyof EventTileProps>;
|
||||
@@ -725,17 +678,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
};
|
||||
|
||||
private renderE2EPadlock(): ReactNode {
|
||||
// if the event was edited, show the verification info for the edit, not
|
||||
// the original
|
||||
const verificationEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
const e2ePadlockViewState = getEventTileE2ePadlockViewState({
|
||||
mxEvent: this.props.mxEvent,
|
||||
verificationEvent,
|
||||
shieldColour: this.state.shieldColour,
|
||||
shieldReason: this.state.shieldReason,
|
||||
isRoomEncrypted: this.context.isRoomEncrypted,
|
||||
isLocalRoom: isLocalRoom(verificationEvent.getRoomId()!),
|
||||
});
|
||||
const e2ePadlockViewState = this.e2eViewModel.getSnapshot();
|
||||
|
||||
switch (e2ePadlockViewState.kind) {
|
||||
case "none":
|
||||
@@ -964,7 +907,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
// Use `getSender()` because searched events might not have a proper `sender`.
|
||||
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
|
||||
|
||||
const eventTileSnapshot = EventTileViewModel.createSnapshot({
|
||||
const eventTileRenderState = EventTileViewModel.createRenderState({
|
||||
event: {
|
||||
mxEvent: this.props.mxEvent,
|
||||
eventSendStatus: this.props.eventSendStatus,
|
||||
@@ -1012,11 +955,12 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
hasPinnedMessageBadge,
|
||||
},
|
||||
});
|
||||
const eventTileSnapshot = eventTileRenderState.snapshot;
|
||||
|
||||
const lineClasses = classNames("mx_EventTile_line", eventTileSnapshot.line.classState);
|
||||
const tileClasses = classNames(eventTileSnapshot.root.classState);
|
||||
const tileAriaLive = eventTileSnapshot.root.ariaLive;
|
||||
const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification;
|
||||
const lineClasses = eventTileRenderState.line.className;
|
||||
const tileClasses = eventTileRenderState.root.className;
|
||||
const tileAriaLive = eventTileRenderState.root.ariaLive;
|
||||
const isRenderingNotification = eventTileRenderState.root.isRenderingNotification;
|
||||
|
||||
let permalink = "#";
|
||||
if (this.props.permalinkCreator) {
|
||||
@@ -1025,7 +969,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
|
||||
// we can't use local echoes as scroll tokens, because their event IDs change.
|
||||
// Local echos have a send "status".
|
||||
const scrollToken = eventTileSnapshot.root.scrollToken;
|
||||
const scrollToken = eventTileRenderState.root.scrollToken;
|
||||
|
||||
let avatar: JSX.Element | null = null;
|
||||
let sender: JSX.Element | null = null;
|
||||
@@ -1068,7 +1012,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
) : undefined;
|
||||
|
||||
// Thread panel shows the timestamp of the last reply in that thread
|
||||
const ts = eventTileSnapshot.timestamp.value;
|
||||
const ts = eventTileRenderState.timestamp.value;
|
||||
|
||||
const messageTimestampProps: MessageTimestampViewModelProps = {
|
||||
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
|
||||
@@ -1086,11 +1030,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
/>
|
||||
);
|
||||
|
||||
const { useIRCLayout, showRealTimestamp, showLinkedTimestamp } = eventTileSnapshot.timestamp.displayState;
|
||||
// Used to simplify the UI layout where necessary by not conditionally rendering an element at the start
|
||||
const dummyTimestamp = useIRCLayout ? <span className="mx_MessageTimestamp" /> : null;
|
||||
const timestamp = showRealTimestamp ? messageTimestamp : dummyTimestamp;
|
||||
const linkedTimestamp = showLinkedTimestamp ? linkedMessageTimestamp : dummyTimestamp;
|
||||
const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? (
|
||||
<span className="mx_MessageTimestamp" />
|
||||
) : null;
|
||||
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp
|
||||
? messageTimestamp
|
||||
: dummyTimestamp;
|
||||
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp
|
||||
? linkedMessageTimestamp
|
||||
: dummyTimestamp;
|
||||
|
||||
let pinnedMessageBadge: JSX.Element | undefined;
|
||||
if (hasPinnedMessageBadge) {
|
||||
@@ -1108,10 +1057,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
|
||||
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
|
||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
const groupTimestamp = eventTileRenderState.timestamp.showInGroupLine ? linkedTimestamp : null;
|
||||
const ircTimestamp = eventTileRenderState.timestamp.showInIrcLine ? linkedTimestamp : null;
|
||||
const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock();
|
||||
const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock();
|
||||
|
||||
const receiptState = this.receiptState;
|
||||
let msgOption: JSX.Element | undefined;
|
||||
@@ -1154,7 +1103,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
const { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = eventTileSnapshot.footer;
|
||||
const { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = eventTileRenderState.footer;
|
||||
|
||||
switch (this.context.timelineRenderingType) {
|
||||
case TimelineRenderingType.Thread: {
|
||||
@@ -1400,7 +1349,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
{this.props.layout === Layout.IRC && (
|
||||
{eventTileRenderState.footer.showInIrcLayout && (
|
||||
<>
|
||||
{hasFooter && (
|
||||
<div className="mx_EventTile_footer">
|
||||
@@ -1412,7 +1361,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{this.props.layout !== Layout.IRC && (
|
||||
{eventTileRenderState.footer.showInDefaultLayout && (
|
||||
<>
|
||||
{hasFooter && (
|
||||
<div className="mx_EventTile_footer">
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
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 { type MatrixClient, MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CryptoEvent, EventShieldColour, type EventShieldReason } from "matrix-js-sdk/src/crypto-api";
|
||||
import { BaseViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
|
||||
import { objectHasDiff } from "../../../../utils/objects";
|
||||
import { getEventTileE2ePadlockViewState, type EventTileE2ePadlockViewState } from "./EventTileE2eState";
|
||||
|
||||
export interface EventTileE2eViewModelProps {
|
||||
/** Matrix client used for crypto verification lookups. */
|
||||
cli: MatrixClient;
|
||||
/** Matrix event rendered by the tile. */
|
||||
mxEvent: MatrixEvent;
|
||||
/** Whether the room is encrypted. */
|
||||
isRoomEncrypted?: boolean | null;
|
||||
/** Current event send status. Used to re-check verification as local echoes progress. */
|
||||
eventSendStatus?: MatrixEvent["status"] | null;
|
||||
/** Whether the view model should register live event and trust listeners. */
|
||||
enableListeners: boolean;
|
||||
/** Optional local-room predicate for tests. */
|
||||
isLocalRoom?: (roomId: string) => boolean;
|
||||
}
|
||||
|
||||
interface EventTileE2eShieldState {
|
||||
shieldColour: EventShieldColour;
|
||||
shieldReason: EventShieldReason | null;
|
||||
}
|
||||
|
||||
/** View model for EventTile E2E padlock state and verification refreshes. */
|
||||
export class EventTileE2eViewModel extends BaseViewModel<EventTileE2ePadlockViewState, EventTileE2eViewModelProps> {
|
||||
private shieldState: EventTileE2eShieldState = {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
};
|
||||
private listenerCleanups: Array<() => void> = [];
|
||||
private started = false;
|
||||
private verificationRequestId = 0;
|
||||
|
||||
public constructor(props: EventTileE2eViewModelProps) {
|
||||
super(
|
||||
props,
|
||||
EventTileE2eViewModel.calculateSnapshot(props, {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private static calculateSnapshot(
|
||||
props: EventTileE2eViewModelProps,
|
||||
shieldState: EventTileE2eShieldState,
|
||||
): EventTileE2ePadlockViewState {
|
||||
const verificationEvent = props.mxEvent.replacingEvent() ?? props.mxEvent;
|
||||
const roomId = verificationEvent.getRoomId()!;
|
||||
|
||||
return getEventTileE2ePadlockViewState({
|
||||
mxEvent: props.mxEvent,
|
||||
verificationEvent,
|
||||
shieldColour: shieldState.shieldColour,
|
||||
shieldReason: shieldState.shieldReason,
|
||||
isRoomEncrypted: props.isRoomEncrypted,
|
||||
isLocalRoom: props.isLocalRoom?.(roomId) ?? isLocalRoom(roomId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts live listeners and runs the initial verification check. */
|
||||
public start(): void {
|
||||
if (this.started) return;
|
||||
|
||||
this.started = true;
|
||||
this.setupListeners();
|
||||
this.verifyEvent();
|
||||
}
|
||||
|
||||
/** Updates inputs, refreshes listeners when the event changes, and re-checks verification when needed. */
|
||||
public setProps(newProps: Partial<EventTileE2eViewModelProps>): void {
|
||||
const prevProps = this.props;
|
||||
const prevEvent = this.props.mxEvent;
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
...newProps,
|
||||
};
|
||||
|
||||
const eventChanged = prevEvent !== this.props.mxEvent;
|
||||
const shouldVerify =
|
||||
eventChanged ||
|
||||
prevProps.eventSendStatus !== this.props.eventSendStatus ||
|
||||
prevProps.enableListeners !== this.props.enableListeners;
|
||||
|
||||
if (eventChanged) {
|
||||
this.shieldState = {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
};
|
||||
this.refreshSnapshot();
|
||||
this.setupListeners();
|
||||
} else if (prevProps.enableListeners !== this.props.enableListeners) {
|
||||
this.setupListeners();
|
||||
} else if (prevProps.isRoomEncrypted !== this.props.isRoomEncrypted) {
|
||||
this.refreshSnapshot();
|
||||
}
|
||||
|
||||
if (shouldVerify) {
|
||||
this.verifyEvent();
|
||||
}
|
||||
}
|
||||
|
||||
public override dispose(): void {
|
||||
this.teardownListeners();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
this.teardownListeners();
|
||||
|
||||
if (!this.started || !this.props.enableListeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cli, mxEvent } = this.props;
|
||||
|
||||
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
|
||||
this.listenerCleanups.push(() => {
|
||||
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
|
||||
});
|
||||
|
||||
mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted);
|
||||
this.listenerCleanups.push(() => {
|
||||
mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
|
||||
});
|
||||
|
||||
mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced);
|
||||
this.listenerCleanups.push(() => {
|
||||
mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced);
|
||||
});
|
||||
}
|
||||
|
||||
private teardownListeners(): void {
|
||||
for (const cleanup of this.listenerCleanups) {
|
||||
cleanup();
|
||||
}
|
||||
this.listenerCleanups = [];
|
||||
}
|
||||
|
||||
private readonly onDecrypted = (): void => {
|
||||
this.verifyEvent();
|
||||
};
|
||||
|
||||
private readonly onReplaced = (): void => {
|
||||
this.verifyEvent();
|
||||
};
|
||||
|
||||
private readonly onUserVerificationChanged = (userId: string): void => {
|
||||
if (userId === this.props.mxEvent.getSender()) {
|
||||
this.verifyEvent();
|
||||
}
|
||||
};
|
||||
|
||||
private verifyEvent(): void {
|
||||
const requestId = ++this.verificationRequestId;
|
||||
|
||||
this.doVerifyEvent(requestId).catch((e) => {
|
||||
const event = this.props.mxEvent;
|
||||
logger.error(`Error getting encryption info on event ${event.getId()} in room ${event.getRoomId()}`, e);
|
||||
});
|
||||
}
|
||||
|
||||
private async doVerifyEvent(requestId: number): Promise<void> {
|
||||
const verificationEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
|
||||
if (!verificationEvent.isEncrypted() || verificationEvent.isRedacted()) {
|
||||
this.setShieldState(EventShieldColour.NONE, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptionInfo = (await this.props.cli.getCrypto()?.getEncryptionInfoForEvent(verificationEvent)) ?? null;
|
||||
if (this.isDisposed || requestId !== this.verificationRequestId) return;
|
||||
|
||||
if (encryptionInfo === null) {
|
||||
// likely a decryption error
|
||||
this.setShieldState(EventShieldColour.NONE, null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setShieldState(encryptionInfo.shieldColour, encryptionInfo.shieldReason);
|
||||
}
|
||||
|
||||
private setShieldState(shieldColour: EventShieldColour, shieldReason: EventShieldReason | null): void {
|
||||
this.shieldState = {
|
||||
shieldColour,
|
||||
shieldReason,
|
||||
};
|
||||
this.refreshSnapshot();
|
||||
}
|
||||
|
||||
private refreshSnapshot(): void {
|
||||
const nextSnapshot = EventTileE2eViewModel.calculateSnapshot(this.props, this.shieldState);
|
||||
|
||||
if (objectHasDiff(this.snapshot.current, nextSnapshot)) {
|
||||
this.snapshot.set(nextSnapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 {
|
||||
type EventTileSenderProfileState,
|
||||
@@ -229,8 +230,88 @@ export interface EventTileViewModelSnapshot {
|
||||
footer: EventTileFooterSnapshot;
|
||||
}
|
||||
|
||||
/** Render-ready EventTile state consumed by the existing component. */
|
||||
export interface EventTileRenderState {
|
||||
/** Derived EventTile view state. */
|
||||
snapshot: EventTileViewModelSnapshot;
|
||||
/** EventTile root render state. */
|
||||
root: {
|
||||
/** EventTile root CSS classes. */
|
||||
className: string;
|
||||
/** EventTile aria-live value. */
|
||||
ariaLive?: "off";
|
||||
/** Stable scroll token for the event. */
|
||||
scrollToken?: string;
|
||||
/** Whether the tile is rendering as a notification. */
|
||||
isRenderingNotification: boolean;
|
||||
};
|
||||
/** EventTile line render state. */
|
||||
line: {
|
||||
/** EventTile line CSS classes. */
|
||||
className: string;
|
||||
};
|
||||
/** EventTile timestamp render state. */
|
||||
timestamp: EventTileTimestampSnapshot & {
|
||||
/** Whether EventTile should render the placeholder timestamp used by IRC layout. */
|
||||
showDummy: boolean;
|
||||
/** Whether the timestamp slot belongs in the group-layout line. */
|
||||
showInGroupLine: boolean;
|
||||
/** Whether the timestamp slot belongs in the IRC-layout line. */
|
||||
showInIrcLine: boolean;
|
||||
};
|
||||
/** EventTile E2E padlock slot state. */
|
||||
e2ePadlock: {
|
||||
/** Whether the padlock should render in the group-layout timestamp area. */
|
||||
showInGroupLine: boolean;
|
||||
/** Whether the padlock should render in the IRC-layout timestamp area. */
|
||||
showInIrcLine: boolean;
|
||||
};
|
||||
/** EventTile footer slot state. */
|
||||
footer: EventTileFooterSnapshot & {
|
||||
/** Whether the footer belongs inside the IRC-layout message line. */
|
||||
showInIrcLayout: boolean;
|
||||
/** Whether the footer belongs below the message line. */
|
||||
showInDefaultLayout: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Derives the current EventTile snapshot from component-owned inputs. */
|
||||
export class EventTileViewModel {
|
||||
/** Derives render-ready EventTile state from component-owned inputs. */
|
||||
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
|
||||
const snapshot = EventTileViewModel.createSnapshot(props);
|
||||
const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout;
|
||||
const showPadlock = !props.display.isBubbleMessage;
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
root: {
|
||||
className: classNames(snapshot.root.classState),
|
||||
ariaLive: snapshot.root.ariaLive,
|
||||
scrollToken: snapshot.root.scrollToken,
|
||||
isRenderingNotification: snapshot.event.isRenderingNotification,
|
||||
},
|
||||
line: {
|
||||
className: classNames("mx_EventTile_line", snapshot.line.classState),
|
||||
},
|
||||
timestamp: {
|
||||
...snapshot.timestamp,
|
||||
showDummy: useIRCLayout,
|
||||
showInGroupLine: !useIRCLayout,
|
||||
showInIrcLine: useIRCLayout,
|
||||
},
|
||||
e2ePadlock: {
|
||||
showInGroupLine: !useIRCLayout && showPadlock,
|
||||
showInIrcLine: useIRCLayout && showPadlock,
|
||||
},
|
||||
footer: {
|
||||
...snapshot.footer,
|
||||
showInIrcLayout: useIRCLayout,
|
||||
showInDefaultLayout: !useIRCLayout,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an EventTile view model snapshot. */
|
||||
public static createSnapshot(props: EventTileViewModelProps): EventTileViewModelSnapshot {
|
||||
const { event, display, interaction, sender, timestamp, footer } = props;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
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 { waitFor } from "@testing-library/dom";
|
||||
import { EventType, MatrixEventEvent, type MatrixClient, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
CryptoEvent,
|
||||
type EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import { E2ePadlockIcon } from "@element-hq/web-shared-components";
|
||||
|
||||
import { mkEvent, MockClientWithEventEmitter } from "../../test-utils";
|
||||
import { EventTileE2eViewModel } from "../../../src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
||||
|
||||
function makeClient(
|
||||
getEncryptionInfoForEvent = jest.fn<Promise<EventEncryptionInfo | null>, [MatrixEvent]>(),
|
||||
): MockClientWithEventEmitter & MatrixClient {
|
||||
return new MockClientWithEventEmitter({
|
||||
getCrypto: jest.fn(() => ({
|
||||
getEncryptionInfoForEvent,
|
||||
})),
|
||||
}) as MockClientWithEventEmitter & MatrixClient;
|
||||
}
|
||||
|
||||
describe("EventTileE2eViewModel", () => {
|
||||
const roomId = "!room:example.org";
|
||||
const userId = "@alice:example.org";
|
||||
let viewModels: EventTileE2eViewModel[];
|
||||
|
||||
beforeEach(() => {
|
||||
viewModels = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const vm of viewModels) {
|
||||
vm.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
function makeEvent(): MatrixEvent {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
room: roomId,
|
||||
user: userId,
|
||||
content: {
|
||||
msgtype: MsgType.Text,
|
||||
body: "Hello",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeViewModel(props: ConstructorParameters<typeof EventTileE2eViewModel>[0]): EventTileE2eViewModel {
|
||||
const vm = new EventTileE2eViewModel(props);
|
||||
viewModels.push(vm);
|
||||
return vm;
|
||||
}
|
||||
|
||||
it("verifies encrypted events on start", async () => {
|
||||
const mxEvent = makeEvent();
|
||||
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
||||
const cli = makeClient(
|
||||
jest.fn().mockResolvedValue({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
} as EventEncryptionInfo),
|
||||
);
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
isRoomEncrypted: false,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
kind: "icon",
|
||||
icon: E2ePadlockIcon.Normal,
|
||||
title: "Encrypted by a device not verified by its owner.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-verifies when the sender verification changes", async () => {
|
||||
const mxEvent = makeEvent();
|
||||
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
||||
const cli = makeClient(
|
||||
jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
} as EventEncryptionInfo)
|
||||
.mockResolvedValueOnce({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||
} as EventEncryptionInfo),
|
||||
);
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
await waitFor(() => expect(vm.getSnapshot().kind).toBe("icon"));
|
||||
|
||||
cli.emit(CryptoEvent.UserTrustStatusChanged, userId, {});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
kind: "icon",
|
||||
icon: E2ePadlockIcon.Warning,
|
||||
title: "Encrypted by an unknown or deleted device.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the replacing event for verification after edits", async () => {
|
||||
const mxEvent = makeEvent();
|
||||
const replacementEvent = makeEvent();
|
||||
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
||||
jest.spyOn(replacementEvent, "isEncrypted").mockReturnValue(true);
|
||||
const getEncryptionInfoForEvent = jest.fn(async (event: MatrixEvent) => {
|
||||
return {
|
||||
shieldColour: event === replacementEvent ? EventShieldColour.RED : EventShieldColour.NONE,
|
||||
shieldReason: event === replacementEvent ? EventShieldReason.UNKNOWN_DEVICE : null,
|
||||
} as EventEncryptionInfo;
|
||||
});
|
||||
const cli = makeClient(getEncryptionInfoForEvent);
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
await waitFor(() => expect(vm.getSnapshot().kind).toBe("none"));
|
||||
|
||||
mxEvent.makeReplaced(replacementEvent);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
kind: "icon",
|
||||
icon: E2ePadlockIcon.Warning,
|
||||
title: "Encrypted by an unknown or deleted device.",
|
||||
}),
|
||||
);
|
||||
expect(getEncryptionInfoForEvent).toHaveBeenLastCalledWith(replacementEvent);
|
||||
});
|
||||
|
||||
it("derives the unencrypted warning from room encryption state", () => {
|
||||
const vm = makeViewModel({
|
||||
cli: makeClient(),
|
||||
mxEvent: makeEvent(),
|
||||
isRoomEncrypted: true,
|
||||
enableListeners: false,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
kind: "icon",
|
||||
icon: E2ePadlockIcon.Warning,
|
||||
title: "Not encrypted",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes registered listeners on dispose", () => {
|
||||
const mxEvent = makeEvent();
|
||||
const cli = makeClient();
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
|
||||
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(1);
|
||||
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(1);
|
||||
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(1);
|
||||
|
||||
vm.dispose();
|
||||
|
||||
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(0);
|
||||
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
|
||||
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
|
||||
});
|
||||
|
||||
it("moves event listeners when the event prop changes", () => {
|
||||
const oldEvent = makeEvent();
|
||||
const newEvent = makeEvent();
|
||||
const cli = makeClient();
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent: oldEvent,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
|
||||
vm.setProps({ mxEvent: newEvent });
|
||||
|
||||
expect(oldEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
|
||||
expect(oldEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
|
||||
expect(newEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(1);
|
||||
expect(newEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(1);
|
||||
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not register live listeners when disabled", () => {
|
||||
const mxEvent = makeEvent();
|
||||
const cli = makeClient();
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
enableListeners: false,
|
||||
});
|
||||
vm.start();
|
||||
|
||||
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(0);
|
||||
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
|
||||
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not re-verify when a different user's verification changes", async () => {
|
||||
const mxEvent = makeEvent();
|
||||
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
||||
const getEncryptionInfoForEvent = jest.fn().mockResolvedValue({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
} as EventEncryptionInfo);
|
||||
const cli = makeClient(getEncryptionInfoForEvent);
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
await waitFor(() => expect(getEncryptionInfoForEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
cli.emit(CryptoEvent.UserTrustStatusChanged, "@bob:example.org", {});
|
||||
|
||||
expect(getEncryptionInfoForEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores stale async verification results", async () => {
|
||||
const mxEvent = makeEvent();
|
||||
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
||||
|
||||
let resolveFirst: (value: EventEncryptionInfo) => void;
|
||||
const firstResult = new Promise<EventEncryptionInfo>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const getEncryptionInfoForEvent = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstResult)
|
||||
.mockResolvedValueOnce({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||
} as EventEncryptionInfo);
|
||||
const cli = makeClient(getEncryptionInfoForEvent);
|
||||
|
||||
const vm = makeViewModel({
|
||||
cli,
|
||||
mxEvent,
|
||||
enableListeners: true,
|
||||
});
|
||||
vm.start();
|
||||
cli.emit(CryptoEvent.UserTrustStatusChanged, userId, {});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
kind: "icon",
|
||||
icon: E2ePadlockIcon.Warning,
|
||||
title: "Encrypted by an unknown or deleted device.",
|
||||
}),
|
||||
);
|
||||
|
||||
resolveFirst!({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
} as EventEncryptionInfo);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
kind: "icon",
|
||||
icon: E2ePadlockIcon.Warning,
|
||||
title: "Encrypted by an unknown or deleted device.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,135 @@ 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 });
|
||||
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
event: {
|
||||
mxEvent,
|
||||
eventSendStatus: EventStatus.SENDING,
|
||||
},
|
||||
display: {
|
||||
isHighlighted: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.snapshot.event.isSending).toBe(true);
|
||||
expect(renderState.root.className).toContain("mx_EventTile");
|
||||
expect(renderState.root.className).toContain("mx_EventTile_sending");
|
||||
expect(renderState.root.className).toContain("mx_EventTile_highlight");
|
||||
expect(renderState.root.ariaLive).toBe("off");
|
||||
expect(renderState.root.scrollToken).toBeUndefined();
|
||||
expect(renderState.root.isRenderingNotification).toBe(false);
|
||||
expect(renderState.line.className).toContain("mx_EventTile_line");
|
||||
expect(renderState.timestamp).toMatchObject(renderState.snapshot.timestamp);
|
||||
});
|
||||
|
||||
it("derives E2E padlock placement for group layout", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.Group,
|
||||
isBubbleMessage: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.e2ePadlock).toEqual({
|
||||
showInGroupLine: true,
|
||||
showInIrcLine: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives E2E padlock placement for IRC layout", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.IRC,
|
||||
isBubbleMessage: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.e2ePadlock).toEqual({
|
||||
showInGroupLine: false,
|
||||
showInIrcLine: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not place E2E padlocks for bubble messages", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.Group,
|
||||
isBubbleMessage: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.e2ePadlock).toEqual({
|
||||
showInGroupLine: false,
|
||||
showInIrcLine: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives timestamp slots for group layout", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.Group,
|
||||
},
|
||||
interaction: {
|
||||
hover: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.timestamp.showDummy).toBe(false);
|
||||
expect(renderState.timestamp.showInGroupLine).toBe(true);
|
||||
expect(renderState.timestamp.showInIrcLine).toBe(false);
|
||||
});
|
||||
|
||||
it("derives timestamp slots for IRC layout", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.IRC,
|
||||
},
|
||||
interaction: {
|
||||
hover: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.timestamp.showDummy).toBe(true);
|
||||
expect(renderState.timestamp.showInGroupLine).toBe(false);
|
||||
expect(renderState.timestamp.showInIrcLine).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the IRC timestamp slot for the dummy timestamp when timestamps are hidden", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.IRC,
|
||||
},
|
||||
interaction: {
|
||||
hover: true,
|
||||
},
|
||||
timestamp: {
|
||||
hideTimestamp: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.timestamp.showDummy).toBe(true);
|
||||
expect(renderState.timestamp.displayState.showLinkedTimestamp).toBe(false);
|
||||
expect(renderState.timestamp.showInGroupLine).toBe(false);
|
||||
expect(renderState.timestamp.showInIrcLine).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes continuation by rendering mode and bubble layout", () => {
|
||||
const fileSnapshot = EventTileViewModel.createSnapshot(
|
||||
makeProps({
|
||||
@@ -279,4 +408,46 @@ describe("EventTileViewModel", () => {
|
||||
showBubblePinnedMessageBadge: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives footer render placement for default layouts", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.Group,
|
||||
},
|
||||
footer: {
|
||||
isOwnEvent: true,
|
||||
hasReactionsRow: true,
|
||||
hasReactions: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.footer).toMatchObject({
|
||||
hasFooter: true,
|
||||
showInIrcLayout: false,
|
||||
showInDefaultLayout: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives footer render placement for IRC layout", () => {
|
||||
const renderState = EventTileViewModel.createRenderState(
|
||||
makeProps({
|
||||
display: {
|
||||
layout: Layout.IRC,
|
||||
},
|
||||
footer: {
|
||||
isOwnEvent: true,
|
||||
hasReactionsRow: true,
|
||||
hasReactions: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderState.footer).toMatchObject({
|
||||
hasFooter: true,
|
||||
showInIrcLayout: true,
|
||||
showInDefaultLayout: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user