diff --git a/package.json b/package.json index f0ab2c266b..3973d22fb8 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.2.0", - "@matrix-org/matrix-wysiwyg": "^0.3.0", + "@matrix-org/matrix-wysiwyg": "^0.3.2", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 819afe64a4..4417382b20 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -299,8 +299,10 @@ @import "./views/rooms/_TopUnreadMessagesBar.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @import "./views/rooms/_WhoIsTypingTile.pcss"; -@import "./views/rooms/wysiwyg_composer/_FormattingButtons.pcss"; -@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss"; +@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss"; +@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss"; +@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss"; +@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss"; @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; diff --git a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss new file mode 100644 index 0000000000..73e5fef6e9 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss @@ -0,0 +1,58 @@ +/* +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. +*/ + +.mx_EditWysiwygComposer { + --EditWysiwygComposer-padding-inline: 3px; + + display: flex; + flex-direction: column; + max-width: 100%; /* disable overflow */ + width: auto; + gap: 8px; + padding: 8px var(--EditWysiwygComposer-padding-inline); + + .mx_WysiwygComposer_content { + border-radius: 4px; + border: solid 1px $primary-hairline-color; + background-color: $background; + max-height: 200px; + padding: 3px 6px; + + &:focus { + border-color: rgba($accent, 0.5); /* Only ever used here */ + } + } + + .mx_EditWysiwygComposer_buttons { + display: flex; + flex-flow: row wrap-reverse; /* display "Save" over "Cancel" */ + justify-content: flex-end; + gap: 5px; + margin-inline-start: auto; + + .mx_AccessibleButton { + flex: 1; + box-sizing: border-box; + min-width: 100px; /* magic number to align the edge of the button with the input area */ + } + } + + .mx_FormattingButtons_Button { + &:first-child { + margin-left: 0px; + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss similarity index 98% rename from res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss rename to res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss index 133b66388e..a00f8c7e11 100644 --- a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_WysiwygComposer { +.mx_SendWysiwygComposer { flex: 1; display: flex; flex-direction: column; diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss new file mode 100644 index 0000000000..6a6b68af7c --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -0,0 +1,37 @@ +/* +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. +*/ + +.mx_WysiwygComposer_container { + position: relative; + + @keyframes visualbell { + from { background-color: $visual-bell-bg-color; } + to { background-color: $background; } + } + + .mx_WysiwygComposer_content { + white-space: pre-wrap; + word-wrap: break-word; + outline: none; + overflow-x: hidden; + + /* Force caret nodes to be selected in full so that they can be */ + /* navigated through in a single keypress */ + .caretNode { + user-select: all; + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss similarity index 98% rename from res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss rename to res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss index 36f84ae5f1..499b2b457b 100644 --- a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss @@ -45,7 +45,7 @@ limitations under the License. left: 6px; height: 16px; width: 16px; - background-color: $icon-button-color; + background-color: $tertiary-content; mask-repeat: no-repeat; mask-size: contain; mask-position: center; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 23ba901acd..ab9c27f7fb 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -48,6 +48,7 @@ import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from '../elements/AccessibleButton'; import { options as linkifyOpts } from "../../../linkify-matrix"; import { getParentEventId } from '../../../utils/Reply'; +import { EditWysiwygComposer } from '../rooms/wysiwyg_composer'; const MAX_HIGHLIGHT_LENGTH = 4096; @@ -562,7 +563,10 @@ export default class TextualBody extends React.Component { render() { if (this.props.editState) { - return ; + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + return isWysiwygComposerEnabled ? + : + ; } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index d1521c7b0c..674635d896 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -58,7 +58,8 @@ import { startNewVoiceBroadcastRecording, VoiceBroadcastRecordingsStore, } from '../../../voice-broadcast'; -import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer'; +import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/'; +import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext'; let instanceCount = 0; @@ -78,7 +79,7 @@ function SendButton(props: ISendButtonProps) { ); } -interface IProps { +interface IProps extends MatrixClientProps { room: Room; resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; @@ -89,6 +90,7 @@ interface IProps { } interface IState { + composerContent: string; isComposerEmpty: boolean; haveRecording: boolean; recordingTimeLeftSeconds?: number; @@ -100,13 +102,12 @@ interface IState { showVoiceBroadcastButton: boolean; } -export default class MessageComposer extends React.Component { +export class MessageComposer extends React.Component { private dispatcherRef?: string; private messageComposerInput = createRef(); private voiceRecordingButton = createRef(); private ref: React.RefObject = createRef(); private instanceId: number; - private composerSendMessage?: () => void; private _voiceRecording: Optional; @@ -124,6 +125,7 @@ export default class MessageComposer extends React.Component { this.state = { isComposerEmpty: true, + composerContent: '', haveRecording: false, recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast isMenuOpen: false, @@ -315,7 +317,14 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - this.composerSendMessage?.(); + + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + if (isWysiwygComposerEnabled) { + const { permalinkCreator, relation, replyToEvent } = this.props; + sendMessage(this.state.composerContent, + { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent }); + dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer }); + } }; private onChange = (model: EditorModel) => { @@ -326,6 +335,7 @@ export default class MessageComposer extends React.Component { private onWysiwygChange = (content: string) => { this.setState({ + composerContent: content, isComposerEmpty: content?.length === 0, }); }; @@ -402,16 +412,11 @@ export default class MessageComposer extends React.Component { if (canSendMessages) { if (isWysiwygComposerEnabled) { controls.push( - - { (sendMessage) => { - this.composerSendMessage = sendMessage; - } } - , + onSend={this.sendMessage} + />, ); } else { controls.push( @@ -551,3 +556,6 @@ export default class MessageComposer extends React.Component { ); } } + +const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); +export default MessageComposerWithMatrixClient; diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx new file mode 100644 index 0000000000..c03e87c526 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -0,0 +1,64 @@ +/* +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, { forwardRef, RefObject } from 'react'; +import classNames from 'classnames'; + +import EditorStateTransfer from '../../../../utils/EditorStateTransfer'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { EditionButtons } from './components/EditionButtons'; +import { useWysiwygEditActionHandler } from './hooks/useWysiwygEditActionHandler'; +import { useEditing } from './hooks/useEditing'; +import { useInitialContent } from './hooks/useInitialContent'; + +interface ContentProps { + disabled: boolean; +} + +const Content = forwardRef( + function Content({ disabled }: ContentProps, forwardRef: RefObject) { + useWysiwygEditActionHandler(disabled, forwardRef); + return null; + }, +); + +interface EditWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + editorStateTransfer: EditorStateTransfer; + className?: string; +} + +export function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) { + const initialContent = useInitialContent(editorStateTransfer); + const isReady = !editorStateTransfer || Boolean(initialContent); + + const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(initialContent, editorStateTransfer); + + return isReady && + { (ref) => ( + <> + + + ) + } + ; +} diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx new file mode 100644 index 0000000000..2a485d9975 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -0,0 +1,46 @@ +/* +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, { forwardRef, RefObject } from 'react'; +import { FormattingFunctions } from '@matrix-org/matrix-wysiwyg'; + +import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler'; +import { WysiwygComposer } from './components/WysiwygComposer'; + +interface SendWysiwygComposerProps { + disabled?: boolean; + onChange: (content: string) => void; + onSend: () => void; +} +interface ContentProps { + disabled: boolean; + formattingFunctions: FormattingFunctions; +} + +const Content = forwardRef( + function Content({ disabled, formattingFunctions: wysiwyg }: ContentProps, forwardRef: RefObject) { + useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg); + return null; + }, +); + +export function SendWysiwygComposer(props: SendWysiwygComposerProps) { + return ( + { (ref, wysiwyg) => ( + + ) } + ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx deleted file mode 100644 index c22e3406fa..0000000000 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useCallback, useEffect } from 'react'; -import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { useWysiwyg, Wysiwyg, WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg"; - -import { Editor } from './Editor'; -import { FormattingButtons } from './FormattingButtons'; -import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks'; -import { sendMessage } from './message'; -import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; -import { useRoomContext } from '../../../../contexts/RoomContext'; -import { useWysiwygActionHandler } from './useWysiwygActionHandler'; -import { useSettingValue } from '../../../../hooks/useSettings'; - -interface WysiwygProps { - disabled?: boolean; - onChange: (content: string) => void; - relation?: IEventRelation; - replyToEvent?: MatrixEvent; - permalinkCreator: RoomPermalinkCreator; - includeReplyLegacyFallback?: boolean; - children?: (sendMessage: () => void) => void; -} - -export function WysiwygComposer( - { disabled = false, onChange, children, ...props }: WysiwygProps, -) { - const roomContext = useRoomContext(); - const mxClient = useMatrixClientContext(); - const ctrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); - - function inputEventProcessor(event: WysiwygInputEvent, wysiwyg: Wysiwyg): WysiwygInputEvent | null { - if (event instanceof ClipboardEvent) { - return event; - } - - if ( - (event.inputType === 'insertParagraph' && !ctrlEnterToSend) || - event.inputType === 'sendMessage' - ) { - sendMessage(content, { mxClient, roomContext, ...props }); - wysiwyg.actions.clear(); - ref.current?.focus(); - return null; - } - - return event; - } - - const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ inputEventProcessor }); - - useEffect(() => { - if (!disabled && content !== null) { - onChange(content); - } - }, [onChange, content, disabled]); - - const memoizedSendMessage = useCallback(() => { - sendMessage(content, { mxClient, roomContext, ...props }); - wysiwyg.clear(); - ref.current?.focus(); - }, [content, mxClient, roomContext, wysiwyg, props, ref]); - - useWysiwygActionHandler(disabled, ref); - - return ( -
- - - { children?.(memoizedSendMessage) } -
- ); -} diff --git a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx new file mode 100644 index 0000000000..4fdc99a79c --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx @@ -0,0 +1,37 @@ +/* +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, { MouseEventHandler } from 'react'; + +import { _t } from '../../../../../languageHandler'; +import AccessibleButton from '../../../elements/AccessibleButton'; + +interface EditionButtonsProps { + onCancelClick: MouseEventHandler; + onSaveClick: MouseEventHandler; + isSaveDisabled?: boolean; +} + +export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) { + return
+ + { _t("Cancel") } + + + { _t("Save") } + +
; +} diff --git a/src/components/views/rooms/wysiwyg_composer/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx similarity index 100% rename from src/components/views/rooms/wysiwyg_composer/Editor.tsx rename to src/components/views/rooms/wysiwyg_composer/components/Editor.tsx diff --git a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx similarity index 84% rename from src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx rename to src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 19941ad3f9..00127e5e43 100644 --- a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -15,14 +15,14 @@ limitations under the License. */ import React, { MouseEventHandler } from "react"; -import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; +import { FormattingFunctions, FormattingStates } from "@matrix-org/matrix-wysiwyg"; import classNames from "classnames"; -import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; -import { Alignment } from "../../elements/Tooltip"; -import { KeyboardShortcut } from "../../settings/KeyboardShortcut"; -import { KeyCombo } from "../../../../KeyBindingsManager"; -import { _td } from "../../../../languageHandler"; +import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton"; +import { Alignment } from "../../../elements/Tooltip"; +import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; +import { KeyCombo } from "../../../../../KeyBindingsManager"; +import { _td } from "../../../../../languageHandler"; interface TooltipProps { label: string; @@ -55,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: ReturnType['wysiwyg']; - formattingStates: ReturnType['formattingStates']; + composer: FormattingFunctions; + formattingStates: FormattingStates; } export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) { diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx new file mode 100644 index 0000000000..7dc059ffb2 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -0,0 +1,57 @@ +/* +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, { memo, MutableRefObject, ReactNode, useEffect } from 'react'; +import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; + +import { FormattingButtons } from './FormattingButtons'; +import { Editor } from './Editor'; +import { useInputEventProcessor } from '../hooks/useInputEventProcessor'; + +interface WysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + onSend: () => void; + initialContent?: string; + className?: string; + children?: ( + ref: MutableRefObject, + wysiwyg: FormattingFunctions, + ) => ReactNode; +} + +export const WysiwygComposer = memo(function WysiwygComposer( + { disabled = false, onChange, onSend, initialContent, className, children }: WysiwygComposerProps, +) { + const inputEventProcessor = useInputEventProcessor(onSend); + + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = + useWysiwyg({ initialContent, inputEventProcessor }); + + useEffect(() => { + if (!disabled && content !== null) { + onChange?.(content); + } + }, [onChange, content, disabled]); + + return ( +
+ + + { children?.(ref, wysiwyg) } +
+ ); +}); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts new file mode 100644 index 0000000000..fcd4471cb1 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts @@ -0,0 +1,43 @@ +/* +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 { useCallback, useState } from "react"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { useRoomContext } from "../../../../../contexts/RoomContext"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { endEditing } from "../utils/editing"; +import { editMessage } from "../utils/message"; + +export function useEditing(initialContent: string, editorStateTransfer: EditorStateTransfer) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + const [isSaveDisabled, setIsSaveDisabled] = useState(true); + const [content, setContent] = useState(initialContent); + const onChange = useCallback((_content: string) => { + setContent(_content); + setIsSaveDisabled(_isSaveDisabled => _isSaveDisabled && _content === initialContent); + }, [initialContent]); + + const editMessageMemoized = useCallback(() => + editMessage(content, { roomContext, mxClient, editorStateTransfer }), + [content, roomContext, mxClient, editorStateTransfer], + ); + + const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]); + return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled }; +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts new file mode 100644 index 0000000000..331ea1b6c3 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts @@ -0,0 +1,67 @@ +/* +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 { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { useMemo } from "react"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { useRoomContext } from "../../../../../contexts/RoomContext"; +import { parseEvent } from "../../../../../editor/deserialize"; +import { CommandPartCreator, Part } from "../../../../../editor/parts"; +import SettingsStore from "../../../../../settings/SettingsStore"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +function parseEditorStateTransfer( + editorStateTransfer: EditorStateTransfer, + room: Room, + mxClient: MatrixClient, +): string { + const partCreator = new CommandPartCreator(room, mxClient); + + let parts: Part[]; + if (editorStateTransfer.hasEditorState()) { + // if restoring state from a previous editor, + // restore serialized parts from the state + parts = editorStateTransfer.getSerializedParts().map(p => partCreator.deserializePart(p)); + } else { + // otherwise, either restore serialized parts from localStorage or parse the body of the event + // TODO local storage + // const restoredParts = this.restoreStoredEditorState(partCreator); + + if (editorStateTransfer.getEvent().getContent().format === 'org.matrix.custom.html') { + return editorStateTransfer.getEvent().getContent().formatted_body || ""; + } + + parts = parseEvent(editorStateTransfer.getEvent(), partCreator, { + shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + }); + } + + return parts.reduce((content, part) => content + part.text, ''); + // Todo local storage + // this.saveStoredEditorState(); +} + +export function useInitialContent(editorStateTransfer: EditorStateTransfer) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + return useMemo(() => { + if (editorStateTransfer && roomContext.room) { + return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient); + } + }, [editorStateTransfer, roomContext, mxClient]); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts new file mode 100644 index 0000000000..414b6df45c --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -0,0 +1,40 @@ +/* +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 { WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg"; +import { useCallback } from "react"; + +import { useSettingValue } from "../../../../../hooks/useSettings"; + +export function useInputEventProcessor(onSend: () => void) { + const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend") as boolean; + return useCallback((event: WysiwygInputEvent) => { + if (event instanceof ClipboardEvent) { + return event; + } + + if ( + (event.inputType === 'insertParagraph' && !isCtrlEnter) || + event.inputType === 'sendMessage' + ) { + onSend(); + return null; + } + + return event; + } + , [isCtrlEnter, onSend]); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts new file mode 100644 index 0000000000..b39fe18007 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -0,0 +1,48 @@ +/* +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, useCallback, useRef } from "react"; + +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../../dispatcher/payloads"; +import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../../hooks/useDispatcher"; +import { focusComposer } from "./utils"; + +export function useWysiwygEditActionHandler( + disabled: boolean, + composerElement: RefObject, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + const handler = useCallback((payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled || !composerElement.current) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case Action.FocusEditMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + } + }, [disabled, composerElement, timeoutId, roomContext]); + + useDispatcher(defaultDispatcher, handler); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts new file mode 100644 index 0000000000..b7c18f19c2 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -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 { RefObject, useCallback, useRef } from "react"; +import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; + +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../../dispatcher/payloads"; +import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../../hooks/useDispatcher"; +import { focusComposer } from "./utils"; + +export function useWysiwygSendActionHandler( + disabled: boolean, + composerElement: RefObject, + wysiwyg: FormattingFunctions, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + const handler = useCallback((payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled || !composerElement.current) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case "reply_to_event": + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + case Action.ClearAndFocusSendMessageComposer: + wysiwyg.clear(); + focusComposer(composerElement, context, roomContext, timeoutId); + break; + // TODO: case Action.ComposerInsert: - see SendMessageComposer + } + }, [disabled, composerElement, wysiwyg, timeoutId, roomContext]); + + useDispatcher(defaultDispatcher, handler); +} diff --git a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts similarity index 52% rename from src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts rename to src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 683498d485..eab855e086 100644 --- a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -14,40 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useRef } from "react"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; +import { IRoomState } from "../../../../structures/RoomView"; -import defaultDispatcher from "../../../../dispatcher/dispatcher"; -import { Action } from "../../../../dispatcher/actions"; -import { ActionPayload } from "../../../../dispatcher/payloads"; -import { IRoomState } from "../../../structures/RoomView"; -import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext"; -import { useDispatcher } from "../../../../hooks/useDispatcher"; - -export function useWysiwygActionHandler( - disabled: boolean, - composerElement: React.MutableRefObject, -) { - const roomContext = useRoomContext(); - const timeoutId = useRef(); - - useDispatcher(defaultDispatcher, (payload: ActionPayload) => { - // don't let the user into the composer if it is disabled - all of these branches lead - // to the cursor being in the composer - if (disabled) return; - - const context = payload.context ?? TimelineRenderingType.Room; - - switch (payload.action) { - case "reply_to_event": - case Action.FocusSendMessageComposer: - focusComposer(composerElement, context, roomContext, timeoutId); - break; - // TODO: case Action.ComposerInsert: - see SendMessageComposer - } - }); -} - -function focusComposer( +export function focusComposer( composerElement: React.MutableRefObject, renderingType: TimelineRenderingType, roomContext: IRoomState, diff --git a/src/components/views/rooms/wysiwyg_composer/index.ts b/src/components/views/rooms/wysiwyg_composer/index.ts new file mode 100644 index 0000000000..ec8c9cff23 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/index.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 { SendWysiwygComposer } from './SendWysiwygComposer'; +export { EditWysiwygComposer } from './EditWysiwygComposer'; +export { sendMessage } from './utils/message'; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts new file mode 100644 index 0000000000..cc0d2235bf --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -0,0 +1,117 @@ +/* +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 { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; + +import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; +import { addReplyToMessageContent } from "../../../../../utils/Reply"; + +// Merges favouring the given relation +function attachRelation(content: IContent, relation?: IEventRelation): void { + if (relation) { + content['m.relates_to'] = { + ...(content['m.relates_to'] || {}), + ...relation, + }; + } +} + +function getHtmlReplyFallback(mxEvent: MatrixEvent): string { + const html = mxEvent.getContent().formatted_body; + if (!html) { + return ""; + } + const rootNode = new DOMParser().parseFromString(html, "text/html").body; + const mxReply = rootNode.querySelector("mx-reply"); + return (mxReply && mxReply.outerHTML) || ""; +} + +interface CreateMessageContentParams { + relation?: IEventRelation; + replyToEvent?: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + includeReplyLegacyFallback?: boolean; + editedEvent?: MatrixEvent; +} + +export function createMessageContent( + message: string, + { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true, editedEvent }: + CreateMessageContentParams, +): IContent { + // TODO emote ? + + const isEditing = Boolean(editedEvent); + const isReply = isEditing ? Boolean(editedEvent?.replyEventId) : Boolean(replyToEvent); + + /*const isEmote = containsEmote(model); + if (isEmote) { + model = stripEmoteCommand(model); + } + if (startsWith(model, "//")) { + model = stripPrefix(model, "/"); + } + model = unescapeMessage(model);*/ + + // const body = textSerialize(model); + const body = message; + + const content: IContent = { + // TODO emote + // msgtype: isEmote ? "m.emote" : "m.text", + msgtype: MsgType.Text, + body: body, + }; + + // TODO markdown support + + /*const formattedBody = htmlSerializeIfNeeded(model, { + forceHTML: !!replyToEvent, + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + });*/ + const formattedBody = message; + + if (formattedBody) { + content.format = "org.matrix.custom.html"; + + const htmlPrefix = isReply && isEditing ? getHtmlReplyFallback(editedEvent) : ''; + content.formatted_body = isEditing ? `${htmlPrefix} * ${formattedBody}` : formattedBody; + } + + if (isEditing) { + content['m.new_content'] = { + "msgtype": content.msgtype, + "body": body, + "format": "org.matrix.custom.html", + 'formatted_body': formattedBody, + }; + } + + const newRelation = isEditing ? + { ...relation, 'rel_type': 'm.replace', 'event_id': editedEvent.getId() } + : relation; + + attachRelation(content, newRelation); + + if (!isEditing && replyToEvent && permalinkCreator) { + addReplyToMessageContent(content, replyToEvent, { + permalinkCreator, + includeLegacyFallback: includeReplyLegacyFallback, + }); + } + + return content; +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts new file mode 100644 index 0000000000..a0cb608383 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/editing.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 { EventStatus, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { IRoomState } from "../../../../structures/RoomView"; +import dis from '../../../../../dispatcher/dispatcher'; +import { Action } from "../../../../../dispatcher/actions"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +export function endEditing(roomContext: IRoomState) { + // todo local storage + // localStorage.removeItem(this.editorRoomKey); + // localStorage.removeItem(this.editorStateKey); + + // close the event editing and focus composer + dis.dispatch({ + action: Action.EditEvent, + event: null, + timelineRenderingType: roomContext.timelineRenderingType, + }); + dis.dispatch({ + action: Action.FocusSendMessageComposer, + context: roomContext.timelineRenderingType, + }); +} + +export function cancelPreviousPendingEdit(mxClient: MatrixClient, editorStateTransfer: EditorStateTransfer) { + const originalEvent = editorStateTransfer.getEvent(); + const previousEdit = originalEvent.replacingEvent(); + if (previousEdit && ( + previousEdit.status === EventStatus.QUEUED || + previousEdit.status === EventStatus.NOT_SENT + )) { + mxClient.cancelPendingEvent(previousEdit); + } +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts new file mode 100644 index 0000000000..88715dda38 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts @@ -0,0 +1,30 @@ +/* +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 { IContent } from "matrix-js-sdk/src/matrix"; + +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +export function isContentModified(newContent: IContent, editorStateTransfer: EditorStateTransfer): boolean { + // if nothing has changed then bail + const oldContent = editorStateTransfer.getEvent().getContent(); + if (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && + oldContent["format"] === newContent["format"] && + oldContent["formatted_body"] === newContent["formatted_body"]) { + return false; + } + return true; +} diff --git a/src/components/views/rooms/wysiwyg_composer/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts similarity index 56% rename from src/components/views/rooms/wysiwyg_composer/message.ts rename to src/components/views/rooms/wysiwyg_composer/utils/message.ts index 5569af02a9..dbea29c848 100644 --- a/src/components/views/rooms/wysiwyg_composer/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -16,93 +16,35 @@ limitations under the License. import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer"; import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; -import { PosthogAnalytics } from "../../../../PosthogAnalytics"; -import SettingsStore from "../../../../settings/SettingsStore"; -import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics"; -import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; -import { doMaybeLocalRoomAction } from "../../../../utils/local-room"; -import { CHAT_EFFECTS } from "../../../../effects"; -import { containsEmoji } from "../../../../effects/utils"; -import { IRoomState } from "../../../structures/RoomView"; -import dis from '../../../../dispatcher/dispatcher'; -import { addReplyToMessageContent } from "../../../../utils/Reply"; - -// Merges favouring the given relation -function attachRelation(content: IContent, relation?: IEventRelation): void { - if (relation) { - content['m.relates_to'] = { - ...(content['m.relates_to'] || {}), - ...relation, - }; - } -} +import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; +import SettingsStore from "../../../../../settings/SettingsStore"; +import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics"; +import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; +import { doMaybeLocalRoomAction } from "../../../../../utils/local-room"; +import { CHAT_EFFECTS } from "../../../../../effects"; +import { containsEmoji } from "../../../../../effects/utils"; +import { IRoomState } from "../../../../structures/RoomView"; +import dis from '../../../../../dispatcher/dispatcher'; +import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog"; +import { endEditing, cancelPreviousPendingEdit } from "./editing"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { createMessageContent } from "./createMessageContent"; +import { isContentModified } from "./isContentModified"; interface SendMessageParams { mxClient: MatrixClient; relation?: IEventRelation; replyToEvent?: MatrixEvent; roomContext: IRoomState; - permalinkCreator: RoomPermalinkCreator; + permalinkCreator?: RoomPermalinkCreator; includeReplyLegacyFallback?: boolean; } -// exported for tests -export function createMessageContent( - message: string, - { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }: - Omit, -): IContent { - // TODO emote ? - - /*const isEmote = containsEmote(model); - if (isEmote) { - model = stripEmoteCommand(model); - } - if (startsWith(model, "//")) { - model = stripPrefix(model, "/"); - } - model = unescapeMessage(model);*/ - - // const body = textSerialize(model); - const body = message; - - const content: IContent = { - // TODO emote - // msgtype: isEmote ? "m.emote" : "m.text", - msgtype: "m.text", - body: body, - }; - - // TODO markdown support - - /*const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: !!replyToEvent, - useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), - });*/ - const formattedBody = message; - - if (formattedBody) { - content.format = "org.matrix.custom.html"; - content.formatted_body = formattedBody; - } - - attachRelation(content, relation); - - if (replyToEvent) { - addReplyToMessageContent(content, replyToEvent, { - permalinkCreator, - includeLegacyFallback: includeReplyLegacyFallback, - }); - } - - return content; -} - export function sendMessage( - message: string, + html: string, { roomContext, mxClient, ...params }: SendMessageParams, ) { const { relation, replyToEvent } = params; @@ -113,6 +55,7 @@ export function sendMessage( eventName: "Composer", isEditing: false, isReply: Boolean(replyToEvent), + // TODO thread inThread: relation?.rel_type === THREAD_RELATION_TYPE.name, }; @@ -133,7 +76,7 @@ export function sendMessage( if (!content) { content = createMessageContent( - message, + html, params, ); } @@ -197,3 +140,68 @@ export function sendMessage( return prom; } + +interface EditMessageParams { + mxClient: MatrixClient; + roomContext: IRoomState; + editorStateTransfer: EditorStateTransfer; +} + +export function editMessage( + html: string, + { roomContext, mxClient, editorStateTransfer }: EditMessageParams, +) { + const editedEvent = editorStateTransfer.getEvent(); + + PosthogAnalytics.instance.trackEvent({ + eventName: "Composer", + isEditing: true, + inThread: Boolean(editedEvent?.getThread()), + isReply: Boolean(editedEvent.replyEventId), + }); + + // TODO emoji + // Replace emoticon at the end of the message + /* if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { + const caret = this.editorRef.current?.getCaret(); + const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd); + this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON); + }*/ + const editContent = createMessageContent(html, { editedEvent }); + const newContent = editContent["m.new_content"]; + + const shouldSend = true; + + if (newContent?.body === '') { + cancelPreviousPendingEdit(mxClient, editorStateTransfer); + createRedactEventDialog({ + mxEvent: editedEvent, + onCloseDialog: () => { + endEditing(roomContext); + }, + }); + return; + } + + let response: Promise | undefined; + + // If content is modified then send an updated event into the room + if (isContentModified(newContent, editorStateTransfer)) { + const roomId = editedEvent.getRoomId(); + + // TODO Slash Commands + + if (shouldSend) { + cancelPreviousPendingEdit(mxClient, editorStateTransfer); + + const event = editorStateTransfer.getEvent(); + const threadId = event.threadRootId || null; + + response = mxClient.sendMessage(roomId, threadId, editContent); + dis.dispatch({ action: "message_sent" }); + } + } + + endEditing(roomContext); + return response; +} diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 2b2e443e81..7d2d935f70 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -75,6 +75,11 @@ export enum Action { */ FocusSendMessageComposer = "focus_send_message_composer", + /** + * Clear the to the send message composer. Should be used with a FocusComposerPayload. + */ + ClearAndFocusSendMessageComposer = "clear_focus_send_message_composer", + /** * Focuses the user's cursor to the edit message composer. Should be used with a FocusComposerPayload. */ diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index 8ebeda676a..debeb7b5e6 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -21,7 +21,8 @@ import { MatrixEvent, MsgType, RoomMember } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../test-utils"; -import MessageComposer from "../../../../src/components/views/rooms/MessageComposer"; +import MessageComposer, { MessageComposer as MessageComposerClass } + from "../../../../src/components/views/rooms/MessageComposer"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import RoomContext from "../../../../src/contexts/RoomContext"; @@ -39,7 +40,7 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send import { E2EStatus } from "../../../../src/utils/ShieldUtils"; import { addTextToComposer } from "../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; -import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { SendWysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts @@ -106,7 +107,7 @@ describe("MessageComposer", () => { it("should call notifyTimelineHeightChanged() for the same context", () => { dis.dispatch({ action: "reply_to_event", - context: (wrapper.instance as unknown as MessageComposer).context, + context: (wrapper.instance as unknown as MessageComposerClass).context, }); wrapper.update(); @@ -207,7 +208,7 @@ describe("MessageComposer", () => { let stateBefore: any; beforeEach(() => { - wrapper = wrapAndRender({ room }); + wrapper = wrapAndRender({ room }).children(); stateBefore = { ...wrapper.instance().state }; resizeCallback("test", {}); wrapper.update(); @@ -220,7 +221,8 @@ describe("MessageComposer", () => { describe("when a resize to narrow event occurred in UIStore", () => { beforeEach(() => { - wrapper = wrapAndRender({ room }, true, true); + wrapper = wrapAndRender({ room }, true, true).children(); + wrapper.setState({ isMenuOpen: true, isStickerPickerOpen: true, @@ -240,7 +242,7 @@ describe("MessageComposer", () => { describe("when a resize to non-narrow event occurred in UIStore", () => { beforeEach(() => { - wrapper = wrapAndRender({ room }, true, false); + wrapper = wrapAndRender({ room }, true, false).children(); wrapper.setState({ isMenuOpen: true, isStickerPickerOpen: true, @@ -346,14 +348,14 @@ describe("MessageComposer", () => { }); }); - it('should render WysiwygComposer', () => { + it('should render SendWysiwygComposer', () => { const room = mkStubRoom("!roomId:server", "Room 1", cli); SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true); const wrapper = wrapAndRender({ room }); SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false); - expect(wrapper.find(WysiwygComposer)).toBeTruthy(); + expect(wrapper.find(SendWysiwygComposer)).toBeTruthy(); }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx new file mode 100644 index 0000000000..72fd52be57 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -0,0 +1,224 @@ +/* +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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { WysiwygProps } from "@matrix-org/matrix-wysiwyg"; + +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +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 { EditWysiwygComposer } + from "../../../../../src/components/views/rooms/wysiwyg_composer"; +import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; + +const mockClear = jest.fn(); + +let initialContent: string; +const defaultContent = 'html'; +let mockContent = defaultContent; + +// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement +// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts +jest.mock("@matrix-org/matrix-wysiwyg", () => ({ + useWysiwyg: (props: WysiwygProps) => { + initialContent = props.initialContent; + return { + ref: { current: null }, + content: mockContent, + isWysiwygReady: true, + wysiwyg: { clear: mockClear }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; + }, +})); + +describe('EditWysiwygComposer', () => { + afterEach(() => { + jest.resetAllMocks(); + mockContent = defaultContent; + }); + + const mockClient = createTestClient(); + const mockEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { + "msgtype": "m.text", + "body": "Replying to this", + "format": "org.matrix.custom.html", + "formatted_body": "Replying to this new content", + }, + 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 editorStateTransfer = new EditorStateTransfer(mockEvent); + + const customRender = (disabled = false, _editorStateTransfer = editorStateTransfer) => { + return render( + + + + + , + ); + }; + + describe('Initialize with content', () => { + it('Should initialize useWysiwyg with html content', async () => { + // When + customRender(true); + + // Then + expect(initialContent).toBe(mockEvent.getContent()['formatted_body']); + }); + + it('Should initialize useWysiwyg with plain text content', async () => { + // When + const mockEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { + "msgtype": "m.text", + "body": "Replying to this", + }, + event: true, + }); + const editorStateTransfer = new EditorStateTransfer(mockEvent); + + customRender(true, editorStateTransfer); + + // Then + expect(initialContent).toBe(mockEvent.getContent().body); + }); + }); + + describe('Edit and save actions', () => { + const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + afterEach(() => { + spyDispatcher.mockRestore(); + }); + + it('Should cancel edit on cancel button click', async () => { + // When + customRender(true); + (await screen.findByText('Cancel')).click(); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: null, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + expect(spyDispatcher).toBeCalledWith({ + action: Action.FocusSendMessageComposer, + context: defaultRoomContext.timelineRenderingType, + }); + }); + + it('Should send message on save button click', async () => { + // When + const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + + const renderer = customRender(true); + + mockContent = 'my new content'; + renderer.rerender( + + + + ); + + (await screen.findByText('Save')).click(); + + // Then + const expectedContent = { + "body": mockContent, + "format": "org.matrix.custom.html", + "formatted_body": ` * ${mockContent}`, + "m.new_content": { + "body": mockContent, + "format": "org.matrix.custom.html", + "formatted_body": mockContent, + "msgtype": "m.text", + }, + "m.relates_to": { + "event_id": mockEvent.getId(), + "rel_type": "m.replace", + }, + "msgtype": "m.text", + }; + expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent); + expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' }); + }); + }); + + it('Should focus when receiving an Action.FocusEditMessageComposer action', async () => { + // Given we don't have focus + customRender(); + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // When we send the right action + defaultDispatcher.dispatch({ + action: Action.FocusEditMessageComposer, + 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(true); + expect(screen.getByRole('textbox')).not.toHaveFocus(); + + // When we send an action that would cause us to get focus + defaultDispatcher.dispatch({ + action: Action.FocusEditMessageComposer, + context: null, + }); + // (Send a second event to exercise the clearTimeout logic) + defaultDispatcher.dispatch({ + action: Action.FocusEditMessageComposer, + 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(); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx new file mode 100644 index 0000000000..20148b802a --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -0,0 +1,150 @@ +/* +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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { WysiwygProps } from "@matrix-org/matrix-wysiwyg"; + +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +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 { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; + +const mockClear = jest.fn(); + +// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement +// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts +jest.mock("@matrix-org/matrix-wysiwyg", () => ({ + useWysiwyg: (props: WysiwygProps) => { + return { + ref: { current: null }, + content: 'html', + isWysiwygReady: true, + wysiwyg: { clear: mockClear }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; + }, +})); + +describe('SendWysiwygComposer', () => { + 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) => { + 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(); + + // 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); + }); + + 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(); + + // 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); + 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(); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx deleted file mode 100644 index 583bf1d36d..0000000000 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import "@testing-library/jest-dom"; -import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg"; - -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -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 { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; -import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; - -// Work around missing ClipboardEvent type -class MyClipbardEvent {} -window.ClipboardEvent = MyClipbardEvent as any; - -let inputEventProcessor: InputEventProcessor | null = null; - -// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement -// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts -jest.mock("@matrix-org/matrix-wysiwyg", () => ({ - useWysiwyg: (props: WysiwygProps) => { - inputEventProcessor = props.inputEventProcessor ?? null; - return { - ref: { current: null }, - content: 'html', - isWysiwygReady: true, - wysiwyg: { clear: () => void 0 }, - formattingStates: { - bold: 'enabled', - italic: 'enabled', - underline: 'enabled', - strikeThrough: 'enabled', - }, - }; - }, -})); - -describe('WysiwygComposer', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - const permalinkCreator = jest.fn() as any; - 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, {}); - - let sendMessage: () => void; - const customRender = (onChange = (_content: string) => void 0, disabled = false) => { - return render( - - - - { (_sendMessage) => { - sendMessage = _sendMessage; - } } - - , - ); - }; - - it('Should have contentEditable at false when disabled', () => { - // When - customRender(null, true); - - // Then - expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "false"); - }); - - it('Should call onChange handler', (done) => { - const html = 'html'; - customRender((content) => { - expect(content).toBe((html)); - done(); - }); - // act(() => callOnChange(html)); - }); - - it('Should send message, call clear and focus the textbox', async () => { - // When - const html = 'html'; - await new Promise((resolve) => { - customRender(() => resolve(null)); - }); - act(() => sendMessage()); - - // Then - const expectedContent = { - "body": html, - "format": "org.matrix.custom.html", - "formatted_body": html, - "msgtype": "m.text", - }; - expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent); - expect(screen.getByRole('textbox')).toHaveFocus(); - }); - - it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => { - // Given we don't have focus - customRender(() => {}, false); - 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 when receiving a reply_to_event action', async () => { - // Given we don't have focus - customRender(() => {}, false); - 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(() => {}, 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, - }); - - // 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(); - }); - - it('sends a message when Enter is pressed', async () => { - // Given a composer - customRender(() => {}, false); - - // When we tell its inputEventProcesser that the user pressed Enter - const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); - const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; - inputEventProcessor(event, wysiwyg); - - // Then it sends a message - expect(mockClient.sendMessage).toBeCalledWith( - "myfakeroom", - null, - { - "body": "html", - "format": "org.matrix.custom.html", - "formatted_body": "html", - "msgtype": "m.text", - }, - ); - // TODO: plain text body above is wrong - will be fixed when we provide markdown for it - }); - - describe('when settings require Ctrl+Enter to send', () => { - beforeEach(() => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { - if (name === "MessageComposerInput.ctrlEnterToSend") return true; - }); - }); - - it('does not send a message when Enter is pressed', async () => { - // Given a composer - customRender(() => {}, false); - - // When we tell its inputEventProcesser that the user pressed Enter - const event = new InputEvent("input", { inputType: "insertParagraph" }); - const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; - inputEventProcessor(event, wysiwyg); - - // Then it does not send a message - expect(mockClient.sendMessage).toBeCalledTimes(0); - }); - - it('sends a message when Ctrl+Enter is pressed', async () => { - // Given a composer - customRender(() => {}, false); - - // When we tell its inputEventProcesser that the user pressed Ctrl+Enter - const event = new InputEvent("input", { inputType: "sendMessage" }); - const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; - inputEventProcessor(event, wysiwyg); - - // Then it sends a message - expect(mockClient.sendMessage).toBeCalledTimes(1); - }); - }); -}); - diff --git a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx similarity index 95% rename from test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx rename to test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx index 6c3e8573ae..e935b62ae5 100644 --- a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx @@ -18,7 +18,8 @@ import React from 'react'; import { render, screen } from "@testing-library/react"; import userEvent from '@testing-library/user-event'; -import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/FormattingButtons"; +import { FormattingButtons } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; describe('FormattingButtons', () => { const wysiwyg = { diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx new file mode 100644 index 0000000000..e7e21ca839 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -0,0 +1,152 @@ +/* +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 "@testing-library/jest-dom"; +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"; + +// Work around missing ClipboardEvent type +class MyClipboardEvent {} +window.ClipboardEvent = MyClipboardEvent as any; + +let inputEventProcessor: InputEventProcessor | null = null; + +// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement +// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts +jest.mock("@matrix-org/matrix-wysiwyg", () => ({ + useWysiwyg: (props: WysiwygProps) => { + inputEventProcessor = props.inputEventProcessor ?? null; + return { + ref: { current: null }, + content: 'html', + isWysiwygReady: true, + wysiwyg: { clear: () => void 0 }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; + }, +})); + +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) => { + 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 call onChange handler', (done) => { + const html = 'html'; + customRender((content) => { + expect(content).toBe((html)); + done(); + }, jest.fn()); + }); + + it('Should call onSend when Enter is pressed ', () => { + //When + const onSend = jest.fn(); + customRender(jest.fn(), onSend); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(onSend).toBeCalledTimes(1); + }); + + describe('When settings require Ctrl+Enter to send', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "MessageComposerInput.ctrlEnterToSend") return true; + }); + }); + + it('Should not call onSend when Enter is pressed', async () => { + // Given a composer + const onSend = jest.fn(); + customRender(() => {}, onSend, false); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("input", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it does not send a message + expect(onSend).toBeCalledTimes(0); + }); + + it('Should send a message when Ctrl+Enter is pressed', async () => { + // Given a composer + const onSend = jest.fn(); + customRender(() => {}, onSend, false); + + // When we tell its inputEventProcesser that the user pressed Ctrl+Enter + const event = new InputEvent("input", { inputType: "sendMessage" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(onSend).toBeCalledTimes(1); + }); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts new file mode 100644 index 0000000000..a4335b2bf1 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts @@ -0,0 +1,133 @@ +/* +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 { mkEvent } from "../../../../../test-utils"; +import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks"; +import { createMessageContent } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/createMessageContent"; + +describe('createMessageContent', () => { + const permalinkCreator = { + forEvent(eventId: string): string { + return "$$permalink$$"; + }, + } as RoomPermalinkCreator; + const message = 'hello world'; + const mockEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { "msgtype": "m.text", "body": "Replying to this" }, + event: true, + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("Should create html message", () => { + // When + const content = createMessageContent(message, { permalinkCreator }); + + // Then + expect(content).toEqual({ + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": message, + "msgtype": "m.text", + }); + }); + + it('Should add reply to message content', () => { + // When + const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent }); + + // Then + expect(content).toEqual({ + "body": "> Replying to this\n\nhello world", + "format": "org.matrix.custom.html", + "formatted_body": "
In reply to" + + " myfakeuser"+ + "
Replying to this
hello world", + "msgtype": "m.text", + "m.relates_to": { + "m.in_reply_to": { + "event_id": mockEvent.getId(), + }, + }, + }); + }); + + it("Should add relation to message", () => { + // When + const relation = { + rel_type: "m.thread", + event_id: "myFakeThreadId", + }; + const content = createMessageContent(message, { permalinkCreator, relation }); + + // Then + expect(content).toEqual({ + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": message, + "msgtype": "m.text", + "m.relates_to": { + "event_id": "myFakeThreadId", + "rel_type": "m.thread", + }, + }); + }); + + it('Should add fields related to edition', () => { + // When + const editedEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser2', + content: { + "msgtype": "m.text", + "body": "First message", + "formatted_body": "First Message", + "m.relates_to": { + "m.in_reply_to": { + "event_id": 'eventId', + }, + } }, + event: true, + }); + const content = + createMessageContent(message, { permalinkCreator, editedEvent }); + + // Then + expect(content).toEqual({ + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": ` * ${message}`, + "msgtype": "m.text", + "m.new_content": { + "body": message, + "format": "org.matrix.custom.html", + "formatted_body": message, + "msgtype": "m.text", + }, + "m.relates_to": { + "event_id": editedEvent.getId(), + "rel_type": "m.replace", + }, + }); + }); +}); diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts similarity index 53% rename from test/components/views/rooms/wysiwyg_composer/message-test.ts rename to test/components/views/rooms/wysiwyg_composer/utils/message-test.ts index 79197a3188..9d13f28176 100644 --- a/test/components/views/rooms/wysiwyg_composer/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -14,15 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message"; -import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; -import { Layout } from "../../../../../src/settings/enums/Layout"; -import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; -import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel"; -import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; +import { EventStatus } from "matrix-js-sdk/src/matrix"; + +import { IRoomState } from "../../../../../../src/components/structures/RoomView"; +import { editMessage, sendMessage } + from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message"; +import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils"; +import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; +import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks"; +import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer"; +import * as ConfirmRedactDialog + from "../../../../../../src/components/views/dialogs/ConfirmRedactDialog"; describe('message', () => { const permalinkCreator = { @@ -35,117 +39,30 @@ describe('message', () => { type: "m.room.message", room: 'myfakeroom', user: 'myfakeuser', - content: { "msgtype": "m.text", "body": "Replying to this" }, + content: { + "msgtype": "m.text", + "body": "Replying to this", + "format": 'org.matrix.custom.html', + "formatted_body": 'Replying to this', + }, event: true, }); + const mockClient = createTestClient(); + 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 spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + afterEach(() => { jest.resetAllMocks(); }); - describe('createMessageContent', () => { - it("Should create html message", () => { - // When - const content = createMessageContent(message, { permalinkCreator }); - - // Then - expect(content).toEqual({ - "body": message, - "format": "org.matrix.custom.html", - "formatted_body": message, - "msgtype": "m.text", - }); - }); - - it('Should add reply to message content', () => { - // When - const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent }); - - // Then - expect(content).toEqual({ - "body": "> Replying to this\n\nhello world", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to" + - " myfakeuser"+ - "
Replying to this
hello world", - "msgtype": "m.text", - "m.relates_to": { - "m.in_reply_to": { - "event_id": mockEvent.getId(), - }, - }, - }); - }); - - it("Should add relation to message", () => { - // When - const relation = { - rel_type: "m.thread", - event_id: "myFakeThreadId", - }; - const content = createMessageContent(message, { permalinkCreator, relation }); - - // Then - expect(content).toEqual({ - "body": message, - "format": "org.matrix.custom.html", - "formatted_body": message, - "msgtype": "m.text", - "m.relates_to": { - "event_id": "myFakeThreadId", - "rel_type": "m.thread", - }, - }); - }); - }); - describe('sendMessage', () => { - const mockClient = createTestClient(); - const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any; - mockRoom.findEventById = jest.fn(eventId => { - return eventId === mockEvent.getId() ? mockEvent : null; - }); - - const defaultRoomContext: IRoomState = { - room: mockRoom, - roomLoading: true, - peekLoading: false, - shouldPeek: true, - membersLoaded: false, - numUnreadMessages: 0, - canPeek: false, - showApps: false, - isPeeking: false, - showRightPanel: true, - joining: false, - atEndOfLiveTimeline: true, - showTopUnreadMessagesBar: false, - statusBarVisible: false, - canReact: false, - canSendMessages: false, - layout: Layout.Group, - lowBandwidth: false, - alwaysShowTimestamps: false, - showTwelveHourTimestamps: false, - readMarkerInViewThresholdMs: 3000, - readMarkerOutOfViewThresholdMs: 30000, - showHiddenEvents: false, - showReadReceipts: true, - showRedactions: true, - showJoinLeaves: true, - showAvatarChanges: true, - showDisplaynameChanges: true, - matrixClientIsReady: false, - timelineRenderingType: TimelineRenderingType.Room, - liveTimeline: undefined, - canSelfRedact: false, - resizing: false, - narrow: false, - activeCall: null, - }; - - const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); - it('Should not send empty html message', async () => { // When await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator }); @@ -231,4 +148,78 @@ describe('message', () => { ); }); }); + + describe('editMessage', () => { + const editorStateTransfer = new EditorStateTransfer(mockEvent); + + it('Should cancel editing and ask for event removal when message is empty', async () => { + // When + const mockCreateRedactEventDialog = jest.spyOn(ConfirmRedactDialog, 'createRedactEventDialog'); + + const mockEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { "msgtype": "m.text", "body": "Replying to this" }, + event: true, + }); + const replacingEvent = mkEvent({ + type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', + content: { "msgtype": "m.text", "body": "ReplacingEvent" }, + event: true, + }); + replacingEvent.setStatus(EventStatus.QUEUED); + mockEvent.makeReplaced(replacingEvent); + const editorStateTransfer = new EditorStateTransfer(mockEvent); + + await editMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer }); + + // Then + expect(mockClient.sendMessage).toBeCalledTimes(0); + expect(mockClient.cancelPendingEvent).toBeCalledTimes(1); + expect(mockCreateRedactEventDialog).toBeCalledTimes(1); + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it('Should do nothing if the content is unmodified', async () => { + // When + await editMessage( + mockEvent.getContent().body, + { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer }); + + // Then + expect(mockClient.sendMessage).toBeCalledTimes(0); + }); + + it('Should send a message when the content is modified', async () => { + // When + const newMessage = `${mockEvent.getContent().body} new content`; + await editMessage( + newMessage, + { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer }); + + // Then + const { msgtype, format } = mockEvent.getContent(); + const expectedContent = { + "body": newMessage, + "formatted_body": ` * ${newMessage}`, + "m.new_content": { + "body": "Replying to this new content", + "format": "org.matrix.custom.html", + "formatted_body": "Replying to this new content", + "msgtype": "m.text", + }, + "m.relates_to": { + "event_id": mockEvent.getId(), + "rel_type": "m.replace", + }, + msgtype, + format, + }; + expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent); + expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' }); + }); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 8bd6e3d460..97a17e3b70 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -179,6 +179,7 @@ export function createTestClient(): MatrixClient { sendToDevice: jest.fn().mockResolvedValue(undefined), queueToDevice: jest.fn().mockResolvedValue(undefined), encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined), + cancelPendingEvent: jest.fn(), getMediaHandler: jest.fn().mockReturnValue({ setVideoInput: jest.fn(), diff --git a/yarn.lock b/yarn.lock index add14d4c3e..d1e87319af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1660,10 +1660,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8" integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww== -"@matrix-org/matrix-wysiwyg@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.0.tgz#9a0b996c47fbb63fb235a0810b678158b253f721" - integrity sha512-m33qOo64VIZRqzMZ5vJ9m2gYns+sCaFFy3R5Nn9JfDnldQ1oh+ra611I9keFmO/Ls6548ZN8hUkv+49Ua3iBHA== +"@matrix-org/matrix-wysiwyg@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.2.tgz#586f3ad2f4a7bf39d8e2063630c52294c877bcd6" + integrity sha512-Q6Ntj2q1/7rVUlro94snn9eZy/3EbrGqaq5nqNMbttXcnFzYtgligDV1avViB4Um6ZRdDOxnQEPkMca/SqYSmw== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz": version "3.2.8"