mirror of
https://github.com/vector-im/element-web.git
synced 2026-07-20 22:28:58 +00:00
* Extract EventTile interaction state transitions * Fix sonar issues
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user