Merge pull request #9488 from matrix-org/feat/add-message-edition-wysiwyg-composer
Add message editing to wysiwyg composerpull/28788/head^2
commit
6e73a853a8
|
@ -57,7 +57,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/analytics-events": "^0.2.0",
|
"@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",
|
"@matrix-org/react-sdk-module-api": "^0.0.3",
|
||||||
"@sentry/browser": "^6.11.0",
|
"@sentry/browser": "^6.11.0",
|
||||||
"@sentry/tracing": "^6.11.0",
|
"@sentry/tracing": "^6.11.0",
|
||||||
|
|
|
@ -299,8 +299,10 @@
|
||||||
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
||||||
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
||||||
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
||||||
@import "./views/rooms/wysiwyg_composer/_FormattingButtons.pcss";
|
@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss";
|
||||||
@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.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/_AvatarSetting.pcss";
|
||||||
@import "./views/settings/_CrossSigningPanel.pcss";
|
@import "./views/settings/_CrossSigningPanel.pcss";
|
||||||
@import "./views/settings/_CryptographyPanel.pcss";
|
@import "./views/settings/_CryptographyPanel.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_WysiwygComposer {
|
.mx_SendWysiwygComposer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,7 +45,7 @@ limitations under the License.
|
||||||
left: 6px;
|
left: 6px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
background-color: $icon-button-color;
|
background-color: $tertiary-content;
|
||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
mask-size: contain;
|
mask-size: contain;
|
||||||
mask-position: center;
|
mask-position: center;
|
|
@ -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} className="mx_EventTile_content" /> :
|
||||||
|
<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();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -78,7 +79,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> {
|
export 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,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageComposerInput.current?.sendMessage();
|
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) => {
|
private onChange = (model: EditorModel) => {
|
||||||
|
@ -326,6 +335,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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -402,16 +412,11 @@ 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}
|
onSend={this.sendMessage}
|
||||||
relation={this.props.relation}
|
/>,
|
||||||
replyToEvent={this.props.replyToEvent}>
|
|
||||||
{ (sendMessage) => {
|
|
||||||
this.composerSendMessage = sendMessage;
|
|
||||||
} }
|
|
||||||
</WysiwygComposer>,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
controls.push(
|
controls.push(
|
||||||
|
@ -551,3 +556,6 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
|
||||||
|
export default MessageComposerWithMatrixClient;
|
||||||
|
|
|
@ -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<HTMLElement, ContentProps>(
|
||||||
|
function Content({ disabled }: ContentProps, forwardRef: RefObject<HTMLElement>) {
|
||||||
|
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 && <WysiwygComposer
|
||||||
|
className={classNames("mx_EditWysiwygComposer", className)}
|
||||||
|
initialContent={initialContent}
|
||||||
|
onChange={onChange}
|
||||||
|
onSend={editMessage}
|
||||||
|
{...props}>
|
||||||
|
{ (ref) => (
|
||||||
|
<>
|
||||||
|
<Content disabled={props.disabled} ref={ref} />
|
||||||
|
<EditionButtons onCancelClick={endEditing} onSaveClick={editMessage} isSaveDisabled={isSaveDisabled} />
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
</WysiwygComposer>;
|
||||||
|
}
|
|
@ -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<HTMLElement, ContentProps>(
|
||||||
|
function Content({ disabled, formattingFunctions: wysiwyg }: ContentProps, forwardRef: RefObject<HTMLElement>) {
|
||||||
|
useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function SendWysiwygComposer(props: SendWysiwygComposerProps) {
|
||||||
|
return (
|
||||||
|
<WysiwygComposer className="mx_SendWysiwygComposer" {...props}>{ (ref, wysiwyg) => (
|
||||||
|
<Content disabled={props.disabled} ref={ref} formattingFunctions={wysiwyg} />
|
||||||
|
) }
|
||||||
|
</WysiwygComposer>);
|
||||||
|
}
|
|
@ -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 (
|
|
||||||
<div className="mx_WysiwygComposer">
|
|
||||||
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
|
|
||||||
<Editor ref={ref} disabled={!isWysiwygReady || disabled} />
|
|
||||||
{ children?.(memoizedSendMessage) }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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<HTMLButtonElement>;
|
||||||
|
onSaveClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
isSaveDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) {
|
||||||
|
return <div className="mx_EditWysiwygComposer_buttons">
|
||||||
|
<AccessibleButton kind="secondary" onClick={onCancelClick}>
|
||||||
|
{ _t("Cancel") }
|
||||||
|
</AccessibleButton>
|
||||||
|
<AccessibleButton kind="primary" onClick={onSaveClick} disabled={isSaveDisabled}>
|
||||||
|
{ _t("Save") }
|
||||||
|
</AccessibleButton>
|
||||||
|
</div>;
|
||||||
|
}
|
|
@ -15,14 +15,14 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { MouseEventHandler } from "react";
|
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 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";
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -55,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormattingButtonsProps {
|
interface FormattingButtonsProps {
|
||||||
composer: ReturnType<typeof useWysiwyg>['wysiwyg'];
|
composer: FormattingFunctions;
|
||||||
formattingStates: ReturnType<typeof useWysiwyg>['formattingStates'];
|
formattingStates: FormattingStates;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) {
|
export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) {
|
|
@ -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<HTMLDivElement | null>,
|
||||||
|
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 (
|
||||||
|
<div className={className}>
|
||||||
|
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
|
||||||
|
<Editor ref={ref} disabled={!isWysiwygReady || disabled} />
|
||||||
|
{ children?.(ref, wysiwyg) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
|
@ -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 };
|
||||||
|
}
|
|
@ -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<string>(() => {
|
||||||
|
if (editorStateTransfer && roomContext.room) {
|
||||||
|
return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient);
|
||||||
|
}
|
||||||
|
}, [editorStateTransfer, roomContext, mxClient]);
|
||||||
|
}
|
|
@ -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]);
|
||||||
|
}
|
|
@ -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.FocusEditMessageComposer:
|
||||||
|
focusComposer(composerElement, context, roomContext, timeoutId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [disabled, composerElement, timeoutId, roomContext]);
|
||||||
|
|
||||||
|
useDispatcher(defaultDispatcher, handler);
|
||||||
|
}
|
|
@ -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<HTMLElement>,
|
||||||
|
wysiwyg: FormattingFunctions,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
|
@ -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,
|
|
@ -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';
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -16,93 +16,35 @@ limitations under the License.
|
||||||
|
|
||||||
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
||||||
import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
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 { 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,68 @@ 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),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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<ISendEventResponse> | 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;
|
||||||
|
}
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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 { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||||
|
|
||||||
import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../test-utils";
|
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 MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
import RoomContext from "../../../../src/contexts/RoomContext";
|
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 { 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 { 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
|
// 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
|
||||||
|
@ -106,7 +107,7 @@ describe("MessageComposer", () => {
|
||||||
it("should call notifyTimelineHeightChanged() for the same context", () => {
|
it("should call notifyTimelineHeightChanged() for the same context", () => {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: "reply_to_event",
|
action: "reply_to_event",
|
||||||
context: (wrapper.instance as unknown as MessageComposer).context,
|
context: (wrapper.instance as unknown as MessageComposerClass).context,
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
|
@ -207,7 +208,7 @@ describe("MessageComposer", () => {
|
||||||
let stateBefore: any;
|
let stateBefore: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = wrapAndRender({ room });
|
wrapper = wrapAndRender({ room }).children();
|
||||||
stateBefore = { ...wrapper.instance().state };
|
stateBefore = { ...wrapper.instance().state };
|
||||||
resizeCallback("test", {});
|
resizeCallback("test", {});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
@ -220,7 +221,8 @@ describe("MessageComposer", () => {
|
||||||
|
|
||||||
describe("when a resize to narrow event occurred in UIStore", () => {
|
describe("when a resize to narrow event occurred in UIStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = wrapAndRender({ room }, true, true);
|
wrapper = wrapAndRender({ room }, true, true).children();
|
||||||
|
|
||||||
wrapper.setState({
|
wrapper.setState({
|
||||||
isMenuOpen: true,
|
isMenuOpen: true,
|
||||||
isStickerPickerOpen: true,
|
isStickerPickerOpen: true,
|
||||||
|
@ -240,7 +242,7 @@ describe("MessageComposer", () => {
|
||||||
|
|
||||||
describe("when a resize to non-narrow event occurred in UIStore", () => {
|
describe("when a resize to non-narrow event occurred in UIStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = wrapAndRender({ room }, true, false);
|
wrapper = wrapAndRender({ room }, true, false).children();
|
||||||
wrapper.setState({
|
wrapper.setState({
|
||||||
isMenuOpen: true,
|
isMenuOpen: true,
|
||||||
isStickerPickerOpen: 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);
|
const room = mkStubRoom("!roomId:server", "Room 1", cli);
|
||||||
|
|
||||||
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true);
|
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true);
|
||||||
const wrapper = wrapAndRender({ room });
|
const wrapper = wrapAndRender({ room });
|
||||||
|
|
||||||
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false);
|
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false);
|
||||||
expect(wrapper.find(WysiwygComposer)).toBeTruthy();
|
expect(wrapper.find(SendWysiwygComposer)).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 = '<b>html</b>';
|
||||||
|
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 <b>to</b> 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(
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<RoomContext.Provider value={defaultRoomContext}>
|
||||||
|
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||||
|
</RoomContext.Provider>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<RoomContext.Provider value={defaultRoomContext}>
|
||||||
|
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
||||||
|
</RoomContext.Provider>
|
||||||
|
</MatrixClientContext.Provider>);
|
||||||
|
|
||||||
|
(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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: '<b>html</b>',
|
||||||
|
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(
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<RoomContext.Provider value={defaultRoomContext}>
|
||||||
|
<SendWysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} />
|
||||||
|
</RoomContext.Provider>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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: '<b>html</b>',
|
|
||||||
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(
|
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
|
||||||
<RoomContext.Provider value={defaultRoomContext}>
|
|
||||||
<WysiwygComposer onChange={onChange} permalinkCreator={permalinkCreator} disabled={disabled}>
|
|
||||||
{ (_sendMessage) => {
|
|
||||||
sendMessage = _sendMessage;
|
|
||||||
} }</WysiwygComposer>
|
|
||||||
</RoomContext.Provider>
|
|
||||||
</MatrixClientContext.Provider>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = '<b>html</b>';
|
|
||||||
customRender((content) => {
|
|
||||||
expect(content).toBe((html));
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
// act(() => callOnChange(html));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Should send message, call clear and focus the textbox', async () => {
|
|
||||||
// When
|
|
||||||
const html = '<b>html</b>';
|
|
||||||
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": "<b>html</b>",
|
|
||||||
"format": "org.matrix.custom.html",
|
|
||||||
"formatted_body": "<b>html</b>",
|
|
||||||
"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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -18,7 +18,8 @@ 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 = {
|
|
@ -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: '<b>html</b>',
|
||||||
|
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(
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<RoomContext.Provider value={defaultRoomContext}>
|
||||||
|
<WysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} />
|
||||||
|
</RoomContext.Provider>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = '<b>html</b>';
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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 = '<i><b>hello</b> world</i>';
|
||||||
|
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": "> <myfakeuser> Replying to this\n\n<i><b>hello</b> world</i>",
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": "<mx-reply><blockquote><a href=\"$$permalink$$\">In reply to</a>" +
|
||||||
|
" <a href=\"https://matrix.to/#/myfakeuser\">myfakeuser</a>"+
|
||||||
|
"<br>Replying to this</blockquote></mx-reply><i><b>hello</b> world</i>",
|
||||||
|
"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": "<b>First Message</b>",
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -14,15 +14,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
import { EventStatus } from "matrix-js-sdk/src/matrix";
|
||||||
import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message";
|
|
||||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
import { IRoomState } from "../../../../../../src/components/structures/RoomView";
|
||||||
import { Layout } from "../../../../../src/settings/enums/Layout";
|
import { editMessage, sendMessage }
|
||||||
import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";
|
from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message";
|
||||||
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
|
import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils";
|
||||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
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', () => {
|
describe('message', () => {
|
||||||
const permalinkCreator = {
|
const permalinkCreator = {
|
||||||
|
@ -35,117 +39,30 @@ describe('message', () => {
|
||||||
type: "m.room.message",
|
type: "m.room.message",
|
||||||
room: 'myfakeroom',
|
room: 'myfakeroom',
|
||||||
user: 'myfakeuser',
|
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,
|
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(() => {
|
afterEach(() => {
|
||||||
jest.resetAllMocks();
|
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": "> <myfakeuser> Replying to this\n\n<i><b>hello</b> world</i>",
|
|
||||||
"format": "org.matrix.custom.html",
|
|
||||||
"formatted_body": "<mx-reply><blockquote><a href=\"$$permalink$$\">In reply to</a>" +
|
|
||||||
" <a href=\"https://matrix.to/#/myfakeuser\">myfakeuser</a>"+
|
|
||||||
"<br>Replying to this</blockquote></mx-reply><i><b>hello</b> world</i>",
|
|
||||||
"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', () => {
|
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 () => {
|
it('Should not send empty html message', async () => {
|
||||||
// When
|
// When
|
||||||
await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
|
@ -179,6 +179,7 @@ export function createTestClient(): MatrixClient {
|
||||||
sendToDevice: jest.fn().mockResolvedValue(undefined),
|
sendToDevice: jest.fn().mockResolvedValue(undefined),
|
||||||
queueToDevice: jest.fn().mockResolvedValue(undefined),
|
queueToDevice: jest.fn().mockResolvedValue(undefined),
|
||||||
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
|
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
|
||||||
|
cancelPendingEvent: jest.fn(),
|
||||||
|
|
||||||
getMediaHandler: jest.fn().mockReturnValue({
|
getMediaHandler: jest.fn().mockReturnValue({
|
||||||
setVideoInput: jest.fn(),
|
setVideoInput: jest.fn(),
|
||||||
|
|
|
@ -1660,10 +1660,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8"
|
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8"
|
||||||
integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww==
|
integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww==
|
||||||
|
|
||||||
"@matrix-org/matrix-wysiwyg@^0.3.0":
|
"@matrix-org/matrix-wysiwyg@^0.3.2":
|
||||||
version "0.3.0"
|
version "0.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.0.tgz#9a0b996c47fbb63fb235a0810b678158b253f721"
|
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.2.tgz#586f3ad2f4a7bf39d8e2063630c52294c877bcd6"
|
||||||
integrity sha512-m33qOo64VIZRqzMZ5vJ9m2gYns+sCaFFy3R5Nn9JfDnldQ1oh+ra611I9keFmO/Ls6548ZN8hUkv+49Ua3iBHA==
|
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":
|
"@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"
|
version "3.2.8"
|
||||||
|
|
Loading…
Reference in New Issue