Refactor EventTile using the MVVM pattern - #4 (#33539)

* Extract EventTile interaction state transitions

* Fix sonar issues
This commit is contained in:
rbondesson
2026-05-21 15:41:58 +02:00
committed by GitHub
parent f5eb18bc9d
commit 70e59c6221
3 changed files with 259 additions and 50 deletions
@@ -105,6 +105,18 @@ import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
import SettingsStore from "../../../settings/SettingsStore";
import { CardContext } from "../right_panel/context";
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
import {
eventTileActionBarFocusChange,
eventTileBlurWithin,
eventTileClearHover,
eventTileCloseContextMenu,
eventTileFocusWithin,
eventTileMouseEnter,
eventTileMouseLeave,
eventTileOpenContextMenu,
initialEventTileInteractionState,
type EventTileInteractionState,
} from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
@@ -270,9 +282,7 @@ export interface EventTileProps {
}
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
showActionBarFromFocus: boolean;
interaction: EventTileInteractionState;
/**
* E2EE shield we should show for decryption problems.
@@ -289,15 +299,6 @@ interface IState {
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: Relations | null | undefined;
hover: boolean;
focusWithin: boolean;
// Position of the context menu
contextMenu?: {
position: Pick<DOMRect, "top" | "left" | "bottom">;
link?: string;
};
isQuoteExpanded?: boolean;
thread: Thread | null;
@@ -346,9 +347,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const thread = this.thread;
this.state = {
// Whether the action bar is focused.
actionBarFocused: false,
showActionBarFromFocus: false,
interaction: initialEventTileInteractionState,
shieldColour: EventShieldColour.NONE,
shieldReason: null,
@@ -356,9 +355,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(),
hover: false,
focusWithin: false,
thread,
};
@@ -495,9 +491,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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.hover && this.state.hover) {
if (!prevState.interaction.hover && this.state.interaction.hover) {
this.startStaleHoverCheck();
} else if (prevState.hover && !this.state.hover) {
} else if (prevState.interaction.hover && !this.state.interaction.hover) {
this.stopStaleHoverCheck();
}
@@ -516,12 +512,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Moving between edited messages can remount the editor without a reliable blur event.
// Clear stale focus-derived action bar state when focus has actually left this tile.
if (
this.state.focusWithin &&
this.state.interaction.focusWithin &&
this.ref.current &&
document.activeElement instanceof HTMLElement &&
!this.ref.current.contains(document.activeElement)
) {
this.setState({ focusWithin: false, showActionBarFromFocus: false });
this.setState((prevState) => ({
interaction: eventTileBlurWithin(prevState.interaction),
}));
}
}
@@ -902,8 +900,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => {
this.setState((prevState) => ({
actionBarFocused,
hover: actionBarFocused ? prevState.hover : (this.ref.current?.matches(":hover") ?? false),
interaction: eventTileActionBarFocusChange(
prevState.interaction,
actionBarFocused,
this.ref.current?.matches(":hover") ?? false,
),
}));
};
@@ -920,17 +921,23 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
private readonly onDocumentMouseMove = (): void => {
if (this.state.hover && !(this.ref.current?.matches(":hover") ?? false)) {
this.setState({ hover: false });
if (this.state.interaction.hover && !(this.ref.current?.matches(":hover") ?? false)) {
this.setState((prevState) => ({
interaction: eventTileClearHover(prevState.interaction),
}));
}
};
private readonly onMouseEnter = (): void => {
this.setState({ hover: true });
this.setState((prevState) => ({
interaction: eventTileMouseEnter(prevState.interaction),
}));
};
private readonly onMouseLeave = (): void => {
this.setState({ hover: false });
this.setState((prevState) => ({
interaction: eventTileMouseLeave(prevState.interaction),
}));
};
private readonly onFocusWithin = (event: FocusEvent<HTMLElement>): void => {
@@ -938,7 +945,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const target = event.target as HTMLElement;
const showActionBarFromFocus =
target.matches(":focus-visible") || document.body.dataset["data-whatinput"] === "keyboard";
this.setState({ focusWithin: true, showActionBarFromFocus });
this.setState((prevState) => ({
interaction: eventTileFocusWithin(prevState.interaction, showActionBarFromFocus),
}));
};
private readonly onBlurWithin = (event: FocusEvent<HTMLElement>): void => {
@@ -946,7 +955,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return;
}
this.setState({ focusWithin: false, showActionBarFromFocus: false });
this.setState((prevState) => ({
interaction: eventTileBlurWithin(prevState.interaction),
}));
};
private readonly getTile: () => IEventTileType | null = () => this.tile.current;
@@ -997,26 +1008,22 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenu: {
this.setState((prevState) => ({
interaction: eventTileOpenContextMenu(prevState.interaction, {
position: {
left: ev.clientX,
top: ev.clientY,
bottom: ev.clientY,
},
link: anchorElement?.href || permalink,
},
actionBarFocused: true,
hover: false,
});
}),
}));
}
private readonly onCloseMenu = (): void => {
this.setState({
contextMenu: undefined,
actionBarFocused: false,
hover: false,
});
this.setState((prevState) => ({
interaction: eventTileCloseContextMenu(prevState.interaction),
}));
};
private readonly setQuoteExpanded = (expanded: boolean): void => {
@@ -1038,7 +1045,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
private renderContextMenu(): ReactNode {
if (!this.state.contextMenu) return null;
if (!this.state.interaction.contextMenu) return null;
const tile = this.getTile();
const replyChain = this.getReplyChain();
@@ -1047,7 +1054,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return (
<MessageContextMenu
{...aboveRightOf(this.state.contextMenu.position)}
{...aboveRightOf(this.state.interaction.contextMenu.position)}
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator}
eventTileOps={eventTileOps}
@@ -1055,7 +1062,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
onFinished={this.onCloseMenu}
rightClick={true}
reactions={this.state.reactions}
link={this.state.contextMenu.link}
link={this.state.interaction.contextMenu.link}
getRelationsForEvent={this.props.getRelationsForEvent}
/>
);
@@ -1122,17 +1129,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
noBubbleEvent,
isTwelveHour: this.props.isTwelveHour,
isHighlighted: this.shouldHighlight(),
isSelected: this.props.isSelectedEvent || !!this.state.contextMenu,
isSelected: this.props.isSelectedEvent || !!this.state.interaction.contextMenu,
isLast: this.props.last,
isLastInSection: this.props.lastInSection,
isContextual: this.props.contextual,
},
interaction: {
hover: this.state.hover,
showActionBarFromFocus: this.state.showActionBarFromFocus,
focusWithin: this.state.focusWithin,
isActionBarFocused: this.state.actionBarFocused,
hasContextMenu: !!this.state.contextMenu,
hover: this.state.interaction.hover,
showActionBarFromFocus: this.state.interaction.showActionBarFromFocus,
focusWithin: this.state.interaction.focusWithin,
isActionBarFocused: this.state.interaction.actionBarFocused,
hasContextMenu: !!this.state.interaction.contextMenu,
inhibitInteraction: this.props.inhibitInteraction,
},
sender: {
@@ -0,0 +1,114 @@
/*
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.
*/
/** Context menu state owned by EventTile interaction handling. */
export interface EventTileContextMenuState {
/** Menu anchor position. */
position: Pick<DOMRect, "top" | "left" | "bottom">;
/** Optional link target captured from the context-menu target. */
link?: string;
}
/** Interaction state owned by EventTile. */
export interface EventTileInteractionState {
/** Whether the action bar is focused. */
actionBarFocused: boolean;
/** Whether focus should force the action bar visible. */
showActionBarFromFocus: boolean;
/** Whether the tile is currently hovered. */
hover: boolean;
/** Whether focus is currently inside the tile. */
focusWithin: boolean;
/** Context menu state, when the EventTile context menu is open. */
contextMenu?: EventTileContextMenuState;
}
/** Initial EventTile interaction state. */
export const initialEventTileInteractionState: EventTileInteractionState = {
actionBarFocused: false,
showActionBarFromFocus: false,
hover: false,
focusWithin: false,
};
/** Updates interaction state when the tile is hovered. */
export function eventTileMouseEnter(state: EventTileInteractionState): EventTileInteractionState {
return {
...state,
hover: true,
};
}
/** Updates interaction state when the tile is no longer hovered. */
export function eventTileMouseLeave(state: EventTileInteractionState): EventTileInteractionState {
return {
...state,
hover: false,
};
}
/** Updates interaction state when focus enters the tile. */
export function eventTileFocusWithin(
state: EventTileInteractionState,
showActionBarFromFocus: boolean,
): EventTileInteractionState {
return {
...state,
focusWithin: true,
showActionBarFromFocus,
};
}
/** Updates interaction state when focus leaves the tile. */
export function eventTileBlurWithin(state: EventTileInteractionState): EventTileInteractionState {
return {
...state,
focusWithin: false,
showActionBarFromFocus: false,
};
}
/** Updates interaction state when the action bar focus changes. */
export function eventTileActionBarFocusChange(
state: EventTileInteractionState,
actionBarFocused: boolean,
tileHovered: boolean,
): EventTileInteractionState {
return {
...state,
actionBarFocused,
hover: actionBarFocused ? state.hover : tileHovered,
};
}
/** Updates interaction state when stale hover is detected. */
export function eventTileClearHover(state: EventTileInteractionState): EventTileInteractionState {
return eventTileMouseLeave(state);
}
/** Updates interaction state when the EventTile context menu opens. */
export function eventTileOpenContextMenu(
state: EventTileInteractionState,
contextMenu: EventTileContextMenuState,
): EventTileInteractionState {
return {
...state,
contextMenu,
actionBarFocused: true,
hover: false,
};
}
/** Updates interaction state when the EventTile context menu closes. */
export function eventTileCloseContextMenu(state: EventTileInteractionState): EventTileInteractionState {
return {
...state,
contextMenu: undefined,
actionBarFocused: false,
hover: false,
};
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import {
eventTileActionBarFocusChange,
eventTileBlurWithin,
eventTileClearHover,
eventTileCloseContextMenu,
eventTileFocusWithin,
eventTileMouseEnter,
eventTileMouseLeave,
eventTileOpenContextMenu,
initialEventTileInteractionState,
type EventTileInteractionState,
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileInteractionState";
function makeState(overrides: Partial<EventTileInteractionState> = {}): EventTileInteractionState {
return {
...initialEventTileInteractionState,
...overrides,
};
}
describe("EventTileInteractionState", () => {
it("tracks hover state", () => {
const hovered = eventTileMouseEnter(makeState());
const unhovered = eventTileMouseLeave(hovered);
expect(hovered.hover).toBe(true);
expect(unhovered.hover).toBe(false);
});
it("tracks focus-within state and keyboard action bar visibility", () => {
const focused = eventTileFocusWithin(makeState(), true);
const blurred = eventTileBlurWithin(focused);
expect(focused.focusWithin).toBe(true);
expect(focused.showActionBarFromFocus).toBe(true);
expect(blurred.focusWithin).toBe(false);
expect(blurred.showActionBarFromFocus).toBe(false);
});
it("preserves hover while action bar receives focus", () => {
const state = makeState({ hover: true });
const focused = eventTileActionBarFocusChange(state, true, false);
expect(focused.actionBarFocused).toBe(true);
expect(focused.hover).toBe(true);
});
it("restores hover from the tile when action bar loses focus", () => {
const state = makeState({ actionBarFocused: true, hover: false });
const focused = eventTileActionBarFocusChange(state, false, true);
expect(focused.actionBarFocused).toBe(false);
expect(focused.hover).toBe(true);
});
it("clears stale hover", () => {
const state = eventTileClearHover(makeState({ hover: true }));
expect(state.hover).toBe(false);
});
it("opens and closes the context menu", () => {
const menu = {
position: {
left: 1,
top: 2,
bottom: 3,
},
link: "https://example.org/",
};
const opened = eventTileOpenContextMenu(makeState({ hover: true }), menu);
const closed = eventTileCloseContextMenu(opened);
expect(opened.contextMenu).toEqual(menu);
expect(opened.actionBarFocused).toBe(true);
expect(opened.hover).toBe(false);
expect(closed.contextMenu).toBeUndefined();
expect(closed.actionBarFocused).toBe(false);
expect(closed.hover).toBe(false);
});
});