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 13:41:58 +00: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 SettingsStore from "../../../settings/SettingsStore";
import { CardContext } from "../right_panel/context"; import { CardContext } from "../right_panel/context";
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; 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 { import {
MessageTimestampViewModel, MessageTimestampViewModel,
type MessageTimestampViewModelProps, type MessageTimestampViewModelProps,
@@ -270,9 +282,7 @@ export interface EventTileProps {
} }
interface IState { interface IState {
// Whether the action bar is focused. interaction: EventTileInteractionState;
actionBarFocused: boolean;
showActionBarFromFocus: boolean;
/** /**
* E2EE shield we should show for decryption problems. * 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` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: Relations | null | undefined; reactions?: Relations | null | undefined;
hover: boolean;
focusWithin: boolean;
// Position of the context menu
contextMenu?: {
position: Pick<DOMRect, "top" | "left" | "bottom">;
link?: string;
};
isQuoteExpanded?: boolean; isQuoteExpanded?: boolean;
thread: Thread | null; thread: Thread | null;
@@ -346,9 +347,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const thread = this.thread; const thread = this.thread;
this.state = { this.state = {
// Whether the action bar is focused. interaction: initialEventTileInteractionState,
actionBarFocused: false,
showActionBarFromFocus: false,
shieldColour: EventShieldColour.NONE, shieldColour: EventShieldColour.NONE,
shieldReason: null, 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` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(), reactions: this.getReactions(),
hover: false,
focusWithin: false,
thread, thread,
}; };
@@ -495,9 +491,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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. // 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. // 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(); this.startStaleHoverCheck();
} else if (prevState.hover && !this.state.hover) { } else if (prevState.interaction.hover && !this.state.interaction.hover) {
this.stopStaleHoverCheck(); 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. // 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. // Clear stale focus-derived action bar state when focus has actually left this tile.
if ( if (
this.state.focusWithin && this.state.interaction.focusWithin &&
this.ref.current && this.ref.current &&
document.activeElement instanceof HTMLElement && document.activeElement instanceof HTMLElement &&
!this.ref.current.contains(document.activeElement) !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 => { private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => {
this.setState((prevState) => ({ this.setState((prevState) => ({
actionBarFocused, interaction: eventTileActionBarFocusChange(
hover: actionBarFocused ? prevState.hover : (this.ref.current?.matches(":hover") ?? false), 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 => { private readonly onDocumentMouseMove = (): void => {
if (this.state.hover && !(this.ref.current?.matches(":hover") ?? false)) { if (this.state.interaction.hover && !(this.ref.current?.matches(":hover") ?? false)) {
this.setState({ hover: false }); this.setState((prevState) => ({
interaction: eventTileClearHover(prevState.interaction),
}));
} }
}; };
private readonly onMouseEnter = (): void => { private readonly onMouseEnter = (): void => {
this.setState({ hover: true }); this.setState((prevState) => ({
interaction: eventTileMouseEnter(prevState.interaction),
}));
}; };
private readonly onMouseLeave = (): void => { private readonly onMouseLeave = (): void => {
this.setState({ hover: false }); this.setState((prevState) => ({
interaction: eventTileMouseLeave(prevState.interaction),
}));
}; };
private readonly onFocusWithin = (event: FocusEvent<HTMLElement>): void => { 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 target = event.target as HTMLElement;
const showActionBarFromFocus = const showActionBarFromFocus =
target.matches(":focus-visible") || document.body.dataset["data-whatinput"] === "keyboard"; 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 => { private readonly onBlurWithin = (event: FocusEvent<HTMLElement>): void => {
@@ -946,7 +955,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return; return;
} }
this.setState({ focusWithin: false, showActionBarFromFocus: false }); this.setState((prevState) => ({
interaction: eventTileBlurWithin(prevState.interaction),
}));
}; };
private readonly getTile: () => IEventTileType | null = () => this.tile.current; private readonly getTile: () => IEventTileType | null = () => this.tile.current;
@@ -997,26 +1008,22 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({ this.setState((prevState) => ({
contextMenu: { interaction: eventTileOpenContextMenu(prevState.interaction, {
position: { position: {
left: ev.clientX, left: ev.clientX,
top: ev.clientY, top: ev.clientY,
bottom: ev.clientY, bottom: ev.clientY,
}, },
link: anchorElement?.href || permalink, link: anchorElement?.href || permalink,
}, }),
actionBarFocused: true, }));
hover: false,
});
} }
private readonly onCloseMenu = (): void => { private readonly onCloseMenu = (): void => {
this.setState({ this.setState((prevState) => ({
contextMenu: undefined, interaction: eventTileCloseContextMenu(prevState.interaction),
actionBarFocused: false, }));
hover: false,
});
}; };
private readonly setQuoteExpanded = (expanded: boolean): void => { private readonly setQuoteExpanded = (expanded: boolean): void => {
@@ -1038,7 +1045,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} }
private renderContextMenu(): ReactNode { private renderContextMenu(): ReactNode {
if (!this.state.contextMenu) return null; if (!this.state.interaction.contextMenu) return null;
const tile = this.getTile(); const tile = this.getTile();
const replyChain = this.getReplyChain(); const replyChain = this.getReplyChain();
@@ -1047,7 +1054,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return ( return (
<MessageContextMenu <MessageContextMenu
{...aboveRightOf(this.state.contextMenu.position)} {...aboveRightOf(this.state.interaction.contextMenu.position)}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
eventTileOps={eventTileOps} eventTileOps={eventTileOps}
@@ -1055,7 +1062,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
rightClick={true} rightClick={true}
reactions={this.state.reactions} reactions={this.state.reactions}
link={this.state.contextMenu.link} link={this.state.interaction.contextMenu.link}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
/> />
); );
@@ -1122,17 +1129,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
noBubbleEvent, noBubbleEvent,
isTwelveHour: this.props.isTwelveHour, isTwelveHour: this.props.isTwelveHour,
isHighlighted: this.shouldHighlight(), isHighlighted: this.shouldHighlight(),
isSelected: this.props.isSelectedEvent || !!this.state.contextMenu, isSelected: this.props.isSelectedEvent || !!this.state.interaction.contextMenu,
isLast: this.props.last, isLast: this.props.last,
isLastInSection: this.props.lastInSection, isLastInSection: this.props.lastInSection,
isContextual: this.props.contextual, isContextual: this.props.contextual,
}, },
interaction: { interaction: {
hover: this.state.hover, hover: this.state.interaction.hover,
showActionBarFromFocus: this.state.showActionBarFromFocus, showActionBarFromFocus: this.state.interaction.showActionBarFromFocus,
focusWithin: this.state.focusWithin, focusWithin: this.state.interaction.focusWithin,
isActionBarFocused: this.state.actionBarFocused, isActionBarFocused: this.state.interaction.actionBarFocused,
hasContextMenu: !!this.state.contextMenu, hasContextMenu: !!this.state.interaction.contextMenu,
inhibitInteraction: this.props.inhibitInteraction, inhibitInteraction: this.props.inhibitInteraction,
}, },
sender: { 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);
});
});