* Improve coverage * Add view model unit tests to improve coverage * Extract initial pure EventTile derived state helpers * Extract EventTile derived state helpers * Extract EventTile class state derivation * Extract EventTile line class derivation * Extract EventTile sender profile state * Extract EventTile avatar member selection * Extract EventTile avatar clickability state * Extract EventTile sender profile mode * Extract EventTile action bar visibility * Extract EventTile timestamp visibility * Extract EventTile timestamp selection * Extract EventTile timestamp display state * Extract ReplyChain timestamp visibility * Extract EventTile footer display state * Fix sonar issue
This commit is contained in:
@@ -26,7 +26,6 @@ import {
|
||||
EventType,
|
||||
type MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
type NotificationCountType,
|
||||
type Relations,
|
||||
type RelationType,
|
||||
@@ -100,9 +99,26 @@ import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { EventPreview } from "./EventPreview";
|
||||
import { ElementCallEventType } from "../../../call-types";
|
||||
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
|
||||
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
|
||||
import {
|
||||
getAriaLive,
|
||||
getEventTileAvatarMember,
|
||||
getEventTileClassState,
|
||||
getEventTileLineClassState,
|
||||
getEventTileSenderProfileState,
|
||||
getEventTileTimestamp,
|
||||
getFooterDisplayState,
|
||||
getIsContinuation,
|
||||
getReplyChainAlwaysShowTimestamps,
|
||||
getScrollToken,
|
||||
getSenderProfileMode,
|
||||
getShouldShowMessageActionBar,
|
||||
getShouldShowTimestamp,
|
||||
getShouldViewUserOnClick,
|
||||
getTimestampDisplayState,
|
||||
isSendingStatus,
|
||||
} from "./EventTile/eventTileDerivedState";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { CardContext } from "../right_panel/context";
|
||||
import {
|
||||
@@ -1076,61 +1092,55 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
|
||||
const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
|
||||
|
||||
const lineClasses = classNames("mx_EventTile_line", {
|
||||
mx_EventTile_mediaLine: isProbablyMedia,
|
||||
mx_EventTile_image:
|
||||
this.props.mxEvent.getType() === EventType.RoomMessage &&
|
||||
this.props.mxEvent.getContent().msgtype === MsgType.Image,
|
||||
mx_EventTile_sticker: this.props.mxEvent.getType() === EventType.Sticker,
|
||||
mx_EventTile_emote:
|
||||
this.props.mxEvent.getType() === EventType.RoomMessage &&
|
||||
this.props.mxEvent.getContent().msgtype === MsgType.Emote,
|
||||
});
|
||||
const lineClasses = classNames(
|
||||
"mx_EventTile_line",
|
||||
getEventTileLineClassState({
|
||||
isProbablyMedia,
|
||||
eventType,
|
||||
msgtype,
|
||||
}),
|
||||
);
|
||||
|
||||
const isSending = ["sending", "queued", "encrypting"].includes(this.props.eventSendStatus!);
|
||||
const isSending = isSendingStatus(this.props.eventSendStatus);
|
||||
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
|
||||
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
|
||||
|
||||
let isContinuation = this.props.continuation;
|
||||
if (
|
||||
this.context.timelineRenderingType !== TimelineRenderingType.Room &&
|
||||
this.context.timelineRenderingType !== TimelineRenderingType.Search &&
|
||||
this.context.timelineRenderingType !== TimelineRenderingType.Thread &&
|
||||
this.props.layout !== Layout.Bubble
|
||||
) {
|
||||
isContinuation = false;
|
||||
}
|
||||
const isContinuation = getIsContinuation(
|
||||
this.props.continuation,
|
||||
this.context.timelineRenderingType,
|
||||
this.props.layout,
|
||||
);
|
||||
|
||||
const isRenderingNotification = this.context.timelineRenderingType === TimelineRenderingType.Notification;
|
||||
|
||||
const isEditing = !!this.props.editState;
|
||||
const classes = classNames({
|
||||
mx_EventTile_bubbleContainer: isBubbleMessage,
|
||||
mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage,
|
||||
mx_EventTile: true,
|
||||
mx_EventTile_isEditing: isEditing,
|
||||
mx_EventTile_info: isInfoMessage,
|
||||
mx_EventTile_12hr: this.props.isTwelveHour,
|
||||
// Note: we keep the `sending` state class for tests, not for our styles
|
||||
mx_EventTile_sending: !isEditing && isSending,
|
||||
mx_EventTile_highlight: this.shouldHighlight(),
|
||||
mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu,
|
||||
mx_EventTile_continuation:
|
||||
isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType),
|
||||
mx_EventTile_last: this.props.last,
|
||||
mx_EventTile_lastInSection: this.props.lastInSection,
|
||||
mx_EventTile_contextual: this.props.contextual,
|
||||
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
|
||||
mx_EventTile_bad: isEncryptionFailure,
|
||||
mx_EventTile_emote: msgtype === MsgType.Emote,
|
||||
mx_EventTile_noSender: this.props.hideSender,
|
||||
mx_EventTile_clamp:
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification,
|
||||
mx_EventTile_noBubble: noBubbleEvent,
|
||||
});
|
||||
const classes = classNames(
|
||||
getEventTileClassState({
|
||||
isBubbleMessage,
|
||||
isLeftAlignedBubbleMessage,
|
||||
isEditing,
|
||||
isInfoMessage,
|
||||
isTwelveHour: this.props.isTwelveHour,
|
||||
isSending,
|
||||
isHighlighted: this.shouldHighlight(),
|
||||
isSelected: this.props.isSelectedEvent || !!this.state.contextMenu,
|
||||
isContinuation,
|
||||
eventType,
|
||||
isLast: this.props.last,
|
||||
isLastInSection: this.props.lastInSection,
|
||||
isContextual: this.props.contextual,
|
||||
isActionBarFocused: this.state.actionBarFocused,
|
||||
isEncryptionFailure,
|
||||
msgtype,
|
||||
hideSender: this.props.hideSender,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
isRenderingNotification,
|
||||
noBubbleEvent,
|
||||
}),
|
||||
);
|
||||
|
||||
// If the tile is in the Sending state, don't speak the message.
|
||||
const ariaLive = this.props.eventSendStatus !== null ? "off" : undefined;
|
||||
const ariaLive = getAriaLive(this.props.eventSendStatus);
|
||||
|
||||
let permalink = "#";
|
||||
if (this.props.permalinkCreator) {
|
||||
@@ -1139,66 +1149,26 @@ 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 = this.props.mxEvent.status ? undefined : this.props.mxEvent.getId();
|
||||
const scrollToken = getScrollToken(this.props.mxEvent);
|
||||
|
||||
let avatar: JSX.Element | null = null;
|
||||
let sender: JSX.Element | null = null;
|
||||
let avatarSize: string | null;
|
||||
let needsSenderProfile: boolean;
|
||||
|
||||
if (isRenderingNotification) {
|
||||
avatarSize = "24px";
|
||||
needsSenderProfile = true;
|
||||
} else if (isInfoMessage) {
|
||||
// a small avatar, with no sender profile, for
|
||||
// joins/parts/etc
|
||||
avatarSize = "14px";
|
||||
needsSenderProfile = false;
|
||||
} else if (
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList ||
|
||||
(this.context.timelineRenderingType === TimelineRenderingType.Thread && !this.props.continuation)
|
||||
) {
|
||||
avatarSize = "32px";
|
||||
needsSenderProfile = true;
|
||||
} else if (eventType === EventType.RoomCreate || isBubbleMessage) {
|
||||
avatarSize = null;
|
||||
needsSenderProfile = false;
|
||||
} else if (this.props.layout == Layout.IRC) {
|
||||
avatarSize = "14px";
|
||||
needsSenderProfile = true;
|
||||
} else if (
|
||||
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) ||
|
||||
eventType === EventType.CallInvite ||
|
||||
ElementCallEventType.matches(eventType) ||
|
||||
eventType === EventType.RTCNotification
|
||||
) {
|
||||
// no avatar or sender profile for continuation messages and call tiles
|
||||
avatarSize = null;
|
||||
needsSenderProfile = false;
|
||||
} else if (this.context.timelineRenderingType === TimelineRenderingType.File) {
|
||||
avatarSize = "20px";
|
||||
needsSenderProfile = true;
|
||||
} else {
|
||||
avatarSize = "30px";
|
||||
needsSenderProfile = true;
|
||||
}
|
||||
const { avatarSize, needsSenderProfile } = getEventTileSenderProfileState({
|
||||
isRenderingNotification,
|
||||
isInfoMessage,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
continuation: this.props.continuation,
|
||||
eventType,
|
||||
isBubbleMessage,
|
||||
layout: this.props.layout,
|
||||
});
|
||||
|
||||
if (this.props.mxEvent.sender && avatarSize !== null) {
|
||||
let member: RoomMember | null = null;
|
||||
// set member to receiver (target) if it is a 3PID invite
|
||||
// so that the correct avatar is shown as the text is
|
||||
// `$target accepted the invitation for $email`
|
||||
if (this.props.mxEvent.getContent().third_party_invite) {
|
||||
member = this.props.mxEvent.target;
|
||||
} else {
|
||||
member = this.props.mxEvent.sender;
|
||||
}
|
||||
// In the ThreadsList view we use the entire EventTile as a click target to open the thread instead
|
||||
const viewUserOnClick =
|
||||
!this.props.inhibitInteraction &&
|
||||
![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes(
|
||||
this.context.timelineRenderingType,
|
||||
);
|
||||
const member = getEventTileAvatarMember(this.props.mxEvent);
|
||||
const viewUserOnClick = getShouldViewUserOnClick(
|
||||
this.props.inhibitInteraction,
|
||||
this.context.timelineRenderingType,
|
||||
);
|
||||
avatar = (
|
||||
<div className="mx_EventTile_avatar">
|
||||
<MemberAvatar
|
||||
@@ -1211,27 +1181,27 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSenderProfile && this.props.hideSender !== true) {
|
||||
if (
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Room ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Thread
|
||||
) {
|
||||
sender = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} />;
|
||||
} else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) {
|
||||
sender = <SenderProfile mxEvent={this.props.mxEvent} withTooltip />;
|
||||
} else {
|
||||
sender = <SenderProfile mxEvent={this.props.mxEvent} />;
|
||||
}
|
||||
const senderProfileMode = getSenderProfileMode({
|
||||
needsSenderProfile,
|
||||
hideSender: this.props.hideSender,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
if (senderProfileMode === "clickable") {
|
||||
sender = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} />;
|
||||
} else if (senderProfileMode === "tooltip") {
|
||||
sender = <SenderProfile mxEvent={this.props.mxEvent} withTooltip />;
|
||||
} else if (senderProfileMode === "default") {
|
||||
sender = <SenderProfile mxEvent={this.props.mxEvent} />;
|
||||
}
|
||||
|
||||
const showMessageActionBar =
|
||||
!isEditing &&
|
||||
!this.props.forExport &&
|
||||
(this.state.hover ||
|
||||
this.state.showActionBarFromFocus ||
|
||||
(this.state.actionBarFocused && !this.state.contextMenu));
|
||||
const showMessageActionBar = getShouldShowMessageActionBar({
|
||||
isEditing,
|
||||
forExport: this.props.forExport,
|
||||
hover: this.state.hover,
|
||||
showActionBarFromFocus: this.state.showActionBarFromFocus,
|
||||
actionBarFocused: this.state.actionBarFocused,
|
||||
hasContextMenu: !!this.state.contextMenu,
|
||||
});
|
||||
const actionBar = showMessageActionBar ? (
|
||||
<ActionBarWrapper
|
||||
mxEvent={this.props.mxEvent}
|
||||
@@ -1246,28 +1216,24 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const showTimestamp =
|
||||
this.props.mxEvent.getTs() &&
|
||||
// Don't show timestamp for the CallStarted tile because
|
||||
// the tile content already renders a timestamp.
|
||||
this.props.mxEvent.getType() !== EventType.RTCNotification &&
|
||||
!this.props.hideTimestamp &&
|
||||
(this.props.alwaysShowTimestamps ||
|
||||
this.props.last ||
|
||||
this.state.hover ||
|
||||
this.state.focusWithin ||
|
||||
this.state.actionBarFocused ||
|
||||
Boolean(this.state.contextMenu));
|
||||
const showTimestamp = getShouldShowTimestamp({
|
||||
eventTs: this.props.mxEvent.getTs(),
|
||||
eventType,
|
||||
hideTimestamp: this.props.hideTimestamp,
|
||||
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
|
||||
last: this.props.last,
|
||||
hover: this.state.hover,
|
||||
focusWithin: this.state.focusWithin,
|
||||
actionBarFocused: this.state.actionBarFocused,
|
||||
hasContextMenu: Boolean(this.state.contextMenu),
|
||||
});
|
||||
|
||||
// Thread panel shows the timestamp of the last reply in that thread
|
||||
let ts =
|
||||
this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList
|
||||
? this.props.mxEvent.getTs()
|
||||
: this.state.thread?.replyToEvent?.getTs();
|
||||
if (typeof ts !== "number") {
|
||||
// Fall back to something we can use
|
||||
ts = this.props.mxEvent.getTs();
|
||||
}
|
||||
const ts = getEventTileTimestamp({
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
eventTs: this.props.mxEvent.getTs(),
|
||||
threadReplyEventTs: this.state.thread?.replyToEvent?.getTs(),
|
||||
});
|
||||
|
||||
const messageTimestampProps: MessageTimestampViewModelProps = {
|
||||
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
|
||||
@@ -1285,12 +1251,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
/>
|
||||
);
|
||||
|
||||
const useIRCLayout = this.props.layout === Layout.IRC;
|
||||
const { useIRCLayout, showRealTimestamp, showLinkedTimestamp } = getTimestampDisplayState({
|
||||
layout: this.props.layout,
|
||||
showTimestamp,
|
||||
timestamp: ts,
|
||||
hideTimestamp: this.props.hideTimestamp,
|
||||
});
|
||||
// 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 = showTimestamp && ts ? messageTimestamp : dummyTimestamp;
|
||||
const linkedTimestamp =
|
||||
timestamp !== dummyTimestamp && !this.props.hideTimestamp ? linkedMessageTimestamp : dummyTimestamp;
|
||||
const timestamp = showRealTimestamp ? messageTimestamp : dummyTimestamp;
|
||||
const linkedTimestamp = showLinkedTimestamp ? linkedMessageTimestamp : dummyTimestamp;
|
||||
|
||||
let pinnedMessageBadge: JSX.Element | undefined;
|
||||
if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
|
||||
@@ -1308,9 +1278,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
// If we have reactions or a pinned message badge, we need a footer
|
||||
const hasFooter = Boolean((reactionsRow && this.state.reactions) || pinnedMessageBadge);
|
||||
|
||||
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
|
||||
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
|
||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
@@ -1343,7 +1310,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
forExport={this.props.forExport}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
layout={this.props.layout}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover || this.state.focusWithin}
|
||||
alwaysShowTimestamps={getReplyChainAlwaysShowTimestamps({
|
||||
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
|
||||
hover: this.state.hover,
|
||||
focusWithin: this.state.focusWithin,
|
||||
})}
|
||||
isQuoteExpanded={isQuoteExpanded}
|
||||
setQuoteExpanded={this.setQuoteExpanded}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
@@ -1354,6 +1325,14 @@ 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 { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = getFooterDisplayState({
|
||||
hasReactionsRow: !!reactionsRow,
|
||||
hasReactions: !!this.state.reactions,
|
||||
hasPinnedMessageBadge: !!pinnedMessageBadge,
|
||||
layout: this.props.layout,
|
||||
isOwnEvent,
|
||||
});
|
||||
|
||||
switch (this.context.timelineRenderingType) {
|
||||
case TimelineRenderingType.Thread: {
|
||||
return React.createElement(
|
||||
@@ -1406,9 +1385,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
</div>,
|
||||
hasFooter && (
|
||||
<div className="mx_EventTile_footer" key="mx_EventTile_footer">
|
||||
{(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge}
|
||||
{showMainPinnedMessageBadge && pinnedMessageBadge}
|
||||
{reactionsRow}
|
||||
{this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge}
|
||||
{showBubblePinnedMessageBadge && pinnedMessageBadge}
|
||||
</div>
|
||||
),
|
||||
],
|
||||
@@ -1614,9 +1593,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
<>
|
||||
{hasFooter && (
|
||||
<div className="mx_EventTile_footer">
|
||||
{(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge}
|
||||
{showMainPinnedMessageBadge && pinnedMessageBadge}
|
||||
{reactionsRow}
|
||||
{this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge}
|
||||
{showBubblePinnedMessageBadge && pinnedMessageBadge}
|
||||
</div>
|
||||
)}
|
||||
{this.renderThreadInfo()}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
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 { EventStatus, EventType, type MatrixEvent, MsgType, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { ElementCallEventType } from "../../../../call-types";
|
||||
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||
import { Layout } from "../../../../settings/enums/Layout";
|
||||
|
||||
/**
|
||||
* Pure EventTile derivations extracted ahead of EventTileViewModel.
|
||||
* Keep this module free of React lifecycle, DOM access, dispatch, and MatrixClientPeg lookups.
|
||||
*/
|
||||
|
||||
/** Whether the event send status represents a pending send state. */
|
||||
export function isSendingStatus(eventSendStatus?: EventStatus): boolean {
|
||||
return [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.ENCRYPTING].includes(eventSendStatus!);
|
||||
}
|
||||
|
||||
/** The aria-live setting used by EventTile for the current send status. */
|
||||
export function getAriaLive(eventSendStatus?: EventStatus | null): "off" | undefined {
|
||||
return eventSendStatus === null ? undefined : "off";
|
||||
}
|
||||
|
||||
/** The stable scroll token for a non-local-echo event. */
|
||||
export function getScrollToken(mxEvent: MatrixEvent): string | undefined {
|
||||
return mxEvent.status ? undefined : mxEvent.getId();
|
||||
}
|
||||
|
||||
/** Whether EventTile should render as a continuation in the current layout/rendering mode. */
|
||||
export function getIsContinuation(
|
||||
continuation: boolean | undefined,
|
||||
timelineRenderingType: TimelineRenderingType,
|
||||
layout: Layout | undefined,
|
||||
): boolean | undefined {
|
||||
if (
|
||||
timelineRenderingType !== TimelineRenderingType.Room &&
|
||||
timelineRenderingType !== TimelineRenderingType.Search &&
|
||||
timelineRenderingType !== TimelineRenderingType.Thread &&
|
||||
layout !== Layout.Bubble
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return continuation;
|
||||
}
|
||||
|
||||
/** Inputs for EventTile line CSS class derivation. */
|
||||
export interface EventTileLineClassState {
|
||||
/** Whether the event body is likely to render media content. */
|
||||
isProbablyMedia: boolean;
|
||||
/** The Matrix event type for event-type class derivation. */
|
||||
eventType: string;
|
||||
/** The Matrix message type for message-type class derivation. */
|
||||
msgtype?: string;
|
||||
}
|
||||
|
||||
/** The EventTile line CSS class flags for the current derived state. */
|
||||
export function getEventTileLineClassState({
|
||||
isProbablyMedia,
|
||||
eventType,
|
||||
msgtype,
|
||||
}: EventTileLineClassState): Record<string, boolean> {
|
||||
return {
|
||||
mx_EventTile_mediaLine: isProbablyMedia,
|
||||
mx_EventTile_image: eventType === EventType.RoomMessage && msgtype === MsgType.Image,
|
||||
mx_EventTile_sticker: eventType === EventType.Sticker,
|
||||
mx_EventTile_emote: eventType === EventType.RoomMessage && msgtype === MsgType.Emote,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inputs for EventTile avatar and sender profile derivation. */
|
||||
export interface EventTileSenderProfileStateInput {
|
||||
/** Whether the tile is rendering as a notification. */
|
||||
isRenderingNotification: boolean;
|
||||
/** Whether the event renders as an informational timeline item. */
|
||||
isInfoMessage: boolean;
|
||||
/** The current timeline rendering mode. */
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
/** Whether the tile is a continuation of the previous event. */
|
||||
continuation?: boolean;
|
||||
/** The Matrix event type for event-type sender profile derivation. */
|
||||
eventType: string;
|
||||
/** Whether the tile should use bubble container styling. */
|
||||
isBubbleMessage: boolean;
|
||||
/** The current timeline layout. */
|
||||
layout?: Layout;
|
||||
}
|
||||
|
||||
/** EventTile avatar and sender profile display state. */
|
||||
export interface EventTileSenderProfileState {
|
||||
/** The avatar size to render, or null when no avatar should render. */
|
||||
avatarSize: string | null;
|
||||
/** Whether sender profile details should render. */
|
||||
needsSenderProfile: boolean;
|
||||
}
|
||||
|
||||
/** The EventTile avatar and sender profile state for the current derived state. */
|
||||
export function getEventTileSenderProfileState({
|
||||
isRenderingNotification,
|
||||
isInfoMessage,
|
||||
timelineRenderingType,
|
||||
continuation,
|
||||
eventType,
|
||||
isBubbleMessage,
|
||||
layout,
|
||||
}: EventTileSenderProfileStateInput): EventTileSenderProfileState {
|
||||
if (isRenderingNotification) {
|
||||
return { avatarSize: "24px", needsSenderProfile: true };
|
||||
}
|
||||
|
||||
if (isInfoMessage) {
|
||||
return { avatarSize: "14px", needsSenderProfile: false };
|
||||
}
|
||||
|
||||
if (
|
||||
timelineRenderingType === TimelineRenderingType.ThreadsList ||
|
||||
(timelineRenderingType === TimelineRenderingType.Thread && !continuation)
|
||||
) {
|
||||
return { avatarSize: "32px", needsSenderProfile: true };
|
||||
}
|
||||
|
||||
if (eventType === EventType.RoomCreate || isBubbleMessage) {
|
||||
return { avatarSize: null, needsSenderProfile: false };
|
||||
}
|
||||
|
||||
if (layout === Layout.IRC) {
|
||||
return { avatarSize: "14px", needsSenderProfile: true };
|
||||
}
|
||||
|
||||
if (
|
||||
(continuation && timelineRenderingType !== TimelineRenderingType.File) ||
|
||||
eventType === EventType.CallInvite ||
|
||||
ElementCallEventType.matches(eventType) ||
|
||||
eventType === EventType.RTCNotification
|
||||
) {
|
||||
return { avatarSize: null, needsSenderProfile: false };
|
||||
}
|
||||
|
||||
if (timelineRenderingType === TimelineRenderingType.File) {
|
||||
return { avatarSize: "20px", needsSenderProfile: true };
|
||||
}
|
||||
|
||||
return { avatarSize: "30px", needsSenderProfile: true };
|
||||
}
|
||||
|
||||
/** The room member whose avatar should render for the EventTile. */
|
||||
export function getEventTileAvatarMember(mxEvent: MatrixEvent): RoomMember | null {
|
||||
if (mxEvent.getContent().third_party_invite) {
|
||||
return mxEvent.target;
|
||||
}
|
||||
|
||||
return mxEvent.sender;
|
||||
}
|
||||
|
||||
/** Whether clicking the avatar should open the user profile. */
|
||||
export function getShouldViewUserOnClick(
|
||||
inhibitInteraction: boolean | undefined,
|
||||
timelineRenderingType: TimelineRenderingType,
|
||||
): boolean {
|
||||
return (
|
||||
!inhibitInteraction &&
|
||||
![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes(timelineRenderingType)
|
||||
);
|
||||
}
|
||||
|
||||
/** SenderProfile rendering mode for EventTile. */
|
||||
export type SenderProfileMode = "hidden" | "clickable" | "tooltip" | "default";
|
||||
|
||||
/** Inputs for EventTile sender profile mode derivation. */
|
||||
export interface SenderProfileModeInput {
|
||||
/** Whether sender profile details should render. */
|
||||
needsSenderProfile: boolean;
|
||||
/** Whether sender details should be hidden. */
|
||||
hideSender?: boolean;
|
||||
/** The current timeline rendering mode. */
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
}
|
||||
|
||||
/** The SenderProfile rendering mode for the current EventTile state. */
|
||||
export function getSenderProfileMode({
|
||||
needsSenderProfile,
|
||||
hideSender,
|
||||
timelineRenderingType,
|
||||
}: SenderProfileModeInput): SenderProfileMode {
|
||||
if (!needsSenderProfile || hideSender === true) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
if (
|
||||
timelineRenderingType === TimelineRenderingType.Room ||
|
||||
timelineRenderingType === TimelineRenderingType.Search ||
|
||||
timelineRenderingType === TimelineRenderingType.Pinned ||
|
||||
timelineRenderingType === TimelineRenderingType.Thread
|
||||
) {
|
||||
return "clickable";
|
||||
}
|
||||
|
||||
if (timelineRenderingType === TimelineRenderingType.ThreadsList) {
|
||||
return "tooltip";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Inputs for EventTile message action bar visibility derivation. */
|
||||
export interface ShouldShowMessageActionBarInput {
|
||||
/** Whether the event is currently being edited. */
|
||||
isEditing: boolean;
|
||||
/** Whether the tile is rendering for export. */
|
||||
forExport?: boolean;
|
||||
/** Whether the tile is currently hovered. */
|
||||
hover: boolean;
|
||||
/** Whether focus should force the action bar visible. */
|
||||
showActionBarFromFocus: boolean;
|
||||
/** Whether the action bar currently has focus. */
|
||||
actionBarFocused: boolean;
|
||||
/** Whether an EventTile context menu is currently open. */
|
||||
hasContextMenu: boolean;
|
||||
}
|
||||
|
||||
/** Whether EventTile should render the message action bar. */
|
||||
export function getShouldShowMessageActionBar({
|
||||
isEditing,
|
||||
forExport,
|
||||
hover,
|
||||
showActionBarFromFocus,
|
||||
actionBarFocused,
|
||||
hasContextMenu,
|
||||
}: ShouldShowMessageActionBarInput): boolean {
|
||||
return !isEditing && !forExport && (hover || showActionBarFromFocus || (actionBarFocused && !hasContextMenu));
|
||||
}
|
||||
|
||||
/** Inputs for EventTile timestamp visibility derivation. */
|
||||
export interface ShouldShowTimestampInput {
|
||||
/** The event origin timestamp. */
|
||||
eventTs: number;
|
||||
/** The Matrix event type for event-type timestamp derivation. */
|
||||
eventType: string;
|
||||
/** Whether timestamp rendering is disabled. */
|
||||
hideTimestamp?: boolean;
|
||||
/** Whether timestamps should always show. */
|
||||
alwaysShowTimestamps?: boolean;
|
||||
/** Whether the tile is the last event in the timeline. */
|
||||
last?: boolean;
|
||||
/** Whether the tile is currently hovered. */
|
||||
hover: boolean;
|
||||
/** Whether focus is currently inside the tile. */
|
||||
focusWithin: boolean;
|
||||
/** Whether the action bar currently has focus. */
|
||||
actionBarFocused: boolean;
|
||||
/** Whether an EventTile context menu is currently open. */
|
||||
hasContextMenu: boolean;
|
||||
}
|
||||
|
||||
/** Whether EventTile should render the message timestamp. */
|
||||
export function getShouldShowTimestamp({
|
||||
eventTs,
|
||||
eventType,
|
||||
hideTimestamp,
|
||||
alwaysShowTimestamps,
|
||||
last,
|
||||
hover,
|
||||
focusWithin,
|
||||
actionBarFocused,
|
||||
hasContextMenu,
|
||||
}: ShouldShowTimestampInput): boolean {
|
||||
return (
|
||||
!!eventTs &&
|
||||
eventType !== EventType.RTCNotification &&
|
||||
!hideTimestamp &&
|
||||
(alwaysShowTimestamps || last || hover || focusWithin || actionBarFocused || hasContextMenu)
|
||||
);
|
||||
}
|
||||
|
||||
/** Inputs for EventTile timestamp value derivation. */
|
||||
export interface EventTileTimestampInput {
|
||||
/** The current timeline rendering mode. */
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
/** The event origin timestamp. */
|
||||
eventTs: number;
|
||||
/** The latest thread reply timestamp, when available. */
|
||||
threadReplyEventTs?: number;
|
||||
}
|
||||
|
||||
/** The timestamp EventTile should display for the current rendering mode. */
|
||||
export function getEventTileTimestamp({
|
||||
timelineRenderingType,
|
||||
eventTs,
|
||||
threadReplyEventTs,
|
||||
}: EventTileTimestampInput): number {
|
||||
if (timelineRenderingType === TimelineRenderingType.ThreadsList && typeof threadReplyEventTs === "number") {
|
||||
return threadReplyEventTs;
|
||||
}
|
||||
|
||||
return eventTs;
|
||||
}
|
||||
|
||||
/** Inputs for EventTile timestamp display state derivation. */
|
||||
export interface TimestampDisplayStateInput {
|
||||
/** The current timeline layout. */
|
||||
layout?: Layout;
|
||||
/** Whether the timestamp should render. */
|
||||
showTimestamp: boolean;
|
||||
/** The timestamp EventTile may display. */
|
||||
timestamp: number;
|
||||
/** Whether timestamp rendering is disabled. */
|
||||
hideTimestamp?: boolean;
|
||||
}
|
||||
|
||||
/** EventTile timestamp display state. */
|
||||
export interface TimestampDisplayState {
|
||||
/** Whether the current layout is IRC. */
|
||||
useIRCLayout: boolean;
|
||||
/** Whether EventTile should render the real timestamp element. */
|
||||
showRealTimestamp: boolean;
|
||||
/** Whether EventTile should render the linked timestamp element. */
|
||||
showLinkedTimestamp: boolean;
|
||||
}
|
||||
|
||||
/** The EventTile timestamp display state for the current derived state. */
|
||||
export function getTimestampDisplayState({
|
||||
layout,
|
||||
showTimestamp,
|
||||
timestamp,
|
||||
hideTimestamp,
|
||||
}: TimestampDisplayStateInput): TimestampDisplayState {
|
||||
const showRealTimestamp = showTimestamp && !!timestamp;
|
||||
|
||||
return {
|
||||
useIRCLayout: layout === Layout.IRC,
|
||||
showRealTimestamp,
|
||||
showLinkedTimestamp: showRealTimestamp && !hideTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inputs for ReplyChain timestamp visibility derivation. */
|
||||
export interface ReplyChainTimestampInput {
|
||||
/** Whether timestamps should always show. */
|
||||
alwaysShowTimestamps?: boolean;
|
||||
/** Whether the tile is currently hovered. */
|
||||
hover: boolean;
|
||||
/** Whether focus is currently inside the tile. */
|
||||
focusWithin: boolean;
|
||||
}
|
||||
|
||||
/** Whether ReplyChain should always show timestamps. */
|
||||
export function getReplyChainAlwaysShowTimestamps({
|
||||
alwaysShowTimestamps,
|
||||
hover,
|
||||
focusWithin,
|
||||
}: ReplyChainTimestampInput): boolean {
|
||||
return !!alwaysShowTimestamps || hover || focusWithin;
|
||||
}
|
||||
|
||||
/** Inputs for EventTile footer display state derivation. */
|
||||
export interface FooterDisplayStateInput {
|
||||
/** Whether a reactions row element will render. */
|
||||
hasReactionsRow: boolean;
|
||||
/** Whether reactions data is available. */
|
||||
hasReactions: boolean;
|
||||
/** Whether a pinned message badge element will render. */
|
||||
hasPinnedMessageBadge: boolean;
|
||||
/** The current timeline layout. */
|
||||
layout?: Layout;
|
||||
/** Whether the event was sent by the current user. */
|
||||
isOwnEvent: boolean;
|
||||
}
|
||||
|
||||
/** EventTile footer display state. */
|
||||
export interface FooterDisplayState {
|
||||
/** Whether EventTile should render a footer. */
|
||||
hasFooter: boolean;
|
||||
/** Whether the main footer position should render the pinned message badge. */
|
||||
showMainPinnedMessageBadge: boolean;
|
||||
/** Whether the bubble footer position should render the pinned message badge. */
|
||||
showBubblePinnedMessageBadge: boolean;
|
||||
}
|
||||
|
||||
/** The EventTile footer display state for the current derived state. */
|
||||
export function getFooterDisplayState({
|
||||
hasReactionsRow,
|
||||
hasReactions,
|
||||
hasPinnedMessageBadge,
|
||||
layout,
|
||||
isOwnEvent,
|
||||
}: FooterDisplayStateInput): FooterDisplayState {
|
||||
return {
|
||||
hasFooter: (hasReactionsRow && hasReactions) || hasPinnedMessageBadge,
|
||||
showMainPinnedMessageBadge: hasPinnedMessageBadge && (layout === Layout.Group || !isOwnEvent),
|
||||
showBubblePinnedMessageBadge: hasPinnedMessageBadge && layout === Layout.Bubble && isOwnEvent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inputs for EventTile root CSS class derivation. */
|
||||
export interface EventTileClassState {
|
||||
/** Whether the tile should use bubble container styling. */
|
||||
isBubbleMessage: boolean;
|
||||
/** Whether the bubble tile is left-aligned. */
|
||||
isLeftAlignedBubbleMessage: boolean;
|
||||
/** Whether the event is currently being edited. */
|
||||
isEditing: boolean;
|
||||
/** Whether the event renders as an informational timeline item. */
|
||||
isInfoMessage: boolean;
|
||||
/** Whether timestamps use twelve-hour formatting. */
|
||||
isTwelveHour?: boolean;
|
||||
/** Whether the event is in a pending send state. */
|
||||
isSending: boolean;
|
||||
/** Whether the event should be highlighted. */
|
||||
isHighlighted: boolean;
|
||||
/** Whether the tile is selected or has an open context menu. */
|
||||
isSelected: boolean;
|
||||
/** Whether the tile is a continuation of the previous event. */
|
||||
isContinuation?: boolean;
|
||||
/** The Matrix event type for event-type class derivation. */
|
||||
eventType: string;
|
||||
/** Whether the tile is the last event in the timeline. */
|
||||
isLast?: boolean;
|
||||
/** Whether the tile is the last event in its section. */
|
||||
isLastInSection?: boolean;
|
||||
/** Whether the tile is being rendered in contextual mode. */
|
||||
isContextual?: boolean;
|
||||
/** Whether the action bar currently has focus. */
|
||||
isActionBarFocused: boolean;
|
||||
/** Whether the event failed decryption. */
|
||||
isEncryptionFailure: boolean;
|
||||
/** The Matrix message type for message-type class derivation. */
|
||||
msgtype?: string;
|
||||
/** Whether sender details should be hidden. */
|
||||
hideSender?: boolean;
|
||||
/** The current timeline rendering mode. */
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
/** Whether the tile is rendering as a notification. */
|
||||
isRenderingNotification: boolean;
|
||||
/** Whether bubble styling should be suppressed for this event. */
|
||||
noBubbleEvent: boolean;
|
||||
}
|
||||
|
||||
/** The EventTile root CSS class flags for the current derived state. */
|
||||
export function getEventTileClassState({
|
||||
isBubbleMessage,
|
||||
isLeftAlignedBubbleMessage,
|
||||
isEditing,
|
||||
isInfoMessage,
|
||||
isTwelveHour,
|
||||
isSending,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
isContinuation,
|
||||
eventType,
|
||||
isLast,
|
||||
isLastInSection,
|
||||
isContextual,
|
||||
isActionBarFocused,
|
||||
isEncryptionFailure,
|
||||
msgtype,
|
||||
hideSender,
|
||||
timelineRenderingType,
|
||||
isRenderingNotification,
|
||||
noBubbleEvent,
|
||||
}: EventTileClassState): Record<string, boolean | undefined> {
|
||||
return {
|
||||
mx_EventTile_bubbleContainer: isBubbleMessage,
|
||||
mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage,
|
||||
mx_EventTile: true,
|
||||
mx_EventTile_isEditing: isEditing,
|
||||
mx_EventTile_info: isInfoMessage,
|
||||
mx_EventTile_12hr: isTwelveHour,
|
||||
// Note: we keep the `sending` state class for tests, not for our styles
|
||||
mx_EventTile_sending: !isEditing && isSending,
|
||||
mx_EventTile_highlight: isHighlighted,
|
||||
mx_EventTile_selected: isSelected,
|
||||
mx_EventTile_continuation:
|
||||
isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType),
|
||||
mx_EventTile_last: isLast,
|
||||
mx_EventTile_lastInSection: isLastInSection,
|
||||
mx_EventTile_contextual: isContextual,
|
||||
mx_EventTile_actionBarFocused: isActionBarFocused,
|
||||
mx_EventTile_bad: isEncryptionFailure,
|
||||
mx_EventTile_emote: msgtype === MsgType.Emote,
|
||||
mx_EventTile_noSender: hideSender,
|
||||
mx_EventTile_clamp: timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification,
|
||||
mx_EventTile_noBubble: noBubbleEvent,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
MsgType,
|
||||
NotificationCountType,
|
||||
PendingEventOrdering,
|
||||
RelationType,
|
||||
type Relations,
|
||||
Room,
|
||||
TweakName,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
@@ -101,6 +103,41 @@ function makeThreadReplyEvent(roomId: string): MatrixEvent {
|
||||
});
|
||||
}
|
||||
|
||||
function makeReactionEvent(roomId: string, targetEventId: string, sender: string, key: string): MatrixEvent {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
room: roomId,
|
||||
user: sender,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: targetEventId,
|
||||
key,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeRelations(
|
||||
reactionsByKey: Map<string, MatrixEvent[]>,
|
||||
reactionsBySender: Record<string, MatrixEvent[]> = {},
|
||||
): Relations {
|
||||
return {
|
||||
getSortedAnnotationsByKey: () =>
|
||||
[...reactionsByKey.entries()].map(([key, events]) => [key, new Set(events)] as [string, Set<MatrixEvent>]),
|
||||
getAnnotationsBySender: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries(reactionsBySender).map(([sender, events]) => [
|
||||
sender,
|
||||
new Map(events.map((ev) => [ev.getId(), ev])),
|
||||
]),
|
||||
),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Relations;
|
||||
}
|
||||
|
||||
describe("EventTile", () => {
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
let mxEvent: MatrixEvent;
|
||||
@@ -259,6 +296,18 @@ describe("EventTile", () => {
|
||||
|
||||
expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens");
|
||||
});
|
||||
|
||||
it("sets aria-live to off when the send status is undefined", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("aria-live", "off");
|
||||
});
|
||||
|
||||
it("does not set aria-live when the send status is explicitly null", () => {
|
||||
const { container } = getComponent({ eventSendStatus: null as unknown as EventStatus });
|
||||
|
||||
expect(getTile(container)).not.toHaveAttribute("aria-live");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering root attributes", () => {
|
||||
@@ -507,6 +556,68 @@ describe("EventTile", () => {
|
||||
expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
|
||||
expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar"));
|
||||
});
|
||||
|
||||
it("keeps sender and avatar when only the layout prop is set to bubble", () => {
|
||||
const { container } = getComponent({ layout: Layout.Bubble });
|
||||
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the sender but keeps the info-message avatar for room create events", () => {
|
||||
const createEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomCreate,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: { creator: "@alice:example.org", room_version: "1" },
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: createEvent }, TimelineRenderingType.Room, {
|
||||
showHiddenEvents: true,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the notification avatar independently from the sender details", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.Notification);
|
||||
const details = container.querySelector<HTMLElement>(".mx_EventTile_details");
|
||||
const avatar = container.querySelector<HTMLElement>(".mx_EventTile_avatar");
|
||||
|
||||
expect(details).not.toBeNull();
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(details).not.toContainElement(avatar);
|
||||
});
|
||||
});
|
||||
|
||||
describe("continuation rendering", () => {
|
||||
it.each([TimelineRenderingType.Room, TimelineRenderingType.Search, TimelineRenderingType.Thread])(
|
||||
"keeps continuation styling in %s timelines",
|
||||
(renderingType) => {
|
||||
const { container } = getComponent({ continuation: true }, renderingType);
|
||||
|
||||
expect(getTile(container)).toHaveClass("mx_EventTile_continuation");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
|
||||
"drops continuation styling in %s timelines when not using bubble layout",
|
||||
(renderingType) => {
|
||||
const { container } = getComponent({ continuation: true }, renderingType);
|
||||
|
||||
expect(getTile(container)).not.toHaveClass("mx_EventTile_continuation");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
|
||||
"keeps continuation styling in %s timelines when using bubble layout",
|
||||
(renderingType) => {
|
||||
const { container } = getComponent({ continuation: true, layout: Layout.Bubble }, renderingType);
|
||||
|
||||
expect(getTile(container)).toHaveClass("mx_EventTile_continuation");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("read receipt option", () => {
|
||||
@@ -614,6 +725,55 @@ describe("EventTile", () => {
|
||||
expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull();
|
||||
expect(screen.getByText("Pinned message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the IRC footer inside the event line", () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
const { container } = getComponent({ layout: Layout.IRC });
|
||||
|
||||
expect(getLine(container).querySelector(".mx_EventTile_footer")).not.toBeNull();
|
||||
expect(getTile(container).querySelector(":scope > .mx_EventTile_footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a bubble footer for an own pinned message", () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { container } = getComponent({ mxEvent: ownEvent, layout: Layout.Bubble });
|
||||
const footer = container.querySelector(".mx_EventTile_footer");
|
||||
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer).toHaveTextContent("Pinned message");
|
||||
});
|
||||
|
||||
it("renders relation groups and deduplicates reactions from the same sender", () => {
|
||||
const bobReaction1 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍");
|
||||
const bobReaction2 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍");
|
||||
const getRelationsForEvent = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeRelations(new Map([["👍", [bobReaction1, bobReaction2]]])));
|
||||
|
||||
getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, {
|
||||
canReact: true,
|
||||
});
|
||||
|
||||
const reactionButton = screen.getByRole("button", { name: /@bob:example\.org reacted with 👍/ });
|
||||
expect(reactionButton).toHaveTextContent("👍1");
|
||||
});
|
||||
|
||||
it("detects the current user's reaction when rendering relation groups", () => {
|
||||
const ownReaction = makeReactionEvent(room.roomId, mxEvent.getId()!, client.getSafeUserId(), "👍");
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(
|
||||
makeRelations(new Map([["👍", [ownReaction]]]), {
|
||||
[client.getSafeUserId()]: [ownReaction],
|
||||
}),
|
||||
);
|
||||
|
||||
getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, {
|
||||
canReact: true,
|
||||
canSelfRedact: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /reacted with 👍/ })).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("action bar", () => {
|
||||
@@ -907,6 +1067,20 @@ describe("EventTile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a notice when the event has no renderer", () => {
|
||||
const unsupportedEvent = mkEvent({
|
||||
event: true,
|
||||
type: "org.example.unsupported",
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: {},
|
||||
});
|
||||
|
||||
getComponent({ mxEvent: unsupportedEvent });
|
||||
|
||||
expect(screen.getByText("This event could not be displayed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates msgtype-derived tile classes when an edit changes msgtype to m.emote", async () => {
|
||||
const { container } = getComponent();
|
||||
expect(container.querySelector(".mx_EventTile_emote")).toBeNull();
|
||||
@@ -1251,6 +1425,57 @@ describe("EventTile", () => {
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName("Not encrypted");
|
||||
});
|
||||
|
||||
it.each([EventStatus.ENCRYPTING, EventStatus.NOT_SENT])(
|
||||
"does not show the unencrypted warning for %s events in encrypted rooms",
|
||||
(status) => {
|
||||
const event = makeOwnMessage();
|
||||
event.setStatus(status);
|
||||
const { container } = getComponent(
|
||||
{ mxEvent: event, eventSendStatus: status },
|
||||
TimelineRenderingType.Room,
|
||||
{
|
||||
isRoomEncrypted: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not show the unencrypted warning for state events in encrypted rooms", () => {
|
||||
const stateEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomTopic,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
skey: "",
|
||||
content: { topic: "Topic" },
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: stateEvent }, TimelineRenderingType.Room, {
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not show the unencrypted warning for redacted events in encrypted rooms", () => {
|
||||
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
|
||||
const { container } = getComponent({}, TimelineRenderingType.Room, {
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not show the unencrypted warning for local-room events in encrypted rooms", () => {
|
||||
const localEvent = makeTimestampedMessage({ room: "local+room" });
|
||||
const { container } = getComponent({ mxEvent: localEvent }, TimelineRenderingType.Room, {
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event highlighting", () => {
|
||||
|
||||
@@ -65,6 +65,18 @@ describe("AudioPlayerViewModel", () => {
|
||||
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
|
||||
});
|
||||
|
||||
it("does not stop propagation for unhandled key down events", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
const event = new KeyboardEvent("keydown", { key: "a" });
|
||||
const stopPropagationSpy = jest.spyOn(event, "stopPropagation");
|
||||
|
||||
vm.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLDivElement>);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(playback.toggle).not.toHaveBeenCalled();
|
||||
expect(playback.skipTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update snapshot when setProps is called with new mediaName", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" });
|
||||
expect(vm.getSnapshot().mediaName).toBe("oldName");
|
||||
|
||||
@@ -119,4 +119,68 @@ describe("MessageTimestampViewModel", () => {
|
||||
href: "https://example.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the timestamp and received timestamp", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
|
||||
vm.setTimestamp(nowDate.getTime() + HOUR_MS);
|
||||
vm.setReceivedTimestamp(nowDate.getTime() + DAY_MS);
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "09:09",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 09:09:00",
|
||||
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates display options", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
|
||||
vm.setDisplayOptions({
|
||||
showTwelveHour: true,
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "8:09:00 AM",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 8:09:00 AM",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates tooltip, href, and handlers", () => {
|
||||
const onClick = jest.fn();
|
||||
const onContextMenu = jest.fn();
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
|
||||
vm.setTooltipInhibited(true);
|
||||
vm.setHref("https://example.test/event");
|
||||
vm.setHandlers({ onClick, onContextMenu });
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
inhibitTooltip: true,
|
||||
href: "https://example.test/event",
|
||||
});
|
||||
expect(vm.onClick).toBe(onClick);
|
||||
expect(vm.onContextMenu).toBe(onContextMenu);
|
||||
});
|
||||
|
||||
it("does not emit an update when props are unchanged", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
href: "https://example.test/event",
|
||||
});
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setTimestamp(nowDate.getTime());
|
||||
vm.setHref("https://example.test/event");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ import { createTestClient, mkEvent, mkStubRoom } from "../../test-utils";
|
||||
import dis from "../../../src/dispatcher/dispatcher";
|
||||
|
||||
jest.mock("../../../src/dispatcher/dispatcher");
|
||||
jest.mock("../../../src/customisations/Media", () => ({
|
||||
mediaFromMxc: jest.fn(() => ({ srcHttp: "https://example.org/_matrix/media/reaction.png" })),
|
||||
}));
|
||||
|
||||
describe("ReactionsRowButtonViewModel", () => {
|
||||
let client: MatrixClient;
|
||||
@@ -91,6 +94,35 @@ describe("ReactionsRowButtonViewModel", () => {
|
||||
expect(getAriaLabel(vm)).toContain("reacted with 👍");
|
||||
});
|
||||
|
||||
it("falls back when no room is available", () => {
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(null);
|
||||
|
||||
const vm = new ReactionsRowButtonViewModel(createProps());
|
||||
|
||||
expect(getAriaLabel(vm)).toBeUndefined();
|
||||
expect(vm.getSnapshot().content).toBe("👍");
|
||||
expect(vm.getSnapshot().count).toBe(2);
|
||||
});
|
||||
|
||||
it("renders custom reaction images with shortcode labels when enabled", () => {
|
||||
const reactionEvent = createReactionEvent("@alice:example.org", "mxc://example.org/reaction");
|
||||
reactionEvent.getContent()["shortcode"] = "party";
|
||||
|
||||
const vm = new ReactionsRowButtonViewModel(
|
||||
createProps({
|
||||
content: "mxc://example.org/reaction",
|
||||
reactionEvents: [reactionEvent],
|
||||
customReactionImagesEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
imageSrc: "https://example.org/_matrix/media/reaction.png",
|
||||
imageAlt: "party",
|
||||
});
|
||||
expect(getAriaLabel(vm)).toContain("reacted with party");
|
||||
});
|
||||
|
||||
it("updates selected state with myReactionEvent without touching tooltip props", () => {
|
||||
const vm = new ReactionsRowButtonViewModel(createProps());
|
||||
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { ActionBarAction } from "@element-hq/web-shared-components";
|
||||
|
||||
import { ThreadListActionBarViewModel } from "../../../src/viewmodels/room/ThreadListActionBarViewModel";
|
||||
|
||||
describe("ThreadListActionBarViewModel", () => {
|
||||
it("builds the thread-list action bar snapshot", () => {
|
||||
const vm = new ThreadListActionBarViewModel({});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink],
|
||||
presentation: "icon",
|
||||
isDownloadEncrypted: false,
|
||||
isDownloadLoading: false,
|
||||
isPinned: false,
|
||||
isQuoteExpanded: false,
|
||||
isThreadReplyAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards actions to the configured handlers", () => {
|
||||
const onViewInRoomClick = jest.fn();
|
||||
const onCopyLinkClick = jest.fn();
|
||||
const vm = new ThreadListActionBarViewModel({
|
||||
onViewInRoomClick,
|
||||
onCopyLinkClick,
|
||||
});
|
||||
const anchor = document.createElement("button");
|
||||
|
||||
vm.onViewInRoomClick(anchor);
|
||||
vm.onCopyLinkClick(anchor);
|
||||
|
||||
expect(onViewInRoomClick).toHaveBeenCalledWith(anchor);
|
||||
expect(onCopyLinkClick).toHaveBeenCalledWith(anchor);
|
||||
});
|
||||
|
||||
it("uses updated handlers after setProps", () => {
|
||||
const initialOnViewInRoomClick = jest.fn();
|
||||
const nextOnViewInRoomClick = jest.fn();
|
||||
const vm = new ThreadListActionBarViewModel({
|
||||
onViewInRoomClick: initialOnViewInRoomClick,
|
||||
});
|
||||
const anchor = document.createElement("button");
|
||||
|
||||
vm.setProps({ onViewInRoomClick: nextOnViewInRoomClick });
|
||||
vm.onViewInRoomClick(anchor);
|
||||
|
||||
expect(initialOnViewInRoomClick).not.toHaveBeenCalled();
|
||||
expect(nextOnViewInRoomClick).toHaveBeenCalledWith(anchor);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user