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 a7ff75f1cd..4e119e33db 100644 Binary files a/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png and b/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png differ 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,