From 50279c8870410fd48261a27ea375619e702aa4b1 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 25 Oct 2022 17:26:14 +0200 Subject: [PATCH 01/14] Add basic plain text editor --- res/css/views/rooms/_MessageComposer.pcss | 10 +++- .../room/composer/plain_text.svg | 11 ++++ .../element-icons/room/composer/rich_text.svg | 10 ++++ .../views/rooms/MessageComposer.tsx | 31 ++++++++++-- .../views/rooms/MessageComposerButtons.tsx | 24 ++++++++- .../wysiwyg_composer/SendWysiwygComposer.tsx | 33 ++++++------ .../components/PlainTextComposer.tsx | 44 ++++++++++++++++ .../hooks/useComposerFunctions.tsx | 27 ++++++++++ .../hooks/useInputEventProcessor.ts | 2 +- .../hooks/usePlainTextListeners.tsx | 50 +++++++++++++++++++ .../hooks/useWysiwygSendActionHandler.ts | 8 +-- .../views/rooms/wysiwyg_composer/types.ts | 19 +++++++ .../utils/createMessageContent.ts | 49 +++++++++++++----- .../rooms/wysiwyg_composer/utils/message.ts | 8 +-- .../EditWysiwygComposer-test.tsx | 2 +- .../SendWysiwygComposer-test.tsx | 2 +- .../utils/createMessageContent-test.ts | 18 +++---- .../wysiwyg_composer/utils/message-test.ts | 28 ++++++++--- 18 files changed, 315 insertions(+), 61 deletions(-) create mode 100644 res/img/element-icons/room/composer/plain_text.svg create mode 100644 res/img/element-icons/room/composer/rich_text.svg create mode 100644 src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/types.ts 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..7ff47fe085c --- /dev/null +++ b/res/img/element-icons/room/composer/plain_text.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + 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..d2da9d25516 --- /dev/null +++ b/res/img/element-icons/room/composer/rich_text.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 674635d8961..f663f57e9c2 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -100,6 +100,8 @@ interface IState { showStickersButton: boolean; showPollsButton: boolean; showVoiceBroadcastButton: boolean; + isWysiwygLabEnabled: boolean; + isRichTextEnabled: boolean; } export class MessageComposer extends React.Component { @@ -117,6 +119,7 @@ export class MessageComposer extends React.Component { public static defaultProps = { compact: false, showVoiceBroadcastButton: false, + isRichTextEnabled: true, }; public constructor(props: IProps) { @@ -133,6 +136,8 @@ 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, }; this.instanceId = instanceCount++; @@ -140,6 +145,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 +226,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,10 +330,10 @@ 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 }); } @@ -340,6 +352,12 @@ export class MessageComposer extends React.Component { }); }; + private onRichTextToggle = () => { + this.setState(state => ({ + isRichTextEnabled: !state.isRichTextEnabled, + })); + }; + private onVoiceStoreUpdate = () => { this.updateRecordingState(); }; @@ -395,7 +413,6 @@ export class MessageComposer extends React.Component { } public render() { - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); const controls = [ this.props.e2eStatus ? : @@ -410,12 +427,13 @@ 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 +521,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 +550,9 @@ export class MessageComposer extends React.Component { showLocationButton={!window.electron} showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} + showComposerModeButton={this.state.isWysiwygLabEnabled} + isComposerModeToggled={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..8ab4c2570b5 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; + isComposerModeToggled: 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,21 @@ function showLocationButton( ); } +interface WysiwygToggleButtonProps { + isToggled: boolean; + onClick: MouseEventHandler; +} + +function ComposerModeButton({ isToggled, onClick }: WysiwygToggleButtonProps) { + 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..9208e79ac40 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -15,32 +15,37 @@ 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 { + 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..cf251757552 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -0,0 +1,44 @@ +/* +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 { usePlainTextListeners } from '../hooks/usePlainTextListeners'; +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 }: PlainTextComposerProps) { + const {ref, onInput, onPaste, onKeyDown} = usePlainTextListeners(onChange, onSend) + const composerFunctions = useComposerFunctions(ref) + + return
+ + {children?.(ref, composerFunctions)} +
; +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.tsx b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.tsx new file mode 100644 index 00000000000..99a89589ee4 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.tsx @@ -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/usePlainTextListeners.tsx b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.tsx new file mode 100644 index 00000000000..98d09c12b05 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.tsx @@ -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 { useInputEventProcessor } from "./useInputEventProcessor"; + +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.innerText = ''; + } + onSend(); + }), [ref, onSend]); + + const inputEventProcessor = useInputEventProcessor(send); + + const onInput = useCallback((event: SyntheticEvent) => { + if (isDivElement(event.target)) { + onChange(event.target.innerText); + } + inputEventProcessor(event.nativeEvent); + }, [onChange, inputEventProcessor]); + + const onKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Enter') { + send(); + } + }, [send]); + + return { ref, onInput, onPaste: onInput, onKeyDown }; +} 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/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..df42b0062bc 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -16,6 +16,8 @@ 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"; @@ -39,6 +41,19 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { return (mxReply && mxReply.outerHTML) || ""; } +function getTextReplyFallback(mxEvent: MatrixEvent): string { + const body = mxEvent.getContent().body; + 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 ""; +} + +function htmlToPlainText(html: string) { + return new DOMParser().parseFromString(html, 'text/html').documentElement.textContent; +} + 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,43 @@ export function createMessageContent( model = unescapeMessage(model);*/ // const body = textSerialize(model); - const body = message; + + const body = isHTML && htmlToPlainText(message) || message; + 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/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 72fd52be574..051d25b4356 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": { diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 20148b802a7..c97e5e90867 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -72,7 +72,7 @@ describe('SendWysiwygComposer', () => { return render( - + , ); 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", From c7dbb5947fbf491f1bbb3676a8521d33dae35a94 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 25 Oct 2022 18:33:25 +0200 Subject: [PATCH 02/14] Keep content when switching between rich text and plain text mode --- .../views/rooms/MessageComposer.tsx | 8 ++++++++ .../wysiwyg_composer/SendWysiwygComposer.tsx | 1 + .../components/PlainTextComposer.tsx | 20 +++++++++++++------ ...rFunctions.tsx => useComposerFunctions.ts} | 0 ...Listeners.tsx => usePlainTextListeners.ts} | 0 .../utils/createMessageContent.ts | 5 +---- src/utils/room/htmlToPlaintext.ts | 19 ++++++++++++++++++ 7 files changed, 43 insertions(+), 10 deletions(-) rename src/components/views/rooms/wysiwyg_composer/hooks/{useComposerFunctions.tsx => useComposerFunctions.ts} (100%) rename src/components/views/rooms/wysiwyg_composer/hooks/{usePlainTextListeners.tsx => usePlainTextListeners.ts} (100%) create mode 100644 src/utils/room/htmlToPlaintext.ts diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index f663f57e9c2..787b4e2e5b7 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; @@ -102,6 +103,7 @@ interface IState { showVoiceBroadcastButton: boolean; isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; + initialComposerContent: string; } export class MessageComposer extends React.Component { @@ -138,6 +140,7 @@ export class MessageComposer extends React.Component { showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), isWysiwygLabEnabled: SettingsStore.getValue("feature_wysiwyg_composer"), isRichTextEnabled: true, + initialComposerContent: '', }; this.instanceId = instanceCount++; @@ -355,6 +358,10 @@ 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), })); }; @@ -434,6 +441,7 @@ export class MessageComposer extends React.Component { onChange={this.onWysiwygChange} onSend={this.sendMessage} isRichTextEnabled={this.state.isRichTextEnabled} + initialContent={this.state.initialComposerContent} />, ); } else { diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 9208e79ac40..a612cef4339 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -34,6 +34,7 @@ const Content = forwardRef( ); interface SendWysiwygComposerProps { + initialContent: string; isRichTextEnabled: boolean; disabled?: boolean; onChange: (content: string) => void; diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index cf251757552..d873c3549c0 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { MutableRefObject, ReactNode } from 'react'; +import React, { MutableRefObject, ReactNode, useEffect } from 'react'; + import { useComposerFunctions } from '../hooks/useComposerFunctions'; import { usePlainTextListeners } from '../hooks/usePlainTextListeners'; import { ComposerFunctions } from '../types'; - import { Editor } from "./Editor"; interface PlainTextComposerProps { @@ -33,12 +33,20 @@ interface PlainTextComposerProps { ) => ReactNode; } -export function PlainTextComposer({ className, disabled, onSend, onChange, children }: PlainTextComposerProps) { - const {ref, onInput, onPaste, onKeyDown} = usePlainTextListeners(onChange, onSend) - const composerFunctions = useComposerFunctions(ref) +export function PlainTextComposer({ + className, disabled, onSend, onChange, children, initialContent }: PlainTextComposerProps, +) { + const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend); + const composerFunctions = useComposerFunctions(ref); + + useEffect(() => { + if (ref.current) { + ref.current.innerText = initialContent; + } + }, [ref, initialContent]); return
- {children?.(ref, composerFunctions)} + { children?.(ref, composerFunctions) }
; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.tsx b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts similarity index 100% rename from src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.tsx rename to src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.tsx b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts similarity index 100% rename from src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.tsx rename to src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index df42b0062bc..0b4c983acf0 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -20,6 +20,7 @@ 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 { @@ -50,10 +51,6 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string { return ""; } -function htmlToPlainText(html: string) { - return new DOMParser().parseFromString(html, 'text/html').documentElement.textContent; -} - interface CreateMessageContentParams { relation?: IEventRelation; replyToEvent?: MatrixEvent; 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; +} From bdaa1acf592b612c1015db555ac5de6eff022eb8 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 10:42:18 +0200 Subject: [PATCH 03/14] Invert composer mode icon --- .../element-icons/room/composer/plain_text.svg | 7 +++---- .../element-icons/room/composer/rich_text.svg | 7 ++++--- src/components/views/rooms/MessageComposer.tsx | 2 +- .../views/rooms/MessageComposerButtons.tsx | 16 +++++++++------- src/i18n/strings/en_EN.json | 2 ++ 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/res/img/element-icons/room/composer/plain_text.svg b/res/img/element-icons/room/composer/plain_text.svg index 7ff47fe085c..d2da9d25516 100644 --- a/res/img/element-icons/room/composer/plain_text.svg +++ b/res/img/element-icons/room/composer/plain_text.svg @@ -1,10 +1,9 @@ - - - + + - + diff --git a/res/img/element-icons/room/composer/rich_text.svg b/res/img/element-icons/room/composer/rich_text.svg index d2da9d25516..7ff47fe085c 100644 --- a/res/img/element-icons/room/composer/rich_text.svg +++ b/res/img/element-icons/room/composer/rich_text.svg @@ -1,9 +1,10 @@ - - + + + - + diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 787b4e2e5b7..7874ab94e67 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -559,7 +559,7 @@ export class MessageComposer extends React.Component { showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} showComposerModeButton={this.state.isWysiwygLabEnabled} - isComposerModeToggled={this.state.isRichTextEnabled} + isRichTextEnabled={this.state.isRichTextEnabled} onComposerModeClick={this.onRichTextToggle} toggleButtonMenu={this.toggleButtonMenu} showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 8ab4c2570b5..d31f6fea27f 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -55,7 +55,7 @@ interface IProps { toggleButtonMenu: () => void; showVoiceBroadcastButton: boolean; onStartVoiceBroadcastClick: () => void; - isComposerModeToggled: boolean; + isRichTextEnabled: boolean; showComposerModeButton: boolean; onComposerModeClick: () => void; } @@ -89,7 +89,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { mainButtons = [ emojiButton(props), props.showComposerModeButton && - , + , uploadButton(), // props passed via UploadButtonContext ]; moreButtons = [ @@ -403,19 +403,21 @@ function showLocationButton( } interface WysiwygToggleButtonProps { - isToggled: boolean; + isRichTextEnabled: boolean; onClick: MouseEventHandler; } -function ComposerModeButton({ isToggled, onClick }: WysiwygToggleButtonProps) { +function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) { + const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting"); + return ; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a33de8ea150..fc4e8d848dd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1874,6 +1874,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 formatting": "Show formatting", + "Show plain text": "Show plain text", "Bold": "Bold", "Italics": "Italics", "Strikethrough": "Strikethrough", From b85b5dacee8482a61fdd94aef5ffca34740da2f6 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 12:39:18 +0200 Subject: [PATCH 04/14] Fix cursor position --- .../components/PlainTextComposer.tsx | 10 ++----- .../components/WysiwygComposer.tsx | 3 ++ .../hooks/usePlainTextInitialization.ts | 28 +++++++++++++++++++ .../hooks/useSetCursorPosition.ts | 27 ++++++++++++++++++ .../rooms/wysiwyg_composer/hooks/utils.ts | 11 ++++++++ 5 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index d873c3549c0..f23c1725c22 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { MutableRefObject, ReactNode, useEffect } from 'react'; +import React, { MutableRefObject, ReactNode } from 'react'; import { useComposerFunctions } from '../hooks/useComposerFunctions'; +import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization'; import { usePlainTextListeners } from '../hooks/usePlainTextListeners'; import { ComposerFunctions } from '../types'; import { Editor } from "./Editor"; @@ -38,12 +39,7 @@ export function PlainTextComposer({ ) { const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend); const composerFunctions = useComposerFunctions(ref); - - useEffect(() => { - if (ref.current) { - ref.current.innerText = initialContent; - } - }, [ref, initialContent]); + usePlainTextInitialization(initialContent, ref); return
diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 7dc059ffb22..dce20f68150 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,6 +48,8 @@ export const WysiwygComposer = memo(function WysiwygComposer( } }, [onChange, content, disabled]); + useSetCursorPosition(isWysiwygReady, ref); + return (
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..dcaacd98ea4 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts @@ -0,0 +1,28 @@ +/* +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 usePlainTextInitialization(initialContent: string, ref: RefObject) { + useEffect(() => { + if (ref.current) { + ref.current.innerText = initialContent; + setCursorPositionAtTheEnd(ref.current); + } + }, [ref, initialContent]); +} 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..92555f563f3 --- /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(isComposerReady: boolean, ref: RefObject) { + useEffect(() => { + if (ref.current && isComposerReady) { + setCursorPositionAtTheEnd(ref.current); + } + }, [ref, isComposerReady]); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index eab855e0868..0ae3d7a8802 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 sel = document.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + + element.focus(); +} From 5d86b5ac48c2a656ff596cd1c800fbb3c3be7fa7 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 12:41:44 +0200 Subject: [PATCH 05/14] Make initialContent optional is SendWysiwygComposer --- .../views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index a612cef4339..380b0430cef 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -34,7 +34,7 @@ const Content = forwardRef( ); interface SendWysiwygComposerProps { - initialContent: string; + initialContent?: string; isRichTextEnabled: boolean; disabled?: boolean; onChange: (content: string) => void; From d001ddebbca0e6979fed83dd9be7074597253177 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 12:42:58 +0200 Subject: [PATCH 06/14] Update i18n --- src/i18n/strings/en_EN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index fc4e8d848dd..54a8445520a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1874,8 +1874,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 formatting": "Show formatting", "Show plain text": "Show plain text", + "Show formatting": "Show formatting", "Bold": "Bold", "Italics": "Italics", "Strikethrough": "Strikethrough", From f1610dae3ddc7068298e7de5cf5763c5976c3f44 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 14:53:44 +0200 Subject: [PATCH 07/14] Fix selection --- .../components/PlainTextComposer.tsx | 2 ++ .../components/WysiwygComposer.tsx | 5 +++-- .../hooks/useSetCursorPosition.ts | 6 +++--- .../rooms/wysiwyg_composer/hooks/utils.ts | 6 +++--- .../EditWysiwygComposer-test.tsx | 2 ++ .../SendWysiwygComposer-test.tsx | 3 +++ .../components/WysiwygComposer-test.tsx | 18 +++++++++++++++--- 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index f23c1725c22..64c940c2fd2 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -19,6 +19,7 @@ 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"; @@ -40,6 +41,7 @@ export function PlainTextComposer({ const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend); const composerFunctions = useComposerFunctions(ref); usePlainTextInitialization(initialContent, ref); + useSetCursorPosition(disabled, ref); return
diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index dce20f68150..73125a910ad 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -48,12 +48,13 @@ export const WysiwygComposer = memo(function WysiwygComposer( } }, [onChange, content, disabled]); - useSetCursorPosition(isWysiwygReady, ref); + const isReady = isWysiwygReady && !disabled; + useSetCursorPosition(!isReady, ref); return (
- + { children?.(ref, wysiwyg) }
); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts index 92555f563f3..ef14d44255d 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts @@ -18,10 +18,10 @@ import { RefObject, useEffect } from "react"; import { setCursorPositionAtTheEnd } from "./utils"; -export function useSetCursorPosition(isComposerReady: boolean, ref: RefObject) { +export function useSetCursorPosition(disabled: boolean, ref: RefObject) { useEffect(() => { - if (ref.current && isComposerReady) { + if (ref.current && !disabled) { setCursorPositionAtTheEnd(ref.current); } - }, [ref, isComposerReady]); + }, [ref, disabled]); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 0ae3d7a8802..bfaf526f72e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -46,9 +46,9 @@ export function setCursorPositionAtTheEnd(element: HTMLElement) { const range = document.createRange(); range.selectNodeContents(element); range.collapse(false); - const sel = document.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); element.focus(); } diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 051d25b4356..00d6a43f977 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -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 c97e5e90867..573ae451bea 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -81,6 +81,7 @@ describe('SendWysiwygComposer', () => { it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => { // Given we don't have focus customRender(jest.fn(), jest.fn()); + screen.getByLabelText('Bold').focus(); expect(screen.getByRole('textbox')).not.toHaveFocus(); // When we send the right action @@ -96,6 +97,7 @@ describe('SendWysiwygComposer', () => { it('Should focus and clear when receiving an Action.ClearAndFocusSendMessageComposer', async () => { // Given we don't have focus customRender(jest.fn(), jest.fn()); + screen.getByLabelText('Bold').focus(); expect(screen.getByRole('textbox')).not.toHaveFocus(); // When we send the right action @@ -112,6 +114,7 @@ describe('SendWysiwygComposer', () => { it('Should focus when receiving a reply_to_event action', async () => { // Given we don't have focus customRender(jest.fn(), jest.fn()); + screen.getByLabelText('Bold').focus(); expect(screen.getByRole('textbox')).not.toHaveFocus(); // When we send the right action 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..cb9c37071cd 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -73,11 +73,15 @@ describe('WysiwygComposer', () => { 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 +95,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 +116,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); From fb751c3c7b7ae1e821eba5cde224cd874f85f616 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 17:16:13 +0200 Subject: [PATCH 08/14] Add test for plain mode --- .../components/PlainTextComposer.tsx | 8 +- .../components/WysiwygComposer.tsx | 2 +- .../hooks/usePlainTextInitialization.ts | 3 - .../hooks/usePlainTextListeners.ts | 18 +- .../SendWysiwygComposer-test.tsx | 167 +++++++++++------- .../components/PlainTextComposer-test.tsx | 94 ++++++++++ .../components/WysiwygComposer-test.tsx | 30 +--- 7 files changed, 214 insertions(+), 108 deletions(-) create mode 100644 test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index 64c940c2fd2..e15b5ef57f7 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -43,7 +43,13 @@ export function PlainTextComposer({ usePlainTextInitialization(initialContent, ref); useSetCursorPosition(disabled, ref); - return
+ 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 73125a910ad..974e89f0cee 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -52,7 +52,7 @@ export const WysiwygComposer = memo(function WysiwygComposer( useSetCursorPosition(!isReady, ref); return ( -
+
{ children?.(ref, wysiwyg) } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts index dcaacd98ea4..abf2a6a6d27 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts @@ -16,13 +16,10 @@ limitations under the License. import { RefObject, useEffect } from "react"; -import { setCursorPositionAtTheEnd } from "./utils"; - export function usePlainTextInitialization(initialContent: string, ref: RefObject) { useEffect(() => { if (ref.current) { ref.current.innerText = initialContent; - setCursorPositionAtTheEnd(ref.current); } }, [ref, initialContent]); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 98d09c12b05..02063ddcfb0 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -16,7 +16,7 @@ limitations under the License. import { KeyboardEvent, SyntheticEvent, useCallback, useRef } from "react"; -import { useInputEventProcessor } from "./useInputEventProcessor"; +import { useSettingValue } from "../../../../../hooks/useSettings"; function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; @@ -26,25 +26,25 @@ export function usePlainTextListeners(onChange: (content: string) => void, onSen const ref = useRef(); const send = useCallback((() => { if (ref.current) { - ref.current.innerText = ''; + ref.current.innerHTML = ''; } onSend(); }), [ref, onSend]); - const inputEventProcessor = useInputEventProcessor(send); - const onInput = useCallback((event: SyntheticEvent) => { if (isDivElement(event.target)) { - onChange(event.target.innerText); + onChange(event.target.innerHTML); } - inputEventProcessor(event.nativeEvent); - }, [onChange, inputEventProcessor]); + }, [onChange]); + const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend"); const onKeyDown = useCallback((event: KeyboardEvent) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) { + event.preventDefault(); + event.stopPropagation(); send(); } - }, [send]); + }, [isCtrlEnter, send]); return { ref, onInput, onPaste: onInput, onKeyDown }; } diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 573ae451bea..c2e1b8a1fbd 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -26,6 +26,8 @@ import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { createTestClient, 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,86 +70,119 @@ 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()); - screen.getByLabelText('Bold').focus(); - 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()); - screen.getByLabelText('Bold').focus(); - 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()); - screen.getByLabelText('Bold').focus(); - 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); + document.head.focus(); + // screen.getByLabelText('Bold').focus(); + // expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // 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); + // screen.getByLabelText('Bold').focus(); + // 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); + + 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); + // screen.getByLabelText('Bold').focus(); + // expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // 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 new Promise((r) => setTimeout(r, 200)); + + // 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 cb9c37071cd..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,36 +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, initialContent?: string) => { return render( - - - - - , + , + ); }; From 928a23e9d0f93103e8c6cfdc5fbe3644ac616f3b Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 18:18:34 +0200 Subject: [PATCH 09/14] Fix switching bug and br tag in firefox --- src/components/views/rooms/MessageComposer.tsx | 1 + .../views/rooms/wysiwyg_composer/utils/createMessageContent.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 7874ab94e67..4b04b87daef 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -339,6 +339,7 @@ export class MessageComposer extends React.Component { this.state.isRichTextEnabled, { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent }); dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer }); + this.setState({ composerContent: '', initialComposerContent: '' }); } }; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index 0b4c983acf0..bdf07fb15ec 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -82,7 +82,8 @@ export function createMessageContent( // const body = textSerialize(model); - const body = isHTML && htmlToPlainText(message) || 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) || ''; From 92b4c8bf4e30e23a9076156eb415cac5f03ecf54 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 19:05:11 +0200 Subject: [PATCH 10/14] Review clean --- .../wysiwyg_composer/SendWysiwygComposer-test.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index c2e1b8a1fbd..c85692d221a 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -24,7 +24,7 @@ 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"; @@ -110,9 +110,6 @@ describe('SendWysiwygComposer', () => { it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => { // Given we don't have focus customRender(jest.fn(), jest.fn(), false, isRichTextEnabled); - document.head.focus(); - // screen.getByLabelText('Bold').focus(); - // expect(screen.getByRole('textbox')).not.toHaveFocus(); // When we send the right action defaultDispatcher.dispatch({ @@ -129,8 +126,6 @@ describe('SendWysiwygComposer', () => { const mock = jest.spyOn(useComposerFunctions, 'useComposerFunctions'); mock.mockReturnValue({ clear: mockClear }); customRender(jest.fn(), jest.fn(), false, isRichTextEnabled); - // screen.getByLabelText('Bold').focus(); - // expect(screen.getByRole('textbox')).not.toHaveFocus(); // When we send the right action defaultDispatcher.dispatch({ @@ -148,8 +143,6 @@ describe('SendWysiwygComposer', () => { it('Should focus when receiving a reply_to_event action', async () => { // Given we don't have focus customRender(jest.fn(), jest.fn(), false, isRichTextEnabled); - // screen.getByLabelText('Bold').focus(); - // expect(screen.getByRole('textbox')).not.toHaveFocus(); // When we send the right action defaultDispatcher.dispatch({ @@ -178,7 +171,7 @@ describe('SendWysiwygComposer', () => { }); // Wait for event dispatch to happen - await new Promise((r) => setTimeout(r, 200)); + await flushPromises(); // Then we don't get it because we are disabled expect(screen.getByRole('textbox')).not.toHaveFocus(); From c0282e0351ea18d591dae0f5de4d1a57f33799f4 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 19:09:12 +0200 Subject: [PATCH 11/14] Add check for body --- src/components/views/rooms/EditMessageComposer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 52312e1a998..e75168d4134 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -60,6 +60,9 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; + if (!body) { + 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`; From 39f1dc224c18e99ed0e0c0190ba8d67a068d2a6c Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 19:16:29 +0200 Subject: [PATCH 12/14] Add typeof check for body --- src/components/views/rooms/EditMessageComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index e75168d4134..a9f756d1c2e 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -60,7 +60,7 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; - if (!body) { + if (!body || typeof body !== 'string') { return ""; } const lines = body.split("\n").map(l => l.trim()); From 0a7f44b226eedbe2930cfaa7b00c19f29ec93fd8 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 19:19:32 +0200 Subject: [PATCH 13/14] Add typeof check for body --- src/components/views/rooms/EditMessageComposer.tsx | 3 --- .../views/rooms/wysiwyg_composer/utils/createMessageContent.ts | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index a9f756d1c2e..52312e1a998 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -60,9 +60,6 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; - if (!body || 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`; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index bdf07fb15ec..e6a3baabddb 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -44,6 +44,9 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; + if (!body || 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`; From aae93a9af2807fc7d75d07ba6bb2c8befb6f3fea Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 26 Oct 2022 19:21:42 +0200 Subject: [PATCH 14/14] Cleaner test on body --- .../views/rooms/wysiwyg_composer/utils/createMessageContent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index e6a3baabddb..6d8a9f218e4 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -44,7 +44,7 @@ function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; - if (!body || typeof body !== 'string') { + if (typeof body !== 'string') { return ""; } const lines = body.split("\n").map(l => l.trim());