* Module API experiments * Move ResizerNotifier into SDKContext so we don't have to pass it into RoomView * Add the MultiRoomViewStore * Make RoomViewStore able to take a roomId prop * Different interface to add space panel items A bit less flexible but probably simpler and will help keep things actually consistent rather than just allowing modules to stick any JSX into the space panel (which means they also have to worry about styling if they *do* want it to be consistent). * Allow space panel items to be updated and manage which one is selected, allowing module "spaces" to be considered spaces * Remove fetchRoomFn from SpaceNotificationStore which didn't really seem to have any point as it was only called from one place * Switch to using module api via .instance * Fairly awful workaround to actually break the dependency nightmare * Add test for multiroomviewstore * add test * Make room names deterministic So the tests don't fail if you add other tests or run them individually * Add test for builtinsapi * Update module api * RVS is not needed as prop anymore Since it's passed through context * Add roomId to prop * Remove RoomViewStore from state This is now accessed through class field * Fix test * No need to pass RVS from LoggedInView * Add RoomContextType * Implement new builtins api * Add tests * Fix import * Fix circular dependency issue * Fix import * Add more tests * Improve comment * room-id is optional * Update license * Add implementation for AccountDataApi * Add implementation for Room * Add implementation for ClientApi * Create ClientApi in Api.ts * Write tests * Use nullish coalescing assignment * Implement openRoom in NavigationApi * Write tests * Add implementation for StoresApi * Write tests * Fix circular dependency * Add comments in lieu of type and fix else block * Change to class field --------- Co-authored-by: R Midhun Suresh <hi@midhun.dev>
550 lines
21 KiB
TypeScript
550 lines
21 KiB
TypeScript
/*
|
|
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 OR LicenseRef-Element-Commercial
|
|
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 { type ReplacementEvent, type 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 MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
|
import Autocompleter, { type IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter";
|
|
import NotifProvider from "../../../../../src/autocomplete/NotifProvider";
|
|
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
|
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
|
|
|
describe("<EditMessageComposer/>", () => {
|
|
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 <a href="https://matrix.to/#/@bob:server.org">Bob</a> and <a href="https://matrix.to/#/@charlie:server.org">Charlie</a>',
|
|
"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: RoomContextType = defaultRoomContext) =>
|
|
render(<EditMessageComposerWithMatrixClient editState={editState} />, {
|
|
wrapper: ({ children }) => (
|
|
<MatrixClientContext.Provider value={mockClient}>
|
|
<ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
|
|
</MatrixClientContext.Provider>
|
|
),
|
|
});
|
|
|
|
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: <span>Dan</span>,
|
|
},
|
|
],
|
|
command: {
|
|
command: ["@d"],
|
|
},
|
|
provider: new NotifProvider(room),
|
|
} as unknown as IProviderCompletions,
|
|
]);
|
|
});
|
|
|
|
const editText = async (text: string, shouldClear?: boolean): Promise<void> => {
|
|
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 <em>world</em>",
|
|
"m.new_content": {
|
|
"body": "hello *world*",
|
|
"msgtype": "m.text",
|
|
"format": "org.matrix.custom.html",
|
|
"formatted_body": "hello <em>world</em>",
|
|
"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 <strong>quickly</strong>",
|
|
"m.new_content": {
|
|
"body": "blinks __quickly__",
|
|
"msgtype": "m.emote",
|
|
"format": "org.matrix.custom.html",
|
|
"formatted_body": "blinks <strong>quickly</strong>",
|
|
"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<RoomMessageEventContent>;
|
|
|
|
// 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<RoomMessageEventContent>;
|
|
|
|
// 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<RoomMessageEventContent>;
|
|
|
|
// 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<RoomMessageEventContent>;
|
|
|
|
// 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<RoomMessageEventContent>;
|
|
|
|
// 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 <a href="https://matrix.to/#/@bob:server.org">Bob</a>',
|
|
"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,
|
|
});
|
|
|
|
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<RoomMessageEventContent>;
|
|
|
|
// 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<RoomMessageEventContent>;
|
|
|
|
// 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<RoomMessageEventContent>;
|
|
|
|
// 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 <a href="https://matrix.to/#/${originalEvent.getSender()!}">Ernie</a>`,
|
|
"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<RoomMessageEventContent>;
|
|
|
|
// 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()],
|
|
});
|
|
});
|
|
});
|
|
});
|