diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx index 300417f66d4..8701f5be778 100644 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx @@ -15,15 +15,16 @@ limitations under the License. */ import React, { useCallback, useEffect } from 'react'; -import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; -import { useRoomContext } from '../../../../contexts/RoomContext'; -import { sendMessage } from './message'; +import { Editor } from './Editor'; +import { FormattingButtons } from './FormattingButtons'; import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks'; +import { sendMessage } from './message'; import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; -import { FormattingButtons } from './FormattingButtons'; -import { Editor } from './Editor'; +import { useRoomContext } from '../../../../contexts/RoomContext'; +import { useWysiwygActionHandler } from './useWysiwygActionHandler'; interface WysiwygProps { disabled?: boolean; @@ -55,6 +56,8 @@ export function WysiwygComposer( ref.current?.focus(); }, [content, mxClient, roomContext, wysiwyg, props, ref]); + useWysiwygActionHandler(disabled, ref); + return (
diff --git a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts new file mode 100644 index 00000000000..683498d485e --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts @@ -0,0 +1,73 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useRef } from "react"; + +import defaultDispatcher from "../../../../dispatcher/dispatcher"; +import { Action } from "../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../dispatcher/payloads"; +import { IRoomState } from "../../../structures/RoomView"; +import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../hooks/useDispatcher"; + +export function useWysiwygActionHandler( + disabled: boolean, + composerElement: React.MutableRefObject, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + useDispatcher(defaultDispatcher, (payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case "reply_to_event": + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + // TODO: case Action.ComposerInsert: - see SendMessageComposer + } + }); +} + +function focusComposer( + composerElement: React.MutableRefObject, + renderingType: TimelineRenderingType, + roomContext: IRoomState, + timeoutId: React.MutableRefObject, +) { + if (renderingType === roomContext.timelineRenderingType) { + // Immediately set the focus, so if you start typing it + // will appear in the composer + composerElement.current?.focus(); + // If we call focus immediate, the focus _is_ in the right + // place, but the cursor is invisible, presumably because + // some other event is still processing. + // The following line ensures that the cursor is actually + // visible in composer. + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + timeoutId.current = setTimeout( + () => composerElement.current?.focus(), + 200, + ); + } +} diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index d1d4596f5a7..b0aa838879b 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -14,15 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "@testing-library/jest-dom"; import React from "react"; -import { act, render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; -import { IRoomState } from "../../../../../src/components/structures/RoomView"; +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../../src/dispatcher/actions"; +import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { Layout } from "../../../../../src/settings/enums/Layout"; -import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts @@ -92,7 +95,7 @@ describe('WysiwygComposer', () => { }; let sendMessage: () => void; - const customRender = (onChange = (content: string) => void 0, disabled = false) => { + const customRender = (onChange = (_content: string) => void 0, disabled = false) => { return render( @@ -140,5 +143,58 @@ describe('WysiwygComposer', () => { expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent); expect(screen.getByRole('textbox')).toHaveFocus(); }); + + it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => { + // Given we don't have focus + customRender(() => {}, false); + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // When we send the right action + defaultDispatcher.dispatch({ + action: Action.FocusSendMessageComposer, + context: null, + }); + + // Then the component gets the focus + await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus()); + }); + + it('Should focus when receiving a reply_to_event action', async () => { + // Given we don't have focus + customRender(() => {}, false); + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // When we send the right action + defaultDispatcher.dispatch({ + action: "reply_to_event", + context: null, + }); + + // Then the component gets the focus + await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus()); + }); + + it('Should not focus when disabled', async () => { + // Given we don't have focus and we are disabled + customRender(() => {}, true); + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // When we send an action that would cause us to get focus + defaultDispatcher.dispatch({ + action: Action.FocusSendMessageComposer, + context: null, + }); + // (Send a second event to exercise the clearTimeout logic) + defaultDispatcher.dispatch({ + action: Action.FocusSendMessageComposer, + context: null, + }); + + // Wait for event dispatch to happen + await new Promise((r) => setTimeout(r, 200)); + + // Then we don't get it because we are disabled + expect(screen.getByRole('textbox')).not.toHaveFocus(); + }); });