From 70e59c62214ff0908409ccc822a68488dfd07ef1 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Thu, 21 May 2026 15:41:58 +0200 Subject: [PATCH] Refactor EventTile using the MVVM pattern - #4 (#33539) * Extract EventTile interaction state transitions * Fix sonar issues --- .../src/components/views/rooms/EventTile.tsx | 107 ++++++++-------- .../event-tile/EventTileInteractionState.ts | 114 ++++++++++++++++++ .../EventTileInteractionState-test.ts | 88 ++++++++++++++ 3 files changed, 259 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileInteractionState.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileInteractionState-test.ts diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 6281eb3909..1b84f9e7fa 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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; - link?: string; - }; - isQuoteExpanded?: boolean; thread: Thread | null; @@ -346,9 +347,7 @@ export class UnwrappedEventTile extends React.Component 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 // 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 public componentDidUpdate(prevProps: Readonly, prevState: Readonly): 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 // 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 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 } 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): void => { @@ -938,7 +945,9 @@ export class UnwrappedEventTile extends React.Component 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): void => { @@ -946,7 +955,9 @@ export class UnwrappedEventTile extends React.Component 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 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 } 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 return ( 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 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: { diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileInteractionState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileInteractionState.ts new file mode 100644 index 0000000000..7cb3f576a1 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileInteractionState.ts @@ -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; + /** 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, + }; +} diff --git a/apps/web/test/viewmodels/event-tiles/EventTileInteractionState-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileInteractionState-test.ts new file mode 100644 index 0000000000..f9e75b9d60 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileInteractionState-test.ts @@ -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 { + 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); + }); +});