From 97da3be67a15def9649ce2a67ef8513bef90f4d3 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Wed, 13 May 2026 09:52:47 +0200 Subject: [PATCH] Fix for message action bar visibility getting out of sync with the real UI state (#33445) * Make sure action bar is not visible when using up/down arrows during editing * Add a temporary mouse move listener to handle tooltips stealing onMouseLeave events * Better naming, add comments and test * Fix test that performs its own hover/pointer movement before clicking. * Fix playwright test that actually displayed a message time stamp when hover state was stale * Fixes after merge --- .../pinned-message-Msg1-linux.png | Bin 6351 -> 6323 bytes .../src/components/views/rooms/EventTile.tsx | 59 +++++++++++-- .../components/structures/RoomView-test.tsx | 4 +- .../components/views/rooms/EventTile-test.tsx | 82 +++++++++++++++++- 4 files changed, 133 insertions(+), 12 deletions(-) diff --git a/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png b/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png index a7ff75f1cdd1249ea7b685109e8abcd16f95a9d9..4e119e33dbcf8fe3b2f5623674513b1404529626 100644 GIT binary patch literal 6323 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 XCJ4QGw)qzJ3!buqhJ3N?`!D|obc6?` literal 6351 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 diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 171cc38c0c..0f3ba769e7 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -338,6 +338,7 @@ export class UnwrappedEventTile extends React.Component private unmounted = false; private readonly id = uniqueId(); + private staleHoverCheckActive = false; public constructor(props: EventTileProps, context: React.ContextType) { super(props, context); @@ -472,6 +473,7 @@ export class UnwrappedEventTile extends React.Component } public componentWillUnmount(): void { + this.stopStaleHoverCheck(); const client = MatrixClientPeg.get(); if (client) { client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); @@ -491,6 +493,14 @@ 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) { + this.startStaleHoverCheck(); + } else if (prevState.hover && !this.state.hover) { + this.stopStaleHoverCheck(); + } + // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt); @@ -502,6 +512,17 @@ export class UnwrappedEventTile extends React.Component } if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current); + + // 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.ref.current && + document.activeElement instanceof HTMLElement && + !this.ref.current.contains(document.activeElement) + ) { + this.setState({ focusWithin: false, showActionBarFromFocus: false }); + } } private readonly onNewThread = (thread: Thread): void => { @@ -868,6 +889,32 @@ export class UnwrappedEventTile extends React.Component })); }; + private startStaleHoverCheck(): void { + if (this.staleHoverCheckActive) return; + document.addEventListener("mousemove", this.onDocumentMouseMove, true); + this.staleHoverCheckActive = true; + } + + private stopStaleHoverCheck(): void { + if (!this.staleHoverCheckActive) return; + document.removeEventListener("mousemove", this.onDocumentMouseMove, true); + this.staleHoverCheckActive = false; + } + + private readonly onDocumentMouseMove = (): void => { + if (this.state.hover && !(this.ref.current?.matches(":hover") ?? false)) { + this.setState({ hover: false }); + } + }; + + private readonly onMouseEnter = (): void => { + this.setState({ hover: true }); + }; + + private readonly onMouseLeave = (): void => { + this.setState({ hover: false }); + }; + private readonly onFocusWithin = (event: FocusEvent): void => { // Show the action toolbar for keyboard-visible focus, with what-input as a fallback signal. const target = event.target as HTMLElement; @@ -1321,8 +1368,8 @@ export class UnwrappedEventTile extends React.Component "data-layout": this.props.layout, "data-self": isOwnEvent, "data-event-id": this.props.mxEvent.getId(), - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), + "onMouseEnter": this.onMouseEnter, + "onMouseLeave": this.onMouseLeave, "onFocus": this.onFocusWithin, "onBlur": this.onBlurWithin, }, @@ -1384,8 +1431,8 @@ export class UnwrappedEventTile extends React.Component "data-shape": this.context.timelineRenderingType, "data-self": isOwnEvent, "data-has-reply": !!replyChain, - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), + "onMouseEnter": this.onMouseEnter, + "onMouseLeave": this.onMouseLeave, "onFocus": this.onFocusWithin, "onBlur": this.onBlurWithin, "onClick": (ev: MouseEvent) => { @@ -1517,8 +1564,8 @@ export class UnwrappedEventTile extends React.Component "data-self": isOwnEvent, "data-event-id": this.props.mxEvent.getId(), "data-has-reply": !!replyChain, - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), + "onMouseEnter": this.onMouseEnter, + "onMouseLeave": this.onMouseLeave, "onFocus": this.onFocusWithin, "onBlur": this.onBlurWithin, }, diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index c00a3573e2..155ad5d508 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -955,7 +955,7 @@ describe("RoomView", () => { expect(searchResultTile).not.toBeNull(); await userEvent.hover(searchResultTile!); - await userEvent.click(await findByLabelText("Edit")); + await userEvent.click(await findByLabelText("Edit"), { skipHover: true }); await waitFor(() => { expect(container.querySelector(".mx_RoomView_searchResultsPanel")).not.toBeInTheDocument(); @@ -1024,7 +1024,7 @@ describe("RoomView", () => { expect(searchResultTile).not.toBeNull(); await userEvent.hover(searchResultTile!); - await userEvent.click(await findByLabelText("Edit")); + await userEvent.click(await findByLabelText("Edit"), { skipHover: true }); await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index b0f0c0d346..26346f321a 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -46,6 +46,7 @@ import PinningUtils from "../../../../../src/utils/PinningUtils"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import SettingsStore from "../../../../../src/settings/SettingsStore"; +import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import PlatformPeg from "../../../../../src/PlatformPeg"; @@ -159,6 +160,29 @@ describe("EventTile", () => { }); } + function WrappedEventTiles(props: { events: MatrixEvent[]; editEvent?: MatrixEvent }) { + const roomContext = getRoomContext(room, { + timelineRenderingType: TimelineRenderingType.Room, + }); + + return ( + + + {props.events.map((event) => ( + + ))} + + + ); + } + beforeEach(() => { jest.clearAllMocks(); @@ -389,7 +413,9 @@ describe("EventTile", () => { expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); - fireEvent.focus(getTile(container)); + act(() => { + getTile(container).focus(); + }); expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); }); @@ -613,7 +639,9 @@ describe("EventTile", () => { }); const { container } = getComponent(); - fireEvent.focus(getTile(container)); + act(() => { + getTile(container).focus(); + }); expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); }); @@ -627,10 +655,14 @@ describe("EventTile", () => { const { container } = getComponent(); const tile = getTile(container); - fireEvent.focus(tile); + act(() => { + tile.focus(); + }); expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); - fireEvent.blur(tile); + act(() => { + tile.blur(); + }); expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); }); @@ -1366,6 +1398,48 @@ describe("EventTile", () => { }); }); + it("does not leave a stale message action bar when switching edited events", async () => { + const firstEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "First message", + event: true, + }); + const secondEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Second message", + event: true, + }); + const events = [firstEvent, secondEvent]; + + const matches = jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function ( + this: HTMLElement, + selector: string, + ) { + if (selector === ":focus-visible") { + return true; + } + return Element.prototype.matches.call(this, selector); + }); + + const { container, rerender } = render(); + const editingTile = container.querySelector(".mx_EventTile_isEditing"); + + expect(editingTile).not.toBeNull(); + fireEvent.focusIn(editingTile!); + expect(container.querySelectorAll(".mx_MessageActionBar")).toHaveLength(0); + + rerender(); + + await waitFor(() => { + expect(container.querySelectorAll(".mx_EventTile_isEditing")).toHaveLength(1); + expect(container.querySelectorAll(".mx_MessageActionBar")).toHaveLength(0); + }); + + matches.mockRestore(); + }); + it("should display the not encrypted status for an unencrypted event when the room becomes encrypted", async () => { jest.spyOn(client.getCrypto()!, "getEncryptionInfoForEvent").mockResolvedValue({ shieldColour: EventShieldColour.NONE,