Open message in editing mode when keyboard up is pressed (RTE) (#10079)

Move to previous message when arrow up is pressed in the main composer (RTE)
This commit is contained in:
Florian Duros 2023-02-03 17:43:02 +01:00 committed by GitHub
parent f1a08cd572
commit 2b66cfc25f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 487 additions and 365 deletions

View file

@ -489,6 +489,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
e2eStatus={this.props.e2eStatus} e2eStatus={this.props.e2eStatus}
menuPosition={menuPosition} menuPosition={menuPosition}
placeholder={this.renderPlaceholderText()} placeholder={this.renderPlaceholderText()}
eventRelation={this.props.relation}
/> />
); );
} else { } else {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { SubSelection } from "./types"; import { SubSelection } from "./types";
import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
@ -29,6 +30,7 @@ export function getDefaultContextValue(defaultValue?: Partial<ComposerContextSta
export interface ComposerContextState { export interface ComposerContextState {
selection: SubSelection; selection: SubSelection;
editorStateTransfer?: EditorStateTransfer; editorStateTransfer?: EditorStateTransfer;
eventRelation?: IEventRelation;
} }
export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue()); export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue());

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler"; import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler";
import { WysiwygComposer } from "./components/WysiwygComposer"; import { WysiwygComposer } from "./components/WysiwygComposer";
@ -48,6 +49,7 @@ interface SendWysiwygComposerProps {
onChange: (content: string) => void; onChange: (content: string) => void;
onSend: () => void; onSend: () => void;
menuPosition: MenuProps; menuPosition: MenuProps;
eventRelation?: IEventRelation;
} }
// Default needed for React.lazy // Default needed for React.lazy
@ -55,10 +57,11 @@ export default function SendWysiwygComposer({
isRichTextEnabled, isRichTextEnabled,
e2eStatus, e2eStatus,
menuPosition, menuPosition,
eventRelation,
...props ...props
}: SendWysiwygComposerProps): JSX.Element { }: SendWysiwygComposerProps): JSX.Element {
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
const defaultContextValue = useRef(getDefaultContextValue()); const defaultContextValue = useRef(getDefaultContextValue({ eventRelation }));
return ( return (
<ComposerContext.Provider value={defaultContextValue.current}> <ComposerContext.Provider value={defaultContextValue.current}>

View file

@ -33,7 +33,7 @@ function getFormattedContent(editorStateTransfer: EditorStateTransfer): string {
); );
} }
function parseEditorStateTransfer( export function parseEditorStateTransfer(
editorStateTransfer: EditorStateTransfer, editorStateTransfer: EditorStateTransfer,
room: Room, room: Room,
mxClient: MatrixClient, mxClient: MatrixClient,
@ -64,7 +64,7 @@ function parseEditorStateTransfer(
// this.saveStoredEditorState(); // this.saveStoredEditorState();
} }
export function useInitialContent(editorStateTransfer: EditorStateTransfer): string { export function useInitialContent(editorStateTransfer: EditorStateTransfer): string | undefined {
const roomContext = useRoomContext(); const roomContext = useRoomContext();
const mxClient = useMatrixClientContext(); const mxClient = useMatrixClientContext();

View file

@ -30,7 +30,7 @@ import { ComposerContextState, useComposerContext } from "../ComposerContext";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection"; import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
import { getEventsFromEditorStateTransfer } from "../utils/event"; import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
import { endEditing } from "../utils/editing"; import { endEditing } from "../utils/editing";
export function useInputEventProcessor( export function useInputEventProcessor(
@ -87,7 +87,8 @@ function handleKeyboardEvent(
mxClient: MatrixClient, mxClient: MatrixClient,
): KeyboardEvent | null { ): KeyboardEvent | null {
const { editorStateTransfer } = composerContext; const { editorStateTransfer } = composerContext;
const isEditorModified = initialContent !== composer.content(); const isEditing = Boolean(editorStateTransfer);
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
const action = getKeyBindingsManager().getMessageComposerAction(event); const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) { switch (action) {
@ -95,14 +96,21 @@ function handleKeyboardEvent(
send(); send();
return null; return null;
case KeyBindingAction.EditPrevMessage: { case KeyBindingAction.EditPrevMessage: {
// If not in edition
// Or if the caret is not at the beginning of the editor // Or if the caret is not at the beginning of the editor
// Or the editor is modified // Or the editor is modified
if (!editorStateTransfer || !isCaretAtStart(editor) || isEditorModified) { if (!isCaretAtStart(editor) || isEditorModified) {
break; break;
} }
const isDispatched = dispatchEditEvent(event, false, editorStateTransfer, roomContext, mxClient); const isDispatched = dispatchEditEvent(
event,
false,
editorStateTransfer,
composerContext,
roomContext,
mxClient,
);
if (isDispatched) { if (isDispatched) {
return null; return null;
} }
@ -117,7 +125,14 @@ function handleKeyboardEvent(
break; break;
} }
const isDispatched = dispatchEditEvent(event, true, editorStateTransfer, roomContext, mxClient); const isDispatched = dispatchEditEvent(
event,
true,
editorStateTransfer,
composerContext,
roomContext,
mxClient,
);
if (!isDispatched) { if (!isDispatched) {
endEditing(roomContext); endEditing(roomContext);
event.preventDefault(); event.preventDefault();
@ -134,11 +149,14 @@ function handleKeyboardEvent(
function dispatchEditEvent( function dispatchEditEvent(
event: KeyboardEvent, event: KeyboardEvent,
isForward: boolean, isForward: boolean,
editorStateTransfer: EditorStateTransfer, editorStateTransfer: EditorStateTransfer | undefined,
composerContext: ComposerContextState,
roomContext: IRoomState, roomContext: IRoomState,
mxClient: MatrixClient, mxClient: MatrixClient,
): boolean { ): boolean {
const foundEvents = getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient); const foundEvents = editorStateTransfer
? getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient)
: getEventsFromRoom(composerContext, roomContext);
if (!foundEvents) { if (!foundEvents) {
return false; return false;
} }
@ -146,7 +164,7 @@ function dispatchEditEvent(
const newEvent = findEditableEvent({ const newEvent = findEditableEvent({
events: foundEvents, events: foundEvents,
isForward, isForward,
fromEventId: editorStateTransfer.getEvent().getId(), fromEventId: editorStateTransfer?.getEvent().getId(),
}); });
if (newEvent) { if (newEvent) {
dis.dispatch({ dis.dispatch({

View file

@ -15,9 +15,11 @@ limitations under the License.
*/ */
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { IRoomState } from "../../../../structures/RoomView"; import { IRoomState } from "../../../../structures/RoomView";
import { ComposerContextState } from "../ComposerContext";
// From EditMessageComposer private get events(): MatrixEvent[] // From EditMessageComposer private get events(): MatrixEvent[]
export function getEventsFromEditorStateTransfer( export function getEventsFromEditorStateTransfer(
@ -44,3 +46,14 @@ export function getEventsFromEditorStateTransfer(
const isInThread = Boolean(editorStateTransfer.getEvent().getThread()); const isInThread = Boolean(editorStateTransfer.getEvent().getThread());
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents); return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
} }
// From SendMessageComposer private onKeyDown = (event: KeyboardEvent): void
export function getEventsFromRoom(
composerContext: ComposerContextState,
roomContext: IRoomState,
): MatrixEvent[] | undefined {
const isReplyingToThread = composerContext.eventRelation?.key === THREAD_RELATION_TYPE.name;
return roomContext.liveTimeline
?.getEvents()
.concat(isReplyingToThread ? [] : roomContext.room?.getPendingEvents() || []);
}

View file

@ -44,15 +44,21 @@ export function isCaretAtStart(editor: HTMLElement): boolean {
const selection = document.getSelection(); const selection = document.getSelection();
// No selection or the caret is not at the beginning of the selected element // No selection or the caret is not at the beginning of the selected element
if (!selection || selection.anchorOffset !== 0) { if (!selection) {
return false; return false;
} }
// When we are pressing keyboard up in an empty main composer, the selection is on the editor with an anchorOffset at O or 1 (yes, this is strange)
const isOnFirstElement = selection.anchorNode === editor && selection.anchorOffset <= 1;
if (isOnFirstElement) {
return true;
}
// In case of nested html elements (list, code blocks), we are going through all the first child // In case of nested html elements (list, code blocks), we are going through all the first child
let child = editor.firstChild; let child = editor.firstChild;
do { do {
if (child === selection.anchorNode) { if (child === selection.anchorNode) {
return true; return selection.anchorOffset === 0;
} }
} while ((child = child?.firstChild || null)); } while ((child = child?.firstChild || null));

View file

@ -17,21 +17,12 @@ limitations under the License.
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import React from "react"; import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../src/contexts/RoomContext"; import RoomContext from "../../../../../src/contexts/RoomContext";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { flushPromises, mkEvent } from "../../../../test-utils";
import {
createTestClient,
flushPromises,
getRoomContext,
mkEvent,
mkStubRoom,
mockPlatformPeg,
} from "../../../../test-utils";
import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
import { Emoji } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji"; import { Emoji } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji";
@ -40,43 +31,13 @@ import dis from "../../../../../src/dispatcher/dispatcher";
import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload";
import { ActionPayload } from "../../../../../src/dispatcher/payloads"; import { ActionPayload } from "../../../../../src/dispatcher/payloads";
import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton"; import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton";
import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; import { createMocks } from "./utils";
import * as EventUtils from "../../../../../src/utils/EventUtils";
import { SubSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/types";
describe("EditWysiwygComposer", () => { describe("EditWysiwygComposer", () => {
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
const mockClient = createTestClient();
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: {
msgtype: "m.text",
body: "Replying to this",
format: "org.matrix.custom.html",
formatted_body: eventContent,
},
event: true,
});
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {
liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline,
});
const editorStateTransfer = new EditorStateTransfer(mockEvent);
return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent };
}
const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks(); const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks();
const customRender = ( const customRender = (
@ -342,290 +303,4 @@ describe("EditWysiwygComposer", () => {
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/)); await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/));
dis.unregister(dispatcherRef); dis.unregister(dispatcherRef);
}); });
describe("Keyboard navigation", () => {
const setup = async (
editorState = editorStateTransfer,
client = createTestClient(),
roomContext = defaultRoomContext,
) => {
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
customRender(false, editorState, client, roomContext);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
return { textbox: screen.getByRole("textbox"), spyDispatcher };
};
beforeEach(() => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
});
function select(selection: SubSelection) {
return act(async () => {
await setSelection(selection);
// the event is not automatically fired by jest
document.dispatchEvent(new CustomEvent("selectionchange"));
});
}
describe("Moving up", () => {
it("Should not moving when caret is not at beginning of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup();
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 1,
focusNode: textNode,
focusOffset: 2,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving up", async () => {
// When
const { textbox, spyDispatcher } = await setup();
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving up in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
});
describe("Moving down", () => {
it("Should not moving when caret is not at the end of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup();
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving down", async () => {
// When
const { textbox, spyDispatcher } = await setup();
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving down in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
// Skipping the BR tag and get the text node inside the last LI tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox;
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
it("Should close editing", async () => {
// When
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined);
const { textbox, spyDispatcher } = await setup();
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: null,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
});
});
}); });

View file

@ -22,12 +22,12 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext
import RoomContext from "../../../../../src/contexts/RoomContext"; import RoomContext from "../../../../../src/contexts/RoomContext";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { flushPromises } from "../../../../test-utils";
import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/"; import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/";
import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu"; import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu";
import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload";
import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
import { createMocks } from "./utils";
jest.mock("../../../../../src/components/views/rooms/EmojiButton", () => ({ jest.mock("../../../../../src/components/views/rooms/EmojiButton", () => ({
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
@ -44,20 +44,7 @@ describe("SendWysiwygComposer", () => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
const mockClient = createTestClient(); const { defaultRoomContext, mockClient } = createMocks();
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: { msgtype: "m.text", body: "Replying to this" },
event: true,
});
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
const registerId = defaultDispatcher.register((payload) => { const registerId = defaultDispatcher.register((payload) => {
switch (payload.action) { switch (payload.action) {

View file

@ -16,25 +16,38 @@ limitations under the License.
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import React from "react"; import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
import SettingsStore from "../../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { mockPlatformPeg } from "../../../../../test-utils"; import { createTestClient, flushPromises, mockPlatformPeg } from "../../../../../test-utils";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
import * as EventUtils from "../../../../../../src/utils/EventUtils";
import { Action } from "../../../../../../src/dispatcher/actions";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../../src/contexts/RoomContext";
import {
ComposerContext,
getDefaultContextValue,
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/ComposerContext";
import { createMocks } from "../utils";
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types";
import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
import { parseEditorStateTransfer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent";
describe("WysiwygComposer", () => { describe("WysiwygComposer", () => {
const customRender = ( const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => {
onChange = (_content: string) => void 0,
onSend = () => void 0,
disabled = false,
initialContent?: string,
) => {
return render( return render(
<WysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} initialContent={initialContent} />, <WysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} initialContent={initialContent} />,
); );
}; };
afterEach(() => {
jest.resetAllMocks();
});
it("Should have contentEditable at false when disabled", () => { it("Should have contentEditable at false when disabled", () => {
// When // When
customRender(jest.fn(), jest.fn(), true); customRender(jest.fn(), jest.fn(), true);
@ -191,4 +204,359 @@ describe("WysiwygComposer", () => {
await waitFor(() => expect(onSend).toBeCalledTimes(1)); await waitFor(() => expect(onSend).toBeCalledTimes(1));
}); });
}); });
describe("Keyboard navigation", () => {
const { mockClient, defaultRoomContext, mockEvent, editorStateTransfer } = createMocks();
const customRender = (
client = mockClient,
roomContext = defaultRoomContext,
_editorStateTransfer?: EditorStateTransfer,
) => {
return render(
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={roomContext}>
<ComposerContext.Provider
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
>
<WysiwygComposer
onChange={jest.fn()}
onSend={jest.fn()}
initialContent={
roomContext.room && _editorStateTransfer
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
: undefined
}
/>
</ComposerContext.Provider>
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
};
afterEach(() => {
jest.resetAllMocks();
});
const setup = async (
editorState?: EditorStateTransfer,
client = createTestClient(),
roomContext = defaultRoomContext,
) => {
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
customRender(client, roomContext, editorState);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
return { textbox: screen.getByRole("textbox"), spyDispatcher };
};
beforeEach(() => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
});
describe("In message creation", () => {
it("Should not moving when the composer is filled", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
// Move at the beginning of the composer
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving when the composer is empty", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
});
describe("In message editing", () => {
function select(selection: SubSelection) {
return act(async () => {
await setSelection(selection);
// the event is not automatically fired by jest
document.dispatchEvent(new CustomEvent("selectionchange"));
});
}
describe("Moving up", () => {
it("Should not moving when caret is not at beginning of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 1,
focusNode: textNode,
focusOffset: 2,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving up", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving up in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
});
describe("Moving down", () => {
it("Should not moving when caret is not at the end of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving down", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving down in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
// Skipping the BR tag and get the text node inside the last LI tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox;
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
it("Should close editing", async () => {
// When
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined);
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: null,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
});
});
});
}); });

View file

@ -0,0 +1,49 @@
/*
Copyright 2023 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 { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
import { IRoomState } from "../../../../../src/components/structures/RoomView";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
export function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
const mockClient = createTestClient();
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: {
msgtype: "m.text",
body: "Replying to this",
format: "org.matrix.custom.html",
formatted_body: eventContent,
},
event: true,
});
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {
liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline,
});
const editorStateTransfer = new EditorStateTransfer(mockEvent);
return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent };
}