diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts index 2cbd7cf52c..b39fe18007 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -38,7 +38,7 @@ export function useWysiwygEditActionHandler( const context = payload.context ?? TimelineRenderingType.Room; switch (payload.action) { - case Action.FocusSendMessageComposer: + case Action.FocusEditMessageComposer: focusComposer(composerElement, context, roomContext, timeoutId); break; } diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index f0488e4599..cc0d2235bf 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -54,8 +54,8 @@ export function createMessageContent( ): IContent { // TODO emote ? - const isReply = Boolean(replyToEvent?.replyEventId); const isEditing = Boolean(editedEvent); + const isReply = isEditing ? Boolean(editedEvent?.replyEventId) : Boolean(replyToEvent); /*const isEmote = containsEmote(model); if (isEmote) { @@ -87,7 +87,7 @@ export function createMessageContent( if (formattedBody) { content.format = "org.matrix.custom.html"; - const htmlPrefix = isReply ? getHtmlReplyFallback(editedEvent) : ''; + const htmlPrefix = isReply && isEditing ? getHtmlReplyFallback(editedEvent) : ''; content.formatted_body = isEditing ? `${htmlPrefix} * ${formattedBody}` : formattedBody; } diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts index 0f4de2d8a3..dbea29c848 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -16,7 +16,7 @@ limitations under the License. import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer"; import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; @@ -160,6 +160,7 @@ export function editMessage( isReply: Boolean(editedEvent.replyEventId), }); + // TODO emoji // Replace emoticon at the end of the message /* if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { const caret = this.editorRef.current?.getCaret(); @@ -182,6 +183,8 @@ export function editMessage( return; } + let response: Promise | undefined; + // If content is modified then send an updated event into the room if (isContentModified(newContent, editorStateTransfer)) { const roomId = editedEvent.getRoomId(); @@ -194,11 +197,11 @@ export function editMessage( const event = editorStateTransfer.getEvent(); const threadId = event.threadRootId || null; - console.log('editContent', editContent); - mxClient.sendMessage(roomId, threadId, editContent); + response = mxClient.sendMessage(roomId, threadId, editContent); dis.dispatch({ action: "message_sent" }); } } endEditing(roomContext); + return response; } diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx new file mode 100644 index 0000000000..72fd52be57 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -0,0 +1,224 @@ +/* +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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { WysiwygProps } from "@matrix-org/matrix-wysiwyg"; + +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, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { EditWysiwygComposer } + from "../../../../../src/components/views/rooms/wysiwyg_composer"; +import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; + +const mockClear = jest.fn(); + +let initialContent: string; +const defaultContent = 'html'; +let mockContent = defaultContent; + +// 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 +jest.mock("@matrix-org/matrix-wysiwyg", () => ({ + useWysiwyg: (props: WysiwygProps) => { + initialContent = props.initialContent; + return { + ref: { current: null }, + content: mockContent, + isWysiwygReady: true, + wysiwyg: { clear: mockClear }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; + }, +})); + +describe('EditWysiwygComposer', () => { + afterEach(() => { + jest.resetAllMocks(); + mockContent = defaultContent; + }); + + 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": "Replying to this new content", + }, + 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 editorStateTransfer = new EditorStateTransfer(mockEvent); + + const customRender = (disabled = false, _editorStateTransfer = editorStateTransfer) => { + return render( + + + + + , + ); + }; + + describe('Initialize with content', () => { + it('Should initialize useWysiwyg with html content', async () => { + // When + customRender(true); + + // Then + expect(initialContent).toBe(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(true, editorStateTransfer); + + // Then + expect(initialContent).toBe(mockEvent.getContent().body); + }); + }); + + describe('Edit and save actions', () => { + const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + afterEach(() => { + spyDispatcher.mockRestore(); + }); + + it('Should cancel edit on cancel button click', async () => { + // When + customRender(true); + (await screen.findByText('Cancel')).click(); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: null, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + expect(spyDispatcher).toBeCalledWith({ + action: Action.FocusSendMessageComposer, + context: defaultRoomContext.timelineRenderingType, + }); + }); + + it('Should send message on save button click', async () => { + // When + const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + + const renderer = customRender(true); + + mockContent = 'my new content'; + renderer.rerender( + + + + ); + + (await screen.findByText('Save')).click(); + + // Then + const expectedContent = { + "body": mockContent, + "format": "org.matrix.custom.html", + "formatted_body": ` * ${mockContent}`, + "m.new_content": { + "body": mockContent, + "format": "org.matrix.custom.html", + "formatted_body": mockContent, + "msgtype": "m.text", + }, + "m.relates_to": { + "event_id": mockEvent.getId(), + "rel_type": "m.replace", + }, + "msgtype": "m.text", + }; + expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent); + expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' }); + }); + }); + + it('Should focus when receiving an Action.FocusEditMessageComposer action', async () => { + // Given we don't have focus + customRender(); + 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); + 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 new Promise((r) => setTimeout(r, 200)); + + // Then we don't get it because we are disabled + expect(screen.getByRole('textbox')).not.toHaveFocus(); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx new file mode 100644 index 0000000000..20148b802a --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -0,0 +1,150 @@ +/* +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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { WysiwygProps } from "@matrix-org/matrix-wysiwyg"; + +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, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; + +const mockClear = jest.fn(); + +// 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 +jest.mock("@matrix-org/matrix-wysiwyg", () => ({ + useWysiwyg: (props: WysiwygProps) => { + return { + ref: { current: null }, + content: 'html', + isWysiwygReady: true, + wysiwyg: { clear: mockClear }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; + }, +})); + +describe('SendWysiwygComposer', () => { + afterEach(() => { + 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 customRender = (onChange = (_content: string) => void 0, onSend = () => void 0, disabled = false) => { + return render( + + + + + , + ); + }; + + it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => { + // Given we don't have focus + customRender(jest.fn(), jest.fn()); + 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 and clear when receiving an Action.ClearAndFocusSendMessageComposer', async () => { + // Given we don't have focus + customRender(jest.fn(), jest.fn()); + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // When we send the right action + defaultDispatcher.dispatch({ + action: Action.ClearAndFocusSendMessageComposer, + context: null, + }); + + // Then the component gets the focus + await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus()); + expect(mockClear).toBeCalledTimes(1); + }); + + it('Should focus when receiving a reply_to_event action', async () => { + // Given we don't have focus + customRender(jest.fn(), jest.fn()); + 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(jest.fn(), jest.fn(), 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(); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx deleted file mode 100644 index fba6753ac2..0000000000 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/* -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 "@testing-library/jest-dom"; -import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg"; - -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 { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; -import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; - -// Work around missing ClipboardEvent type -class MyClipbardEvent {} -window.ClipboardEvent = MyClipbardEvent as any; - -let inputEventProcessor: InputEventProcessor | null = null; - -// 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 -jest.mock("@matrix-org/matrix-wysiwyg", () => ({ - useWysiwyg: (props: WysiwygProps) => { - inputEventProcessor = props.inputEventProcessor ?? null; - return { - ref: { current: null }, - content: 'html', - isWysiwygReady: true, - wysiwyg: { clear: () => void 0 }, - formattingStates: { - bold: 'enabled', - italic: 'enabled', - underline: 'enabled', - strikeThrough: 'enabled', - }, - }; - }, -})); - -describe('WysiwygComposer', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - const permalinkCreator = jest.fn() as any; - 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, {}); - - let sendMessage: () => void; - const customRender = (onChange = (_content: string) => void 0, disabled = false) => { - return render( - - - - { (_sendMessage) => { - sendMessage = _sendMessage; - } } - - , - ); - }; - - it('Should have contentEditable at false when disabled', () => { - // When - customRender(null, true); - - // Then - expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "false"); - }); - - it('Should call onChange handler', (done) => { - const html = 'html'; - customRender((content) => { - expect(content).toBe((html)); - done(); - }); - // act(() => callOnChange(html)); - }); - - it('Should send message, call clear and focus the textbox', async () => { - // When - const html = 'html'; - await new Promise((resolve) => { - customRender(() => resolve(null)); - }); - act(() => sendMessage()); - - // Then - const expectedContent = { - "body": html, - "format": "org.matrix.custom.html", - "formatted_body": html, - "msgtype": "m.text", - }; - 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(); - }); - - it('sends a message when Enter is pressed', async () => { - // Given a composer - customRender(() => {}, false); - - // When we tell its inputEventProcesser that the user pressed Enter - const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); - const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; - inputEventProcessor(event, wysiwyg); - - // Then it sends a message - expect(mockClient.sendMessage).toBeCalledWith( - "myfakeroom", - null, - { - "body": "html", - "format": "org.matrix.custom.html", - "formatted_body": "html", - "msgtype": "m.text", - }, - ); - // TODO: plain text body above is wrong - will be fixed when we provide markdown for it - }); - - describe('when settings require Ctrl+Enter to send', () => { - beforeEach(() => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { - if (name === "MessageComposerInput.ctrlEnterToSend") return true; - }); - }); - - it('does not send a message when Enter is pressed', async () => { - // Given a composer - customRender(() => {}, false); - - // When we tell its inputEventProcesser that the user pressed Enter - const event = new InputEvent("input", { inputType: "insertParagraph" }); - const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; - inputEventProcessor(event, wysiwyg); - - // Then it does not send a message - expect(mockClient.sendMessage).toBeCalledTimes(0); - }); - - it('sends a message when Ctrl+Enter is pressed', async () => { - // Given a composer - customRender(() => {}, false); - - // When we tell its inputEventProcesser that the user pressed Ctrl+Enter - const event = new InputEvent("input", { inputType: "sendMessage" }); - const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; - inputEventProcessor(event, wysiwyg); - - // Then it sends a message - expect(mockClient.sendMessage).toBeCalledTimes(1); - }); - }); -}); - diff --git a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx similarity index 95% rename from test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx rename to test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx index a9838ecaca..e935b62ae5 100644 --- a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx @@ -18,7 +18,8 @@ import React from 'react'; import { render, screen } from "@testing-library/react"; import userEvent from '@testing-library/user-event'; -import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; +import { FormattingButtons } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; describe('FormattingButtons', () => { const wysiwyg = { diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx new file mode 100644 index 0000000000..e7e21ca839 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -0,0 +1,152 @@ +/* +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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg"; + +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { IRoomState } from "../../../../../../src/components/structures/RoomView"; +import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils"; +import RoomContext from "../../../../../../src/contexts/RoomContext"; +import { WysiwygComposer } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; + +// Work around missing ClipboardEvent type +class MyClipboardEvent {} +window.ClipboardEvent = MyClipboardEvent as any; + +let inputEventProcessor: InputEventProcessor | null = null; + +// 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 +jest.mock("@matrix-org/matrix-wysiwyg", () => ({ + useWysiwyg: (props: WysiwygProps) => { + inputEventProcessor = props.inputEventProcessor ?? null; + return { + ref: { current: null }, + content: 'html', + isWysiwygReady: true, + wysiwyg: { clear: () => void 0 }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; + }, +})); + +describe('WysiwygComposer', () => { + afterEach(() => { + 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 customRender = (onChange = (_content: string) => void 0, onSend = () => void 0, disabled = false) => { + return render( + + + + + , + ); + }; + + it('Should have contentEditable at false when disabled', () => { + // When + customRender(jest.fn(), jest.fn(), true); + + // Then + expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "false"); + }); + + it('Should call onChange handler', (done) => { + const html = 'html'; + customRender((content) => { + expect(content).toBe((html)); + done(); + }, jest.fn()); + }); + + it('Should call onSend when Enter is pressed ', () => { + //When + const onSend = jest.fn(); + customRender(jest.fn(), onSend); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(onSend).toBeCalledTimes(1); + }); + + describe('When settings require Ctrl+Enter to send', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "MessageComposerInput.ctrlEnterToSend") return true; + }); + }); + + it('Should not call onSend when Enter is pressed', async () => { + // Given a composer + const onSend = jest.fn(); + customRender(() => {}, onSend, false); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("input", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it does not send a message + expect(onSend).toBeCalledTimes(0); + }); + + it('Should send a message when Ctrl+Enter is pressed', async () => { + // Given a composer + const onSend = jest.fn(); + customRender(() => {}, onSend, false); + + // When we tell its inputEventProcesser that the user pressed Ctrl+Enter + const event = new InputEvent("input", { inputType: "sendMessage" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(onSend).toBeCalledTimes(1); + }); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts new file mode 100644 index 0000000000..a4335b2bf1 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts @@ -0,0 +1,133 @@ +/* +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 { mkEvent } from "../../../../../test-utils"; +import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks"; +import { createMessageContent } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/createMessageContent"; + +describe('createMessageContent', () => { + const permalinkCreator = { + forEvent(eventId: string): string { + return "$$permalink$$"; + }, + } as RoomPermalinkCreator; + const message = 'hello world'; + const mockEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { "msgtype": "m.text", "body": "Replying to this" }, + event: true, + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("Should create html message", () => { + // When + const content = createMessageContent(message, { permalinkCreator }); + + // Then + expect(content).toEqual({ + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": message, + "msgtype": "m.text", + }); + }); + + it('Should add reply to message content', () => { + // When + const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent }); + + // Then + expect(content).toEqual({ + "body": "> Replying to this\n\nhello world", + "format": "org.matrix.custom.html", + "formatted_body": "
In reply to" + + " myfakeuser"+ + "
Replying to this
hello world", + "msgtype": "m.text", + "m.relates_to": { + "m.in_reply_to": { + "event_id": mockEvent.getId(), + }, + }, + }); + }); + + it("Should add relation to message", () => { + // When + const relation = { + rel_type: "m.thread", + event_id: "myFakeThreadId", + }; + const content = createMessageContent(message, { permalinkCreator, relation }); + + // Then + expect(content).toEqual({ + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": message, + "msgtype": "m.text", + "m.relates_to": { + "event_id": "myFakeThreadId", + "rel_type": "m.thread", + }, + }); + }); + + it('Should add fields related to edition', () => { + // When + const editedEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser2', + content: { + "msgtype": "m.text", + "body": "First message", + "formatted_body": "First Message", + "m.relates_to": { + "m.in_reply_to": { + "event_id": 'eventId', + }, + } }, + event: true, + }); + const content = + createMessageContent(message, { permalinkCreator, editedEvent }); + + // Then + expect(content).toEqual({ + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": ` * ${message}`, + "msgtype": "m.text", + "m.new_content": { + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": message, + "msgtype": "m.text", + }, + "m.relates_to": { + "event_id": editedEvent.getId(), + "rel_type": "m.replace", + }, + }); + }); +}); diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts similarity index 53% rename from test/components/views/rooms/wysiwyg_composer/message-test.ts rename to test/components/views/rooms/wysiwyg_composer/utils/message-test.ts index e672d2639b..9d13f28176 100644 --- a/test/components/views/rooms/wysiwyg_composer/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -14,15 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/message"; -import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; -import { Layout } from "../../../../../src/settings/enums/Layout"; -import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; -import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel"; -import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; +import { EventStatus } from "matrix-js-sdk/src/matrix"; + +import { IRoomState } from "../../../../../../src/components/structures/RoomView"; +import { editMessage, sendMessage } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message"; +import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils"; +import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; +import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks"; +import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer"; +import * as ConfirmRedactDialog + from "../../../../../../src/components/views/dialogs/ConfirmRedactDialog"; describe('message', () => { const permalinkCreator = { @@ -35,117 +39,30 @@ describe('message', () => { type: "m.room.message", room: 'myfakeroom', user: 'myfakeuser', - content: { "msgtype": "m.text", "body": "Replying to this" }, + content: { + "msgtype": "m.text", + "body": "Replying to this", + "format": 'org.matrix.custom.html', + "formatted_body": 'Replying to this', + }, event: true, }); + const mockClient = createTestClient(); + 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 spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + afterEach(() => { jest.resetAllMocks(); }); - describe('createMessageContent', () => { - it("Should create html message", () => { - // When - const content = createMessageContent(message, { permalinkCreator }); - - // Then - expect(content).toEqual({ - "body": message, - "format": "org.matrix.custom.html", - "formatted_body": message, - "msgtype": "m.text", - }); - }); - - it('Should add reply to message content', () => { - // When - const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent }); - - // Then - expect(content).toEqual({ - "body": "> Replying to this\n\nhello world", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to" + - " myfakeuser"+ - "
Replying to this
hello world", - "msgtype": "m.text", - "m.relates_to": { - "m.in_reply_to": { - "event_id": mockEvent.getId(), - }, - }, - }); - }); - - it("Should add relation to message", () => { - // When - const relation = { - rel_type: "m.thread", - event_id: "myFakeThreadId", - }; - const content = createMessageContent(message, { permalinkCreator, relation }); - - // Then - expect(content).toEqual({ - "body": message, - "format": "org.matrix.custom.html", - "formatted_body": message, - "msgtype": "m.text", - "m.relates_to": { - "event_id": "myFakeThreadId", - "rel_type": "m.thread", - }, - }); - }); - }); - describe('sendMessage', () => { - const mockClient = createTestClient(); - const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any; - mockRoom.findEventById = jest.fn(eventId => { - return eventId === mockEvent.getId() ? mockEvent : null; - }); - - const defaultRoomContext: IRoomState = { - room: mockRoom, - roomLoading: true, - peekLoading: false, - shouldPeek: true, - membersLoaded: false, - numUnreadMessages: 0, - canPeek: false, - showApps: false, - isPeeking: false, - showRightPanel: true, - joining: false, - atEndOfLiveTimeline: true, - showTopUnreadMessagesBar: false, - statusBarVisible: false, - canReact: false, - canSendMessages: false, - layout: Layout.Group, - lowBandwidth: false, - alwaysShowTimestamps: false, - showTwelveHourTimestamps: false, - readMarkerInViewThresholdMs: 3000, - readMarkerOutOfViewThresholdMs: 30000, - showHiddenEvents: false, - showReadReceipts: true, - showRedactions: true, - showJoinLeaves: true, - showAvatarChanges: true, - showDisplaynameChanges: true, - matrixClientIsReady: false, - timelineRenderingType: TimelineRenderingType.Room, - liveTimeline: undefined, - canSelfRedact: false, - resizing: false, - narrow: false, - activeCall: null, - }; - - const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); - it('Should not send empty html message', async () => { // When await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); @@ -231,4 +148,78 @@ describe('message', () => { ); }); }); + + describe('editMessage', () => { + const editorStateTransfer = new EditorStateTransfer(mockEvent); + + it('Should cancel editing and ask for event removal when message is empty', async () => { + // When + const mockCreateRedactEventDialog = jest.spyOn(ConfirmRedactDialog, 'createRedactEventDialog'); + + const mockEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { "msgtype": "m.text", "body": "Replying to this" }, + event: true, + }); + const replacingEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { "msgtype": "m.text", "body": "ReplacingEvent" }, + event: true, + }); + replacingEvent.setStatus(EventStatus.QUEUED); + mockEvent.makeReplaced(replacingEvent); + const editorStateTransfer = new EditorStateTransfer(mockEvent); + + await editMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer }); + + // Then + expect(mockClient.sendMessage).toBeCalledTimes(0); + expect(mockClient.cancelPendingEvent).toBeCalledTimes(1); + expect(mockCreateRedactEventDialog).toBeCalledTimes(1); + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it('Should do nothing if the content is unmodified', async () => { + // When + await editMessage( + mockEvent.getContent().body, + { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer }); + + // Then + expect(mockClient.sendMessage).toBeCalledTimes(0); + }); + + it('Should send a message when the content is modified', async () => { + // When + const newMessage = `${mockEvent.getContent().body} new content`; + await editMessage( + newMessage, + { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer }); + + // Then + const { msgtype, format } = mockEvent.getContent(); + const expectedContent = { + "body": newMessage, + "formatted_body": ` * ${newMessage}`, + "m.new_content": { + "body": "Replying to this new content", + "format": "org.matrix.custom.html", + "formatted_body": "Replying to this new content", + "msgtype": "m.text", + }, + "m.relates_to": { + "event_id": mockEvent.getId(), + "rel_type": "m.replace", + }, + msgtype, + format, + }; + expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent); + expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' }); + }); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 4549190600..4cc2d1e0de 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -178,6 +178,7 @@ export function createTestClient(): MatrixClient { sendToDevice: jest.fn().mockResolvedValue(undefined), queueToDevice: jest.fn().mockResolvedValue(undefined), encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined), + cancelPendingEvent: jest.fn(), getMediaHandler: jest.fn().mockReturnValue({ setVideoInput: jest.fn(),