diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 4cddf310846..4d22f60a122 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -240,7 +240,7 @@ limitations under the License. */ .mx_MessageComposer_wysiwyg { .mx_MessageComposer_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage { - margin-top: 22px; + margin-top: 28px; } } @@ -264,6 +264,14 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg'); } +.mx_MessageComposer_plain_text::before { + mask-image: url('$(res)/img/element-icons/room/composer/plain_text.svg'); +} + +.mx_MessageComposer_rich_text::before { + mask-image: url('$(res)/img/element-icons/room/composer/rich_text.svg'); +} + .mx_MessageComposer_location::before { mask-image: url('$(res)/img/element-icons/room/composer/location.svg'); } diff --git a/res/img/element-icons/room/composer/plain_text.svg b/res/img/element-icons/room/composer/plain_text.svg new file mode 100644 index 00000000000..d2da9d25516 --- /dev/null +++ b/res/img/element-icons/room/composer/plain_text.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/rich_text.svg b/res/img/element-icons/room/composer/rich_text.svg new file mode 100644 index 00000000000..7ff47fe085c --- /dev/null +++ b/res/img/element-icons/room/composer/rich_text.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 674635d8961..4b04b87daef 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -60,6 +60,7 @@ import { } from '../../../voice-broadcast'; import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/'; import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext'; +import { htmlToPlainText } from '../../../utils/room/htmlToPlaintext'; let instanceCount = 0; @@ -100,6 +101,9 @@ interface IState { showStickersButton: boolean; showPollsButton: boolean; showVoiceBroadcastButton: boolean; + isWysiwygLabEnabled: boolean; + isRichTextEnabled: boolean; + initialComposerContent: string; } export class MessageComposer extends React.Component { @@ -117,6 +121,7 @@ export class MessageComposer extends React.Component { public static defaultProps = { compact: false, showVoiceBroadcastButton: false, + isRichTextEnabled: true, }; public constructor(props: IProps) { @@ -133,6 +138,9 @@ export class MessageComposer extends React.Component { showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"), showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), + isWysiwygLabEnabled: SettingsStore.getValue("feature_wysiwyg_composer"), + isRichTextEnabled: true, + initialComposerContent: '', }; this.instanceId = instanceCount++; @@ -140,6 +148,7 @@ export class MessageComposer extends React.Component { SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null); SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null); SettingsStore.monitorSetting(Features.VoiceBroadcast, null); + SettingsStore.monitorSetting("feature_wysiwyg_composer", null); } private get voiceRecording(): Optional { @@ -220,6 +229,12 @@ export class MessageComposer extends React.Component { } break; } + case "feature_wysiwyg_composer": { + if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) { + this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) }); + } + break; + } } } } @@ -318,12 +333,13 @@ export class MessageComposer extends React.Component { this.messageComposerInput.current?.sendMessage(); - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); - if (isWysiwygComposerEnabled) { + if (this.state.isWysiwygLabEnabled) { const { permalinkCreator, relation, replyToEvent } = this.props; sendMessage(this.state.composerContent, + this.state.isRichTextEnabled, { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent }); dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer }); + this.setState({ composerContent: '', initialComposerContent: '' }); } }; @@ -340,6 +356,16 @@ export class MessageComposer extends React.Component { }); }; + 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 onVoiceStoreUpdate = () => { this.updateRecordingState(); }; @@ -395,7 +421,6 @@ export class MessageComposer extends React.Component { } public render() { - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); const controls = [ this.props.e2eStatus ? : @@ -410,12 +435,14 @@ export class MessageComposer extends React.Component { const canSendMessages = this.context.canSendMessages && !this.context.tombstone; if (canSendMessages) { - if (isWysiwygComposerEnabled) { + if (this.state.isWysiwygLabEnabled) { controls.push( , ); } else { @@ -503,7 +530,7 @@ export class MessageComposer extends React.Component { "mx_MessageComposer": true, "mx_MessageComposer--compact": this.props.compact, "mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined, - "mx_MessageComposer_wysiwyg": isWysiwygComposerEnabled, + "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled, }); return ( @@ -532,6 +559,9 @@ export class MessageComposer extends React.Component { showLocationButton={!window.electron} showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} + showComposerModeButton={this.state.isWysiwygLabEnabled} + isRichTextEnabled={this.state.isRichTextEnabled} + onComposerModeClick={this.onRichTextToggle} toggleButtonMenu={this.toggleButtonMenu} showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} onStartVoiceBroadcastClick={() => { diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index b77bff66a8f..d31f6fea27f 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -17,7 +17,7 @@ limitations under the License. import classNames from 'classnames'; import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { M_POLL_START } from "matrix-events-sdk"; -import React, { createContext, ReactElement, useContext, useRef } from 'react'; +import React, { createContext, MouseEventHandler, ReactElement, useContext, useRef } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; @@ -55,6 +55,9 @@ interface IProps { toggleButtonMenu: () => void; showVoiceBroadcastButton: boolean; onStartVoiceBroadcastClick: () => void; + isRichTextEnabled: boolean; + showComposerModeButton: boolean; + onComposerModeClick: () => void; } type OverflowMenuCloser = () => void; @@ -85,6 +88,8 @@ const MessageComposerButtons: React.FC = (props: IProps) => { } else { mainButtons = [ emojiButton(props), + props.showComposerModeButton && + , uploadButton(), // props passed via UploadButtonContext ]; moreButtons = [ @@ -397,4 +402,23 @@ function showLocationButton( ); } +interface WysiwygToggleButtonProps { + isRichTextEnabled: boolean; + onClick: MouseEventHandler; +} + +function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) { + const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting"); + + return ; +} + export default MessageComposerButtons; diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 2a485d99757..380b0430cef 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -15,32 +15,38 @@ limitations under the License. */ import React, { forwardRef, RefObject } from 'react'; -import { FormattingFunctions } from '@matrix-org/matrix-wysiwyg'; import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler'; import { WysiwygComposer } from './components/WysiwygComposer'; +import { PlainTextComposer } from './components/PlainTextComposer'; +import { ComposerFunctions } from './types'; -interface SendWysiwygComposerProps { - disabled?: boolean; - onChange: (content: string) => void; - onSend: () => void; -} interface ContentProps { disabled: boolean; - formattingFunctions: FormattingFunctions; + composerFunctions: ComposerFunctions; } const Content = forwardRef( - function Content({ disabled, formattingFunctions: wysiwyg }: ContentProps, forwardRef: RefObject) { - useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg); + function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject) { + useWysiwygSendActionHandler(disabled, forwardRef, composerFunctions); return null; }, ); -export function SendWysiwygComposer(props: SendWysiwygComposerProps) { - return ( - { (ref, wysiwyg) => ( - +interface SendWysiwygComposerProps { + initialContent?: string; + isRichTextEnabled: boolean; + disabled?: boolean; + onChange: (content: string) => void; + onSend: () => void; +} + +export function SendWysiwygComposer({ isRichTextEnabled, ...props }: SendWysiwygComposerProps) { + const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; + + return + { (ref, composerFunctions) => ( + ) } - ); + ; } diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx new file mode 100644 index 00000000000..e15b5ef57f7 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -0,0 +1,56 @@ +/* +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. +*/ + +import React, { MutableRefObject, ReactNode } from 'react'; + +import { useComposerFunctions } from '../hooks/useComposerFunctions'; +import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization'; +import { usePlainTextListeners } from '../hooks/usePlainTextListeners'; +import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; +import { ComposerFunctions } from '../types'; +import { Editor } from "./Editor"; + +interface PlainTextComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + onSend: () => void; + initialContent?: string; + className?: string; + children?: ( + ref: MutableRefObject, + composerFunctions: ComposerFunctions, + ) => ReactNode; +} + +export function PlainTextComposer({ + className, disabled, onSend, onChange, children, initialContent }: PlainTextComposerProps, +) { + const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend); + const composerFunctions = useComposerFunctions(ref); + usePlainTextInitialization(initialContent, ref); + useSetCursorPosition(disabled, ref); + + return
+ + { children?.(ref, composerFunctions) } +
; +} diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 7dc059ffb22..974e89f0cee 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -20,6 +20,7 @@ import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import { FormattingButtons } from './FormattingButtons'; import { Editor } from './Editor'; import { useInputEventProcessor } from '../hooks/useInputEventProcessor'; +import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; interface WysiwygComposerProps { disabled?: boolean; @@ -47,10 +48,13 @@ export const WysiwygComposer = memo(function WysiwygComposer( } }, [onChange, content, disabled]); + const isReady = isWysiwygReady && !disabled; + useSetCursorPosition(!isReady, ref); + return ( -
+
- + { children?.(ref, wysiwyg) }
); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts new file mode 100644 index 00000000000..99a89589ee4 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts @@ -0,0 +1,27 @@ +/* +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. +*/ + +import { RefObject, useMemo } from "react"; + +export function useComposerFunctions(ref: RefObject) { + return useMemo(() => ({ + clear: () => { + if (ref.current) { + ref.current.innerHTML = ''; + } + }, + }), [ref]); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 414b6df45c5..06839ab262a 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -20,7 +20,7 @@ import { useCallback } from "react"; import { useSettingValue } from "../../../../../hooks/useSettings"; export function useInputEventProcessor(onSend: () => void) { - const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend") as boolean; + const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend"); return useCallback((event: WysiwygInputEvent) => { if (event instanceof ClipboardEvent) { return event; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts new file mode 100644 index 00000000000..abf2a6a6d27 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts @@ -0,0 +1,25 @@ +/* +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. +*/ + +import { RefObject, useEffect } from "react"; + +export function usePlainTextInitialization(initialContent: string, ref: RefObject) { + useEffect(() => { + if (ref.current) { + ref.current.innerText = initialContent; + } + }, [ref, initialContent]); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts new file mode 100644 index 00000000000..02063ddcfb0 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -0,0 +1,50 @@ +/* +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. +*/ + +import { KeyboardEvent, SyntheticEvent, useCallback, useRef } from "react"; + +import { useSettingValue } from "../../../../../hooks/useSettings"; + +function isDivElement(target: EventTarget): target is HTMLDivElement { + return target instanceof HTMLDivElement; +} + +export function usePlainTextListeners(onChange: (content: string) => void, onSend: () => void) { + const ref = useRef(); + const send = useCallback((() => { + if (ref.current) { + ref.current.innerHTML = ''; + } + onSend(); + }), [ref, onSend]); + + const onInput = useCallback((event: SyntheticEvent) => { + if (isDivElement(event.target)) { + onChange(event.target.innerHTML); + } + }, [onChange]); + + const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend"); + const onKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) { + event.preventDefault(); + event.stopPropagation(); + send(); + } + }, [isCtrlEnter, send]); + + return { ref, onInput, onPaste: onInput, onKeyDown }; +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts new file mode 100644 index 00000000000..ef14d44255d --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts @@ -0,0 +1,27 @@ +/* +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. +*/ + +import { RefObject, useEffect } from "react"; + +import { setCursorPositionAtTheEnd } from "./utils"; + +export function useSetCursorPosition(disabled: boolean, ref: RefObject) { + useEffect(() => { + if (ref.current && !disabled) { + setCursorPositionAtTheEnd(ref.current); + } + }, [ref, disabled]); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts index b7c18f19c20..49c6302d5b3 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { RefObject, useCallback, useRef } from "react"; -import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; @@ -23,11 +22,12 @@ import { ActionPayload } from "../../../../../dispatcher/payloads"; import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; import { useDispatcher } from "../../../../../hooks/useDispatcher"; import { focusComposer } from "./utils"; +import { ComposerFunctions } from "../types"; export function useWysiwygSendActionHandler( disabled: boolean, composerElement: RefObject, - wysiwyg: FormattingFunctions, + composerFunctions: ComposerFunctions, ) { const roomContext = useRoomContext(); const timeoutId = useRef(); @@ -45,12 +45,12 @@ export function useWysiwygSendActionHandler( focusComposer(composerElement, context, roomContext, timeoutId); break; case Action.ClearAndFocusSendMessageComposer: - wysiwyg.clear(); + composerFunctions.clear(); focusComposer(composerElement, context, roomContext, timeoutId); break; // TODO: case Action.ComposerInsert: - see SendMessageComposer } - }, [disabled, composerElement, wysiwyg, timeoutId, roomContext]); + }, [disabled, composerElement, composerFunctions, timeoutId, roomContext]); useDispatcher(defaultDispatcher, handler); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index eab855e0868..bfaf526f72e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -41,3 +41,14 @@ export function focusComposer( ); } } + +export function setCursorPositionAtTheEnd(element: HTMLElement) { + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + element.focus(); +} diff --git a/src/components/views/rooms/wysiwyg_composer/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts new file mode 100644 index 00000000000..96095abebfd --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/types.ts @@ -0,0 +1,19 @@ +/* +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 type ComposerFunctions = { + clear: () => void; +}; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index cc0d2235bf9..6d8a9f218e4 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -16,8 +16,11 @@ limitations under the License. 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 { @@ -39,6 +42,18 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { return (mxReply && mxReply.outerHTML) || ""; } +function getTextReplyFallback(mxEvent: MatrixEvent): string { + const body = mxEvent.getContent().body; + if (typeof body !== 'string') { + return ""; + } + const lines = body.split("\n").map(l => l.trim()); + if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { + return `${lines[0]}\n\n`; + } + return ""; +} + interface CreateMessageContentParams { relation?: IEventRelation; replyToEvent?: MatrixEvent; @@ -49,6 +64,7 @@ interface CreateMessageContentParams { export function createMessageContent( message: string, + isHTML: boolean, { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true, editedEvent }: CreateMessageContentParams, ): IContent { @@ -56,6 +72,7 @@ export function createMessageContent( const isEditing = Boolean(editedEvent); const isReply = isEditing ? Boolean(editedEvent?.replyEventId) : Boolean(replyToEvent); + const isReplyAndEditing = isEditing && isReply; /*const isEmote = containsEmote(model); if (isEmote) { @@ -67,37 +84,44 @@ export function createMessageContent( model = unescapeMessage(model);*/ // const body = textSerialize(model); - const body = message; + + // TODO remove this ugly hack for replace br tag + const body = isHTML && htmlToPlainText(message) || message.replace(/
/g, '\n'); + const bodyPrefix = isReplyAndEditing && getTextReplyFallback(editedEvent) || ''; + const formattedBodyPrefix = isReplyAndEditing && getHtmlReplyFallback(editedEvent) || ''; const content: IContent = { // TODO emote - // msgtype: isEmote ? "m.emote" : "m.text", msgtype: MsgType.Text, - body: body, + // TODO when available, use HTML --> Plain text conversion from wysiwyg rust model + body: isEditing ? `${bodyPrefix} * ${body}` : body, }; // TODO markdown support - /*const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: !!replyToEvent, - useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), - });*/ - const formattedBody = message; + const isMarkdownEnabled = SettingsStore.getValue("MessageComposerInput.useMarkdown"); + const formattedBody = + isHTML ? + message : + isMarkdownEnabled ? + htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply }) : + null; if (formattedBody) { content.format = "org.matrix.custom.html"; - - const htmlPrefix = isReply && isEditing ? getHtmlReplyFallback(editedEvent) : ''; - content.formatted_body = isEditing ? `${htmlPrefix} * ${formattedBody}` : formattedBody; + content.formatted_body = isEditing ? `${formattedBodyPrefix} * ${formattedBody}` : formattedBody; } if (isEditing) { content['m.new_content'] = { "msgtype": content.msgtype, "body": body, - "format": "org.matrix.custom.html", - 'formatted_body': formattedBody, }; + + if (formattedBody) { + content['m.new_content'].format = "org.matrix.custom.html"; + content['m.new_content']['formatted_body'] = formattedBody; + } } const newRelation = isEditing ? diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts index dbea29c848c..d84392c18e7 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -44,7 +44,8 @@ interface SendMessageParams { } export function sendMessage( - html: string, + message: string, + isHTML: boolean, { roomContext, mxClient, ...params }: SendMessageParams, ) { const { relation, replyToEvent } = params; @@ -76,7 +77,8 @@ export function sendMessage( if (!content) { content = createMessageContent( - html, + message, + isHTML, params, ); } @@ -167,7 +169,7 @@ export function editMessage( const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd); this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON); }*/ - const editContent = createMessageContent(html, { editedEvent }); + const editContent = createMessageContent(html, true, { editedEvent }); const newContent = editContent["m.new_content"]; const shouldSend = true; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8af41255fce..b892c67bf92 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1881,6 +1881,8 @@ "Voice Message": "Voice Message", "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", "Poll": "Poll", + "Show plain text": "Show plain text", + "Show formatting": "Show formatting", "Bold": "Bold", "Italics": "Italics", "Strikethrough": "Strikethrough", diff --git a/src/utils/room/htmlToPlaintext.ts b/src/utils/room/htmlToPlaintext.ts new file mode 100644 index 00000000000..883db8d360d --- /dev/null +++ b/src/utils/room/htmlToPlaintext.ts @@ -0,0 +1,19 @@ +/* +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; +} diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 72fd52be574..00d6a43f977 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -163,7 +163,7 @@ describe('EditWysiwygComposer', () => { // Then const expectedContent = { - "body": mockContent, + "body": ` * ${mockContent}`, "format": "org.matrix.custom.html", "formatted_body": ` * ${mockContent}`, "m.new_content": { @@ -186,6 +186,7 @@ describe('EditWysiwygComposer', () => { 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 @@ -201,6 +202,7 @@ describe('EditWysiwygComposer', () => { 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 diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 20148b802a7..c85692d221a 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -24,8 +24,10 @@ import RoomContext from "../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; +import * as useComposerFunctions + from "../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions"; const mockClear = jest.fn(); @@ -68,83 +70,112 @@ describe('SendWysiwygComposer', () => { const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {}); - const customRender = (onChange = (_content: string) => void 0, onSend = () => void 0, disabled = false) => { + const customRender = ( + onChange = (_content: string) => void 0, + onSend = () => void 0, + disabled = false, + isRichTextEnabled = true) => { return render( - + , ); }; - it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => { - // Given we don't have focus - customRender(jest.fn(), jest.fn()); - expect(screen.getByRole('textbox')).not.toHaveFocus(); + it('Should render WysiwygComposer when isRichTextEnabled is at true', () => { + // When + customRender(jest.fn(), jest.fn(), false, 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 - customRender(jest.fn(), jest.fn()); - expect(screen.getByRole('textbox')).not.toHaveFocus(); - - // When we send the right action - defaultDispatcher.dispatch({ - action: Action.ClearAndFocusSendMessageComposer, - context: null, - }); - - // Then the component gets the focus - await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus()); - expect(mockClear).toBeCalledTimes(1); + // Then + expect(screen.getByTestId('WysiwygComposer')).toBeTruthy(); }); - it('Should focus when receiving a reply_to_event action', async () => { - // Given we don't have focus - customRender(jest.fn(), jest.fn()); - expect(screen.getByRole('textbox')).not.toHaveFocus(); + it('Should render PlainTextComposer when isRichTextEnabled is at false', () => { + // When + customRender(jest.fn(), jest.fn(), false, false); - // 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()); + // Then + expect(screen.getByTestId('PlainTextComposer')).toBeTruthy(); }); - it('Should not focus when disabled', async () => { - // Given we don't have focus and we are disabled - customRender(jest.fn(), jest.fn(), true); - 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, + 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); + + // 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 mock = jest.spyOn(useComposerFunctions, 'useComposerFunctions'); + mock.mockReturnValue({ clear: mockClear }); + customRender(jest.fn(), jest.fn(), false, isRichTextEnabled); + + // When we send the right action + defaultDispatcher.dispatch({ + action: Action.ClearAndFocusSendMessageComposer, + context: null, + }); + + // Then the component gets the focus + await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus()); + expect(mockClear).toBeCalledTimes(1); + + mock.mockRestore(); + }); + + it('Should focus when receiving a reply_to_event action', async () => { + // Given we don't have focus + customRender(jest.fn(), jest.fn(), false, isRichTextEnabled); + + // 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 flushPromises(); + + // Then we don't get it because we are disabled + expect(screen.getByRole('textbox')).not.toHaveFocus(); + }); }); - - // Wait for event dispatch to happen - await new Promise((r) => setTimeout(r, 200)); - - // Then we don't get it because we are disabled - expect(screen.getByRole('textbox')).not.toHaveFocus(); - }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx new file mode 100644 index 00000000000..5d1b03020cf --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx @@ -0,0 +1,94 @@ +/* +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. +*/ + +import React from 'react'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { PlainTextComposer } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer"; + +// Work around missing ClipboardEvent type +class MyClipboardEvent {} +window.ClipboardEvent = MyClipboardEvent as any; + +describe('PlainTextComposer', () => { + const customRender = ( + onChange = (_content: string) => void 0, + onSend = () => void 0, + disabled = false, + initialContent?: string) => { + return render( + , + ); + }; + + 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).toBeCalledWith(content); + }); + + it('Should call onSend when Enter is pressed', async () => { + //When + const onSend = jest.fn(); + customRender(jest.fn(), onSend); + await userEvent.type(screen.getByRole('textbox'), '{enter}'); + + // Then it sends a message + expect(onSend).toBeCalledTimes(1); + }); + + it('Should clear textbox content when clear is called', async () => { + //When + let composer; + render( + + { (ref, composerFunctions) => { + composer = composerFunctions; + return null; + } } + , + ); + await userEvent.type(screen.getByRole('textbox'), 'content'); + expect(screen.getByRole('textbox').innerHTML).toBe('content'); + composer.clear(); + + // Then + expect(screen.getByRole('textbox').innerHTML).toBeFalsy(); + }); +}); diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index e7e21ca839c..7e3db04abcf 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -19,10 +19,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg"; -import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; -import { IRoomState } from "../../../../../../src/components/structures/RoomView"; -import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils"; -import RoomContext from "../../../../../../src/contexts/RoomContext"; import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; @@ -54,32 +50,14 @@ jest.mock("@matrix-org/matrix-wysiwyg", () => ({ })); describe('WysiwygComposer', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - const mockClient = createTestClient(); - const mockEvent = mkEvent({ - type: "m.room.message", - room: 'myfakeroom', - user: 'myfakeuser', - content: { "msgtype": "m.text", "body": "Replying to this" }, - 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, {}); - - const customRender = (onChange = (_content: string) => void 0, onSend = () => void 0, disabled = false) => { + const customRender = ( + onChange = (_content: string) => void 0, + onSend = () => void 0, + disabled = false, + initialContent?: string) => { return render( - - - - - , + , + ); }; @@ -91,6 +69,14 @@ describe('WysiwygComposer', () => { 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', (done) => { const html = 'html'; customRender((content) => { @@ -104,7 +90,7 @@ describe('WysiwygComposer', () => { const onSend = jest.fn(); customRender(jest.fn(), onSend); - // When we tell its inputEventProcesser that the user pressed Enter + // When we tell its inputEventProcessor that the user pressed Enter const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; inputEventProcessor(event, wysiwyg); diff --git a/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts index a4335b2bf10..4c7028749c4 100644 --- a/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts @@ -40,11 +40,11 @@ describe('createMessageContent', () => { it("Should create html message", () => { // When - const content = createMessageContent(message, { permalinkCreator }); + const content = createMessageContent(message, true, { permalinkCreator }); // Then expect(content).toEqual({ - "body": message, + "body": "hello world", "format": "org.matrix.custom.html", "formatted_body": message, "msgtype": "m.text", @@ -53,11 +53,11 @@ describe('createMessageContent', () => { it('Should add reply to message content', () => { // When - const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent }); + const content = createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent }); // Then expect(content).toEqual({ - "body": "> Replying to this\n\nhello world", + "body": "> Replying to this\n\nhello world", "format": "org.matrix.custom.html", "formatted_body": "
In reply to" + " myfakeuser"+ @@ -77,11 +77,11 @@ describe('createMessageContent', () => { rel_type: "m.thread", event_id: "myFakeThreadId", }; - const content = createMessageContent(message, { permalinkCreator, relation }); + const content = createMessageContent(message, true, { permalinkCreator, relation }); // Then expect(content).toEqual({ - "body": message, + "body": "hello world", "format": "org.matrix.custom.html", "formatted_body": message, "msgtype": "m.text", @@ -110,16 +110,16 @@ describe('createMessageContent', () => { event: true, }); const content = - createMessageContent(message, { permalinkCreator, editedEvent }); + createMessageContent(message, true, { permalinkCreator, editedEvent }); // Then expect(content).toEqual({ - "body": message, + "body": " * hello world", "format": "org.matrix.custom.html", "formatted_body": ` * ${message}`, "msgtype": "m.text", "m.new_content": { - "body": message, + "body": "hello world", "format": "org.matrix.custom.html", "formatted_body": message, "msgtype": "m.text", diff --git a/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts index 9d13f281760..0829b19adb2 100644 --- a/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -65,7 +65,7 @@ describe('message', () => { describe('sendMessage', () => { it('Should not send empty html message', async () => { // When - await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); + await sendMessage('', true, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); // Then expect(mockClient.sendMessage).toBeCalledTimes(0); @@ -74,11 +74,15 @@ describe('message', () => { it('Should send html message', async () => { // When - await sendMessage(message, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); + await sendMessage( + message, + true, + { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }, + ); // Then const expectedContent = { - "body": "hello world", + "body": "hello world", "format": "org.matrix.custom.html", "formatted_body": "hello world", "msgtype": "m.text", @@ -97,7 +101,7 @@ describe('message', () => { }); // When - await sendMessage(message, { + await sendMessage(message, true, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator, @@ -112,7 +116,7 @@ describe('message', () => { }); const expectedContent = { - "body": "> My reply\n\nhello world", + "body": "> My reply\n\nhello world", "format": "org.matrix.custom.html", "formatted_body": "
In reply to" + " myfakeuser2" + @@ -130,7 +134,11 @@ describe('message', () => { it('Should scroll to bottom after sending a html message', async () => { // When SettingsStore.setValue("scrollToBottomOnMessageSent", null, SettingLevel.DEVICE, true); - await sendMessage(message, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); + await sendMessage( + message, + true, + { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }, + ); // Then expect(spyDispatcher).toBeCalledWith( @@ -140,7 +148,11 @@ describe('message', () => { it('Should handle emojis', async () => { // When - await sendMessage('🎉', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); + await sendMessage( + '🎉', + false, + { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }, + ); // Then expect(spyDispatcher).toBeCalledWith( @@ -203,7 +215,7 @@ describe('message', () => { // Then const { msgtype, format } = mockEvent.getContent(); const expectedContent = { - "body": newMessage, + "body": ` * ${newMessage}`, "formatted_body": ` * ${newMessage}`, "m.new_content": { "body": "Replying to this new content",