diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 1e8276ba8f..1515c408d7 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -489,6 +489,7 @@ export class MessageComposer extends React.Component { e2eStatus={this.props.e2eStatus} menuPosition={menuPosition} placeholder={this.renderPlaceholderText()} + eventRelation={this.props.relation} /> ); } else { diff --git a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts index 1de070216c..19daf8fde8 100644 --- a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { createContext, useContext } from "react"; +import { IEventRelation } from "matrix-js-sdk/src/matrix"; import { SubSelection } from "./types"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; @@ -29,6 +30,7 @@ export function getDefaultContextValue(defaultValue?: Partial(getDefaultContextValue()); diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 2691df80dd..d12432481d 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; +import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler"; import { WysiwygComposer } from "./components/WysiwygComposer"; @@ -48,6 +49,7 @@ interface SendWysiwygComposerProps { onChange: (content: string) => void; onSend: () => void; menuPosition: MenuProps; + eventRelation?: IEventRelation; } // Default needed for React.lazy @@ -55,10 +57,11 @@ export default function SendWysiwygComposer({ isRichTextEnabled, e2eStatus, menuPosition, + eventRelation, ...props }: SendWysiwygComposerProps): JSX.Element { const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; - const defaultContextValue = useRef(getDefaultContextValue()); + const defaultContextValue = useRef(getDefaultContextValue({ eventRelation })); return ( diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts index 7ec4c5a313..f4612a097e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts @@ -33,7 +33,7 @@ function getFormattedContent(editorStateTransfer: EditorStateTransfer): string { ); } -function parseEditorStateTransfer( +export function parseEditorStateTransfer( editorStateTransfer: EditorStateTransfer, room: Room, mxClient: MatrixClient, @@ -64,7 +64,7 @@ function parseEditorStateTransfer( // this.saveStoredEditorState(); } -export function useInitialContent(editorStateTransfer: EditorStateTransfer): string { +export function useInitialContent(editorStateTransfer: EditorStateTransfer): string | undefined { const roomContext = useRoomContext(); const mxClient = useMatrixClientContext(); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 405539fc70..def7d74bc0 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -30,7 +30,7 @@ import { ComposerContextState, useComposerContext } from "../ComposerContext"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; import { isCaretAtEnd, isCaretAtStart } from "../utils/selection"; -import { getEventsFromEditorStateTransfer } from "../utils/event"; +import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event"; import { endEditing } from "../utils/editing"; export function useInputEventProcessor( @@ -87,7 +87,8 @@ function handleKeyboardEvent( mxClient: MatrixClient, ): KeyboardEvent | null { 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); switch (action) { @@ -95,14 +96,21 @@ function handleKeyboardEvent( send(); return null; case KeyBindingAction.EditPrevMessage: { - // If not in edition // Or if the caret is not at the beginning of the editor // Or the editor is modified - if (!editorStateTransfer || !isCaretAtStart(editor) || isEditorModified) { + if (!isCaretAtStart(editor) || isEditorModified) { break; } - const isDispatched = dispatchEditEvent(event, false, editorStateTransfer, roomContext, mxClient); + const isDispatched = dispatchEditEvent( + event, + false, + editorStateTransfer, + composerContext, + roomContext, + mxClient, + ); + if (isDispatched) { return null; } @@ -117,7 +125,14 @@ function handleKeyboardEvent( break; } - const isDispatched = dispatchEditEvent(event, true, editorStateTransfer, roomContext, mxClient); + const isDispatched = dispatchEditEvent( + event, + true, + editorStateTransfer, + composerContext, + roomContext, + mxClient, + ); if (!isDispatched) { endEditing(roomContext); event.preventDefault(); @@ -134,11 +149,14 @@ function handleKeyboardEvent( function dispatchEditEvent( event: KeyboardEvent, isForward: boolean, - editorStateTransfer: EditorStateTransfer, + editorStateTransfer: EditorStateTransfer | undefined, + composerContext: ComposerContextState, roomContext: IRoomState, mxClient: MatrixClient, ): boolean { - const foundEvents = getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient); + const foundEvents = editorStateTransfer + ? getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient) + : getEventsFromRoom(composerContext, roomContext); if (!foundEvents) { return false; } @@ -146,7 +164,7 @@ function dispatchEditEvent( const newEvent = findEditableEvent({ events: foundEvents, isForward, - fromEventId: editorStateTransfer.getEvent().getId(), + fromEventId: editorStateTransfer?.getEvent().getId(), }); if (newEvent) { dis.dispatch({ diff --git a/src/components/views/rooms/wysiwyg_composer/utils/event.ts b/src/components/views/rooms/wysiwyg_composer/utils/event.ts index 4d65497fac..2220b7d37a 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/event.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/event.ts @@ -15,9 +15,11 @@ limitations under the License. */ 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 { IRoomState } from "../../../../structures/RoomView"; +import { ComposerContextState } from "../ComposerContext"; // From EditMessageComposer private get events(): MatrixEvent[] export function getEventsFromEditorStateTransfer( @@ -44,3 +46,14 @@ export function getEventsFromEditorStateTransfer( const isInThread = Boolean(editorStateTransfer.getEvent().getThread()); 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() || []); +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts index 4ed64154e5..4af4b00c95 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts @@ -44,15 +44,21 @@ export function isCaretAtStart(editor: HTMLElement): boolean { const selection = document.getSelection(); // No selection or the caret is not at the beginning of the selected element - if (!selection || selection.anchorOffset !== 0) { + if (!selection) { 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 let child = editor.firstChild; do { if (child === selection.anchorNode) { - return true; + return selection.anchorOffset === 0; } } while ((child = child?.firstChild || null)); diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index c2c7052e05..7b7b87e8be 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -17,21 +17,12 @@ limitations under the License. import "@testing-library/jest-dom"; import React from "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 RoomContext from "../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; -import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { - createTestClient, - flushPromises, - getRoomContext, - mkEvent, - mkStubRoom, - mockPlatformPeg, -} from "../../../../test-utils"; +import { flushPromises, mkEvent } from "../../../../test-utils"; import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; 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 { ActionPayload } from "../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton"; -import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; -import * as EventUtils from "../../../../../src/utils/EventUtils"; -import { SubSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/types"; +import { createMocks } from "./utils"; describe("EditWysiwygComposer", () => { afterEach(() => { jest.resetAllMocks(); }); - function createMocks(eventContent = "Replying to 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 customRender = ( @@ -342,290 +303,4 @@ describe("EditWysiwygComposer", () => { await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/)); 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( - "
  • Content
  • Other Content
", - ); - 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( - "
  • Content
  • Other Content
", - ); - 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, - }), - ); - }); - }); - }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index aa39de7a57..4dcf8d504e 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -22,12 +22,12 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import RoomContext from "../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; -import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { flushPromises } from "../../../../test-utils"; import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/"; import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu"; import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; +import { createMocks } from "./utils"; jest.mock("../../../../../src/components/views/rooms/EmojiButton", () => ({ EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { @@ -44,20 +44,7 @@ describe("SendWysiwygComposer", () => { jest.resetAllMocks(); }); - const mockClient = createTestClient(); - 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 { defaultRoomContext, mockClient } = createMocks(); const registerId = defaultDispatcher.register((payload) => { switch (payload.action) { diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 4d485b5a3f..5a41488be4 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -16,25 +16,38 @@ limitations under the License. import "@testing-library/jest-dom"; 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 { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; 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", () => { - const customRender = ( - onChange = (_content: string) => void 0, - onSend = () => void 0, - disabled = false, - initialContent?: string, - ) => { + const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => { return render( , ); }; + afterEach(() => { + jest.resetAllMocks(); + }); + it("Should have contentEditable at false when disabled", () => { // When customRender(jest.fn(), jest.fn(), true); @@ -191,4 +204,359 @@ describe("WysiwygComposer", () => { 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( + + + + + + + , + ); + }; + + 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( + "
  • Content
  • Other Content
", + ); + 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( + "
  • Content
  • Other Content
", + ); + 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, + }), + ); + }); + }); + }); + }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/utils.ts b/test/components/views/rooms/wysiwyg_composer/utils.ts new file mode 100644 index 0000000000..0eb99b251d --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/utils.ts @@ -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 to 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 }; +}