mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 11:47:23 +03:00
Automatically focus the WYSIWYG composer when you enter a room (#9412)
Automatically focus the WYSIWYG composer when you enter a room
This commit is contained in:
parent
13e9e14eaa
commit
e38c9e036c
3 changed files with 144 additions and 12 deletions
|
@ -15,15 +15,16 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
|
|
||||||
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
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 { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks';
|
|
||||||
import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext';
|
|
||||||
import { FormattingButtons } from './FormattingButtons';
|
|
||||||
import { Editor } from './Editor';
|
import { Editor } from './Editor';
|
||||||
|
import { FormattingButtons } from './FormattingButtons';
|
||||||
|
import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks';
|
||||||
|
import { sendMessage } from './message';
|
||||||
|
import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext';
|
||||||
|
import { useRoomContext } from '../../../../contexts/RoomContext';
|
||||||
|
import { useWysiwygActionHandler } from './useWysiwygActionHandler';
|
||||||
|
|
||||||
interface WysiwygProps {
|
interface WysiwygProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -55,6 +56,8 @@ export function WysiwygComposer(
|
||||||
ref.current?.focus();
|
ref.current?.focus();
|
||||||
}, [content, mxClient, roomContext, wysiwyg, props, ref]);
|
}, [content, mxClient, roomContext, wysiwyg, props, ref]);
|
||||||
|
|
||||||
|
useWysiwygActionHandler(disabled, ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_WysiwygComposer">
|
<div className="mx_WysiwygComposer">
|
||||||
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
|
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
|
||||||
|
|
|
@ -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<HTMLElement>,
|
||||||
|
) {
|
||||||
|
const roomContext = useRoomContext();
|
||||||
|
const timeoutId = useRef<number>();
|
||||||
|
|
||||||
|
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<HTMLElement>,
|
||||||
|
renderingType: TimelineRenderingType,
|
||||||
|
roomContext: IRoomState,
|
||||||
|
timeoutId: React.MutableRefObject<number>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,15 +14,18 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
import React from "react";
|
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 RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
|
||||||
import { Layout } from "../../../../../src/settings/enums/Layout";
|
|
||||||
import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";
|
|
||||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
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 { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer";
|
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
|
// 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
|
// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
|
||||||
|
@ -92,7 +95,7 @@ describe('WysiwygComposer', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
let sendMessage: () => void;
|
let sendMessage: () => void;
|
||||||
const customRender = (onChange = (content: string) => void 0, disabled = false) => {
|
const customRender = (onChange = (_content: string) => void 0, disabled = false) => {
|
||||||
return render(
|
return render(
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<RoomContext.Provider value={defaultRoomContext}>
|
<RoomContext.Provider value={defaultRoomContext}>
|
||||||
|
@ -140,5 +143,58 @@ describe('WysiwygComposer', () => {
|
||||||
expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
|
expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
|
||||||
expect(screen.getByRole('textbox')).toHaveFocus();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue