/* 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 React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Room } from "matrix-js-sdk/src/matrix"; import EditMessageComposerWithMatrixClient, { createEditContent, } from "../../../../src/components/views/rooms/EditMessageComposer"; import EditorModel from "../../../../src/editor/model"; import { createPartCreator } from "../../../editor/mock"; import { getMockClientWithEventEmitter, getRoomContext, mkEvent, mockClientMethodsUser, setupRoomWithEventsTimeline, } from "../../../test-utils"; import DocumentOffset from "../../../../src/editor/offset"; import SettingsStore from "../../../../src/settings/SettingsStore"; import EditorStateTransfer from "../../../../src/utils/EditorStateTransfer"; import RoomContext from "../../../../src/contexts/RoomContext"; import { IRoomState } from "../../../../src/components/structures/RoomView"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import Autocompleter, { IProviderCompletions } from "../../../../src/autocomplete/Autocompleter"; import NotifProvider from "../../../../src/autocomplete/NotifProvider"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; describe("", () => { const userId = "@alice:server.org"; const roomId = "!room:server.org"; const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), getRoom: jest.fn(), sendMessage: jest.fn(), }); const room = new Room(roomId, mockClient, userId); const editedEvent = mkEvent({ type: "m.room.message", user: "@alice:test", room: "!abc:test", content: { body: "original message", msgtype: "m.text" }, event: true, }); const eventWithMentions = mkEvent({ type: "m.room.message", user: userId, room: roomId, content: { "msgtype": "m.text", "body": "hey Bob and Charlie", "format": "org.matrix.custom.html", "formatted_body": 'hey Bob and Charlie', "m.mentions": { user_ids: ["@bob:server.org", "@charlie:server.org"], }, }, event: true, }); // message composer emojipicker uses this // which would require more irrelevant mocking jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); const defaultRoomContext = getRoomContext(room, {}); const getComponent = (editState: EditorStateTransfer, roomContext: IRoomState = defaultRoomContext) => render(, { wrapper: ({ children }) => ( {children} ), }); beforeEach(() => { mockClient.getRoom.mockReturnValue(room); mockClient.sendMessage.mockClear(); userEvent.setup(); DMRoomMap.makeShared(mockClient); jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([ { completions: [ { completion: "@dan:server.org", completionId: "@dan:server.org", type: "user", suffix: " ", component: Dan, }, ], command: { command: ["@d"], }, provider: new NotifProvider(room), } as unknown as IProviderCompletions, ]); }); const editText = async (text: string, shouldClear?: boolean): Promise => { const input = screen.getByRole("textbox"); if (shouldClear) { await userEvent.clear(input); } await userEvent.type(input, text); }; it("should edit a simple message", async () => { const editState = new EditorStateTransfer(editedEvent); getComponent(editState); await editText(" + edit"); fireEvent.click(screen.getByText("Save")); const expectedBody = { ...editedEvent.getContent(), "body": " * original message + edit", "m.new_content": { "body": "original message + edit", "msgtype": "m.text", "m.mentions": {}, }, "m.relates_to": { event_id: editedEvent.getId(), rel_type: "m.replace", }, "m.mentions": {}, }; expect(mockClient.sendMessage).toHaveBeenCalledWith(editedEvent.getRoomId()!, null, expectedBody); }); it("should throw when room for message is not found", () => { mockClient.getRoom.mockReturnValue(null); const editState = new EditorStateTransfer(editedEvent); expect(() => getComponent(editState, { ...defaultRoomContext, room: undefined })).toThrow( "Cannot render without room", ); }); describe("createEditContent", () => { it("sends plaintext messages correctly", () => { const model = new EditorModel([], createPartCreator()); const documentOffset = new DocumentOffset(11, true); model.update("hello world", "insertText", documentOffset); const content = createEditContent(model, editedEvent); expect(content).toEqual({ "body": " * hello world", "msgtype": "m.text", "m.new_content": { "body": "hello world", "msgtype": "m.text", "m.mentions": {}, }, "m.relates_to": { event_id: editedEvent.getId(), rel_type: "m.replace", }, "m.mentions": {}, }); }); it("sends markdown messages correctly", () => { const model = new EditorModel([], createPartCreator()); const documentOffset = new DocumentOffset(13, true); model.update("hello *world*", "insertText", documentOffset); const content = createEditContent(model, editedEvent); expect(content).toEqual({ "body": " * hello *world*", "msgtype": "m.text", "format": "org.matrix.custom.html", "formatted_body": " * hello world", "m.new_content": { "body": "hello *world*", "msgtype": "m.text", "format": "org.matrix.custom.html", "formatted_body": "hello world", "m.mentions": {}, }, "m.relates_to": { event_id: editedEvent.getId(), rel_type: "m.replace", }, "m.mentions": {}, }); }); it("strips /me from messages and marks them as m.emote accordingly", () => { const model = new EditorModel([], createPartCreator()); const documentOffset = new DocumentOffset(22, true); model.update("/me blinks __quickly__", "insertText", documentOffset); const content = createEditContent(model, editedEvent); expect(content).toEqual({ "body": " * blinks __quickly__", "msgtype": "m.emote", "format": "org.matrix.custom.html", "formatted_body": " * blinks quickly", "m.new_content": { "body": "blinks __quickly__", "msgtype": "m.emote", "format": "org.matrix.custom.html", "formatted_body": "blinks quickly", "m.mentions": {}, }, "m.relates_to": { event_id: editedEvent.getId(), rel_type: "m.replace", }, "m.mentions": {}, }); }); it("allows emoting with non-text parts", () => { const model = new EditorModel([], createPartCreator()); const documentOffset = new DocumentOffset(16, true); model.update("/me ✨sparkles✨", "insertText", documentOffset); expect(model.parts.length).toEqual(4); // Emoji count as non-text const content = createEditContent(model, editedEvent); expect(content).toEqual({ "body": " * ✨sparkles✨", "msgtype": "m.emote", "m.new_content": { "body": "✨sparkles✨", "msgtype": "m.emote", "m.mentions": {}, }, "m.relates_to": { event_id: editedEvent.getId(), rel_type: "m.replace", }, "m.mentions": {}, }); }); it("allows sending double-slash escaped slash commands correctly", () => { const model = new EditorModel([], createPartCreator()); const documentOffset = new DocumentOffset(32, true); model.update("//dev/null is my favourite place", "insertText", documentOffset); const content = createEditContent(model, editedEvent); // TODO Edits do not properly strip the double slash used to skip // command processing. expect(content).toEqual({ "body": " * //dev/null is my favourite place", "msgtype": "m.text", "m.new_content": { "body": "//dev/null is my favourite place", "msgtype": "m.text", "m.mentions": {}, }, "m.relates_to": { event_id: editedEvent.getId(), rel_type: "m.replace", }, "m.mentions": {}, }); }); }); describe("when message is not a reply", () => { it("should attach an empty mentions object for a message with no mentions", async () => { const editState = new EditorStateTransfer(editedEvent); getComponent(editState); const editContent = " + edit"; await editText(editContent); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // both content.mentions and new_content.mentions are empty expect(messageContent["m.mentions"]).toEqual({}); expect(messageContent["m.new_content"]["m.mentions"]).toEqual({}); }); it("should retain mentions in the original message that are not removed by the edit", async () => { const editState = new EditorStateTransfer(eventWithMentions); getComponent(editState); // Remove charlie from the message const editContent = "{backspace}{backspace}friends"; await editText(editContent); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // no new mentions were added, so nothing in top level mentions expect(messageContent["m.mentions"]).toEqual({}); // bob is still mentioned, charlie removed expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: ["@bob:server.org"], }); }); it("should remove mentions that are removed by the edit", async () => { const editState = new EditorStateTransfer(eventWithMentions); getComponent(editState); const editContent = "new message!"; // clear the original message await editText(editContent, true); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // no new mentions were added, so nothing in top level mentions expect(messageContent["m.mentions"]).toEqual({}); // bob is not longer mentioned in the edited message, so empty mentions in new_content expect(messageContent["m.new_content"]["m.mentions"]).toEqual({}); }); it("should add mentions that were added in the edit", async () => { const editState = new EditorStateTransfer(editedEvent); getComponent(editState); const editContent = " and @d"; await editText(editContent); // wait for autocompletion to render await screen.findByText("Dan"); // submit autocomplete for mention await editText("{enter}"); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // new mention in the edit expect(messageContent["m.mentions"]).toEqual({ user_ids: ["@dan:server.org"], }); expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: ["@dan:server.org"], }); }); it("should add and remove mentions from the edit", async () => { const editState = new EditorStateTransfer(eventWithMentions); getComponent(editState); // Remove charlie from the message await editText("{backspace}{backspace}"); // and replace with @room await editText("@d"); // wait for autocompletion to render await screen.findByText("Dan"); // submit autocomplete for @dan mention await editText("{enter}"); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // new mention in the edit expect(messageContent["m.mentions"]).toEqual({ user_ids: ["@dan:server.org"], }); // all mentions in the edited version of the event expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: ["@bob:server.org", "@dan:server.org"], }); }); }); describe("when message is replying", () => { const originalEvent = mkEvent({ type: "m.room.message", user: "@ernie:test", room: roomId, content: { body: "original message", msgtype: "m.text" }, event: true, }); const replyEvent = mkEvent({ type: "m.room.message", user: "@bert:test", room: roomId, content: { "body": "reply with plain message", "msgtype": "m.text", "m.relates_to": { "m.in_reply_to": { event_id: originalEvent.getId(), }, }, "m.mentions": { user_ids: [originalEvent.getSender()!], }, }, event: true, }); const replyWithMentions = mkEvent({ type: "m.room.message", user: "@bert:test", room: roomId, content: { "body": 'reply that mentions Bob', "msgtype": "m.text", "m.relates_to": { "m.in_reply_to": { event_id: originalEvent.getId(), }, }, "m.mentions": { user_ids: [ // sender of event we replied to originalEvent.getSender()!, // mentions from this event "@bob:server.org", ], }, }, event: true, }); beforeEach(() => { setupRoomWithEventsTimeline(room, [originalEvent, replyEvent]); }); it("should retain parent event sender in mentions when editing with plain text", async () => { const editState = new EditorStateTransfer(replyEvent); getComponent(editState); const editContent = " + edit"; await editText(editContent); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // no new mentions from edit expect(messageContent["m.mentions"]).toEqual({}); // edited reply still mentions the parent event sender expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: [originalEvent.getSender()], }); }); it("should retain parent event sender in mentions when adding a mention", async () => { const editState = new EditorStateTransfer(replyEvent); getComponent(editState); await editText(" and @d"); // wait for autocompletion to render await screen.findByText("Dan"); // submit autocomplete for @dan mention await editText("{enter}"); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // new mention in edit expect(messageContent["m.mentions"]).toEqual({ user_ids: ["@dan:server.org"], }); // edited reply still mentions the parent event sender // plus new mention @dan expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: [originalEvent.getSender(), "@dan:server.org"], }); }); it("should retain parent event sender in mentions when removing all mentions from content", async () => { const editState = new EditorStateTransfer(replyWithMentions); getComponent(editState); // replace text to remove all mentions await editText("no mentions here", true); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // no mentions in edit expect(messageContent["m.mentions"]).toEqual({}); // edited reply still mentions the parent event sender // existing @bob mention removed expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: [originalEvent.getSender()], }); }); it("should retain parent event sender in mentions when removing mention of said user", async () => { const replyThatMentionsParentEventSender = mkEvent({ type: "m.room.message", user: "@bert:test", room: roomId, content: { "body": `reply that mentions the sender of the message we replied to Ernie`, "msgtype": "m.text", "m.relates_to": { "m.in_reply_to": { event_id: originalEvent.getId(), }, }, "m.mentions": { user_ids: [ // sender of event we replied to originalEvent.getSender()!, ], }, }, event: true, }); const editState = new EditorStateTransfer(replyThatMentionsParentEventSender); getComponent(editState); // replace text to remove all mentions await editText("no mentions here", true); fireEvent.click(screen.getByText("Save")); const messageContent = mockClient.sendMessage.mock.calls[0][2]; // no mentions in edit expect(messageContent["m.mentions"]).toEqual({}); // edited reply still mentions the parent event sender expect(messageContent["m.new_content"]["m.mentions"]).toEqual({ user_ids: [originalEvent.getSender()], }); }); }); });