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

Commit 432ce3c

Browse files
authored
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
1 parent 3bcea5f commit 432ce3c

13 files changed

+336
-94
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"dependencies": {
5858
"@babel/runtime": "^7.12.5",
5959
"@matrix-org/analytics-events": "^0.3.0",
60-
"@matrix-org/matrix-wysiwyg": "^0.11.0",
60+
"@matrix-org/matrix-wysiwyg": "^0.13.0",
6161
"@matrix-org/react-sdk-module-api": "^0.0.3",
6262
"@sentry/browser": "^7.0.0",
6363
"@sentry/tracing": "^7.0.0",

src/components/views/rooms/MessageComposer.tsx

+15-11
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
5454
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
5555
import { Features } from "../../../settings/Settings";
5656
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
57-
import { SendWysiwygComposer, sendMessage } from "./wysiwyg_composer/";
57+
import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
5858
import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
59-
import { htmlToPlainText } from "../../../utils/room/htmlToPlaintext";
6059
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
6160
import { SdkContextClass } from "../../../contexts/SDKContext";
6261

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

334333
if (this.state.isWysiwygLabEnabled) {
335334
const { permalinkCreator, relation, replyToEvent } = this.props;
336-
sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
335+
await sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
337336
mxClient: this.props.mxClient,
338337
roomContext: this.context,
339338
permalinkCreator,
@@ -358,14 +357,19 @@ export class MessageComposer extends React.Component<IProps, IState> {
358357
});
359358
};
360359

361-
private onRichTextToggle = () => {
362-
this.setState((state) => ({
363-
isRichTextEnabled: !state.isRichTextEnabled,
364-
initialComposerContent: !state.isRichTextEnabled
365-
? state.composerContent
366-
: // TODO when available use rust model plain text
367-
htmlToPlainText(state.composerContent),
368-
}));
360+
private onRichTextToggle = async () => {
361+
const { richToPlain, plainToRich } = await getConversionFunctions();
362+
363+
const { isRichTextEnabled, composerContent } = this.state;
364+
const convertedContent = isRichTextEnabled
365+
? await richToPlain(composerContent)
366+
: await plainToRich(composerContent);
367+
368+
this.setState({
369+
isRichTextEnabled: !isRichTextEnabled,
370+
composerContent: convertedContent,
371+
initialComposerContent: convertedContent,
372+
});
369373
};
370374

371375
private onVoiceStoreUpdate = () => {

src/components/views/rooms/wysiwyg_composer/DynamicImportWysiwygComposer.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,25 @@ limitations under the License.
1616

1717
import React, { ComponentProps, lazy, Suspense } from "react";
1818

19+
// we need to import the types for TS, but do not import the sendMessage
20+
// function to avoid importing from "@matrix-org/matrix-wysiwyg"
21+
import { SendMessageParams } from "./utils/message";
22+
1923
const SendComposer = lazy(() => import("./SendWysiwygComposer"));
2024
const EditComposer = lazy(() => import("./EditWysiwygComposer"));
2125

26+
export const dynamicImportSendMessage = async (message: string, isHTML: boolean, params: SendMessageParams) => {
27+
const { sendMessage } = await import("./utils/message");
28+
29+
return sendMessage(message, isHTML, params);
30+
};
31+
32+
export const dynamicImportConversionFunctions = async () => {
33+
const { richToPlain, plainToRich } = await import("@matrix-org/matrix-wysiwyg");
34+
35+
return { richToPlain, plainToRich };
36+
};
37+
2238
export function DynamicImportSendWysiwygComposer(props: ComponentProps<typeof SendComposer>) {
2339
return (
2440
<Suspense fallback={<div />}>

src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts

+33-8
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,22 @@ limitations under the License.
1717
import { KeyboardEvent, SyntheticEvent, useCallback, useRef, useState } from "react";
1818

1919
import { useSettingValue } from "../../../../../hooks/useSettings";
20+
import { IS_MAC, Key } from "../../../../../Keyboard";
2021

2122
function isDivElement(target: EventTarget): target is HTMLDivElement {
2223
return target instanceof HTMLDivElement;
2324
}
2425

26+
// Hitting enter inside the editor inserts an editable div, initially containing a <br />
27+
// For correct display, first replace this pattern with a newline character and then remove divs
28+
// noting that they are used to delimit paragraphs
29+
function amendInnerHtml(text: string) {
30+
return text
31+
.replace(/<div><br><\/div>/g, "\n") // this is pressing enter then not typing
32+
.replace(/<div>/g, "\n") // this is from pressing enter, then typing inside the div
33+
.replace(/<\/div>/g, "");
34+
}
35+
2536
export function usePlainTextListeners(
2637
initialContent?: string,
2738
onChange?: (content: string) => void,
@@ -44,25 +55,39 @@ export function usePlainTextListeners(
4455
[onChange],
4556
);
4657

58+
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
4759
const onInput = useCallback(
4860
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
4961
if (isDivElement(event.target)) {
50-
setText(event.target.innerHTML);
62+
// if enterShouldSend, we do not need to amend the html before setting text
63+
const newInnerHTML = enterShouldSend ? event.target.innerHTML : amendInnerHtml(event.target.innerHTML);
64+
setText(newInnerHTML);
5165
}
5266
},
53-
[setText],
67+
[setText, enterShouldSend],
5468
);
5569

56-
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
5770
const onKeyDown = useCallback(
5871
(event: KeyboardEvent<HTMLDivElement>) => {
59-
if (event.key === "Enter" && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) {
60-
event.preventDefault();
61-
event.stopPropagation();
62-
send();
72+
if (event.key === Key.ENTER) {
73+
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
74+
75+
// if enter should send, send if the user is not pushing shift
76+
if (enterShouldSend && !event.shiftKey) {
77+
event.preventDefault();
78+
event.stopPropagation();
79+
send();
80+
}
81+
82+
// if enter should not send, send only if the user is pushing ctrl/cmd
83+
if (!enterShouldSend && sendModifierIsPressed) {
84+
event.preventDefault();
85+
event.stopPropagation();
86+
send();
87+
}
6388
}
6489
},
65-
[isCtrlEnter, send],
90+
[enterShouldSend, send],
6691
);
6792

6893
return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText };

src/components/views/rooms/wysiwyg_composer/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ limitations under the License.
1717
export {
1818
DynamicImportSendWysiwygComposer as SendWysiwygComposer,
1919
DynamicImportEditWysiwygComposer as EditWysiwygComposer,
20+
dynamicImportSendMessage as sendMessage,
21+
dynamicImportConversionFunctions as getConversionFunctions,
2022
} from "./DynamicImportWysiwygComposer";
21-
export { sendMessage } from "./utils/message";

src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts

+7-12
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

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

19-
import { htmlSerializeFromMdIfNeeded } from "../../../../../editor/serialize";
2020
import SettingsStore from "../../../../../settings/SettingsStore";
2121
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
2222
import { addReplyToMessageContent } from "../../../../../utils/Reply";
23-
import { htmlToPlainText } from "../../../../../utils/room/htmlToPlaintext";
2423

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

65-
export function createMessageContent(
64+
export async function createMessageContent(
6665
message: string,
6766
isHTML: boolean,
6867
{
@@ -72,7 +71,7 @@ export function createMessageContent(
7271
includeReplyLegacyFallback = true,
7372
editedEvent,
7473
}: CreateMessageContentParams,
75-
): IContent {
74+
): Promise<IContent> {
7675
// TODO emote ?
7776

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

9190
// const body = textSerialize(model);
9291

93-
// TODO remove this ugly hack for replace br tag
94-
const body = (isHTML && htmlToPlainText(message)) || message.replace(/<br>/g, "\n");
92+
// if we're editing rich text, the message content is pure html
93+
// BUT if we're not, the message content will be plain text
94+
const body = isHTML ? await richToPlain(message) : message;
9595
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
9696
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";
9797

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

105104
// TODO markdown support
106105

107106
const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
108-
const formattedBody = isHTML
109-
? message
110-
: isMarkdownEnabled
111-
? htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply })
112-
: null;
107+
const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message) : null;
113108

114109
if (formattedBody) {
115110
content.format = "org.matrix.custom.html";

src/components/views/rooms/wysiwyg_composer/utils/message.ts

+19-15
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
18-
import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
18+
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
1919
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
2020
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
2121

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

37-
interface SendMessageParams {
37+
export interface SendMessageParams {
3838
mxClient: MatrixClient;
3939
relation?: IEventRelation;
4040
replyToEvent?: MatrixEvent;
@@ -43,10 +43,18 @@ interface SendMessageParams {
4343
includeReplyLegacyFallback?: boolean;
4444
}
4545

46-
export function sendMessage(message: string, isHTML: boolean, { roomContext, mxClient, ...params }: SendMessageParams) {
46+
export async function sendMessage(
47+
message: string,
48+
isHTML: boolean,
49+
{ roomContext, mxClient, ...params }: SendMessageParams,
50+
) {
4751
const { relation, replyToEvent } = params;
4852
const { room } = roomContext;
49-
const { roomId } = room;
53+
const roomId = room?.roomId;
54+
55+
if (!roomId) {
56+
return;
57+
}
5058

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

66-
let content: IContent;
74+
const content = await createMessageContent(message, isHTML, params);
6775

6876
// TODO slash comment
6977

7078
// TODO replace emotion end of message ?
7179

7280
// TODO quick reaction
7381

74-
if (!content) {
75-
content = createMessageContent(message, isHTML, params);
76-
}
77-
7882
// don't bother sending an empty message
7983
if (!content.body.trim()) {
8084
return;
@@ -84,7 +88,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
8488
decorateStartSendingTime(content);
8589
}
8690

87-
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
91+
const threadId = relation?.event_id && relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
8892

8993
const prom = doMaybeLocalRoomAction(
9094
roomId,
@@ -139,7 +143,7 @@ interface EditMessageParams {
139143
editorStateTransfer: EditorStateTransfer;
140144
}
141145

142-
export function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
146+
export async function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
143147
const editedEvent = editorStateTransfer.getEvent();
144148

145149
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
@@ -156,7 +160,7 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
156160
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
157161
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
158162
}*/
159-
const editContent = createMessageContent(html, true, { editedEvent });
163+
const editContent = await createMessageContent(html, true, { editedEvent });
160164
const newContent = editContent["m.new_content"];
161165

162166
const shouldSend = true;
@@ -174,10 +178,10 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
174178

175179
let response: Promise<ISendEventResponse> | undefined;
176180

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

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

183187
if (shouldSend) {

src/utils/room/htmlToPlaintext.ts

-19
This file was deleted.

test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ describe("EditWysiwygComposer", () => {
229229
},
230230
"msgtype": "m.text",
231231
};
232-
expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent);
232+
await waitFor(() =>
233+
expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent),
234+
);
235+
233236
expect(spyDispatcher).toBeCalledWith({ action: "message_sent" });
234237
});
235238
});

0 commit comments

Comments
 (0)