Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Improve switching between rich and plain editing modes (#9776)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
artcodespace authored Jan 4, 2023
1 parent 3bcea5f commit 432ce3c
Showing 13 changed files with 336 additions and 94 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -57,7 +57,7 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@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",
"@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0",
26 changes: 15 additions & 11 deletions src/components/views/rooms/MessageComposer.tsx
Original file line number Diff line number Diff line change
@@ -54,9 +54,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { Features } from "../../../settings/Settings";
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 { htmlToPlainText } from "../../../utils/room/htmlToPlaintext";
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
import { SdkContextClass } from "../../../contexts/SDKContext";

@@ -333,7 +332,7 @@ export class MessageComposer extends React.Component<IProps, IState> {

if (this.state.isWysiwygLabEnabled) {
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,
roomContext: this.context,
permalinkCreator,
@@ -358,14 +357,19 @@ export class MessageComposer extends React.Component<IProps, IState> {
});
};

private onRichTextToggle = () => {
this.setState((state) => ({
isRichTextEnabled: !state.isRichTextEnabled,
initialComposerContent: !state.isRichTextEnabled
? state.composerContent
: // TODO when available use rust model plain text
htmlToPlainText(state.composerContent),
}));
private onRichTextToggle = async () => {
const { richToPlain, plainToRich } = await getConversionFunctions();

const { isRichTextEnabled, composerContent } = this.state;
const convertedContent = isRichTextEnabled
? await richToPlain(composerContent)
: await plainToRich(composerContent);

this.setState({
isRichTextEnabled: !isRichTextEnabled,
composerContent: convertedContent,
initialComposerContent: convertedContent,
});
};

private onVoiceStoreUpdate = () => {
Original file line number Diff line number Diff line change
@@ -16,9 +16,25 @@ limitations under the License.

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 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>) {
return (
<Suspense fallback={<div />}>
Original file line number Diff line number Diff line change
@@ -17,11 +17,22 @@ limitations under the License.
import { KeyboardEvent, SyntheticEvent, useCallback, useRef, useState } from "react";

import { useSettingValue } from "../../../../../hooks/useSettings";
import { IS_MAC, Key } from "../../../../../Keyboard";

function isDivElement(target: EventTarget): target is 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(
initialContent?: string,
onChange?: (content: string) => void,
@@ -44,25 +55,39 @@ export function usePlainTextListeners(
[onChange],
);

const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
const onInput = useCallback(
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
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(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) {
event.preventDefault();
event.stopPropagation();
send();
if (event.key === Key.ENTER) {
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;

// 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 };
3 changes: 2 additions & 1 deletion src/components/views/rooms/wysiwyg_composer/index.ts
Original file line number Diff line number Diff line change
@@ -17,5 +17,6 @@ limitations under the License.
export {
DynamicImportSendWysiwygComposer as SendWysiwygComposer,
DynamicImportEditWysiwygComposer as EditWysiwygComposer,
dynamicImportSendMessage as sendMessage,
dynamicImportConversionFunctions as getConversionFunctions,
} from "./DynamicImportWysiwygComposer";
export { sendMessage } from "./utils/message";
Original file line number Diff line number Diff line change
@@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { richToPlain, plainToRich } from "@matrix-org/matrix-wysiwyg";
import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";

import { htmlSerializeFromMdIfNeeded } from "../../../../../editor/serialize";
import SettingsStore from "../../../../../settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
import { htmlToPlainText } from "../../../../../utils/room/htmlToPlaintext";

// Merges favouring the given relation
function attachRelation(content: IContent, relation?: IEventRelation): void {
@@ -62,7 +61,7 @@ interface CreateMessageContentParams {
editedEvent?: MatrixEvent;
}

export function createMessageContent(
export async function createMessageContent(
message: string,
isHTML: boolean,
{
@@ -72,7 +71,7 @@ export function createMessageContent(
includeReplyLegacyFallback = true,
editedEvent,
}: CreateMessageContentParams,
): IContent {
): Promise<IContent> {
// TODO emote ?

const isEditing = Boolean(editedEvent);
@@ -90,26 +89,22 @@ export function createMessageContent(

// const body = textSerialize(model);

// TODO remove this ugly hack for replace br tag
const body = (isHTML && htmlToPlainText(message)) || message.replace(/<br>/g, "\n");
// if we're editing rich text, the message content is pure html
// 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 formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";

const content: IContent = {
// TODO emote
msgtype: MsgType.Text,
// TODO when available, use HTML --> Plain text conversion from wysiwyg rust model
body: isEditing ? `${bodyPrefix} * ${body}` : body,
};

// TODO markdown support

const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
const formattedBody = isHTML
? message
: isMarkdownEnabled
? htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply })
: null;
const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message) : null;

if (formattedBody) {
content.format = "org.matrix.custom.html";
34 changes: 19 additions & 15 deletions src/components/views/rooms/wysiwyg_composer/utils/message.ts
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ limitations under the License.
*/

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 { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";

@@ -34,7 +34,7 @@ import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { createMessageContent } from "./createMessageContent";
import { isContentModified } from "./isContentModified";

interface SendMessageParams {
export interface SendMessageParams {
mxClient: MatrixClient;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
@@ -43,10 +43,18 @@ interface SendMessageParams {
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 { room } = roomContext;
const { roomId } = room;
const roomId = room?.roomId;

if (!roomId) {
return;
}

const posthogEvent: ComposerEvent = {
eventName: "Composer",
@@ -63,18 +71,14 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
}*/
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);

let content: IContent;
const content = await createMessageContent(message, isHTML, params);

// TODO slash comment

// TODO replace emotion end of message ?

// TODO quick reaction

if (!content) {
content = createMessageContent(message, isHTML, params);
}

// don't bother sending an empty message
if (!content.body.trim()) {
return;
@@ -84,7 +88,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
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(
roomId,
@@ -139,7 +143,7 @@ interface EditMessageParams {
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();

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);
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 shouldSend = true;
@@ -174,10 +178,10 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr

let response: Promise<ISendEventResponse> | undefined;

// If content is modified then send an updated event into the room
if (isContentModified(newContent, editorStateTransfer)) {
const roomId = editedEvent.getRoomId();
const roomId = editedEvent.getRoomId();

// If content is modified then send an updated event into the room
if (isContentModified(newContent, editorStateTransfer) && roomId) {
// TODO Slash Commands

if (shouldSend) {
19 changes: 0 additions & 19 deletions src/utils/room/htmlToPlaintext.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -229,7 +229,10 @@ describe("EditWysiwygComposer", () => {
},
"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" });
});
});
Loading

0 comments on commit 432ce3c

Please sign in to comment.