Improve switching between rich and plain editing modes (#9776)

* allows switching between modes that retains formatting
* updates rich text composer dependency to 0.13.0 (@matrix-org/matrix-wysiwyg)
* improves handling of enter keypresses when ctrlEnterTosend setting is true in plain text editor
* changes the message event content when using the new editor
* adds tests for the changes to the plain text editor
This commit is contained in:
alunturner 2023-01-04 12:57:09 +00:00 committed by GitHub
parent 3bcea5fb0b
commit 432ce3ca31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 336 additions and 94 deletions

View file

@ -57,7 +57,7 @@
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.3.0", "@matrix-org/analytics-events": "^0.3.0",
"@matrix-org/matrix-wysiwyg": "^0.11.0", "@matrix-org/matrix-wysiwyg": "^0.13.0",
"@matrix-org/react-sdk-module-api": "^0.0.3", "@matrix-org/react-sdk-module-api": "^0.0.3",
"@sentry/browser": "^7.0.0", "@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0", "@sentry/tracing": "^7.0.0",

View file

@ -54,9 +54,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { Features } from "../../../settings/Settings"; import { Features } from "../../../settings/Settings";
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
import { SendWysiwygComposer, sendMessage } from "./wysiwyg_composer/"; import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext"; import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
import { htmlToPlainText } from "../../../utils/room/htmlToPlaintext";
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
import { SdkContextClass } from "../../../contexts/SDKContext"; import { SdkContextClass } from "../../../contexts/SDKContext";
@ -333,7 +332,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
if (this.state.isWysiwygLabEnabled) { if (this.state.isWysiwygLabEnabled) {
const { permalinkCreator, relation, replyToEvent } = this.props; const { permalinkCreator, relation, replyToEvent } = this.props;
sendMessage(this.state.composerContent, this.state.isRichTextEnabled, { await sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
mxClient: this.props.mxClient, mxClient: this.props.mxClient,
roomContext: this.context, roomContext: this.context,
permalinkCreator, permalinkCreator,
@ -358,14 +357,19 @@ export class MessageComposer extends React.Component<IProps, IState> {
}); });
}; };
private onRichTextToggle = () => { private onRichTextToggle = async () => {
this.setState((state) => ({ const { richToPlain, plainToRich } = await getConversionFunctions();
isRichTextEnabled: !state.isRichTextEnabled,
initialComposerContent: !state.isRichTextEnabled const { isRichTextEnabled, composerContent } = this.state;
? state.composerContent const convertedContent = isRichTextEnabled
: // TODO when available use rust model plain text ? await richToPlain(composerContent)
htmlToPlainText(state.composerContent), : await plainToRich(composerContent);
}));
this.setState({
isRichTextEnabled: !isRichTextEnabled,
composerContent: convertedContent,
initialComposerContent: convertedContent,
});
}; };
private onVoiceStoreUpdate = () => { private onVoiceStoreUpdate = () => {

View file

@ -16,9 +16,25 @@ limitations under the License.
import React, { ComponentProps, lazy, Suspense } from "react"; import React, { ComponentProps, lazy, Suspense } from "react";
// we need to import the types for TS, but do not import the sendMessage
// function to avoid importing from "@matrix-org/matrix-wysiwyg"
import { SendMessageParams } from "./utils/message";
const SendComposer = lazy(() => import("./SendWysiwygComposer")); const SendComposer = lazy(() => import("./SendWysiwygComposer"));
const EditComposer = lazy(() => import("./EditWysiwygComposer")); const EditComposer = lazy(() => import("./EditWysiwygComposer"));
export const dynamicImportSendMessage = async (message: string, isHTML: boolean, params: SendMessageParams) => {
const { sendMessage } = await import("./utils/message");
return sendMessage(message, isHTML, params);
};
export const dynamicImportConversionFunctions = async () => {
const { richToPlain, plainToRich } = await import("@matrix-org/matrix-wysiwyg");
return { richToPlain, plainToRich };
};
export function DynamicImportSendWysiwygComposer(props: ComponentProps<typeof SendComposer>) { export function DynamicImportSendWysiwygComposer(props: ComponentProps<typeof SendComposer>) {
return ( return (
<Suspense fallback={<div />}> <Suspense fallback={<div />}>

View file

@ -17,11 +17,22 @@ limitations under the License.
import { KeyboardEvent, SyntheticEvent, useCallback, useRef, useState } from "react"; import { KeyboardEvent, SyntheticEvent, useCallback, useRef, useState } from "react";
import { useSettingValue } from "../../../../../hooks/useSettings"; import { useSettingValue } from "../../../../../hooks/useSettings";
import { IS_MAC, Key } from "../../../../../Keyboard";
function isDivElement(target: EventTarget): target is HTMLDivElement { function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement; return target instanceof HTMLDivElement;
} }
// Hitting enter inside the editor inserts an editable div, initially containing a <br />
// For correct display, first replace this pattern with a newline character and then remove divs
// noting that they are used to delimit paragraphs
function amendInnerHtml(text: string) {
return text
.replace(/<div><br><\/div>/g, "\n") // this is pressing enter then not typing
.replace(/<div>/g, "\n") // this is from pressing enter, then typing inside the div
.replace(/<\/div>/g, "");
}
export function usePlainTextListeners( export function usePlainTextListeners(
initialContent?: string, initialContent?: string,
onChange?: (content: string) => void, onChange?: (content: string) => void,
@ -44,25 +55,39 @@ export function usePlainTextListeners(
[onChange], [onChange],
); );
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
const onInput = useCallback( const onInput = useCallback(
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => { (event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
if (isDivElement(event.target)) { if (isDivElement(event.target)) {
setText(event.target.innerHTML); // if enterShouldSend, we do not need to amend the html before setting text
const newInnerHTML = enterShouldSend ? event.target.innerHTML : amendInnerHtml(event.target.innerHTML);
setText(newInnerHTML);
} }
}, },
[setText], [setText, enterShouldSend],
); );
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
const onKeyDown = useCallback( const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => { (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) { if (event.key === Key.ENTER) {
event.preventDefault(); const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
event.stopPropagation();
send(); // if enter should send, send if the user is not pushing shift
if (enterShouldSend && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
send();
}
// if enter should not send, send only if the user is pushing ctrl/cmd
if (!enterShouldSend && sendModifierIsPressed) {
event.preventDefault();
event.stopPropagation();
send();
}
} }
}, },
[isCtrlEnter, send], [enterShouldSend, send],
); );
return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText }; return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText };

View file

@ -17,5 +17,6 @@ limitations under the License.
export { export {
DynamicImportSendWysiwygComposer as SendWysiwygComposer, DynamicImportSendWysiwygComposer as SendWysiwygComposer,
DynamicImportEditWysiwygComposer as EditWysiwygComposer, DynamicImportEditWysiwygComposer as EditWysiwygComposer,
dynamicImportSendMessage as sendMessage,
dynamicImportConversionFunctions as getConversionFunctions,
} from "./DynamicImportWysiwygComposer"; } from "./DynamicImportWysiwygComposer";
export { sendMessage } from "./utils/message";

View file

@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { richToPlain, plainToRich } from "@matrix-org/matrix-wysiwyg";
import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { htmlSerializeFromMdIfNeeded } from "../../../../../editor/serialize";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { addReplyToMessageContent } from "../../../../../utils/Reply"; import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { htmlToPlainText } from "../../../../../utils/room/htmlToPlaintext";
// Merges favouring the given relation // Merges favouring the given relation
function attachRelation(content: IContent, relation?: IEventRelation): void { function attachRelation(content: IContent, relation?: IEventRelation): void {
@ -62,7 +61,7 @@ interface CreateMessageContentParams {
editedEvent?: MatrixEvent; editedEvent?: MatrixEvent;
} }
export function createMessageContent( export async function createMessageContent(
message: string, message: string,
isHTML: boolean, isHTML: boolean,
{ {
@ -72,7 +71,7 @@ export function createMessageContent(
includeReplyLegacyFallback = true, includeReplyLegacyFallback = true,
editedEvent, editedEvent,
}: CreateMessageContentParams, }: CreateMessageContentParams,
): IContent { ): Promise<IContent> {
// TODO emote ? // TODO emote ?
const isEditing = Boolean(editedEvent); const isEditing = Boolean(editedEvent);
@ -90,26 +89,22 @@ export function createMessageContent(
// const body = textSerialize(model); // const body = textSerialize(model);
// TODO remove this ugly hack for replace br tag // if we're editing rich text, the message content is pure html
const body = (isHTML && htmlToPlainText(message)) || message.replace(/<br>/g, "\n"); // BUT if we're not, the message content will be plain text
const body = isHTML ? await richToPlain(message) : message;
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || ""; const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || ""; const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";
const content: IContent = { const content: IContent = {
// TODO emote // TODO emote
msgtype: MsgType.Text, msgtype: MsgType.Text,
// TODO when available, use HTML --> Plain text conversion from wysiwyg rust model
body: isEditing ? `${bodyPrefix} * ${body}` : body, body: isEditing ? `${bodyPrefix} * ${body}` : body,
}; };
// TODO markdown support // TODO markdown support
const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown"); const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
const formattedBody = isHTML const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message) : null;
? message
: isMarkdownEnabled
? htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply })
: null;
if (formattedBody) { if (formattedBody) {
content.format = "org.matrix.custom.html"; content.format = "org.matrix.custom.html";

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer"; import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
@ -34,7 +34,7 @@ import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { createMessageContent } from "./createMessageContent"; import { createMessageContent } from "./createMessageContent";
import { isContentModified } from "./isContentModified"; import { isContentModified } from "./isContentModified";
interface SendMessageParams { export interface SendMessageParams {
mxClient: MatrixClient; mxClient: MatrixClient;
relation?: IEventRelation; relation?: IEventRelation;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
@ -43,10 +43,18 @@ interface SendMessageParams {
includeReplyLegacyFallback?: boolean; includeReplyLegacyFallback?: boolean;
} }
export function sendMessage(message: string, isHTML: boolean, { roomContext, mxClient, ...params }: SendMessageParams) { export async function sendMessage(
message: string,
isHTML: boolean,
{ roomContext, mxClient, ...params }: SendMessageParams,
) {
const { relation, replyToEvent } = params; const { relation, replyToEvent } = params;
const { room } = roomContext; const { room } = roomContext;
const { roomId } = room; const roomId = room?.roomId;
if (!roomId) {
return;
}
const posthogEvent: ComposerEvent = { const posthogEvent: ComposerEvent = {
eventName: "Composer", eventName: "Composer",
@ -63,7 +71,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
}*/ }*/
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent); PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
let content: IContent; const content = await createMessageContent(message, isHTML, params);
// TODO slash comment // TODO slash comment
@ -71,10 +79,6 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
// TODO quick reaction // TODO quick reaction
if (!content) {
content = createMessageContent(message, isHTML, params);
}
// don't bother sending an empty message // don't bother sending an empty message
if (!content.body.trim()) { if (!content.body.trim()) {
return; return;
@ -84,7 +88,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
decorateStartSendingTime(content); decorateStartSendingTime(content);
} }
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; const threadId = relation?.event_id && relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const prom = doMaybeLocalRoomAction( const prom = doMaybeLocalRoomAction(
roomId, roomId,
@ -139,7 +143,7 @@ interface EditMessageParams {
editorStateTransfer: EditorStateTransfer; editorStateTransfer: EditorStateTransfer;
} }
export function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) { export async function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
const editedEvent = editorStateTransfer.getEvent(); const editedEvent = editorStateTransfer.getEvent();
PosthogAnalytics.instance.trackEvent<ComposerEvent>({ PosthogAnalytics.instance.trackEvent<ComposerEvent>({
@ -156,7 +160,7 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd); const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON); this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}*/ }*/
const editContent = createMessageContent(html, true, { editedEvent }); const editContent = await createMessageContent(html, true, { editedEvent });
const newContent = editContent["m.new_content"]; const newContent = editContent["m.new_content"];
const shouldSend = true; const shouldSend = true;
@ -174,10 +178,10 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
let response: Promise<ISendEventResponse> | undefined; let response: Promise<ISendEventResponse> | undefined;
// If content is modified then send an updated event into the room const roomId = editedEvent.getRoomId();
if (isContentModified(newContent, editorStateTransfer)) {
const roomId = editedEvent.getRoomId();
// If content is modified then send an updated event into the room
if (isContentModified(newContent, editorStateTransfer) && roomId) {
// TODO Slash Commands // TODO Slash Commands
if (shouldSend) { if (shouldSend) {

View file

@ -1,19 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function htmlToPlainText(html: string) {
return new DOMParser().parseFromString(html, "text/html").documentElement.textContent;
}

View file

@ -229,7 +229,10 @@ describe("EditWysiwygComposer", () => {
}, },
"msgtype": "m.text", "msgtype": "m.text",
}; };
expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent); await waitFor(() =>
expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent),
);
expect(spyDispatcher).toBeCalledWith({ action: "message_sent" }); expect(spyDispatcher).toBeCalledWith({ action: "message_sent" });
}); });
}); });

View file

@ -19,6 +19,8 @@ import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer"; import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
import * as mockUseSettingsHook from "../../../../../../src/hooks/useSettings";
import * as mockKeyboard from "../../../../../../src/Keyboard";
describe("PlainTextComposer", () => { describe("PlainTextComposer", () => {
const customRender = ( const customRender = (
@ -37,6 +39,17 @@ describe("PlainTextComposer", () => {
); );
}; };
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", () => { it("Should have contentEditable at false when disabled", () => {
// When // When
customRender(jest.fn(), jest.fn(), true); customRender(jest.fn(), jest.fn(), true);
@ -64,7 +77,7 @@ describe("PlainTextComposer", () => {
expect(onChange).toBeCalledWith(content); expect(onChange).toBeCalledWith(content);
}); });
it("Should call onSend when Enter is pressed", async () => { it("Should call onSend when Enter is pressed when ctrlEnterToSend is false", async () => {
//When //When
const onSend = jest.fn(); const onSend = jest.fn();
customRender(jest.fn(), onSend); customRender(jest.fn(), onSend);
@ -74,9 +87,134 @@ describe("PlainTextComposer", () => {
expect(onSend).toBeCalledTimes(1); expect(onSend).toBeCalledTimes(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).toBeCalledTimes(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).toBeCalledTimes(0);
// Then it does NOT send a message on windows+enter
await userEvent.type(textBox, "{meta>}{enter}{meta/}");
expect(onSend).toBeCalledTimes(0);
// Then it does send a message on ctrl+enter
await userEvent.type(textBox, "{control>}{enter}{control/}");
expect(onSend).toBeCalledTimes(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).toBeCalledTimes(0);
// Then it does NOT send a message on ctrl+enter
await userEvent.type(textBox, "{control>}{enter}{control/}");
expect(onSend).toBeCalledTimes(0);
// Then it does send a message on cmd+enter
await userEvent.type(textBox, "{meta>}{enter}{meta/}");
expect(onSend).toBeCalledTimes(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).toBeCalledTimes(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).toBeCalledTimes(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).toBeCalledTimes(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).toBeCalledTimes(0);
expect(textBox).not.toContainHTML(defaultEnterHtml);
});
it("Should clear textbox content when clear is called", async () => { it("Should clear textbox content when clear is called", async () => {
//When //When
let composer; let composer: {
clear: () => void;
insertText: (text: string) => void;
};
render( render(
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()}> <PlainTextComposer onChange={jest.fn()} onSend={jest.fn()}>
{(ref, composerFunctions) => { {(ref, composerFunctions) => {
@ -85,9 +223,11 @@ describe("PlainTextComposer", () => {
}} }}
</PlainTextComposer>, </PlainTextComposer>,
); );
await userEvent.type(screen.getByRole("textbox"), "content"); await userEvent.type(screen.getByRole("textbox"), "content");
expect(screen.getByRole("textbox").innerHTML).toBe("content"); expect(screen.getByRole("textbox").innerHTML).toBe("content");
composer.clear();
composer!.clear();
// Then // Then
expect(screen.getByRole("textbox").innerHTML).toBeFalsy(); expect(screen.getByRole("textbox").innerHTML).toBeFalsy();
@ -112,7 +252,7 @@ describe("PlainTextComposer", () => {
render(<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} />); render(<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} />);
// Then // Then
expect(screen.getByTestId("WysiwygComposerEditor").attributes["data-is-expanded"].value).toBe("false"); expect(screen.getByTestId("WysiwygComposerEditor").dataset["isExpanded"]).toBe("false");
expect(editor).toBe(screen.getByRole("textbox")); expect(editor).toBe(screen.getByRole("textbox"));
// When // When
@ -126,7 +266,7 @@ describe("PlainTextComposer", () => {
}); });
// Then // Then
expect(screen.getByTestId("WysiwygComposerEditor").attributes["data-is-expanded"].value).toBe("true"); expect(screen.getByTestId("WysiwygComposerEditor").dataset["isExpanded"]).toBe("true");
jest.useRealTimers(); jest.useRealTimers();
(global.ResizeObserver as jest.Mock).mockRestore(); (global.ResizeObserver as jest.Mock).mockRestore();

View file

@ -24,7 +24,7 @@ describe("createMessageContent", () => {
return "$$permalink$$"; return "$$permalink$$";
}, },
} as RoomPermalinkCreator; } as RoomPermalinkCreator;
const message = "<i><b>hello</b> world</i>"; const message = "<em><b>hello</b> world</em>";
const mockEvent = mkEvent({ const mockEvent = mkEvent({
type: "m.room.message", type: "m.room.message",
room: "myfakeroom", room: "myfakeroom",
@ -37,31 +37,31 @@ describe("createMessageContent", () => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
it("Should create html message", () => { it("Should create html message", async () => {
// When // When
const content = createMessageContent(message, true, { permalinkCreator }); const content = await createMessageContent(message, true, { permalinkCreator });
// Then // Then
expect(content).toEqual({ expect(content).toEqual({
body: "hello world", body: "*__hello__ world*",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: message, formatted_body: message,
msgtype: "m.text", msgtype: "m.text",
}); });
}); });
it("Should add reply to message content", () => { it("Should add reply to message content", async () => {
// When // When
const content = createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent }); const content = await createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent });
// Then // Then
expect(content).toEqual({ expect(content).toEqual({
"body": "> <myfakeuser> Replying to this\n\nhello world", "body": "> <myfakeuser> Replying to this\n\n*__hello__ world*",
"format": "org.matrix.custom.html", "format": "org.matrix.custom.html",
"formatted_body": "formatted_body":
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' + '<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +
' <a href="https://matrix.to/#/myfakeuser">myfakeuser</a>' + ' <a href="https://matrix.to/#/myfakeuser">myfakeuser</a>' +
"<br>Replying to this</blockquote></mx-reply><i><b>hello</b> world</i>", "<br>Replying to this</blockquote></mx-reply><em><b>hello</b> world</em>",
"msgtype": "m.text", "msgtype": "m.text",
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
@ -71,17 +71,17 @@ describe("createMessageContent", () => {
}); });
}); });
it("Should add relation to message", () => { it("Should add relation to message", async () => {
// When // When
const relation = { const relation = {
rel_type: "m.thread", rel_type: "m.thread",
event_id: "myFakeThreadId", event_id: "myFakeThreadId",
}; };
const content = createMessageContent(message, true, { permalinkCreator, relation }); const content = await createMessageContent(message, true, { permalinkCreator, relation });
// Then // Then
expect(content).toEqual({ expect(content).toEqual({
"body": "hello world", "body": "*__hello__ world*",
"format": "org.matrix.custom.html", "format": "org.matrix.custom.html",
"formatted_body": message, "formatted_body": message,
"msgtype": "m.text", "msgtype": "m.text",
@ -92,7 +92,7 @@ describe("createMessageContent", () => {
}); });
}); });
it("Should add fields related to edition", () => { it("Should add fields related to edition", async () => {
// When // When
const editedEvent = mkEvent({ const editedEvent = mkEvent({
type: "m.room.message", type: "m.room.message",
@ -110,16 +110,16 @@ describe("createMessageContent", () => {
}, },
event: true, event: true,
}); });
const content = createMessageContent(message, true, { permalinkCreator, editedEvent }); const content = await createMessageContent(message, true, { permalinkCreator, editedEvent });
// Then // Then
expect(content).toEqual({ expect(content).toEqual({
"body": " * hello world", "body": " * *__hello__ world*",
"format": "org.matrix.custom.html", "format": "org.matrix.custom.html",
"formatted_body": ` * ${message}`, "formatted_body": ` * ${message}`,
"msgtype": "m.text", "msgtype": "m.text",
"m.new_content": { "m.new_content": {
body: "hello world", body: "*__hello__ world*",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: message, formatted_body: message,
msgtype: "m.text", msgtype: "m.text",

View file

@ -70,6 +70,79 @@ describe("message", () => {
expect(spyDispatcher).toBeCalledTimes(0); expect(spyDispatcher).toBeCalledTimes(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).toBeCalledTimes(0);
expect(spyDispatcher).toBeCalledTimes(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).toBeCalledWith(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).toBeCalledWith(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).toBeCalledWith(expect.anything(), "valid_id", expect.anything());
});
});
it("Should send html message", async () => { it("Should send html message", async () => {
// When // When
await sendMessage(message, true, { await sendMessage(message, true, {
@ -80,7 +153,7 @@ describe("message", () => {
// Then // Then
const expectedContent = { const expectedContent = {
body: "hello world", body: "*__hello__ world*",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<i><b>hello</b> world</i>", formatted_body: "<i><b>hello</b> world</i>",
msgtype: "m.text", msgtype: "m.text",
@ -114,7 +187,7 @@ describe("message", () => {
}); });
const expectedContent = { const expectedContent = {
"body": "> <myfakeuser2> My reply\n\nhello world", "body": "> <myfakeuser2> My reply\n\n*__hello__ world*",
"format": "org.matrix.custom.html", "format": "org.matrix.custom.html",
"formatted_body": "formatted_body":
'<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' + '<mx-reply><blockquote><a href="$$permalink$$">In reply to</a>' +

View file

@ -1525,10 +1525,10 @@
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6"
integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA== integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA==
"@matrix-org/matrix-wysiwyg@^0.11.0": "@matrix-org/matrix-wysiwyg@^0.13.0":
version "0.11.0" version "0.13.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.11.0.tgz#3000ee809a3e38242c5da47bef17c572582f2f6b" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.13.0.tgz#e643df4e13cdc5dbf9285740bc0ce2aef9873c16"
integrity sha512-B16iLfNnW4PKG4fpDuwJVc0QUrUUqTkhwJ/kxzawcxwVNmWbsPCWJ3hkextYrN2gqRL1d4CNASkNbWLCNNiXhA== integrity sha512-MCeTj4hkl0snjlygd1v+mEEOgaN6agyjAVjJEbvEvP/BaYaDiPEXMTDaRQrcUt3OIY53UNhm1DDEn4yPTn83Jg==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14" version "3.2.14"