First attempt to make the edition works in the WysiwygComposer

pull/28217/head
Florian Duros 2022-10-19 12:45:51 +02:00
parent a61076b4fb
commit 460f60e99d
No known key found for this signature in database
GPG Key ID: 9700AA5870258A0B
25 changed files with 705 additions and 200 deletions

View File

@ -48,6 +48,7 @@ import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import { options as linkifyOpts } from "../../../linkify-matrix"; import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from '../../../utils/Reply'; import { getParentEventId } from '../../../utils/Reply';
import { EditWysiwygComposer } from '../rooms/wysiwyg_composer';
const MAX_HIGHLIGHT_LENGTH = 4096; const MAX_HIGHLIGHT_LENGTH = 4096;
@ -562,7 +563,10 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
render() { render() {
if (this.props.editState) { if (this.props.editState) {
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />; const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
return isWysiwygComposerEnabled ?
<EditWysiwygComposer editorStateTransfer={this.props.editState} /> :
<EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
} }
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent(); const content = mxEvent.getContent();

View File

@ -833,6 +833,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
public insertPlaintext(text: string): void { public insertPlaintext(text: string): void {
console.log('insertPlaintext', text);
debugger;
this.modifiedFlag = true; this.modifiedFlag = true;
const { model } = this.props; const { model } = this.props;
const { partCreator } = model; const { partCreator } = model;

View File

@ -350,6 +350,7 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
const event = this.props.editState.getEvent(); const event = this.props.editState.getEvent();
const threadId = event.threadRootId || null; const threadId = event.threadRootId || null;
console.log('editContent', editContent);
this.props.mxClient.sendMessage(roomId, threadId, editContent); this.props.mxClient.sendMessage(roomId, threadId, editContent);
dis.dispatch({ action: "message_sent" }); dis.dispatch({ action: "message_sent" });
} }

View File

@ -58,7 +58,8 @@ import {
startNewVoiceBroadcastRecording, startNewVoiceBroadcastRecording,
VoiceBroadcastRecordingsStore, VoiceBroadcastRecordingsStore,
} from '../../../voice-broadcast'; } from '../../../voice-broadcast';
import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer'; import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/';
import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext';
let instanceCount = 0; let instanceCount = 0;
@ -77,7 +78,7 @@ function SendButton(props: ISendButtonProps) {
); );
} }
interface IProps { interface IProps extends MatrixClientProps {
room: Room; room: Room;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
@ -89,6 +90,7 @@ interface IProps {
} }
interface IState { interface IState {
composerContent: string;
isComposerEmpty: boolean; isComposerEmpty: boolean;
haveRecording: boolean; haveRecording: boolean;
recordingTimeLeftSeconds?: number; recordingTimeLeftSeconds?: number;
@ -100,13 +102,12 @@ interface IState {
showVoiceBroadcastButton: boolean; showVoiceBroadcastButton: boolean;
} }
export default class MessageComposer extends React.Component<IProps, IState> { class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef?: string; private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>(); private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton = createRef<VoiceRecordComposerTile>(); private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number; private instanceId: number;
private composerSendMessage?: () => void;
private _voiceRecording: Optional<VoiceMessageRecording>; private _voiceRecording: Optional<VoiceMessageRecording>;
@ -124,6 +125,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
this.state = { this.state = {
isComposerEmpty: true, isComposerEmpty: true,
composerContent: '',
haveRecording: false, haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false, isMenuOpen: false,
@ -315,7 +317,15 @@ export default class MessageComposer extends React.Component<IProps, IState> {
} }
this.messageComposerInput.current?.sendMessage(); 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) => { private onChange = (model: EditorModel) => {
@ -326,6 +336,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private onWysiwygChange = (content: string) => { private onWysiwygChange = (content: string) => {
this.setState({ this.setState({
composerContent: content,
isComposerEmpty: content?.length === 0, isComposerEmpty: content?.length === 0,
}); });
}; };
@ -406,16 +417,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (canSendMessages) { if (canSendMessages) {
if (isWysiwygComposerEnabled) { if (isWysiwygComposerEnabled) {
controls.push( controls.push(
<WysiwygComposer key="controls_input" <SendWysiwygComposer key="controls_input"
disabled={this.state.haveRecording} disabled={this.state.haveRecording}
onChange={this.onWysiwygChange} onChange={this.onWysiwygChange}
permalinkCreator={this.props.permalinkCreator} />,
relation={this.props.relation}
replyToEvent={this.props.replyToEvent}>
{ (sendMessage) => {
this.composerSendMessage = sendMessage;
} }
</WysiwygComposer>,
); );
} else { } else {
controls.push( controls.push(
@ -555,3 +560,6 @@ export default class MessageComposer extends React.Component<IProps, IState> {
); );
} }
} }
const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
export default MessageComposerWithMatrixClient;

View File

@ -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<HTMLElement, ContentProps>(
function Content({ disabled }: ContentProps, forwardRef: RefObject<HTMLElement>) {
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 && <WysiwygComposer initialContent={initialContent} {...props}>{ (ref, wysiwyg, content) => (
<>
<Content disabled={props.disabled} ref={ref} />
<EditionButtons onCancelClick={() => endEditing(roomContext)} onSaveClick={() => editMessage(content, { roomContext, mxClient, editorStateTransfer })} />
</>)
}
</WysiwygComposer>;
}

View File

@ -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 (
<WysiwygComposer {...props}>{ (ref, wysiwyg) => (
<Content disabled={props.disabled} ref={ref} wysiwyg={wysiwyg} />
) }
</WysiwygComposer>);
}
interface ContentProps {
disabled: boolean;
wysiwyg: Wysiwyg;
}
const Content = forwardRef<HTMLElement, ContentProps>(
function Content({ disabled, wysiwyg }: ContentProps, forwardRef: RefObject<HTMLElement>) {
useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg);
return null;
},
);

View File

@ -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 (
<div className="mx_WysiwygComposer">
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
<Editor ref={ref} disabled={!isWysiwygReady || disabled} />
{ children?.(memoizedSendMessage) }
</div>
);
}

View File

@ -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<HTMLButtonElement>;
onSaveClick: MouseEventHandler<HTMLButtonElement>;
}
export function EditionButtons({ onCancelClick, onSaveClick }: EditionButtonsProps) {
return <div className="mx_EditMessageComposer_buttons">
<AccessibleButton kind="secondary" onClick={onCancelClick}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={false} onClick={onSaveClick}>
{ _t("Save") }
</AccessibleButton>
</div>;
}

View File

@ -18,11 +18,12 @@ import React, { MouseEventHandler } from "react";
import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; import { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
import classNames from "classnames"; import classNames from "classnames";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
import { Alignment } from "../../elements/Tooltip"; import { Alignment } from "../../../elements/Tooltip";
import { KeyboardShortcut } from "../../settings/KeyboardShortcut"; import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
import { KeyCombo } from "../../../../KeyBindingsManager"; import { KeyCombo } from "../../../../../KeyBindingsManager";
import { _td } from "../../../../languageHandler"; import { _td } from "../../../../../languageHandler";
import { Wysiwyg } from "../types";
interface TooltipProps { interface TooltipProps {
label: string; label: string;
@ -55,7 +56,7 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps)
} }
interface FormattingButtonsProps { interface FormattingButtonsProps {
composer: ReturnType<typeof useWysiwyg>['wysiwyg']; composer: Wysiwyg;
formattingStates: ReturnType<typeof useWysiwyg>['formattingStates']; formattingStates: ReturnType<typeof useWysiwyg>['formattingStates'];
} }

View File

@ -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<HTMLDivElement | null>, 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 (
<div className="mx_WysiwygComposer">
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
<Editor ref={ref} disabled={!isWysiwygReady || disabled} />
{ children?.(ref, wysiwyg, content) }
</div>
);
}

View File

@ -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<HTMLElement>,
) {
const roomContext = useRoomContext();
const timeoutId = useRef<number>();
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);
}

View File

@ -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<HTMLElement>,
wysiwyg: Wysiwyg,
) {
const roomContext = useRoomContext();
const timeoutId = useRef<number>();
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);
}

View File

@ -14,40 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { useRef } from "react"; import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
import defaultDispatcher from "../../../../dispatcher/dispatcher"; export function focusComposer(
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<HTMLElement>,
) {
const roomContext = useRoomContext();
const timeoutId = useRef<number>();
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(
composerElement: React.MutableRefObject<HTMLElement>, composerElement: React.MutableRefObject<HTMLElement>,
renderingType: TimelineRenderingType, renderingType: TimelineRenderingType,
roomContext: IRoomState, roomContext: IRoomState,

View File

@ -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';

View File

@ -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<typeof useWysiwyg>['wysiwyg'];

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -19,90 +19,32 @@ import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { PosthogAnalytics } from "../../../../PosthogAnalytics"; import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
import SettingsStore from "../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics";
import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
import { doMaybeLocalRoomAction } from "../../../../utils/local-room"; import { doMaybeLocalRoomAction } from "../../../../../utils/local-room";
import { CHAT_EFFECTS } from "../../../../effects"; import { CHAT_EFFECTS } from "../../../../../effects";
import { containsEmoji } from "../../../../effects/utils"; import { containsEmoji } from "../../../../../effects/utils";
import { IRoomState } from "../../../structures/RoomView"; import { IRoomState } from "../../../../structures/RoomView";
import dis from '../../../../dispatcher/dispatcher'; import dis from '../../../../../dispatcher/dispatcher';
import { addReplyToMessageContent } from "../../../../utils/Reply"; import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog";
import { endEditing, cancelPreviousPendingEdit } from "./editing";
// Merges favouring the given relation import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
function attachRelation(content: IContent, relation?: IEventRelation): void { import { createMessageContent } from "./createMessageContent";
if (relation) { import { isContentModified } from "./isContentModified";
content['m.relates_to'] = {
...(content['m.relates_to'] || {}),
...relation,
};
}
}
interface SendMessageParams { interface SendMessageParams {
mxClient: MatrixClient; mxClient: MatrixClient;
relation?: IEventRelation; relation?: IEventRelation;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
roomContext: IRoomState; roomContext: IRoomState;
permalinkCreator: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
includeReplyLegacyFallback?: boolean; includeReplyLegacyFallback?: boolean;
} }
// exported for tests
export function createMessageContent(
message: string,
{ relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }:
Omit<SendMessageParams, 'roomContext' | 'mxClient'>,
): 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( export function sendMessage(
message: string, html: string,
{ roomContext, mxClient, ...params }: SendMessageParams, { roomContext, mxClient, ...params }: SendMessageParams,
) { ) {
const { relation, replyToEvent } = params; const { relation, replyToEvent } = params;
@ -113,6 +55,7 @@ export function sendMessage(
eventName: "Composer", eventName: "Composer",
isEditing: false, isEditing: false,
isReply: Boolean(replyToEvent), isReply: Boolean(replyToEvent),
// TODO thread
inThread: relation?.rel_type === THREAD_RELATION_TYPE.name, inThread: relation?.rel_type === THREAD_RELATION_TYPE.name,
}; };
@ -133,7 +76,7 @@ export function sendMessage(
if (!content) { if (!content) {
content = createMessageContent( content = createMessageContent(
message, html,
params, params,
); );
} }
@ -197,3 +140,65 @@ export function sendMessage(
return prom; 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<ComposerEvent>({
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);
}

View File

@ -75,6 +75,11 @@ export enum Action {
*/ */
FocusSendMessageComposer = "focus_send_message_composer", 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. * Focuses the user's cursor to the edit message composer. Should be used with a FocusComposerPayload.
*/ */

View File

@ -39,7 +39,7 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send
import { E2EStatus } from "../../../../src/utils/ShieldUtils"; import { E2EStatus } from "../../../../src/utils/ShieldUtils";
import { addTextToComposer } from "../../../test-utils/composer"; import { addTextToComposer } from "../../../test-utils/composer";
import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; 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 // 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 // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts

View File

@ -18,7 +18,7 @@ import React from 'react';
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from '@testing-library/user-event'; 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', () => { describe('FormattingButtons', () => {
const wysiwyg = { const wysiwyg = {

View File

@ -24,7 +24,7 @@ import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { IRoomState } from "../../../../../src/components/structures/RoomView";
import { Layout } from "../../../../../src/settings/enums/Layout"; 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"; 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 // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { IRoomState } from "../../../../../src/components/structures/RoomView"; 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 { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import { Layout } from "../../../../../src/settings/enums/Layout"; import { Layout } from "../../../../../src/settings/enums/Layout";
import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";