Prepare for repo merge
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
0f670b8dc0
commit
b084ff2313
807 changed files with 0 additions and 0 deletions
|
@ -0,0 +1,303 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../src/contexts/RoomContext";
|
||||
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { flushPromises, mkEvent } from "../../../../test-utils";
|
||||
import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer";
|
||||
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
|
||||
import { Emoji } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji";
|
||||
import { ChevronFace } from "../../../../../src/components/structures/ContextMenu";
|
||||
import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload";
|
||||
import { ActionPayload } from "../../../../../src/dispatcher/payloads";
|
||||
import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton";
|
||||
import { createMocks } from "./utils";
|
||||
|
||||
describe("EditWysiwygComposer", () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks();
|
||||
|
||||
const customRender = (
|
||||
disabled = false,
|
||||
_editorStateTransfer = editorStateTransfer,
|
||||
client = mockClient,
|
||||
roomContext = defaultRoomContext,
|
||||
) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={roomContext}>
|
||||
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(
|
||||
async () => {
|
||||
// Load the dynamic import
|
||||
const component = customRender(false);
|
||||
await component.findByRole("textbox");
|
||||
component.unmount();
|
||||
},
|
||||
// it can take a while to load the wasm
|
||||
20000,
|
||||
);
|
||||
|
||||
it("Should not render the component when not ready", async () => {
|
||||
// When
|
||||
const { rerender } = customRender(false);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
rerender(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={{ ...defaultRoomContext, room: undefined }}>
|
||||
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
|
||||
});
|
||||
|
||||
describe("Initialize with content", () => {
|
||||
it("Should initialize useWysiwyg with html content", async () => {
|
||||
// When
|
||||
customRender(false, editorStateTransfer);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"), {
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["formatted_body"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Should initialize useWysiwyg with plain text content", async () => {
|
||||
// When
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Replying to this",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
customRender(false, editorStateTransfer);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["body"]));
|
||||
});
|
||||
|
||||
it("Should ignore when formatted_body is not filled", async () => {
|
||||
// When
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Replying to this",
|
||||
format: "org.matrix.custom.html",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
customRender(false, editorStateTransfer);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
it("Should strip <mx-reply> tag from initial content", async () => {
|
||||
// When
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Replying to this",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<mx-reply>Reply</mx-reply>My content",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
customRender(false, editorStateTransfer);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("textbox")).not.toContainHTML("<mx-reply>Reply</mx-reply>");
|
||||
expect(screen.getByRole("textbox")).toContainHTML("My content");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit and save actions", () => {
|
||||
let spyDispatcher: jest.SpyInstance<void, [payload: ActionPayload, sync?: boolean]>;
|
||||
beforeEach(async () => {
|
||||
spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
customRender();
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
spyDispatcher.mockRestore();
|
||||
});
|
||||
|
||||
it("Should cancel edit on cancel button click", async () => {
|
||||
// When
|
||||
screen.getByText("Cancel").click();
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: null,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should send message on save button click", async () => {
|
||||
// When
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "foo bar",
|
||||
inputType: "insertText",
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("Save")).not.toHaveAttribute("disabled"));
|
||||
|
||||
// Then
|
||||
screen.getByText("Save").click();
|
||||
const expectedContent = {
|
||||
"body": ` * foo bar`,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": ` * foo bar`,
|
||||
"m.new_content": {
|
||||
body: "foo bar",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "foo bar",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: mockEvent.getId(),
|
||||
rel_type: "m.replace",
|
||||
},
|
||||
"msgtype": "m.text",
|
||||
};
|
||||
await waitFor(() =>
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEvent.getRoomId(), null, expectedContent),
|
||||
);
|
||||
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" });
|
||||
});
|
||||
});
|
||||
|
||||
it("Should focus when receiving an Action.FocusEditMessageComposer action", async () => {
|
||||
// Given we don't have focus
|
||||
customRender();
|
||||
screen.getByLabelText("Bold").focus();
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
|
||||
// When we send the right action
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusEditMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Then the component gets the focus
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("Should not focus when disabled", async () => {
|
||||
// Given we don't have focus and we are disabled
|
||||
customRender(true);
|
||||
screen.getByLabelText("Bold").focus();
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
|
||||
// When we send an action that would cause us to get focus
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusEditMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
// (Send a second event to exercise the clearTimeout logic)
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.FocusEditMessageComposer,
|
||||
context: null,
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then we don't get it because we are disabled
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("Should add emoji", async () => {
|
||||
// When
|
||||
|
||||
// We are not testing here the emoji button (open modal, select emoji ...)
|
||||
// Instead we are directly firing an emoji to make the test easier to write
|
||||
jest.spyOn(EmojiButton, "EmojiButton").mockImplementation(
|
||||
({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
||||
return (
|
||||
<button aria-label="Emoji" type="button" onClick={() => addEmoji("🦫")}>
|
||||
Emoji
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
||||
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
// Same behavior as in RoomView.tsx
|
||||
// RoomView is re-dispatching the composer messages.
|
||||
// It adds the composerType fields where the value refers if the composer is in editing or not
|
||||
// The listeners in the RTE ignore the message if the composerType is missing in the payload
|
||||
const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => {
|
||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||
...(payload as ComposerInsertPayload),
|
||||
composerType: ComposerType.Edit,
|
||||
});
|
||||
});
|
||||
|
||||
screen.getByLabelText("Emoji").click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/));
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../src/contexts/RoomContext";
|
||||
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { flushPromises } from "../../../../test-utils";
|
||||
import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/";
|
||||
import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu";
|
||||
import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload";
|
||||
import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
|
||||
import { createMocks } from "./utils";
|
||||
|
||||
jest.mock("../../../../../src/components/views/rooms/EmojiButton", () => ({
|
||||
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
||||
return (
|
||||
<button aria-label="Emoji" type="button" onClick={() => addEmoji("🦫")}>
|
||||
Emoji
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SendWysiwygComposer", () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const { defaultRoomContext, mockClient } = createMocks();
|
||||
|
||||
const registerId = defaultDispatcher.register((payload) => {
|
||||
switch (payload.action) {
|
||||
case Action.ComposerInsert: {
|
||||
if (payload.composerType) break;
|
||||
|
||||
// re-dispatch to the correct composer
|
||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||
...(payload as ComposerInsertPayload),
|
||||
composerType: ComposerType.Send,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
defaultDispatcher.unregister(registerId);
|
||||
});
|
||||
|
||||
const customRender = (
|
||||
onChange = (_content: string): void => void 0,
|
||||
onSend = (): void => void 0,
|
||||
disabled = false,
|
||||
isRichTextEnabled = true,
|
||||
placeholder?: string,
|
||||
) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<SendWysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
isRichTextEnabled={isRichTextEnabled}
|
||||
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("Should render WysiwygComposer when isRichTextEnabled is at true", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, true);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByTestId("WysiwygComposer", undefined, { timeout: 5000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should render PlainTextComposer when isRichTextEnabled is at false", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, false);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByTestId("PlainTextComposer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
|
||||
"Should focus when receiving an Action.FocusSendMessageComposer action",
|
||||
({ isRichTextEnabled }) => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should focus when receiving an Action.FocusSendMessageComposer action", async () => {
|
||||
// Given we don't have focus
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// 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
|
||||
const onChange = jest.fn();
|
||||
customRender(onChange, jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "foo bar",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// When we send the right action
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ClearAndFocusSendMessageComposer,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
|
||||
// Then the component gets the focus
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("textbox")).toHaveTextContent(/^$/);
|
||||
expect(screen.getByRole("textbox")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("Should focus when receiving a reply_to_event action", async () => {
|
||||
// Given we don't have focus
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// 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, isRichTextEnabled);
|
||||
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 act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then we don't get it because we are disabled
|
||||
expect(screen.getByRole("textbox")).not.toHaveFocus();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
|
||||
"Placeholder when %s",
|
||||
({ isRichTextEnabled }) => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should not has placeholder", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).not.toHaveClass("mx_WysiwygComposer_Editor_content_placeholder");
|
||||
});
|
||||
|
||||
it("Should has placeholder", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled, "my placeholder");
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).toHaveClass("mx_WysiwygComposer_Editor_content_placeholder");
|
||||
});
|
||||
|
||||
it("Should display or not placeholder when editor content change", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled, "my placeholder");
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
screen.getByRole("textbox").innerHTML = "f";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "f",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox")).not.toHaveClass(
|
||||
"mx_WysiwygComposer_Editor_content_placeholder",
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
screen.getByRole("textbox").innerHTML = "";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
inputType: "deleteContentBackward",
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox")).toHaveClass("mx_WysiwygComposer_Editor_content_placeholder"),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
|
||||
"Emoji when %s",
|
||||
({ isRichTextEnabled }) => {
|
||||
let emojiButton: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
emojiButton = screen.getByLabelText("Emoji");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should add an emoji in an empty composer", async () => {
|
||||
// When
|
||||
emojiButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/));
|
||||
});
|
||||
|
||||
it("Should add an emoji in the middle of a word", async () => {
|
||||
// When
|
||||
screen.getByRole("textbox").focus();
|
||||
screen.getByRole("textbox").innerHTML = "word";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
const textNode = screen.getByRole("textbox").firstChild;
|
||||
await setSelection({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 2,
|
||||
focusNode: textNode,
|
||||
focusOffset: 2,
|
||||
isForward: true,
|
||||
});
|
||||
// the event is not automatically fired by jest
|
||||
document.dispatchEvent(new CustomEvent("selectionchange"));
|
||||
|
||||
emojiButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/wo🦫rd/));
|
||||
});
|
||||
|
||||
it("Should add an emoji when a word is selected", async () => {
|
||||
// When
|
||||
screen.getByRole("textbox").focus();
|
||||
screen.getByRole("textbox").innerHTML = "word";
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
const textNode = screen.getByRole("textbox").firstChild;
|
||||
await setSelection({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 3,
|
||||
focusNode: textNode,
|
||||
focusOffset: 2,
|
||||
isForward: false,
|
||||
});
|
||||
// the event is not automatically fired by jest
|
||||
document.dispatchEvent(new CustomEvent("selectionchange"));
|
||||
|
||||
emojiButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/wo🦫d/));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cleanup, render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ActionState, ActionTypes, AllActionStates, FormattingFunctions } from "@vector-im/matrix-wysiwyg";
|
||||
|
||||
import { FormattingButtons } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons";
|
||||
import * as LinkModal from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal";
|
||||
import { setLanguage } from "../../../../../../src/languageHandler";
|
||||
|
||||
const mockWysiwyg = {
|
||||
bold: jest.fn(),
|
||||
italic: jest.fn(),
|
||||
underline: jest.fn(),
|
||||
strikeThrough: jest.fn(),
|
||||
inlineCode: jest.fn(),
|
||||
codeBlock: jest.fn(),
|
||||
link: jest.fn(),
|
||||
orderedList: jest.fn(),
|
||||
unorderedList: jest.fn(),
|
||||
quote: jest.fn(),
|
||||
indent: jest.fn(),
|
||||
unIndent: jest.fn(),
|
||||
} as unknown as FormattingFunctions;
|
||||
|
||||
const openLinkModalSpy = jest.spyOn(LinkModal, "openLinkModal");
|
||||
|
||||
const testCases: Record<
|
||||
Exclude<ActionTypes, "undo" | "redo" | "clear" | "indent" | "unindent">,
|
||||
{ label: string; mockFormatFn: jest.Func | jest.SpyInstance }
|
||||
> = {
|
||||
bold: { label: "Bold", mockFormatFn: mockWysiwyg.bold },
|
||||
italic: { label: "Italic", mockFormatFn: mockWysiwyg.italic },
|
||||
underline: { label: "Underline", mockFormatFn: mockWysiwyg.underline },
|
||||
strikeThrough: { label: "Strikethrough", mockFormatFn: mockWysiwyg.strikeThrough },
|
||||
inlineCode: { label: "Code", mockFormatFn: mockWysiwyg.inlineCode },
|
||||
codeBlock: { label: "Code block", mockFormatFn: mockWysiwyg.inlineCode },
|
||||
link: { label: "Link", mockFormatFn: openLinkModalSpy },
|
||||
orderedList: { label: "Numbered list", mockFormatFn: mockWysiwyg.orderedList },
|
||||
unorderedList: { label: "Bulleted list", mockFormatFn: mockWysiwyg.unorderedList },
|
||||
quote: { label: "Quote", mockFormatFn: mockWysiwyg.quote },
|
||||
};
|
||||
|
||||
const createActionStates = (state: ActionState): AllActionStates => {
|
||||
return Object.fromEntries(Object.keys(testCases).map((testKey) => [testKey, state])) as AllActionStates;
|
||||
};
|
||||
|
||||
const defaultActionStates = createActionStates("enabled");
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<FormattingButtons composer={mockWysiwyg} actionStates={defaultActionStates} {...props} />);
|
||||
};
|
||||
|
||||
const classes = {
|
||||
active: "mx_FormattingButtons_active",
|
||||
hover: "mx_FormattingButtons_Button_hover",
|
||||
disabled: "mx_FormattingButtons_disabled",
|
||||
};
|
||||
|
||||
describe("FormattingButtons", () => {
|
||||
beforeEach(() => {
|
||||
openLinkModalSpy.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("renders in german", async () => {
|
||||
await setLanguage("de");
|
||||
const { asFragment } = renderComponent();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
await setLanguage("en");
|
||||
});
|
||||
|
||||
it("Each button should not have active class when enabled", () => {
|
||||
renderComponent();
|
||||
|
||||
Object.values(testCases).forEach(({ label }) => {
|
||||
expect(screen.getByLabelText(label)).not.toHaveClass(classes.active);
|
||||
});
|
||||
});
|
||||
|
||||
it("Each button should have active class when reversed", () => {
|
||||
const reversedActionStates = createActionStates("reversed");
|
||||
renderComponent({ actionStates: reversedActionStates });
|
||||
|
||||
Object.values(testCases).forEach((testCase) => {
|
||||
const { label } = testCase;
|
||||
expect(screen.getByLabelText(label)).toHaveClass(classes.active);
|
||||
});
|
||||
});
|
||||
|
||||
it("Each button should have disabled class when disabled", () => {
|
||||
const disabledActionStates = createActionStates("disabled");
|
||||
renderComponent({ actionStates: disabledActionStates });
|
||||
|
||||
Object.values(testCases).forEach((testCase) => {
|
||||
const { label } = testCase;
|
||||
expect(screen.getByLabelText(label)).toHaveClass(classes.disabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("Should call wysiwyg function on button click", async () => {
|
||||
renderComponent();
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label, mockFormatFn } = testCase;
|
||||
|
||||
screen.getByLabelText(label).click();
|
||||
expect(mockFormatFn).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should display the tooltip on mouse over when not disabled", async () => {
|
||||
renderComponent();
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument());
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should not display the tooltip on mouse over when disabled", async () => {
|
||||
const disabledActionStates = createActionStates("disabled");
|
||||
renderComponent({ actionStates: disabledActionStates });
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should have hover style when hovered and enabled", async () => {
|
||||
renderComponent();
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
expect(screen.getByLabelText(label)).toHaveClass("mx_FormattingButtons_Button_hover");
|
||||
}
|
||||
});
|
||||
|
||||
it("Each button should not have hover style when hovered and reversed", async () => {
|
||||
const reversedActionStates = createActionStates("reversed");
|
||||
renderComponent({ actionStates: reversedActionStates });
|
||||
|
||||
for (const testCase of Object.values(testCases)) {
|
||||
const { label } = testCase;
|
||||
|
||||
await userEvent.hover(screen.getByLabelText(label));
|
||||
expect(screen.getByLabelText(label)).not.toHaveClass("mx_FormattingButtons_Button_hover");
|
||||
}
|
||||
});
|
||||
|
||||
it("Does not show indent or unindent button when outside a list", () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByLabelText("Indent increase")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Indent decrease")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Shows indent and unindent buttons when either a single list type is 'reversed'", () => {
|
||||
const orderedListActive = { ...defaultActionStates, orderedList: "reversed" };
|
||||
renderComponent({ actionStates: orderedListActive });
|
||||
|
||||
expect(screen.getByLabelText("Indent increase")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Indent decrease")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
|
||||
const unorderedListActive = { ...defaultActionStates, unorderedList: "reversed" };
|
||||
|
||||
renderComponent({ actionStates: unorderedListActive });
|
||||
|
||||
expect(screen.getByLabelText("Indent increase")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Indent decrease")).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FormattingFunctions } from "@vector-im/matrix-wysiwyg";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { LinkModal } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal";
|
||||
import { mockPlatformPeg } from "../../../../../test-utils";
|
||||
import * as selection from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
|
||||
import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types";
|
||||
|
||||
describe("LinkModal", () => {
|
||||
const formattingFunctions = {
|
||||
link: jest.fn(),
|
||||
removeLinks: jest.fn(),
|
||||
getLink: jest.fn().mockReturnValue("my initial content"),
|
||||
} as unknown as FormattingFunctions;
|
||||
const defaultValue: SubSelection = {
|
||||
focusNode: null,
|
||||
anchorNode: null,
|
||||
focusOffset: 3,
|
||||
anchorOffset: 4,
|
||||
isForward: true,
|
||||
};
|
||||
|
||||
const customRender = (isTextEnabled: boolean, onFinished: () => void, isEditing = false) => {
|
||||
return render(
|
||||
<LinkModal
|
||||
composer={formattingFunctions}
|
||||
isTextEnabled={isTextEnabled}
|
||||
onFinished={onFinished}
|
||||
composerContext={{ selection: defaultValue }}
|
||||
isEditing={isEditing}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const selectionSpy = jest.spyOn(selection, "setSelection");
|
||||
|
||||
beforeEach(() => mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }));
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Should create a link", async () => {
|
||||
// When
|
||||
const onFinished = jest.fn();
|
||||
customRender(false, onFinished);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Link")).toBeTruthy();
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Link"), "l");
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
expect(screen.getByLabelText("Link")).toHaveAttribute("value", "l");
|
||||
});
|
||||
|
||||
// When
|
||||
jest.useFakeTimers();
|
||||
screen.getByText("Save").click();
|
||||
jest.runAllTimers();
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined);
|
||||
});
|
||||
|
||||
it("Should create a link with text", async () => {
|
||||
// When
|
||||
const onFinished = jest.fn();
|
||||
customRender(true, onFinished);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Text")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Link")).toBeTruthy();
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Text"), "t");
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Text")).toHaveAttribute("value", "t");
|
||||
});
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Link"), "l");
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
expect(screen.getByLabelText("Link")).toHaveAttribute("value", "l");
|
||||
});
|
||||
|
||||
// When
|
||||
jest.useFakeTimers();
|
||||
screen.getByText("Save").click();
|
||||
jest.runAllTimers();
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t");
|
||||
});
|
||||
|
||||
it("Should remove the link", async () => {
|
||||
// When
|
||||
const onFinished = jest.fn();
|
||||
customRender(true, onFinished, true);
|
||||
await userEvent.click(screen.getByText("Remove"));
|
||||
|
||||
// Then
|
||||
expect(formattingFunctions.removeLinks).toHaveBeenCalledTimes(1);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should display the link in editing", async () => {
|
||||
// When
|
||||
customRender(true, jest.fn(), true);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Link")).toContainHTML("my initial content");
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
// When
|
||||
await userEvent.type(screen.getByLabelText("Link"), "l");
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByText("Save")).toBeEnabled());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,297 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { act, render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
|
||||
import * as mockUseSettingsHook from "../../../../../../src/hooks/useSettings";
|
||||
import * as mockKeyboard from "../../../../../../src/Keyboard";
|
||||
import { createMocks } from "../utils";
|
||||
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||
|
||||
describe("PlainTextComposer", () => {
|
||||
const customRender = (
|
||||
onChange = (_content: string): void => void 0,
|
||||
onSend = (): void => void 0,
|
||||
disabled = false,
|
||||
initialContent?: string,
|
||||
) => {
|
||||
return render(
|
||||
<PlainTextComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
initialContent={initialContent}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
let mockUseSettingValue: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
// defaults for these tests are:
|
||||
// ctrlEnterToSend is false
|
||||
mockUseSettingValue = jest.spyOn(mockUseSettingsHook, "useSettingValue").mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
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 have focus", () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), false);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("Should call onChange handler", async () => {
|
||||
// When
|
||||
const content = "content";
|
||||
const onChange = jest.fn();
|
||||
customRender(onChange, jest.fn());
|
||||
await userEvent.type(screen.getByRole("textbox"), content);
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(content);
|
||||
});
|
||||
|
||||
it("Should call onSend when Enter is pressed when ctrlEnterToSend is false", async () => {
|
||||
//When
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
await userEvent.type(screen.getByRole("textbox"), "{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should not call onSend when Enter is pressed when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
await userEvent.type(screen.getByRole("textbox"), "{enter}");
|
||||
|
||||
// Then it does not send a message
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should only call onSend when ctrl+enter is pressed when ctrlEnterToSend is true on windows", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
await userEvent.type(textBox, "hello");
|
||||
|
||||
// Then it does NOT send a message on enter
|
||||
await userEvent.type(textBox, "{enter}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does NOT send a message on windows+enter
|
||||
await userEvent.type(textBox, "{meta>}{enter}{meta/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does send a message on ctrl+enter
|
||||
await userEvent.type(textBox, "{control>}{enter}{control/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should only call onSend when cmd+enter is pressed when ctrlEnterToSend is true on mac", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
Object.defineProperty(mockKeyboard, "IS_MAC", { value: true });
|
||||
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
await userEvent.type(textBox, "hello");
|
||||
|
||||
// Then it does NOT send a message on enter
|
||||
await userEvent.type(textBox, "{enter}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does NOT send a message on ctrl+enter
|
||||
await userEvent.type(textBox, "{control>}{enter}{control/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Then it does send a message on cmd+enter
|
||||
await userEvent.type(textBox, "{meta>}{enter}{meta/}");
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should insert a newline character when shift enter is pressed when ctrlEnterToSend is false", async () => {
|
||||
//When
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const inputWithShiftEnter = "new{Shift>}{enter}{/Shift}line";
|
||||
const expectedInnerHtml = "new\nline";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, inputWithShiftEnter);
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox.innerHTML).toBe(expectedInnerHtml);
|
||||
});
|
||||
|
||||
it("Should insert a newline character when shift enter is pressed when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const keyboardInput = "new{Shift>}{enter}{/Shift}line";
|
||||
const expectedInnerHtml = "new\nline";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, keyboardInput);
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox.innerHTML).toBe(expectedInnerHtml);
|
||||
});
|
||||
|
||||
it("Should not insert div and br tags when enter is pressed when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const enterThenTypeHtml = "<div>hello</div";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, "{enter}hello");
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox).not.toContainHTML(enterThenTypeHtml);
|
||||
});
|
||||
|
||||
it("Should not insert div tags when enter is pressed then user types more when ctrlEnterToSend is true", async () => {
|
||||
//When
|
||||
mockUseSettingValue.mockReturnValue(true);
|
||||
const onSend = jest.fn();
|
||||
customRender(jest.fn(), onSend);
|
||||
const textBox = screen.getByRole("textbox");
|
||||
const defaultEnterHtml = "<div><br></div";
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, "{enter}");
|
||||
|
||||
// Then it does not send a message, but inserts a newline character
|
||||
expect(onSend).toHaveBeenCalledTimes(0);
|
||||
expect(textBox).not.toContainHTML(defaultEnterHtml);
|
||||
});
|
||||
|
||||
it("Should clear textbox content when clear is called", async () => {
|
||||
//When
|
||||
let composer: {
|
||||
clear: () => void;
|
||||
insertText: (text: string) => void;
|
||||
};
|
||||
|
||||
render(
|
||||
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()}>
|
||||
{(ref, composerFunctions) => {
|
||||
composer = composerFunctions;
|
||||
return null;
|
||||
}}
|
||||
</PlainTextComposer>,
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "content");
|
||||
expect(screen.getByRole("textbox").innerHTML).toBe("content");
|
||||
|
||||
composer!.clear();
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("textbox").innerHTML).toBeFalsy();
|
||||
});
|
||||
|
||||
it("Should have data-is-expanded when it has two lines", async () => {
|
||||
let resizeHandler: ResizeObserverCallback = jest.fn();
|
||||
let editor: Element | null = null;
|
||||
jest.spyOn(global, "ResizeObserver").mockImplementation((handler) => {
|
||||
resizeHandler = handler;
|
||||
return {
|
||||
observe: (element) => {
|
||||
editor = element;
|
||||
},
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
//When
|
||||
render(<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("WysiwygComposerEditor").dataset["isExpanded"]).toBe("false");
|
||||
expect(editor).toBe(screen.getByRole("textbox"));
|
||||
|
||||
// When
|
||||
resizeHandler(
|
||||
[{ contentBoxSize: [{ blockSize: 100 }] } as unknown as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("WysiwygComposerEditor").dataset["isExpanded"]).toBe("true");
|
||||
|
||||
jest.useRealTimers();
|
||||
(global.ResizeObserver as jest.Mock).mockRestore();
|
||||
});
|
||||
|
||||
it("Should not render <Autocomplete /> if not wrapped in room context", () => {
|
||||
customRender();
|
||||
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should render <Autocomplete /> if wrapped in room context", () => {
|
||||
const { defaultRoomContext } = createMocks();
|
||||
|
||||
render(
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} disabled={false} initialContent="" />
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should allow pasting of text values", async () => {
|
||||
customRender();
|
||||
|
||||
const textBox = screen.getByRole("textbox");
|
||||
|
||||
await userEvent.click(textBox);
|
||||
await userEvent.type(textBox, "hello");
|
||||
await userEvent.paste(" world");
|
||||
|
||||
expect(textBox).toHaveTextContent("hello world");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
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 "@testing-library/jest-dom";
|
||||
import React, { createRef } from "react";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||
import { WysiwygAutocomplete } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete";
|
||||
import { getRoomContext, mkStubRoom, stubClient } from "../../../../../test-utils";
|
||||
import Autocomplete from "../../../../../../src/components/views/rooms/Autocomplete";
|
||||
import Autocompleter, { ICompletion } from "../../../../../../src/autocomplete/Autocompleter";
|
||||
import AutocompleteProvider from "../../../../../../src/autocomplete/AutocompleteProvider";
|
||||
|
||||
const mockCompletion: ICompletion[] = [
|
||||
{
|
||||
type: "user",
|
||||
completion: "user_1",
|
||||
completionId: "@user_1:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_1</div>,
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
completion: "user_2",
|
||||
completionId: "@user_2:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_2</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const constructMockProvider = (data: ICompletion[]) =>
|
||||
({
|
||||
getCompletions: jest.fn().mockImplementation(async () => data),
|
||||
getName: jest.fn().mockReturnValue("test provider"),
|
||||
renderCompletions: jest.fn().mockImplementation((components) => components),
|
||||
}) as unknown as AutocompleteProvider;
|
||||
|
||||
describe("WysiwygAutocomplete", () => {
|
||||
beforeAll(() => {
|
||||
// scrollTo not implemented in JSDOM
|
||||
window.HTMLElement.prototype.scrollTo = function () {};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const autocompleteRef = createRef<Autocomplete>();
|
||||
const getCompletionsSpy = jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
|
||||
{
|
||||
completions: mockCompletion,
|
||||
provider: constructMockProvider(mockCompletion),
|
||||
command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing
|
||||
},
|
||||
]);
|
||||
const mockHandleMention = jest.fn();
|
||||
const mockHandleCommand = jest.fn();
|
||||
const mockHandleAtRoomMention = jest.fn();
|
||||
|
||||
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
|
||||
const mockClient = stubClient();
|
||||
const mockRoom = mkStubRoom("test_room", "test_room", mockClient);
|
||||
const mockRoomContext = getRoomContext(mockRoom, {});
|
||||
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={mockRoomContext}>
|
||||
<WysiwygAutocomplete
|
||||
ref={autocompleteRef}
|
||||
suggestion={null}
|
||||
handleMention={mockHandleMention}
|
||||
handleCommand={mockHandleCommand}
|
||||
handleAtRoomMention={mockHandleAtRoomMention}
|
||||
{...props}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("does not show the autocomplete when room is undefined", () => {
|
||||
render(
|
||||
<WysiwygAutocomplete
|
||||
ref={autocompleteRef}
|
||||
suggestion={null}
|
||||
handleMention={mockHandleMention}
|
||||
handleCommand={mockHandleCommand}
|
||||
handleAtRoomMention={mockHandleAtRoomMention}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call for suggestions with a null suggestion prop", async () => {
|
||||
// render the component, the default props have suggestion = null
|
||||
renderComponent();
|
||||
|
||||
// check that getCompletions is not called, and we have no suggestions
|
||||
expect(getCompletionsSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls getCompletions when given a valid suggestion prop", async () => {
|
||||
renderComponent({ suggestion: { keyChar: "@", text: "abc", type: "mention" } });
|
||||
|
||||
// wait for getCompletions to have been called
|
||||
await waitFor(() => {
|
||||
expect(getCompletionsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// check that some suggestions are shown
|
||||
expect(screen.getByRole("presentation")).toBeInTheDocument();
|
||||
|
||||
// and that they are the mock completions
|
||||
mockCompletion.forEach(({ completion }) => expect(screen.getByText(completion)).toBeInTheDocument());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,871 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
|
||||
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||
import { flushPromises, mockPlatformPeg, stubClient, mkStubRoom } from "../../../../../test-utils";
|
||||
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||
import * as EventUtils from "../../../../../../src/utils/EventUtils";
|
||||
import { Action } from "../../../../../../src/dispatcher/actions";
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||
import {
|
||||
ComposerContext,
|
||||
getDefaultContextValue,
|
||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/ComposerContext";
|
||||
import { createMocks } from "../utils";
|
||||
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
|
||||
import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types";
|
||||
import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
|
||||
import { parseEditorStateTransfer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent";
|
||||
import Autocompleter, { ICompletion } from "../../../../../../src/autocomplete/Autocompleter";
|
||||
import AutocompleteProvider from "../../../../../../src/autocomplete/AutocompleteProvider";
|
||||
import * as Permalinks from "../../../../../../src/utils/permalinks/Permalinks";
|
||||
import { PermalinkParts } from "../../../../../../src/utils/permalinks/PermalinkConstructor";
|
||||
|
||||
describe("WysiwygComposer", () => {
|
||||
const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => {
|
||||
const { mockClient, defaultRoomContext } = createMocks();
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<WysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should have contentEditable at false when disabled", async () => {
|
||||
// When
|
||||
customRender(jest.fn(), jest.fn(), true);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false"));
|
||||
});
|
||||
|
||||
describe("Standard behavior", () => {
|
||||
const onChange = jest.fn();
|
||||
const onSend = jest.fn();
|
||||
beforeEach(async () => {
|
||||
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
|
||||
customRender(onChange, onSend);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onChange.mockReset();
|
||||
onSend.mockReset();
|
||||
});
|
||||
|
||||
it("Should have contentEditable at true", async () => {
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
it("Should have focus", async () => {
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("Should call onChange handler", async () => {
|
||||
// When
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: "foo bar",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("foo bar"));
|
||||
});
|
||||
|
||||
it("Should call onSend when Enter is pressed", async () => {
|
||||
//When
|
||||
fireEvent(
|
||||
screen.getByRole("textbox"),
|
||||
new InputEvent("input", {
|
||||
inputType: "insertParagraph",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("Should not call onSend when Shift+Enter is pressed", async () => {
|
||||
//When
|
||||
await userEvent.type(screen.getByRole("textbox"), "{shift>}{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it("Should not call onSend when ctrl+Enter is pressed", async () => {
|
||||
//When
|
||||
// Using userEvent.type or .keyboard wasn't working as expected in the case of ctrl+enter
|
||||
fireEvent(
|
||||
screen.getByRole("textbox"),
|
||||
new KeyboardEvent("keydown", {
|
||||
ctrlKey: true,
|
||||
code: "Enter",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it("Should not call onSend when alt+Enter is pressed", async () => {
|
||||
//When
|
||||
await userEvent.type(screen.getByRole("textbox"), "{alt>}{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it("Should not call onSend when meta+Enter is pressed", async () => {
|
||||
//When
|
||||
await userEvent.type(screen.getByRole("textbox"), "{meta>}{enter}");
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mentions and commands", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
const mockCompletions: ICompletion[] = [
|
||||
{
|
||||
type: "user",
|
||||
href: "https://matrix.to/#/@user_1:element.io",
|
||||
completion: "user_1",
|
||||
completionId: "@user_1:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_1</div>,
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
href: "https://matrix.to/#/@user_2:element.io",
|
||||
completion: "user_2",
|
||||
completionId: "@user_2:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_2</div>,
|
||||
},
|
||||
{
|
||||
// no href user
|
||||
type: "user",
|
||||
href: undefined,
|
||||
completion: "user_without_href",
|
||||
completionId: "@user_3:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>user_without_href</div>,
|
||||
},
|
||||
{
|
||||
type: "room",
|
||||
href: "https://matrix.to/#/#room_1:element.io",
|
||||
completion: "#room_with_completion_id",
|
||||
completionId: "@room_1:host.local",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>room_with_completion_id</div>,
|
||||
},
|
||||
{
|
||||
type: "room",
|
||||
href: "https://matrix.to/#/#room_2:element.io",
|
||||
completion: "#room_without_completion_id",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>room_without_completion_id</div>,
|
||||
},
|
||||
{
|
||||
type: "command",
|
||||
completion: "/spoiler",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>/spoiler</div>,
|
||||
},
|
||||
{
|
||||
type: "at-room",
|
||||
completion: "@room",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>@room</div>,
|
||||
},
|
||||
{
|
||||
type: "community",
|
||||
completion: "community-completion",
|
||||
range: { start: 1, end: 1 },
|
||||
component: <div>community</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const constructMockProvider = (data: ICompletion[]) =>
|
||||
({
|
||||
getCompletions: jest.fn().mockImplementation(async () => data),
|
||||
getName: jest.fn().mockReturnValue("test provider"),
|
||||
renderCompletions: jest.fn().mockImplementation((components) => components),
|
||||
}) as unknown as AutocompleteProvider;
|
||||
|
||||
// for each test we will insert input simulating a user mention
|
||||
const initialInput = "@abc";
|
||||
const insertMentionInput = async () => {
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: initialInput,
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// the autocomplete suggestions container has the presentation role, wait for it to be present
|
||||
expect(await screen.findByRole("presentation")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// setup the required spies
|
||||
jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
|
||||
{
|
||||
completions: mockCompletions,
|
||||
provider: constructMockProvider(mockCompletions),
|
||||
command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing
|
||||
},
|
||||
]);
|
||||
jest.spyOn(Permalinks, "parsePermalink").mockReturnValue({
|
||||
userId: "mockParsedUserId",
|
||||
} as unknown as PermalinkParts);
|
||||
|
||||
// then render the component and wait for the composer to be ready
|
||||
customRender();
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the autocomplete when text has @ prefix and autoselects the first item", async () => {
|
||||
await insertMentionInput();
|
||||
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("pressing up and down arrows allows us to change the autocomplete selection", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// press the down arrow - nb using .keyboard allows us to not have to specify a node, which
|
||||
// means that we know the autocomplete is correctly catching the event
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// reverse the process and check again
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("pressing enter selects the mention and inserts it into the composer as a link", async () => {
|
||||
await insertMentionInput();
|
||||
// press enter
|
||||
await userEvent.keyboard("{Enter}");
|
||||
screen.debug();
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it inserts the completion text as a link
|
||||
expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pressing escape closes the autocomplete", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// press escape
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("typing with the autocomplete open still works as expected", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// add some more text, then check the autocomplete is open AND the text is in the composer
|
||||
await userEvent.keyboard("extra");
|
||||
|
||||
expect(screen.queryByRole("presentation")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toHaveTextContent("@abcextra");
|
||||
});
|
||||
|
||||
it("clicking on a mention in the composer dispatches the correct action", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// press enter
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// click on the user mention link that has been inserted
|
||||
await userEvent.click(screen.getByRole("link", { name: mockCompletions[0].completion }));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// this relies on the output from the mock function in mkStubRoom
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewUser,
|
||||
member: expect.objectContaining({
|
||||
userId: mkStubRoom(undefined, undefined, undefined).getMember("any")?.userId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("selecting a mention without a href closes the autocomplete but does not insert a mention", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the relevant user by clicking
|
||||
await userEvent.click(screen.getByText("user_without_href"));
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it has not inserted a link
|
||||
expect(screen.queryByRole("link", { name: "user_without_href" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a room mention with a completionId uses client.getRoom", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion by clicking
|
||||
await userEvent.click(screen.getByText("room_with_completion_id"));
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it has inserted a link and looked up the name from the mock client
|
||||
// which will always return 'My room'
|
||||
expect(screen.getByRole("link", { name: "My room" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a room mention without a completionId uses client.getRooms", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("room_without_completion_id"));
|
||||
|
||||
// check that it closes the autocomplete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// check that it has inserted a link and falls back to the completion text
|
||||
expect(screen.getByRole("link", { name: "#room_without_completion_id" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a command inserts the command", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("/spoiler"));
|
||||
|
||||
// check that it has inserted the plain text
|
||||
expect(screen.getByText("/spoiler")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting an at-room completion inserts @room", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("@room"));
|
||||
|
||||
// check that it has inserted the @room link
|
||||
expect(screen.getByRole("link", { name: "@room" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows a community completion to pass through", async () => {
|
||||
await insertMentionInput();
|
||||
|
||||
// select the room suggestion
|
||||
await userEvent.click(screen.getByText("community"));
|
||||
|
||||
// check that it we still have the initial text
|
||||
expect(screen.getByText(initialInput)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("When emoticons should be replaced by emojis", () => {
|
||||
const onChange = jest.fn();
|
||||
const onSend = jest.fn();
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
|
||||
if (name === "MessageComposerInput.autoReplaceEmoji") return true;
|
||||
});
|
||||
customRender(onChange, onSend);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
it("typing a space to trigger an emoji replacement", async () => {
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: ":P",
|
||||
inputType: "insertText",
|
||||
});
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: " ",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛")));
|
||||
});
|
||||
it("typing a space to trigger an emoji varitation replacement", async () => {
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: ":-P",
|
||||
inputType: "insertText",
|
||||
});
|
||||
fireEvent.input(screen.getByRole("textbox"), {
|
||||
data: " ",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("When settings require Ctrl+Enter to send", () => {
|
||||
const onChange = jest.fn();
|
||||
const onSend = jest.fn();
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
|
||||
if (name === "MessageComposerInput.ctrlEnterToSend") return true;
|
||||
});
|
||||
customRender(onChange, onSend);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onChange.mockReset();
|
||||
onSend.mockReset();
|
||||
});
|
||||
|
||||
it("Should not call onSend when Enter is pressed", async () => {
|
||||
// When
|
||||
const textbox = screen.getByRole("textbox");
|
||||
|
||||
fireEvent(
|
||||
textbox,
|
||||
new InputEvent("input", {
|
||||
inputType: "insertParagraph",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it does not send a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
|
||||
|
||||
fireEvent(
|
||||
textbox,
|
||||
new InputEvent("input", {
|
||||
inputType: "insertText",
|
||||
data: "other",
|
||||
}),
|
||||
);
|
||||
|
||||
// The focus is on the last text node
|
||||
await waitFor(() => {
|
||||
const selection = document.getSelection();
|
||||
if (selection) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(selection.focusNode?.textContent).toEqual("other");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("Should send a message when Ctrl+Enter is pressed", async () => {
|
||||
// When
|
||||
fireEvent(
|
||||
screen.getByRole("textbox"),
|
||||
new InputEvent("input", {
|
||||
inputType: "sendMessage",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then it sends a message
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard navigation", () => {
|
||||
const { mockClient, defaultRoomContext, mockEvent, editorStateTransfer } = createMocks();
|
||||
|
||||
const customRender = (
|
||||
client = mockClient,
|
||||
roomContext = defaultRoomContext,
|
||||
_editorStateTransfer?: EditorStateTransfer,
|
||||
) => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={roomContext}>
|
||||
<ComposerContext.Provider
|
||||
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
|
||||
>
|
||||
<WysiwygComposer
|
||||
onChange={jest.fn()}
|
||||
onSend={jest.fn()}
|
||||
initialContent={
|
||||
roomContext.room && _editorStateTransfer
|
||||
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ComposerContext.Provider>
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const setup = async (
|
||||
editorState?: EditorStateTransfer,
|
||||
client = stubClient(),
|
||||
roomContext = defaultRoomContext,
|
||||
) => {
|
||||
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
customRender(client, roomContext, editorState);
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
|
||||
return { textbox: screen.getByRole("textbox"), spyDispatcher };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
|
||||
});
|
||||
|
||||
describe("In message creation", () => {
|
||||
it("Should not moving when the composer is filled", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup();
|
||||
fireEvent.input(textbox, {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
|
||||
// Move at the beginning of the composer
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should moving when the composer is empty", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup();
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("In message editing", () => {
|
||||
function select(selection: SubSelection) {
|
||||
return act(async () => {
|
||||
await setSelection(selection);
|
||||
// the event is not automatically fired by jest
|
||||
document.dispatchEvent(new CustomEvent("selectionchange"));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Moving up", () => {
|
||||
it("Should not moving when caret is not at beginning of the text", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 1,
|
||||
focusNode: textNode,
|
||||
focusOffset: 2,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should not moving when the content has changed", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
fireEvent.input(textbox, {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: textNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should moving up", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: textNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Should moving up in list", async () => {
|
||||
// When
|
||||
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
|
||||
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
|
||||
);
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
|
||||
|
||||
const textNode = textbox.firstChild;
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: textNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowUp",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Moving down", () => {
|
||||
it("Should not moving when caret is not at the end of the text", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
const brNode = textbox.lastChild;
|
||||
await select({
|
||||
anchorNode: brNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: brNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should not moving when the content has changed", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
fireEvent.input(textbox, {
|
||||
data: "word",
|
||||
inputType: "insertText",
|
||||
});
|
||||
const brNode = textbox.lastChild;
|
||||
await select({
|
||||
anchorNode: brNode,
|
||||
anchorOffset: 0,
|
||||
focusNode: brNode,
|
||||
focusOffset: 0,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should moving down", async () => {
|
||||
// When
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
// Skipping the BR tag
|
||||
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
|
||||
const { length } = textNode.textContent || "";
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: length,
|
||||
focusNode: textNode,
|
||||
focusOffset: length,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Should moving down in list", async () => {
|
||||
// When
|
||||
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
|
||||
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
|
||||
);
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
|
||||
|
||||
// Skipping the BR tag and get the text node inside the last LI tag
|
||||
const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox;
|
||||
const { length } = textNode.textContent || "";
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: length,
|
||||
focusNode: textNode,
|
||||
focusOffset: length,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: mockEvent,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should close editing", async () => {
|
||||
// When
|
||||
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined);
|
||||
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
|
||||
// Skipping the BR tag
|
||||
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
|
||||
const { length } = textNode.textContent || "";
|
||||
await select({
|
||||
anchorNode: textNode,
|
||||
anchorOffset: length,
|
||||
focusNode: textNode,
|
||||
focusOffset: length,
|
||||
isForward: true,
|
||||
});
|
||||
|
||||
fireEvent.keyDown(textbox, {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
|
||||
// Wait for event dispatch to happen
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: Action.EditEvent,
|
||||
event: null,
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,200 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FormattingButtons renders in german 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_FormattingButtons"
|
||||
>
|
||||
<button
|
||||
aria-label="Fett"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.8 19c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 6.8 17V7c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 8.8 5h3.525c1.083 0 2.083.333 3 1 .917.667 1.375 1.592 1.375 2.775 0 .85-.192 1.504-.575 1.963-.383.458-.742.787-1.075.987.417.183.88.525 1.387 1.025.509.5.763 1.25.763 2.25 0 1.483-.542 2.52-1.625 3.113-1.083.591-2.1.887-3.05.887H8.8Zm1.025-2.8h2.6c.8 0 1.287-.204 1.462-.612.175-.409.263-.705.263-.888 0-.183-.088-.48-.263-.887-.175-.409-.687-.613-1.537-.613H9.825v3Zm0-5.7h2.325c.55 0 .95-.142 1.2-.425a1.4 1.4 0 0 0 .375-.95c0-.4-.142-.725-.425-.975-.283-.25-.65-.375-1.1-.375H9.825V10.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Kursiv"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.25 19c-.35 0-.646-.12-.888-.363A1.207 1.207 0 0 1 5 17.75c0-.35.12-.646.362-.887.242-.242.538-.363.888-.363H9l3-9H9.25c-.35 0-.646-.12-.887-.362A1.207 1.207 0 0 1 8 6.25c0-.35.12-.646.363-.888A1.21 1.21 0 0 1 9.25 5h7.5c.35 0 .646.12.887.362.242.242.363.538.363.888s-.12.646-.363.888a1.207 1.207 0 0 1-.887.362H14.5l-3 9h2.25c.35 0 .646.12.887.363.242.241.363.537.363.887s-.12.646-.363.887a1.207 1.207 0 0 1-.887.363h-7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Unterstrichen"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 21a.967.967 0 0 1-.713-.288A.968.968 0 0 1 5 20a.97.97 0 0 1 .287-.712A.967.967 0 0 1 6 19h12c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 18 21H6Zm6-4c-1.683 0-2.992-.525-3.925-1.575-.933-1.05-1.4-2.442-1.4-4.175V4.275c0-.35.13-.65.388-.9A1.27 1.27 0 0 1 7.974 3c.35 0 .65.125.9.375s.375.55.375.9V11.4c0 .933.233 1.692.7 2.275.467.583 1.15.875 2.05.875.9 0 1.583-.292 2.05-.875.467-.583.7-1.342.7-2.275V4.275c0-.35.13-.65.387-.9A1.27 1.27 0 0 1 16.05 3c.35 0 .65.125.9.375s.375.55.375.9v6.975c0 1.733-.467 3.125-1.4 4.175C14.992 16.475 13.683 17 12 17Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Durchgestrichen"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.15 20c-1.267 0-2.392-.375-3.375-1.125-.983-.75-1.692-1.775-2.125-3.075l2.2-.95c.233.8.638 1.458 1.213 1.975.574.517 1.287.775 2.137.775.7 0 1.333-.167 1.9-.5.567-.333.85-.867.85-1.6 0-.3-.058-.575-.175-.825A2.362 2.362 0 0 0 14.3 14h2.8a4.279 4.279 0 0 1 .25 1.5c0 1.433-.513 2.542-1.538 3.325C14.788 19.608 13.567 20 12.15 20ZM3 12a.967.967 0 0 1-.712-.287A.968.968 0 0 1 2 11c0-.283.096-.52.288-.712A.967.967 0 0 1 3 10h18c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.713A.968.968 0 0 1 21 12H3Zm9.05-8.15c1.1 0 2.063.27 2.887.813.825.541 1.463 1.37 1.913 2.487l-2.2.975a2.987 2.987 0 0 0-.838-1.3c-.408-.383-.979-.575-1.712-.575-.683 0-1.25.154-1.7.463-.45.308-.7.737-.75 1.287h-2.4c.033-1.15.487-2.13 1.363-2.937.875-.809 2.02-1.213 3.437-1.213Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Ungeordnete Liste"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.5 7.5a1.45 1.45 0 0 1-1.06-.44A1.444 1.444 0 0 1 3 6c0-.412.147-.766.44-1.06A1.45 1.45 0 0 1 4.5 4.5c.412 0 .766.147 1.06.44.293.294.44.647.44 1.06 0 .412-.147.766-.44 1.06-.294.293-.647.44-1.06.44Zm4.787 11.212c.192.192.43.288.713.288h10c.283 0 .52-.096.712-.288A.968.968 0 0 0 21 18a.968.968 0 0 0-.288-.712A.968.968 0 0 0 20 17H10a.967.967 0 0 0-.713.288A.968.968 0 0 0 9 18c0 .283.096.52.287.712Zm0-5.999c.192.191.43.287.713.287h10a.97.97 0 0 0 .712-.287A.968.968 0 0 0 21 12a.968.968 0 0 0-.288-.713A.968.968 0 0 0 20 11H10a.967.967 0 0 0-.713.287A.968.968 0 0 0 9 12c0 .283.096.52.287.713Zm0-6c.192.191.43.287.713.287h10a.97.97 0 0 0 .712-.287A.967.967 0 0 0 21 6a.967.967 0 0 0-.288-.713A.968.968 0 0 0 20 5H10a.968.968 0 0 0-.713.287A.968.968 0 0 0 9 6c0 .283.096.52.287.713ZM3.44 19.06c.294.293.648.44 1.06.44a1.45 1.45 0 0 0 1.06-.44c.293-.294.44-.647.44-1.06 0-.413-.147-.766-.44-1.06a1.445 1.445 0 0 0-1.06-.44 1.45 1.45 0 0 0-1.06.44c-.293.294-.44.647-.44 1.06 0 .413.147.766.44 1.06ZM4.5 13.5a1.45 1.45 0 0 1-1.06-.44A1.445 1.445 0 0 1 3 12c0-.412.147-.766.44-1.06a1.45 1.45 0 0 1 1.06-.44c.412 0 .766.147 1.06.44.293.294.44.648.44 1.06 0 .412-.147.766-.44 1.06-.294.293-.647.44-1.06.44Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Nummerierte Liste"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 6a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H10a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H10a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H10a1 1 0 0 1-1-1ZM5.604 5.089A.75.75 0 0 1 6 5.75v4.5a.75.75 0 0 1-1.5 0V7.151l-.334.223a.75.75 0 0 1-.832-1.248l1.5-1a.75.75 0 0 1 .77-.037ZM5 13a2.02 2.02 0 0 0-1.139.321 1.846 1.846 0 0 0-.626.719 2.286 2.286 0 0 0-.234.921v.023l-.001.01v.005l.75.001H3a.75.75 0 0 0 1.5.01V15a.789.789 0 0 1 .077-.29.35.35 0 0 1 .116-.14c.04-.027.126-.07.307-.07s.267.043.307.07a.35.35 0 0 1 .116.14.788.788 0 0 1 .076.29v.008a.532.532 0 0 1-.14.352l-2.161 2.351a.748.748 0 0 0-.198.523v.016c0 .414.336.75.75.75h2.5a.75.75 0 0 0 0-1.5h-.82l1.034-1.124C6.809 16 7 15.51 7 15h-.75H7v-.039l-.004-.068a2.285 2.285 0 0 0-.231-.853 1.846 1.846 0 0 0-.626-.719A2.02 2.02 0 0 0 5 13Zm-.5 2.003V15v.01-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Zitieren"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.719 4.34c.094-.642-.366-1.236-1.028-1.328-.663-.092-1.276.354-1.371.996l-.808 5.478c-.094.642.366 1.237 1.028 1.328.663.092 1.276-.354 1.371-.996l.808-5.478Zm12.115 10.174c.095-.642-.366-1.237-1.028-1.328-.662-.092-1.276.354-1.37.996l-.809 5.478c-.094.642.366 1.236 1.028 1.328.663.092 1.277-.354 1.371-.996l.808-5.478ZM9.318 3.009c.665.077 1.138.662 1.058 1.306l-.022.175a220.467 220.467 0 0 1-.266 2.006c-.161 1.171-.368 2.579-.535 3.386-.13.636-.769 1.049-1.425.921-.656-.127-1.082-.745-.95-1.381.148-.72.345-2.052.509-3.237a190.652 190.652 0 0 0 .262-1.981l.021-.17c.08-.644.684-1.103 1.348-1.025Zm13.17 11.505c.094-.642-.366-1.237-1.028-1.328-.663-.092-1.276.354-1.371.996l-.808 5.478c-.094.642.366 1.236 1.028 1.328.663.092 1.276-.354 1.371-.996l.808-5.478Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Code"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.958 5.62a1 1 0 0 0-1.916-.574l-4 13.333a1 1 0 0 0 1.916.575l4-13.333ZM5.974 7.232a1 1 0 0 0-1.409.128l-3.333 4a1 1 0 0 0 0 1.28l3.333 4a1 1 0 1 0 1.537-1.28L3.302 12l2.8-3.36a1 1 0 0 0-.128-1.408Zm12.052 0a1 1 0 0 1 1.409.128l3.333 4a1 1 0 0 1 0 1.28l-3.333 4a1 1 0 1 1-1.537-1.28l2.8-3.36-2.8-3.36a1 1 0 0 1 .128-1.408Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Quelltextblock"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.825 12 1.475-1.475c.2-.2.3-.433.3-.7 0-.267-.1-.5-.3-.7-.2-.2-.438-.3-.713-.3-.274 0-.512.1-.712.3L6.7 11.3c-.1.1-.17.208-.213.325a1.107 1.107 0 0 0-.062.375c0 .133.02.258.063.375a.877.877 0 0 0 .212.325l2.175 2.175c.2.2.438.3.713.3.275 0 .512-.1.712-.3.2-.2.3-.433.3-.7 0-.267-.1-.5-.3-.7L8.825 12Zm6.35 0L13.7 13.475c-.2.2-.3.433-.3.7 0 .267.1.5.3.7.2.2.438.3.713.3.274 0 .512-.1.712-.3L17.3 12.7c.1-.1.17-.208.212-.325.042-.117.063-.242.063-.375s-.02-.258-.063-.375a.877.877 0 0 0-.212-.325l-2.175-2.175a.999.999 0 0 0-1.425 0c-.2.2-.3.433-.3.7 0 .267.1.5.3.7L15.175 12ZM5 21c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 3 19V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 5 3h14c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v14c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 19 21H5Zm0-2h14V5H5v14Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Link"
|
||||
class="mx_AccessibleButton mx_FormattingButtons_Button mx_FormattingButtons_Button_hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="mx_FormattingButtons_Icon"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 19.071c-.978.978-2.157 1.467-3.536 1.467-1.378 0-2.557-.489-3.535-1.467-.978-.978-1.467-2.157-1.467-3.536 0-1.378.489-2.557 1.467-3.535L7.05 9.879c.2-.2.436-.3.707-.3.271 0 .507.1.707.3.2.2.301.436.301.707 0 .27-.1.506-.3.707l-2.122 2.121a2.893 2.893 0 0 0-.884 2.122c0 .824.295 1.532.884 2.12.59.59 1.296.885 2.121.885s1.533-.295 2.122-.884l2.121-2.121c.2-.2.436-.301.707-.301.271 0 .507.1.707.3.2.2.3.437.3.708 0 .27-.1.506-.3.707L12 19.07Zm-1.414-4.243c-.2.2-.436.3-.707.3a.967.967 0 0 1-.707-.3.969.969 0 0 1-.301-.707c0-.27.1-.507.3-.707l4.243-4.242c.2-.2.436-.301.707-.301.271 0 .507.1.707.3.2.2.3.437.3.708 0 .27-.1.506-.3.707l-4.242 4.242Zm6.364-.707c-.2.2-.436.3-.707.3a.968.968 0 0 1-.707-.3.969.969 0 0 1-.301-.707c0-.27.1-.507.3-.707l2.122-2.121c.59-.59.884-1.297.884-2.122s-.295-1.532-.884-2.12a2.893 2.893 0 0 0-2.121-.885c-.825 0-1.532.295-2.122.884l-2.121 2.121c-.2.2-.436.301-.707.301a.968.968 0 0 1-.707-.3.97.97 0 0 1-.3-.708c0-.27.1-.506.3-.707L12 4.93c.978-.978 2.157-1.467 3.536-1.467 1.378 0 2.557.489 3.535 1.467.978.978 1.467 2.157 1.467 3.535 0 1.38-.489 2.558-1.467 3.536l-2.121 2.121Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
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 { renderHook } from "@testing-library/react-hooks";
|
||||
import { act } from "jest-matrix-react";
|
||||
|
||||
import { usePlainTextListeners } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners";
|
||||
|
||||
describe("setContent", () => {
|
||||
it("calling with a string calls the onChange argument", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const { result } = renderHook(() => usePlainTextListeners("initialContent", mockOnChange));
|
||||
|
||||
const newContent = "new content";
|
||||
act(() => {
|
||||
result.current.setContent(newContent);
|
||||
});
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
|
||||
it("calling with no argument and no editor ref does not call onChange", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const { result } = renderHook(() => usePlainTextListeners("initialContent", mockOnChange));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent();
|
||||
});
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calling with no argument and a valid editor ref calls onChange with the editorRef innerHTML", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
// create a div to represent the editor and append some content
|
||||
const mockEditor = document.createElement("div");
|
||||
const mockEditorText = "some text content";
|
||||
const textNode = document.createTextNode(mockEditorText);
|
||||
mockEditor.appendChild(textNode);
|
||||
|
||||
const { result } = renderHook(() => usePlainTextListeners("initialContent", mockOnChange));
|
||||
|
||||
// @ts-ignore in order to allow us to reassign the ref without complaint
|
||||
result.current.ref.current = mockEditor;
|
||||
|
||||
act(() => {
|
||||
result.current.setContent();
|
||||
});
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(mockEditor.innerHTML);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,419 @@
|
|||
/*
|
||||
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 {
|
||||
Suggestion,
|
||||
findSuggestionInText,
|
||||
getMappedSuggestion,
|
||||
processCommand,
|
||||
processEmojiReplacement,
|
||||
processMention,
|
||||
processSelectionChange,
|
||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
||||
|
||||
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||
return {
|
||||
mappedSuggestion: { keyChar: "/", type: "command", text: "some text", ...props.mappedSuggestion },
|
||||
node: document.createTextNode(""),
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockCustomSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||
return {
|
||||
mappedSuggestion: { keyChar: "", type: "custom", text: "🙂", ...props.mappedSuggestion },
|
||||
node: document.createTextNode(":)"),
|
||||
startOffset: 0,
|
||||
endOffset: 2,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processCommand", () => {
|
||||
it("does not change parent hook state if suggestion is null", () => {
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
|
||||
// call the function with a null suggestion
|
||||
processCommand("should not be seen", null, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the parent state setter has not been called
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can change the parent hook state when required", () => {
|
||||
// create a div and append a text node to it with some initial text
|
||||
const editorDiv = document.createElement("div");
|
||||
const initialText = "text";
|
||||
const textNode = document.createTextNode(initialText);
|
||||
editorDiv.appendChild(textNode);
|
||||
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSuggestion = createMockPlainTextSuggestionPattern({ node: textNode });
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
const replacementText = "/replacement text";
|
||||
|
||||
processCommand(replacementText, mockSuggestion, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the text has changed and includes a trailing space
|
||||
expect(mockSetText).toHaveBeenCalledWith(`${replacementText} `);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEmojiReplacement", () => {
|
||||
it("does not change parent hook state if suggestion is null", () => {
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
|
||||
// call the function with a null suggestion
|
||||
processEmojiReplacement(null, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the parent state setter has not been called
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can change the parent hook state when required", () => {
|
||||
// create a div and append a text node to it with some initial text
|
||||
const editorDiv = document.createElement("div");
|
||||
const initialText = ":)";
|
||||
const textNode = document.createTextNode(initialText);
|
||||
editorDiv.appendChild(textNode);
|
||||
|
||||
// create a mockSuggestion using the text node above
|
||||
const mockSuggestion = createMockCustomSuggestionPattern({ node: textNode });
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
const replacementText = "🙂";
|
||||
|
||||
processEmojiReplacement(mockSuggestion, mockSetSuggestion, mockSetText);
|
||||
|
||||
// check that the text has changed and includes a trailing space
|
||||
expect(mockSetText).toHaveBeenCalledWith(replacementText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processMention", () => {
|
||||
// TODO refactor and expand tests when mentions become <a> tags
|
||||
it("returns early when suggestion is null", () => {
|
||||
const mockSetSuggestion = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
processMention("href", "displayName", new Map(), null, mockSetSuggestion, mockSetText);
|
||||
|
||||
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can insert a mention into a text node", () => {
|
||||
// make a text node and an editor div, set the cursor inside the text node and then
|
||||
// append node to editor, then editor to document
|
||||
const textNode = document.createTextNode("@a");
|
||||
const mockEditor = document.createElement("div");
|
||||
mockEditor.appendChild(textNode);
|
||||
document.body.appendChild(mockEditor);
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 1, textNode, 1);
|
||||
|
||||
// call the util function
|
||||
const href = "href";
|
||||
const displayName = "displayName";
|
||||
const mockSetSuggestionData = jest.fn();
|
||||
const mockSetText = jest.fn();
|
||||
processMention(
|
||||
href,
|
||||
displayName,
|
||||
new Map([["style", "test"]]),
|
||||
{ node: textNode, startOffset: 0, endOffset: 2 } as unknown as Suggestion,
|
||||
mockSetSuggestionData,
|
||||
mockSetText,
|
||||
);
|
||||
|
||||
// check that the editor has a single child
|
||||
expect(mockEditor.children).toHaveLength(1);
|
||||
const linkElement = mockEditor.firstElementChild as HTMLElement;
|
||||
|
||||
// and that the child is an <a> tag with the expected attributes and content
|
||||
expect(linkElement).toBeInstanceOf(HTMLAnchorElement);
|
||||
expect(linkElement).toHaveAttribute(href, href);
|
||||
expect(linkElement).toHaveAttribute("contenteditable", "false");
|
||||
expect(linkElement).toHaveAttribute("style", "test");
|
||||
expect(linkElement.textContent).toBe(displayName);
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith();
|
||||
expect(mockSetSuggestionData).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processSelectionChange", () => {
|
||||
function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
|
||||
return { current: element } as React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function appendEditorWithTextNodeContaining(initialText = ""): [HTMLDivElement, Node] {
|
||||
// create the elements/nodes
|
||||
const mockEditor = document.createElement("div");
|
||||
const textNode = document.createTextNode(initialText);
|
||||
|
||||
// append text node to the editor, editor to the document body
|
||||
mockEditor.appendChild(textNode);
|
||||
document.body.appendChild(mockEditor);
|
||||
|
||||
return [mockEditor, textNode];
|
||||
}
|
||||
|
||||
const mockSetSuggestion = jest.fn();
|
||||
beforeEach(() => {
|
||||
mockSetSuggestion.mockClear();
|
||||
});
|
||||
|
||||
it("returns early if current editorRef is null", () => {
|
||||
const mockEditorRef = createMockEditorRef(null);
|
||||
// we monitor for the call to document.createNodeIterator to indicate an early return
|
||||
const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
|
||||
|
||||
processSelectionChange(mockEditorRef, jest.fn());
|
||||
expect(nodeIteratorSpy).not.toHaveBeenCalled();
|
||||
|
||||
// tidy up to avoid potential impacts on other tests
|
||||
nodeIteratorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls setSuggestion with null if selection is not a cursor", () => {
|
||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has different start and end locations ie it
|
||||
// is not a cursor
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
|
||||
|
||||
// process the selection and check that we do not attempt to set the suggestion
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls setSuggestion with null if selection cursor is not inside a text node", () => {
|
||||
const [mockEditor] = appendEditorWithTextNodeContaining("content");
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection that points at the editor element, not the text node it contains
|
||||
document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
|
||||
|
||||
// process the selection and check that we do not attempt to set the suggestion
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
|
||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);
|
||||
|
||||
// the call to process the selection will have an existing suggestion in state due to the second
|
||||
// argument being non-null, expect that we clear this suggestion now that the text is not a command
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls setSuggestion with the expected arguments when text node is valid command", () => {
|
||||
const commandText = "/potentialCommand";
|
||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining(commandText);
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||
document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
|
||||
|
||||
// process the change and check the suggestion that is set looks as we expect it to
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith({
|
||||
mappedSuggestion: {
|
||||
keyChar: "/",
|
||||
type: "command",
|
||||
text: "potentialCommand",
|
||||
},
|
||||
node: textNode,
|
||||
startOffset: 0,
|
||||
endOffset: commandText.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a command outside the first text node to be a suggestion", () => {
|
||||
const [mockEditor] = appendEditorWithTextNodeContaining("some text in first node");
|
||||
const [, commandTextNode] = appendEditorWithTextNodeContaining("/potentialCommand");
|
||||
|
||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||
|
||||
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||
document.getSelection()?.setBaseAndExtent(commandTextNode, 3, commandTextNode, 3);
|
||||
|
||||
// process the change and check the suggestion that is set looks as we expect it to
|
||||
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSuggestionInText", () => {
|
||||
const command = "/someCommand";
|
||||
const userMention = "@userMention";
|
||||
const roomMention = "#roomMention";
|
||||
|
||||
const mentionTestCases = [userMention, roomMention];
|
||||
const allTestCases = [command, userMention, roomMention];
|
||||
|
||||
it("returns null if content does not contain any mention or command characters", () => {
|
||||
expect(findSuggestionInText("hello", 1, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if content contains a command but is not the first text node", () => {
|
||||
expect(findSuggestionInText(command, 1, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if the offset is outside the content length", () => {
|
||||
expect(findSuggestionInText("hi", 30, true)).toBeNull();
|
||||
expect(findSuggestionInText("hi", -10, true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => {
|
||||
const expected = {
|
||||
mappedSuggestion: getMappedSuggestion(text),
|
||||
startOffset: 0,
|
||||
endOffset: text.length,
|
||||
};
|
||||
// test for cursor immediately before and after special character, before end, at end
|
||||
expect(findSuggestionInText(text, 0, true)).toEqual(expected);
|
||||
expect(findSuggestionInText(text, 1, true)).toEqual(expected);
|
||||
expect(findSuggestionInText(text, text.length - 2, true)).toEqual(expected);
|
||||
expect(findSuggestionInText(text, text.length, true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null when a command is followed by other text", () => {
|
||||
const followingText = " followed by something";
|
||||
|
||||
// check for cursor inside and outside the command
|
||||
expect(findSuggestionInText(command + followingText, command.length - 2, true)).toBeNull();
|
||||
expect(findSuggestionInText(command + followingText, command.length + 2, true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => {
|
||||
const followingText = " followed by something else";
|
||||
expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({
|
||||
mappedSuggestion: getMappedSuggestion(mention),
|
||||
startOffset: 0,
|
||||
endOffset: mention.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null if there is a command surrounded by text", () => {
|
||||
const precedingText = "text before the command ";
|
||||
const followingText = " text after the command";
|
||||
expect(
|
||||
findSuggestionInText(precedingText + command + followingText, precedingText.length + 4, true),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => {
|
||||
const precedingText = "I want to mention ";
|
||||
const followingText = " in my message";
|
||||
|
||||
const textInput = precedingText + mention + followingText;
|
||||
const expected = {
|
||||
mappedSuggestion: getMappedSuggestion(mention),
|
||||
startOffset: precedingText.length,
|
||||
endOffset: precedingText.length + mention.length,
|
||||
};
|
||||
|
||||
// when the cursor is immediately before the special character
|
||||
expect(findSuggestionInText(textInput, precedingText.length, true)).toEqual(expected);
|
||||
// when the cursor is inside the mention
|
||||
expect(findSuggestionInText(textInput, precedingText.length + 3, true)).toEqual(expected);
|
||||
// when the cursor is right at the end of the mention
|
||||
expect(findSuggestionInText(textInput, precedingText.length + mention.length, true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for text content with an email address", () => {
|
||||
const emailInput = "send to user@test.com";
|
||||
expect(findSuggestionInText(emailInput, 15, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for double slashed command", () => {
|
||||
const doubleSlashCommand = "//not a command";
|
||||
expect(findSuggestionInText(doubleSlashCommand, 4, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for slash separated text", () => {
|
||||
const slashSeparatedInput = "please to this/that/the other";
|
||||
expect(findSuggestionInText(slashSeparatedInput, 21, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an object for a mention that contains punctuation", () => {
|
||||
const mentionWithPunctuation = "@userX14#5a_-";
|
||||
const precedingText = "mention ";
|
||||
const mentionInput = precedingText + mentionWithPunctuation;
|
||||
expect(findSuggestionInText(mentionInput, 12, true)).toEqual({
|
||||
mappedSuggestion: getMappedSuggestion(mentionWithPunctuation),
|
||||
startOffset: precedingText.length,
|
||||
endOffset: precedingText.length + mentionWithPunctuation.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when user inputs any whitespace after the special character", () => {
|
||||
const mentionWithSpaceAfter = "@ somebody";
|
||||
expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an object for an emoji suggestion", () => {
|
||||
const emoiticon = ":)";
|
||||
const precedingText = "hello ";
|
||||
const mentionInput = precedingText + emoiticon;
|
||||
expect(findSuggestionInText(mentionInput, precedingText.length, true, true)).toEqual({
|
||||
mappedSuggestion: getMappedSuggestion(emoiticon, true),
|
||||
startOffset: precedingText.length,
|
||||
endOffset: precedingText.length + emoiticon.length,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMappedSuggestion", () => {
|
||||
it("returns null when the first character is not / # @", () => {
|
||||
expect(getMappedSuggestion("Zzz")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns the expected mapped suggestion when first character is # or @", () => {
|
||||
expect(getMappedSuggestion("@user-mention")).toEqual({
|
||||
type: "mention",
|
||||
keyChar: "@",
|
||||
text: "user-mention",
|
||||
});
|
||||
expect(getMappedSuggestion("#room-mention")).toEqual({
|
||||
type: "mention",
|
||||
keyChar: "#",
|
||||
text: "room-mention",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the expected mapped suggestion when first character is /", () => {
|
||||
expect(getMappedSuggestion("/command")).toEqual({
|
||||
type: "command",
|
||||
keyChar: "/",
|
||||
text: "command",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the expected mapped suggestion when the text is a plain text emoiticon", () => {
|
||||
expect(getMappedSuggestion(":)", true)).toEqual({
|
||||
type: "custom",
|
||||
keyChar: "",
|
||||
text: "🙂",
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,305 @@
|
|||
/*
|
||||
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 { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { TimelineRenderingType } from "../../../../../../src/contexts/RoomContext";
|
||||
import { mkStubRoom, stubClient } from "../../../../../test-utils";
|
||||
import ContentMessages from "../../../../../../src/ContentMessages";
|
||||
import { IRoomState } from "../../../../../../src/components/structures/RoomView";
|
||||
import {
|
||||
handleClipboardEvent,
|
||||
isEventToHandleAsClipboardEvent,
|
||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils";
|
||||
|
||||
const mockClient = stubClient();
|
||||
const mockRoom = mkStubRoom("mock room", "mock room", mockClient);
|
||||
const mockRoomState = {
|
||||
room: mockRoom,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
replyToEvent: {} as unknown as MatrixEvent,
|
||||
} as unknown as IRoomState;
|
||||
|
||||
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
|
||||
const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom");
|
||||
const fetchSpy = jest.spyOn(window, "fetch");
|
||||
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
describe("handleClipboardEvent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockClipboardEvent(props: any): ClipboardEvent {
|
||||
return { clipboardData: { files: [], types: [] }, ...props } as ClipboardEvent;
|
||||
}
|
||||
|
||||
it("returns false if it is not a paste event", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "copy" });
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if clipboard data is null", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if room is undefined", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "paste" });
|
||||
const { room, ...roomStateWithoutRoom } = mockRoomState;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
roomStateWithoutRoom,
|
||||
mockClient,
|
||||
);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if room clipboardData files and types are empty", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: [], types: [] },
|
||||
});
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("handles event and calls sendContentListToRoom when data files are present", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
originalEvent.clipboardData?.files,
|
||||
mockRoom.roomId,
|
||||
undefined, // this is the event relation, an optional arg
|
||||
mockClient,
|
||||
mockRoomState.timelineRenderingType,
|
||||
);
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendContentListToRoom with eventRelation when present", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
originalEvent.clipboardData?.files,
|
||||
mockRoom.roomId,
|
||||
mockEventRelation, // this is the event relation, an optional arg
|
||||
mockClient,
|
||||
mockRoomState.timelineRenderingType,
|
||||
);
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls the error handler when sentContentListToRoom errors", async () => {
|
||||
const mockErrorMessage = "something went wrong";
|
||||
sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage));
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls the error handler when data types has text/html but data can not be parsed", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue("<div>invalid html"),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("calls fetch when data types has text/html and data can parsed", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith("blob:");
|
||||
});
|
||||
|
||||
it("calls error handler when fetch fails", async () => {
|
||||
const mockErrorMessage = "fetch failed";
|
||||
fetchSpy.mockRejectedValueOnce(mockErrorMessage);
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendContentToRoom when parsing is successful", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
url: "test/file",
|
||||
blob: () => {
|
||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||
},
|
||||
} as Response);
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendContentToRoomSpy).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
mockRoom.roomId,
|
||||
mockEventRelation,
|
||||
mockClient,
|
||||
mockRoomState.replyToEvent,
|
||||
);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls error handler when parsing is not successful", async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
url: "test/file",
|
||||
blob: () => {
|
||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||
},
|
||||
} as Response);
|
||||
const mockErrorMessage = "sendContentToRoom failed";
|
||||
sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage);
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: {
|
||||
files: [],
|
||||
types: ["text/html"],
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEventToHandleAsClipboardEvent", () => {
|
||||
it("returns true for ClipboardEvent", () => {
|
||||
const input = new ClipboardEvent("clipboard");
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for special case input", () => {
|
||||
const input = new InputEvent("insertFromPaste", { inputType: "insertFromPaste" });
|
||||
Object.assign(input, { dataTransfer: "not null" });
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular InputEvent", () => {
|
||||
const input = new InputEvent("input");
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for other input", () => {
|
||||
const input = new KeyboardEvent("keyboard");
|
||||
expect(isEventToHandleAsClipboardEvent(input)).toBe(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
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 { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils";
|
||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
|
||||
|
||||
export function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
|
||||
const mockClient = stubClient();
|
||||
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: eventContent,
|
||||
},
|
||||
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, {
|
||||
liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline,
|
||||
});
|
||||
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
|
||||
return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent };
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
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 { mocked } from "jest-mock";
|
||||
import React from "react";
|
||||
|
||||
import { ICompletion } from "../../../../../../src/autocomplete/Autocompleter";
|
||||
import {
|
||||
buildQuery,
|
||||
getRoomFromCompletion,
|
||||
getMentionDisplayText,
|
||||
getMentionAttributes,
|
||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/autocomplete";
|
||||
import { createTestClient, mkRoom } from "../../../../../test-utils";
|
||||
import * as _mockAvatar from "../../../../../../src/Avatar";
|
||||
|
||||
const mockClient = createTestClient();
|
||||
const mockRoomId = "mockRoomId";
|
||||
const mockRoom = mkRoom(mockClient, mockRoomId);
|
||||
|
||||
const createMockCompletion = (props: Partial<ICompletion>): ICompletion => {
|
||||
return {
|
||||
completion: "mock",
|
||||
range: { beginning: true, start: 0, end: 0 },
|
||||
component: React.createElement("div"),
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
jest.mock("../../../../../../src/Avatar");
|
||||
jest.mock("../../../../../../src/stores/WidgetStore");
|
||||
jest.mock("../../../../../../src/stores/widgets/WidgetLayoutStore");
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
afterAll(() => jest.restoreAllMocks());
|
||||
|
||||
describe("buildQuery", () => {
|
||||
it("returns an empty string for a falsy argument", () => {
|
||||
expect(buildQuery(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string when keyChar is falsy", () => {
|
||||
const noKeyCharSuggestion = { keyChar: "" as const, text: "test", type: "unknown" as const };
|
||||
expect(buildQuery(noKeyCharSuggestion)).toBe("");
|
||||
});
|
||||
|
||||
it("combines the keyChar and text of the suggestion in the query", () => {
|
||||
const handledSuggestion = { keyChar: "@" as const, text: "alice", type: "mention" as const };
|
||||
expect(buildQuery(handledSuggestion)).toBe("@alice");
|
||||
|
||||
const handledCommand = { keyChar: "/" as const, text: "spoiler", type: "mention" as const };
|
||||
expect(buildQuery(handledCommand)).toBe("/spoiler");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoomFromCompletion", () => {
|
||||
const createMockRoomCompletion = (props: Partial<ICompletion>): ICompletion => {
|
||||
return createMockCompletion({ ...props, type: "room" });
|
||||
};
|
||||
|
||||
it("calls getRoom with completionId if present in the completion", () => {
|
||||
const testId = "arbitraryId";
|
||||
const completionWithId = createMockRoomCompletion({ completionId: testId });
|
||||
|
||||
getRoomFromCompletion(completionWithId, mockClient);
|
||||
|
||||
expect(mockClient.getRoom).toHaveBeenCalledWith(testId);
|
||||
});
|
||||
|
||||
it("calls getRoom with completion if present and correct format", () => {
|
||||
const testCompletion = "arbitraryCompletion";
|
||||
const completionWithId = createMockRoomCompletion({ completionId: testCompletion });
|
||||
|
||||
getRoomFromCompletion(completionWithId, mockClient);
|
||||
|
||||
expect(mockClient.getRoom).toHaveBeenCalledWith(testCompletion);
|
||||
});
|
||||
|
||||
it("calls getRooms if no completionId is present and completion starts with #", () => {
|
||||
const completionWithId = createMockRoomCompletion({ completion: "#hash" });
|
||||
|
||||
const result = getRoomFromCompletion(completionWithId, mockClient);
|
||||
|
||||
expect(mockClient.getRoom).not.toHaveBeenCalled();
|
||||
expect(mockClient.getRooms).toHaveBeenCalled();
|
||||
|
||||
// in this case, because the mock client returns an empty array of rooms
|
||||
// from the call to get rooms, we'd expect the result to be null
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMentionDisplayText", () => {
|
||||
it("returns an empty string if we are not handling a user, room or at-room type", () => {
|
||||
const nonHandledCompletionTypes = ["community", "command"] as const;
|
||||
const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type }));
|
||||
|
||||
nonHandledCompletions.forEach((completion) => {
|
||||
expect(getMentionDisplayText(completion, mockClient)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the completion if we are handling a user", () => {
|
||||
const testCompletion = "display this";
|
||||
const userCompletion = createMockCompletion({ type: "user", completion: testCompletion });
|
||||
|
||||
expect(getMentionDisplayText(userCompletion, mockClient)).toBe(testCompletion);
|
||||
});
|
||||
|
||||
it("returns the room name when the room has a valid completionId", () => {
|
||||
const testCompletionId = "testId";
|
||||
const userCompletion = createMockCompletion({ type: "room", completionId: testCompletionId });
|
||||
|
||||
// as this uses the mockClient, the name will be the mock room name returned from there
|
||||
expect(getMentionDisplayText(userCompletion, mockClient)).toBe(mockClient.getRoom("")?.name);
|
||||
});
|
||||
|
||||
it("falls back to the completion for a room if completion starts with #", () => {
|
||||
const testCompletion = "#hash";
|
||||
const userCompletion = createMockCompletion({ type: "room", completion: testCompletion });
|
||||
|
||||
// as this uses the mockClient, the name will be the mock room name returned from there
|
||||
expect(getMentionDisplayText(userCompletion, mockClient)).toBe(testCompletion);
|
||||
});
|
||||
|
||||
it("returns the completion if we are handling an at-room completion", () => {
|
||||
const testCompletion = "display this";
|
||||
const atRoomCompletion = createMockCompletion({ type: "at-room", completion: testCompletion });
|
||||
|
||||
expect(getMentionDisplayText(atRoomCompletion, mockClient)).toBe(testCompletion);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMentionAttributes", () => {
|
||||
it("returns an empty map for completion types other than room, user or at-room", () => {
|
||||
const nonHandledCompletionTypes = ["community", "command"] as const;
|
||||
const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type }));
|
||||
|
||||
nonHandledCompletions.forEach((completion) => {
|
||||
expect(getMentionAttributes(completion, mockClient, mockRoom)).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
const testAvatarUrlForString = "www.stringUrl.com";
|
||||
const testAvatarUrlForMember = "www.memberUrl.com";
|
||||
const testAvatarUrlForRoom = "www.roomUrl.com";
|
||||
const testInitialLetter = "z";
|
||||
|
||||
const mockAvatar = mocked(_mockAvatar);
|
||||
mockAvatar.defaultAvatarUrlForString.mockReturnValue(testAvatarUrlForString);
|
||||
mockAvatar.avatarUrlForMember.mockReturnValue(testAvatarUrlForMember);
|
||||
mockAvatar.avatarUrlForRoom.mockReturnValue(testAvatarUrlForRoom);
|
||||
mockAvatar.getInitialLetter.mockReturnValue(testInitialLetter);
|
||||
|
||||
describe("user mentions", () => {
|
||||
it("returns an empty map when no member can be found", () => {
|
||||
const userCompletion = createMockCompletion({ type: "user" });
|
||||
|
||||
// mock not being able to find a member
|
||||
mockRoom.getMember.mockImplementationOnce(() => null);
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("returns expected attributes when avatar url is not default", () => {
|
||||
const userCompletion = createMockCompletion({ type: "user" });
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "user"],
|
||||
["style", `--avatar-background: url(${testAvatarUrlForMember}); --avatar-letter: '\u200b'`],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected style attributes when avatar url matches default", () => {
|
||||
const userCompletion = createMockCompletion({ type: "user" });
|
||||
|
||||
// mock a single implementation of avatarUrlForMember to make it match the default
|
||||
mockAvatar.avatarUrlForMember.mockReturnValueOnce(testAvatarUrlForString);
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "user"],
|
||||
[
|
||||
"style",
|
||||
`--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("room mentions", () => {
|
||||
it("returns expected attributes when avatar url for room is truthy", () => {
|
||||
const userCompletion = createMockCompletion({ type: "room" });
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "room"],
|
||||
["style", `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '\u200b'`],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected style attributes when avatar url for room is falsy", () => {
|
||||
const userCompletion = createMockCompletion({ type: "room" });
|
||||
|
||||
// mock a single implementation of avatarUrlForRoom to make it falsy
|
||||
mockAvatar.avatarUrlForRoom.mockReturnValueOnce(null);
|
||||
|
||||
const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "room"],
|
||||
[
|
||||
"style",
|
||||
`--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("at-room mentions", () => {
|
||||
it("returns expected attributes when avatar url for room is truthyf", () => {
|
||||
const atRoomCompletion = createMockCompletion({ type: "at-room" });
|
||||
|
||||
const result = getMentionAttributes(atRoomCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "at-room"],
|
||||
["style", `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '\u200b'`],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected style attributes when avatar url for room is falsy", () => {
|
||||
const atRoomCompletion = createMockCompletion({ type: "at-room" });
|
||||
|
||||
// mock a single implementation of avatarUrlForRoom to make it falsy
|
||||
mockAvatar.avatarUrlForRoom.mockReturnValueOnce(null);
|
||||
|
||||
const result = getMentionAttributes(atRoomCompletion, mockClient, mockRoom);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
["data-mention-type", "at-room"],
|
||||
[
|
||||
"style",
|
||||
`--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { filterConsole, mkEvent } from "../../../../../test-utils";
|
||||
import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks";
|
||||
import {
|
||||
createMessageContent,
|
||||
EMOTE_PREFIX,
|
||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/createMessageContent";
|
||||
|
||||
describe("createMessageContent", () => {
|
||||
const permalinkCreator = {
|
||||
forEvent(eventId: string): string {
|
||||
return "$$permalink$$";
|
||||
},
|
||||
} as RoomPermalinkCreator;
|
||||
const message = "<em><b>hello</b> world</em>";
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser",
|
||||
content: { msgtype: "m.text", body: "Replying to this" },
|
||||
event: true,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Richtext composer input", () => {
|
||||
filterConsole(
|
||||
"WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm`",
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
// Warm up by creating the component once, with a long timeout.
|
||||
// This prevents tests timing out because of the time spent loading
|
||||
// the WASM component.
|
||||
await createMessageContent(message, true, { permalinkCreator });
|
||||
}, 10000);
|
||||
|
||||
it("Should create html message", async () => {
|
||||
// When
|
||||
const content = await createMessageContent(message, true, { permalinkCreator });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
body: "*__hello__ world*",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: message,
|
||||
msgtype: "m.text",
|
||||
});
|
||||
});
|
||||
|
||||
it("Should add reply to message content", async () => {
|
||||
// When
|
||||
const content = await createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": "> <myfakeuser> Replying to this\n\n*__hello__ world*",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body":
|
||||
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +
|
||||
' <a href="https://matrix.to/#/myfakeuser">myfakeuser</a>' +
|
||||
"<br>Replying to this</blockquote></mx-reply><em><b>hello</b> world</em>",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: mockEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("Should add relation to message", async () => {
|
||||
// When
|
||||
const relation = {
|
||||
rel_type: "m.thread",
|
||||
event_id: "myFakeThreadId",
|
||||
};
|
||||
const content = await createMessageContent(message, true, { permalinkCreator, relation });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": "*__hello__ world*",
|
||||
"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", async () => {
|
||||
// When
|
||||
const editedEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser2",
|
||||
content: {
|
||||
"msgtype": "m.text",
|
||||
"body": "First message",
|
||||
"formatted_body": "<b>First Message</b>",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "eventId",
|
||||
},
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
const content = await createMessageContent(message, true, { permalinkCreator, editedEvent });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": " * *__hello__ world*",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": ` * ${message}`,
|
||||
"msgtype": "m.text",
|
||||
"m.new_content": {
|
||||
body: "*__hello__ world*",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: message,
|
||||
msgtype: "m.text",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: editedEvent.getId(),
|
||||
rel_type: "m.replace",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("Should strip the /me prefix from a message", async () => {
|
||||
const textBody = "some body text";
|
||||
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ body: textBody, formatted_body: textBody });
|
||||
});
|
||||
|
||||
it("Should strip single / from message prefixed with //", async () => {
|
||||
const content = await createMessageContent("//twoSlashes", true, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ body: "/twoSlashes", formatted_body: "/twoSlashes" });
|
||||
});
|
||||
|
||||
it("Should set the content type to MsgType.Emote when /me prefix is used", async () => {
|
||||
const textBody = "some body text";
|
||||
const content = await createMessageContent(EMOTE_PREFIX + textBody, true, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ msgtype: MsgType.Emote });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plaintext composer input", () => {
|
||||
it("Should replace at-room mentions with `@room` in body", async () => {
|
||||
const messageComposerState = `<a href="#" contenteditable="false" data-mention-type="at-room" style="some styling">@room</a> `;
|
||||
|
||||
const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
|
||||
expect(content).toMatchObject({ body: "@room " });
|
||||
});
|
||||
|
||||
it("Should replace user mentions with user name in body", async () => {
|
||||
const messageComposerState = `<a href="https://matrix.to/#/@test_user:element.io" contenteditable="false" data-mention-type="user" style="some styling">a test user</a> `;
|
||||
|
||||
const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({ body: "a test user " });
|
||||
});
|
||||
|
||||
it("Should replace room mentions with room mxid in body", async () => {
|
||||
const messageComposerState = `<a href="https://matrix.to/#/#test_room:element.io" contenteditable="false" data-mention-type="room" style="some styling">a test room</a> `;
|
||||
|
||||
const content = await createMessageContent(messageComposerState, false, { permalinkCreator });
|
||||
|
||||
expect(content).toMatchObject({
|
||||
body: "#test_room:element.io ",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,463 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventStatus, IEventRelation, MsgType } 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";
|
||||
import * as SlashCommands from "../../../../../../src/SlashCommands";
|
||||
import * as Commands from "../../../../../../src/editor/commands";
|
||||
import * as Reply from "../../../../../../src/utils/Reply";
|
||||
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||
import { Action } from "../../../../../../src/dispatcher/actions";
|
||||
|
||||
describe("message", () => {
|
||||
const permalinkCreator = {
|
||||
forEvent(eventId: string): string {
|
||||
return "$$permalink$$";
|
||||
},
|
||||
} as RoomPermalinkCreator;
|
||||
const message = "<i><b>hello</b> world</i>";
|
||||
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",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const mockClient = createTestClient();
|
||||
mockClient.setDisplayName = jest.fn().mockResolvedValue({});
|
||||
mockClient.setRoomName = jest.fn().mockResolvedValue({});
|
||||
|
||||
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");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("Should not send empty html message", async () => {
|
||||
// When
|
||||
await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("Should not send message when there is no roomId", async () => {
|
||||
// When
|
||||
const mockRoomWithoutId = mkStubRoom("", "room without id", mockClient) as any;
|
||||
const mockRoomContextWithoutId: IRoomState = getRoomContext(mockRoomWithoutId, {});
|
||||
|
||||
await sendMessage(message, true, {
|
||||
roomContext: mockRoomContextWithoutId,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
describe("calls client.sendMessage with", () => {
|
||||
it("a null argument if SendMessageParams is missing relation", async () => {
|
||||
// When
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), null, expect.anything());
|
||||
});
|
||||
it("a null argument if SendMessageParams has relation but relation is missing event_id", async () => {
|
||||
// When
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
relation: {},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), null, expect.anything());
|
||||
});
|
||||
it("a null argument if SendMessageParams has relation but rel_type does not match THREAD_RELATION_TYPE.name", async () => {
|
||||
// When
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
relation: {
|
||||
event_id: "valid_id",
|
||||
rel_type: "m.does_not_match",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), null, expect.anything());
|
||||
});
|
||||
|
||||
it("the event_id if SendMessageParams has relation and rel_type matches THREAD_RELATION_TYPE.name", async () => {
|
||||
// When
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
relation: {
|
||||
event_id: "valid_id",
|
||||
rel_type: "m.thread",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.anything(), "valid_id", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
it("Should send html message", async () => {
|
||||
// When
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
const expectedContent = {
|
||||
body: "*__hello__ world*",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<i><b>hello</b> world</i>",
|
||||
msgtype: "m.text",
|
||||
};
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, expectedContent);
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" });
|
||||
});
|
||||
|
||||
it("Should send reply to html message", async () => {
|
||||
const mockReplyEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: "myfakeroom",
|
||||
user: "myfakeuser2",
|
||||
content: { msgtype: "m.text", body: "My reply" },
|
||||
event: true,
|
||||
});
|
||||
|
||||
// When
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
replyToEvent: mockReplyEvent,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: "reply_to_event",
|
||||
event: null,
|
||||
context: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
|
||||
const expectedContent = {
|
||||
"body": "> <myfakeuser2> My reply\n\n*__hello__ world*",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body":
|
||||
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +
|
||||
' <a href="https://matrix.to/#/myfakeuser2">myfakeuser2</a>' +
|
||||
"<br>My reply</blockquote></mx-reply><i><b>hello</b> world</i>",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: mockReplyEvent.getId(),
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, expectedContent);
|
||||
});
|
||||
|
||||
it("Should scroll to bottom after sending a html message", async () => {
|
||||
// When
|
||||
SettingsStore.setValue("scrollToBottomOnMessageSent", null, SettingLevel.DEVICE, true);
|
||||
await sendMessage(message, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({
|
||||
action: "scroll_to_bottom",
|
||||
timelineRenderingType: defaultRoomContext.timelineRenderingType,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should handle emojis", async () => {
|
||||
// When
|
||||
await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||
|
||||
// Then
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" });
|
||||
});
|
||||
|
||||
describe("slash commands", () => {
|
||||
const getCommandSpy = jest.spyOn(SlashCommands, "getCommand");
|
||||
|
||||
it("calls getCommand for a message starting with a valid command", async () => {
|
||||
// When
|
||||
const validCommand = "/spoiler";
|
||||
await sendMessage(validCommand, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getCommandSpy).toHaveBeenCalledWith(validCommand);
|
||||
});
|
||||
|
||||
it("does not call getCommand for valid command with invalid prefix", async () => {
|
||||
// When
|
||||
const invalidPrefixCommand = "//spoiler";
|
||||
await sendMessage(invalidPrefixCommand, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getCommandSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("returns undefined when the command is not successful", async () => {
|
||||
// When
|
||||
const validCommand = "/spoiler";
|
||||
jest.spyOn(Commands, "runSlashCommand").mockResolvedValueOnce([
|
||||
{ body: "mock content", msgtype: MsgType.Text },
|
||||
false,
|
||||
]);
|
||||
|
||||
const result = await sendMessage(validCommand, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// /spoiler is a .messages category command, /fireworks is an .effect category command
|
||||
const messagesAndEffectCategoryTestCases = ["/spoiler text", "/fireworks"];
|
||||
it.each(messagesAndEffectCategoryTestCases)(
|
||||
"does not add relations for a .messages or .effects category command if there is no relation to add",
|
||||
async (inputText) => {
|
||||
await sendMessage(inputText, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||
"myfakeroom",
|
||||
null,
|
||||
expect.not.objectContaining({ "m.relates_to": expect.any }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(messagesAndEffectCategoryTestCases)(
|
||||
"adds relations for a .messages or .effects category command if there is a relation",
|
||||
async (inputText) => {
|
||||
const mockRelation: IEventRelation = {
|
||||
rel_type: "mock relation type",
|
||||
};
|
||||
await sendMessage(inputText, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
relation: mockRelation,
|
||||
});
|
||||
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||
"myfakeroom",
|
||||
null,
|
||||
expect.objectContaining({ "m.relates_to": expect.objectContaining(mockRelation) }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("calls addReplyToMessageContent when there is an event to reply to", async () => {
|
||||
const addReplySpy = jest.spyOn(Reply, "addReplyToMessageContent");
|
||||
await sendMessage("input", true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
replyToEvent: mockEvent,
|
||||
});
|
||||
|
||||
expect(addReplySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// these test cases are .action and .admin categories
|
||||
const otherCategoryTestCases = ["/nick new_nickname", "/roomname new_room_name"];
|
||||
it.each(otherCategoryTestCases)(
|
||||
"returns undefined when the command category is not .messages or .effects",
|
||||
async (input) => {
|
||||
const result = await sendMessage(input, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
replyToEvent: mockEvent,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("if user enters invalid command and then sends it anyway", async () => {
|
||||
// mock out returning a true value for `shouldSendAnyway` to avoid rendering the modal
|
||||
jest.spyOn(Commands, "shouldSendAnyway").mockResolvedValueOnce(true);
|
||||
const invalidCommandInput = "/badCommand";
|
||||
|
||||
await sendMessage(invalidCommandInput, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
// we expect the message to have been sent
|
||||
// and a composer focus action to have been dispatched
|
||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||
"myfakeroom",
|
||||
null,
|
||||
expect.objectContaining({ body: invalidCommandInput }),
|
||||
);
|
||||
expect(spyDispatcher).toHaveBeenCalledWith(expect.objectContaining({ action: Action.FocusAComposer }));
|
||||
});
|
||||
|
||||
it("if user enters invalid command and then does not send, return undefined", async () => {
|
||||
// mock out returning a false value for `shouldSendAnyway` to avoid rendering the modal
|
||||
jest.spyOn(Commands, "shouldSendAnyway").mockResolvedValueOnce(false);
|
||||
const invalidCommandInput = "/badCommand";
|
||||
|
||||
const result = await sendMessage(invalidCommandInput, true, {
|
||||
roomContext: defaultRoomContext,
|
||||
mxClient: mockClient,
|
||||
permalinkCreator,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledTimes(0);
|
||||
expect(mockClient.cancelPendingEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateRedactEventDialog).toHaveBeenCalledTimes(1);
|
||||
expect(spyDispatcher).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(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).toHaveBeenCalledWith(mockEvent.getRoomId(), null, expectedContent);
|
||||
expect(spyDispatcher).toHaveBeenCalledWith({ action: "message_sent" });
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue