From 460f60e99d447fe1e47b14f6e223acbf1413c4c0 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 12:45:51 +0200 Subject: [PATCH] First attempt to make the edition works in the WysiwygComposer --- src/components/views/messages/TextualBody.tsx | 6 +- .../views/rooms/BasicMessageComposer.tsx | 2 + .../views/rooms/EditMessageComposer.tsx | 1 + .../views/rooms/MessageComposer.tsx | 34 ++-- .../wysiwyg_composer/EditWysiwygComposer.tsx | 105 ++++++++++++ .../wysiwyg_composer/SendWysiwygComposer.tsx | 46 ++++++ .../wysiwyg_composer/WysiwygComposer.tsx | 68 -------- .../components/EditionButtons.tsx | 36 ++++ .../{ => components}/Editor.tsx | 0 .../{ => components}/FormattingButtons.tsx | 13 +- .../components/WysiwygComposer.tsx | 49 ++++++ .../hooks/useWysiwygEditActionHandler.ts | 48 ++++++ .../hooks/useWysiwygSendActionHandler.ts | 56 +++++++ .../utils.ts} | 36 +--- .../views/rooms/wysiwyg_composer/index.ts | 19 +++ .../views/rooms/wysiwyg_composer/types.ts | 21 +++ .../utils/createMessageContent.ts | 117 +++++++++++++ .../rooms/wysiwyg_composer/utils/editing.ts | 50 ++++++ .../utils/isContentModified.ts | 30 ++++ .../wysiwyg_composer/{ => utils}/message.ts | 155 +++++++++--------- src/dispatcher/actions.ts | 5 + .../views/rooms/MessageComposer-test.tsx | 2 +- .../FormattingButtons-test.tsx | 2 +- .../wysiwyg_composer/WysiwygComposer-test.tsx | 2 +- .../rooms/wysiwyg_composer/message-test.ts | 2 +- 25 files changed, 705 insertions(+), 200 deletions(-) create mode 100644 src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx delete mode 100644 src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx rename src/components/views/rooms/wysiwyg_composer/{ => components}/Editor.tsx (100%) rename src/components/views/rooms/wysiwyg_composer/{ => components}/FormattingButtons.tsx (87%) create mode 100644 src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts rename src/components/views/rooms/wysiwyg_composer/{useWysiwygActionHandler.ts => hooks/utils.ts} (52%) create mode 100644 src/components/views/rooms/wysiwyg_composer/index.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/types.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/editing.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts rename src/components/views/rooms/wysiwyg_composer/{ => utils}/message.ts (57%) diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 23ba901acd..983cbe51e3 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/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d74c7b5148..962059091c 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -833,6 +833,8 @@ export default class BasicMessageEditor extends React.Component } public insertPlaintext(text: string): void { + console.log('insertPlaintext', text); + debugger; this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 52312e1a99..bb01454127 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -350,6 +350,7 @@ class EditMessageComposer extends React.Component { +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,15 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - this.composerSendMessage?.(); + // 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 +336,7 @@ export default class MessageComposer extends React.Component { private onWysiwygChange = (content: string) => { this.setState({ + composerContent: content, isComposerEmpty: content?.length === 0, }); }; @@ -406,16 +417,10 @@ export default class MessageComposer extends React.Component { if (canSendMessages) { if (isWysiwygComposerEnabled) { controls.push( - - { (sendMessage) => { - this.composerSendMessage = sendMessage; - } } - , + />, ); } else { controls.push( @@ -555,3 +560,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..17b664410c --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -0,0 +1,105 @@ +/* +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, useMemo } from 'react'; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { useRoomContext } from '../../../../contexts/RoomContext'; +import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; +import EditorStateTransfer from '../../../../utils/EditorStateTransfer'; +import { CommandPartCreator, Part } from '../../../../editor/parts'; +import { IRoomState } from '../../../structures/RoomView'; +import SettingsStore from '../../../../settings/SettingsStore'; +import { parseEvent } from '../../../../editor/deserialize'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { EditionButtons } from './components/EditionButtons'; +import { useWysiwygEditActionHandler } from './hooks/useWysiwygEditActionHandler'; +import { endEditing } from './utils/editing'; +import { editMessage } from './utils/message'; + +function parseEditorStateTransfer( + editorStateTransfer: EditorStateTransfer, + roomContext: IRoomState, + mxClient: MatrixClient, +) { + if (!roomContext.room) { + return; + } + + const { room } = roomContext; + + 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(); +} + +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; +} + +export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiwygComposerProps) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + const initialContent = useMemo(() => { + if (editorStateTransfer) { + return parseEditorStateTransfer(editorStateTransfer, roomContext, mxClient); + } + }, [editorStateTransfer, roomContext, mxClient]); + const isReady = !editorStateTransfer || Boolean(initialContent); + + return isReady && { (ref, wysiwyg, content) => ( + <> + + endEditing(roomContext)} onSaveClick={() => editMessage(content, { roomContext, mxClient, editorStateTransfer })} /> + ) + } + ; +} 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..577374e116 --- /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 { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { Wysiwyg } from './types'; + +interface SendWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; +} + +export function SendWysiwygComposer(props: SendWysiwygComposerProps) { + return ( + { (ref, wysiwyg) => ( + + ) } + ); +} + +interface ContentProps { + disabled: boolean; + wysiwyg: Wysiwyg; +} + +const Content = forwardRef( + function Content({ disabled, wysiwyg }: ContentProps, forwardRef: RefObject) { + useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg); + return null; + }, +); 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 8701f5be77..0000000000 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ /dev/null @@ -1,68 +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 } 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'; - -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 { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); - - 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..20a3df2a7f --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx @@ -0,0 +1,36 @@ +/* +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; +} + +export function EditionButtons({ onCancelClick, onSaveClick }: 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 87% rename from src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx rename to src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 19941ad3f9..c806c27861 100644 --- a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -18,11 +18,12 @@ import React, { MouseEventHandler } from "react"; import { useWysiwyg } 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"; +import { Wysiwyg } from "../types"; interface TooltipProps { label: string; @@ -55,7 +56,7 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: ReturnType['wysiwyg']; + composer: Wysiwyg; formattingStates: ReturnType['formattingStates']; } 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..3e63c35fe3 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MutableRefObject, ReactNode, useEffect } from 'react'; +import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +import { FormattingButtons } from './FormattingButtons'; +import { Editor } from './Editor'; +import { Wysiwyg } from '../types'; + +interface WysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + initialContent?: string; + children?: (ref: MutableRefObject, wysiwyg: Wysiwyg, content: string) => ReactNode; +} + +export function WysiwygComposer( + { disabled = false, onChange, initialContent, children }: WysiwygComposerProps, +) { + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ initialContent }); + + useEffect(() => { + if (!disabled && content !== null) { + onChange?.(content); + } + }, [onChange, content, disabled]); + + return ( +
+ + + { children?.(ref, wysiwyg, content) } +
+ ); +} 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..2cbd7cf52c --- /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.FocusSendMessageComposer: + 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..41169a4e2d --- /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 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 { Wysiwyg } from "../types"; +import { focusComposer } from "./utils"; + +export function useWysiwygSendActionHandler( + disabled: boolean, + composerElement: RefObject, + wysiwyg: Wysiwyg, +) { + 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/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts new file mode 100644 index 0000000000..6c57ce6a86 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/types.ts @@ -0,0 +1,21 @@ +/* +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 { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +// TODO +// Change when the matrix-wysiwyg typescript definition will be refined +export type Wysiwyg = ReturnType['wysiwyg']; 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..fe7f7706b4 --- /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 isReply = Boolean(replyToEvent?.replyEventId); + const isEditing = Boolean(editedEvent); + + /*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 ? 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 57% rename from src/components/views/rooms/wysiwyg_composer/message.ts rename to src/components/views/rooms/wysiwyg_composer/utils/message.ts index 5569af02a9..0f4de2d8a3 100644 --- a/src/components/views/rooms/wysiwyg_composer/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -19,90 +19,32 @@ import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/ import { 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,65 @@ 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), + }); + + // 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; + } + + // 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; + + console.log('editContent', editContent); + mxClient.sendMessage(roomId, threadId, editContent); + dis.dispatch({ action: "message_sent" }); + } + } + + endEditing(roomContext); +} 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 bc0b26f745..596cf0bcfe 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -39,7 +39,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 { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; // 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 diff --git a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx index 6c3e8573ae..a9838ecaca 100644 --- a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx @@ -18,7 +18,7 @@ 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/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index b0aa838879..91020250ae 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -24,7 +24,7 @@ import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { Layout } from "../../../../../src/settings/enums/Layout"; -import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/message-test.ts index 712b671c9f..50f0f77c1a 100644 --- a/test/components/views/rooms/wysiwyg_composer/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/message-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message"; +import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/message"; import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";