/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import "@testing-library/jest-dom"; import React from "react"; import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; 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 { 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"; import { ChevronFace } from "../../../../../../src/components/structures/ContextMenu"; import { ComposerInsertPayload, ComposerType } from "../../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { ActionPayload } from "../../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton"; import { createMocks } from "./utils"; describe("EditWysiwygComposer", () => { afterEach(() => { jest.resetAllMocks(); }); const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks(); const customRender = ( disabled = false, _editorStateTransfer = editorStateTransfer, client = mockClient, roomContext = defaultRoomContext, ) => { return render( , ); }; beforeAll( async () => { // Load the dynamic import const component = customRender(false); await component.findByRole("textbox"); component.unmount(); }, // it can take a while to load the wasm 20000, ); it("Should not render the component when not ready", async () => { // When const { rerender } = customRender(false); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); rerender( , ); // Then await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); }); describe("Initialize with content", () => { it("Should initialize useWysiwyg with html content", async () => { // When customRender(false, editorStateTransfer); // Then await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"), { timeout: 2000, }); await waitFor(() => expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["formatted_body"]), ); }); it("Should initialize useWysiwyg with plain text content", async () => { // When const mockEvent = mkEvent({ type: "m.room.message", room: "myfakeroom", user: "myfakeuser", content: { msgtype: "m.text", body: "Replying to this", }, event: true, }); const editorStateTransfer = new EditorStateTransfer(mockEvent); customRender(false, editorStateTransfer); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); // Then await waitFor(() => expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["body"])); }); it("Should ignore when formatted_body is not filled", async () => { // When const mockEvent = mkEvent({ type: "m.room.message", room: "myfakeroom", user: "myfakeuser", content: { msgtype: "m.text", body: "Replying to this", format: "org.matrix.custom.html", }, event: true, }); const editorStateTransfer = new EditorStateTransfer(mockEvent); customRender(false, editorStateTransfer); // Then await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); }); it("Should strip tag from initial content", async () => { // When 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: "ReplyMy content", }, event: true, }); const editorStateTransfer = new EditorStateTransfer(mockEvent); customRender(false, editorStateTransfer); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); // Then await waitFor(() => { expect(screen.getByRole("textbox")).not.toContainHTML("Reply"); expect(screen.getByRole("textbox")).toContainHTML("My content"); }); }); }); describe("Edit and save actions", () => { let spyDispatcher: jest.SpyInstance; beforeEach(async () => { spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); customRender(); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); }); afterEach(() => { spyDispatcher.mockRestore(); }); it("Should cancel edit on cancel button click", async () => { // When screen.getByText("Cancel").click(); // Then expect(spyDispatcher).toHaveBeenCalledWith({ action: Action.EditEvent, event: null, timelineRenderingType: defaultRoomContext.timelineRenderingType, }); expect(spyDispatcher).toHaveBeenCalledWith({ action: Action.FocusSendMessageComposer, context: defaultRoomContext.timelineRenderingType, }); }); it("Should send message on save button click", async () => { // When fireEvent.input(screen.getByRole("textbox"), { data: "foo bar", inputType: "insertText", }); await waitFor(() => expect(screen.getByText("Save")).not.toHaveAttribute("disabled")); // Then screen.getByText("Save").click(); const expectedContent = { "body": ` * foo bar`, "format": "org.matrix.custom.html", "formatted_body": ` * foo bar`, "m.new_content": { body: "foo bar", format: "org.matrix.custom.html", formatted_body: "foo bar", msgtype: "m.text", }, "m.relates_to": { event_id: mockEvent.getId(), rel_type: "m.replace", }, "msgtype": "m.text", }; await waitFor(() => expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEvent.getRoomId(), null, expectedContent), ); expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" }); }); }); it("Should focus when receiving an Action.FocusEditMessageComposer action", async () => { // Given we don't have focus customRender(); screen.getByLabelText("Bold").focus(); expect(screen.getByRole("textbox")).not.toHaveFocus(); // When we send the right action defaultDispatcher.dispatch({ action: Action.FocusEditMessageComposer, 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); screen.getByLabelText("Bold").focus(); expect(screen.getByRole("textbox")).not.toHaveFocus(); // When we send an action that would cause us to get focus defaultDispatcher.dispatch({ action: Action.FocusEditMessageComposer, context: null, }); // (Send a second event to exercise the clearTimeout logic) defaultDispatcher.dispatch({ action: Action.FocusEditMessageComposer, context: null, }); // Wait for event dispatch to happen await flushPromises(); // Then we don't get it because we are disabled expect(screen.getByRole("textbox")).not.toHaveFocus(); }); it("Should add emoji", async () => { // When // We are not testing here the emoji button (open modal, select emoji ...) // Instead we are directly firing an emoji to make the test easier to write jest.spyOn(EmojiButton, "EmojiButton").mockImplementation( ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { return ( ); }, ); render( , ); // Same behavior as in RoomView.tsx // RoomView is re-dispatching the composer messages. // It adds the composerType fields where the value refers if the composer is in editing or not // The listeners in the RTE ignore the message if the composerType is missing in the payload const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => { defaultDispatcher.dispatch({ ...(payload as ComposerInsertPayload), composerType: ComposerType.Edit, }); }); screen.getByLabelText("Emoji").click(); // Then await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/)); defaultDispatcher.unregister(dispatcherRef); }); });