/* Copyright 2024 New Vector Ltd. Copyright 2023 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 React from "react"; import { fireEvent, render, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { Room } from "matrix-js-sdk/src/matrix"; import { ReplacementEvent, RoomMessageEventContent } from "matrix-js-sdk/src/types"; 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 { 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"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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] as RoomMessageEventContent & ReplacementEvent; // 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()], }); }); }); });