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": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/analytics-events": "^0.2.0",
|
||||
"@matrix-org/matrix-wysiwyg": "^0.3.0",
|
||||
"@matrix-org/matrix-wysiwyg": "^0.3.2",
|
||||
"@matrix-org/react-sdk-module-api": "^0.0.3",
|
||||
"@sentry/browser": "^6.11.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
|
|
|
@ -299,8 +299,10 @@
|
|||
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
||||
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
||||
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/_FormattingButtons.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
|
||||
@import "./views/settings/_AvatarSetting.pcss";
|
||||
@import "./views/settings/_CrossSigningPanel.pcss";
|
||||
@import "./views/settings/_CryptographyPanel.pcss";
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
.mx_WysiwygComposer {
|
||||
.mx_SendWysiwygComposer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
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;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: $icon-button-color;
|
||||
background-color: $tertiary-content;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
|
@ -48,6 +48,7 @@ import RoomContext from "../../../contexts/RoomContext";
|
|||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { options as linkifyOpts } from "../../../linkify-matrix";
|
||||
import { getParentEventId } from '../../../utils/Reply';
|
||||
import { EditWysiwygComposer } from '../rooms/wysiwyg_composer';
|
||||
|
||||
const MAX_HIGHLIGHT_LENGTH = 4096;
|
||||
|
||||
|
@ -562,7 +563,10 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
|
||||
render() {
|
||||
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 content = mxEvent.getContent();
|
||||
|
|
|
@ -58,7 +58,8 @@ import {
|
|||
startNewVoiceBroadcastRecording,
|
||||
VoiceBroadcastRecordingsStore,
|
||||
} from '../../../voice-broadcast';
|
||||
import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer';
|
||||
import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/';
|
||||
import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext';
|
||||
|
||||
let instanceCount = 0;
|
||||
|
||||
|
@ -78,7 +79,7 @@ function SendButton(props: ISendButtonProps) {
|
|||
);
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends MatrixClientProps {
|
||||
room: Room;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
|
@ -89,6 +90,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
composerContent: string;
|
||||
isComposerEmpty: boolean;
|
||||
haveRecording: boolean;
|
||||
recordingTimeLeftSeconds?: number;
|
||||
|
@ -100,13 +102,12 @@ interface IState {
|
|||
showVoiceBroadcastButton: boolean;
|
||||
}
|
||||
|
||||
export default class MessageComposer extends React.Component<IProps, IState> {
|
||||
export class MessageComposer extends React.Component<IProps, IState> {
|
||||
private dispatcherRef?: string;
|
||||
private messageComposerInput = createRef<SendMessageComposerClass>();
|
||||
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private instanceId: number;
|
||||
private composerSendMessage?: () => void;
|
||||
|
||||
private _voiceRecording: Optional<VoiceMessageRecording>;
|
||||
|
||||
|
@ -124,6 +125,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
isComposerEmpty: true,
|
||||
composerContent: '',
|
||||
haveRecording: false,
|
||||
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
|
||||
isMenuOpen: false,
|
||||
|
@ -315,7 +317,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
this.messageComposerInput.current?.sendMessage();
|
||||
this.composerSendMessage?.();
|
||||
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
if (isWysiwygComposerEnabled) {
|
||||
const { permalinkCreator, relation, replyToEvent } = this.props;
|
||||
sendMessage(this.state.composerContent,
|
||||
{ mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent });
|
||||
dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer });
|
||||
}
|
||||
};
|
||||
|
||||
private onChange = (model: EditorModel) => {
|
||||
|
@ -326,6 +335,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
private onWysiwygChange = (content: string) => {
|
||||
this.setState({
|
||||
composerContent: content,
|
||||
isComposerEmpty: content?.length === 0,
|
||||
});
|
||||
};
|
||||
|
@ -402,16 +412,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
if (canSendMessages) {
|
||||
if (isWysiwygComposerEnabled) {
|
||||
controls.push(
|
||||
<WysiwygComposer key="controls_input"
|
||||
<SendWysiwygComposer key="controls_input"
|
||||
disabled={this.state.haveRecording}
|
||||
onChange={this.onWysiwygChange}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
relation={this.props.relation}
|
||||
replyToEvent={this.props.replyToEvent}>
|
||||
{ (sendMessage) => {
|
||||
this.composerSendMessage = sendMessage;
|
||||
} }
|
||||
</WysiwygComposer>,
|
||||
onSend={this.sendMessage}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
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 { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
|
||||
import { FormattingFunctions, FormattingStates } from "@matrix-org/matrix-wysiwyg";
|
||||
import classNames from "classnames";
|
||||
|
||||
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
|
||||
import { Alignment } from "../../elements/Tooltip";
|
||||
import { KeyboardShortcut } from "../../settings/KeyboardShortcut";
|
||||
import { KeyCombo } from "../../../../KeyBindingsManager";
|
||||
import { _td } from "../../../../languageHandler";
|
||||
import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
|
||||
import { Alignment } from "../../../elements/Tooltip";
|
||||
import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
|
||||
import { KeyCombo } from "../../../../../KeyBindingsManager";
|
||||
import { _td } from "../../../../../languageHandler";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
|
@ -55,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps)
|
|||
}
|
||||
|
||||
interface FormattingButtonsProps {
|
||||
composer: ReturnType<typeof useWysiwyg>['wysiwyg'];
|
||||
formattingStates: ReturnType<typeof useWysiwyg>['formattingStates'];
|
||||
composer: FormattingFunctions;
|
||||
formattingStates: FormattingStates;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
||||
import { IRoomState } from "../../../../structures/RoomView";
|
||||
|
||||
import defaultDispatcher from "../../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import { ActionPayload } from "../../../../dispatcher/payloads";
|
||||
import { IRoomState } from "../../../structures/RoomView";
|
||||
import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext";
|
||||
import { useDispatcher } from "../../../../hooks/useDispatcher";
|
||||
|
||||
export function useWysiwygActionHandler(
|
||||
disabled: boolean,
|
||||
composerElement: React.MutableRefObject<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(
|
||||
export function focusComposer(
|
||||
composerElement: React.MutableRefObject<HTMLElement>,
|
||||
renderingType: TimelineRenderingType,
|
||||
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 { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||
|
||||
import { PosthogAnalytics } from "../../../../PosthogAnalytics";
|
||||
import SettingsStore from "../../../../settings/SettingsStore";
|
||||
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics";
|
||||
import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks";
|
||||
import { doMaybeLocalRoomAction } from "../../../../utils/local-room";
|
||||
import { CHAT_EFFECTS } from "../../../../effects";
|
||||
import { containsEmoji } from "../../../../effects/utils";
|
||||
import { IRoomState } from "../../../structures/RoomView";
|
||||
import dis from '../../../../dispatcher/dispatcher';
|
||||
import { addReplyToMessageContent } from "../../../../utils/Reply";
|
||||
|
||||
// Merges favouring the given relation
|
||||
function attachRelation(content: IContent, relation?: IEventRelation): void {
|
||||
if (relation) {
|
||||
content['m.relates_to'] = {
|
||||
...(content['m.relates_to'] || {}),
|
||||
...relation,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics";
|
||||
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
|
||||
import { doMaybeLocalRoomAction } from "../../../../../utils/local-room";
|
||||
import { CHAT_EFFECTS } from "../../../../../effects";
|
||||
import { containsEmoji } from "../../../../../effects/utils";
|
||||
import { IRoomState } from "../../../../structures/RoomView";
|
||||
import dis from '../../../../../dispatcher/dispatcher';
|
||||
import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog";
|
||||
import { endEditing, cancelPreviousPendingEdit } from "./editing";
|
||||
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
|
||||
import { createMessageContent } from "./createMessageContent";
|
||||
import { isContentModified } from "./isContentModified";
|
||||
|
||||
interface SendMessageParams {
|
||||
mxClient: MatrixClient;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
roomContext: IRoomState;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
includeReplyLegacyFallback?: boolean;
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export function createMessageContent(
|
||||
message: string,
|
||||
{ relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }:
|
||||
Omit<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(
|
||||
message: string,
|
||||
html: string,
|
||||
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||
) {
|
||||
const { relation, replyToEvent } = params;
|
||||
|
@ -113,6 +55,7 @@ export function sendMessage(
|
|||
eventName: "Composer",
|
||||
isEditing: false,
|
||||
isReply: Boolean(replyToEvent),
|
||||
// TODO thread
|
||||
inThread: relation?.rel_type === THREAD_RELATION_TYPE.name,
|
||||
};
|
||||
|
||||
|
@ -133,7 +76,7 @@ export function sendMessage(
|
|||
|
||||
if (!content) {
|
||||
content = createMessageContent(
|
||||
message,
|
||||
html,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
@ -197,3 +140,68 @@ export function sendMessage(
|
|||
|
||||
return prom;
|
||||
}
|
||||
|
||||
interface EditMessageParams {
|
||||
mxClient: MatrixClient;
|
||||
roomContext: IRoomState;
|
||||
editorStateTransfer: EditorStateTransfer;
|
||||
}
|
||||
|
||||
export function editMessage(
|
||||
html: string,
|
||||
{ roomContext, mxClient, editorStateTransfer }: EditMessageParams,
|
||||
) {
|
||||
const editedEvent = editorStateTransfer.getEvent();
|
||||
|
||||
PosthogAnalytics.instance.trackEvent<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",
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
|
@ -21,7 +21,8 @@ import { MatrixEvent, MsgType, RoomMember } from "matrix-js-sdk/src/matrix";
|
|||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||
|
||||
import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../test-utils";
|
||||
import MessageComposer from "../../../../src/components/views/rooms/MessageComposer";
|
||||
import MessageComposer, { MessageComposer as MessageComposerClass }
|
||||
from "../../../../src/components/views/rooms/MessageComposer";
|
||||
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import RoomContext from "../../../../src/contexts/RoomContext";
|
||||
|
@ -39,7 +40,7 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send
|
|||
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
|
||||
import { addTextToComposer } from "../../../test-utils/composer";
|
||||
import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore";
|
||||
import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer";
|
||||
import { SendWysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer";
|
||||
|
||||
// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
|
||||
// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
|
||||
|
@ -106,7 +107,7 @@ describe("MessageComposer", () => {
|
|||
it("should call notifyTimelineHeightChanged() for the same context", () => {
|
||||
dis.dispatch({
|
||||
action: "reply_to_event",
|
||||
context: (wrapper.instance as unknown as MessageComposer).context,
|
||||
context: (wrapper.instance as unknown as MessageComposerClass).context,
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
|
@ -207,7 +208,7 @@ describe("MessageComposer", () => {
|
|||
let stateBefore: any;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = wrapAndRender({ room });
|
||||
wrapper = wrapAndRender({ room }).children();
|
||||
stateBefore = { ...wrapper.instance().state };
|
||||
resizeCallback("test", {});
|
||||
wrapper.update();
|
||||
|
@ -220,7 +221,8 @@ describe("MessageComposer", () => {
|
|||
|
||||
describe("when a resize to narrow event occurred in UIStore", () => {
|
||||
beforeEach(() => {
|
||||
wrapper = wrapAndRender({ room }, true, true);
|
||||
wrapper = wrapAndRender({ room }, true, true).children();
|
||||
|
||||
wrapper.setState({
|
||||
isMenuOpen: true,
|
||||
isStickerPickerOpen: true,
|
||||
|
@ -240,7 +242,7 @@ describe("MessageComposer", () => {
|
|||
|
||||
describe("when a resize to non-narrow event occurred in UIStore", () => {
|
||||
beforeEach(() => {
|
||||
wrapper = wrapAndRender({ room }, true, false);
|
||||
wrapper = wrapAndRender({ room }, true, false).children();
|
||||
wrapper.setState({
|
||||
isMenuOpen: true,
|
||||
isStickerPickerOpen: true,
|
||||
|
@ -346,14 +348,14 @@ describe("MessageComposer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should render WysiwygComposer', () => {
|
||||
it('should render SendWysiwygComposer', () => {
|
||||
const room = mkStubRoom("!roomId:server", "Room 1", cli);
|
||||
|
||||
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true);
|
||||
const wrapper = wrapAndRender({ room });
|
||||
|
||||
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false);
|
||||
expect(wrapper.find(WysiwygComposer)).toBeTruthy();
|
||||
expect(wrapper.find(SendWysiwygComposer)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 userEvent from '@testing-library/user-event';
|
||||
|
||||
import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/FormattingButtons";
|
||||
import { FormattingButtons }
|
||||
from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons";
|
||||
|
||||
describe('FormattingButtons', () => {
|
||||
const wysiwyg = {
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||
import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message";
|
||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import { Layout } from "../../../../../src/settings/enums/Layout";
|
||||
import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";
|
||||
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { EventStatus } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { IRoomState } from "../../../../../../src/components/structures/RoomView";
|
||||
import { editMessage, sendMessage }
|
||||
from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message";
|
||||
import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils";
|
||||
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
|
||||
import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks";
|
||||
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
|
||||
import * as ConfirmRedactDialog
|
||||
from "../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
|
||||
|
||||
describe('message', () => {
|
||||
const permalinkCreator = {
|
||||
|
@ -35,117 +39,30 @@ describe('message', () => {
|
|||
type: "m.room.message",
|
||||
room: 'myfakeroom',
|
||||
user: 'myfakeuser',
|
||||
content: { "msgtype": "m.text", "body": "Replying to this" },
|
||||
content: {
|
||||
"msgtype": "m.text",
|
||||
"body": "Replying to this",
|
||||
"format": 'org.matrix.custom.html',
|
||||
"formatted_body": 'Replying to this',
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
|
||||
const mockClient = createTestClient();
|
||||
const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
|
||||
mockRoom.findEventById = jest.fn(eventId => {
|
||||
return eventId === mockEvent.getId() ? mockEvent : null;
|
||||
});
|
||||
|
||||
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
|
||||
|
||||
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('createMessageContent', () => {
|
||||
it("Should create html message", () => {
|
||||
// When
|
||||
const content = createMessageContent(message, { permalinkCreator });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": message,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": message,
|
||||
"msgtype": "m.text",
|
||||
});
|
||||
});
|
||||
|
||||
it('Should add reply to message content', () => {
|
||||
// When
|
||||
const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent });
|
||||
|
||||
// Then
|
||||
expect(content).toEqual({
|
||||
"body": "> <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', () => {
|
||||
const mockClient = createTestClient();
|
||||
const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
|
||||
mockRoom.findEventById = jest.fn(eventId => {
|
||||
return eventId === mockEvent.getId() ? mockEvent : null;
|
||||
});
|
||||
|
||||
const defaultRoomContext: IRoomState = {
|
||||
room: mockRoom,
|
||||
roomLoading: true,
|
||||
peekLoading: false,
|
||||
shouldPeek: true,
|
||||
membersLoaded: false,
|
||||
numUnreadMessages: 0,
|
||||
canPeek: false,
|
||||
showApps: false,
|
||||
isPeeking: false,
|
||||
showRightPanel: true,
|
||||
joining: false,
|
||||
atEndOfLiveTimeline: true,
|
||||
showTopUnreadMessagesBar: false,
|
||||
statusBarVisible: false,
|
||||
canReact: false,
|
||||
canSendMessages: false,
|
||||
layout: Layout.Group,
|
||||
lowBandwidth: false,
|
||||
alwaysShowTimestamps: false,
|
||||
showTwelveHourTimestamps: false,
|
||||
readMarkerInViewThresholdMs: 3000,
|
||||
readMarkerOutOfViewThresholdMs: 30000,
|
||||
showHiddenEvents: false,
|
||||
showReadReceipts: true,
|
||||
showRedactions: true,
|
||||
showJoinLeaves: true,
|
||||
showAvatarChanges: true,
|
||||
showDisplaynameChanges: true,
|
||||
matrixClientIsReady: false,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
liveTimeline: undefined,
|
||||
canSelfRedact: false,
|
||||
resizing: false,
|
||||
narrow: false,
|
||||
activeCall: null,
|
||||
};
|
||||
|
||||
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
|
||||
it('Should not send empty html message', async () => {
|
||||
// When
|
||||
await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||
|
@ -231,4 +148,78 @@ describe('message', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editMessage', () => {
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
|
||||
it('Should cancel editing and ask for event removal when message is empty', async () => {
|
||||
// When
|
||||
const mockCreateRedactEventDialog = jest.spyOn(ConfirmRedactDialog, 'createRedactEventDialog');
|
||||
|
||||
const mockEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: 'myfakeroom',
|
||||
user: 'myfakeuser',
|
||||
content: { "msgtype": "m.text", "body": "Replying to this" },
|
||||
event: true,
|
||||
});
|
||||
const replacingEvent = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: 'myfakeroom',
|
||||
user: 'myfakeuser',
|
||||
content: { "msgtype": "m.text", "body": "ReplacingEvent" },
|
||||
event: true,
|
||||
});
|
||||
replacingEvent.setStatus(EventStatus.QUEUED);
|
||||
mockEvent.makeReplaced(replacingEvent);
|
||||
const editorStateTransfer = new EditorStateTransfer(mockEvent);
|
||||
|
||||
await editMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toBeCalledTimes(0);
|
||||
expect(mockClient.cancelPendingEvent).toBeCalledTimes(1);
|
||||
expect(mockCreateRedactEventDialog).toBeCalledTimes(1);
|
||||
expect(spyDispatcher).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should do nothing if the content is unmodified', async () => {
|
||||
// When
|
||||
await editMessage(
|
||||
mockEvent.getContent().body,
|
||||
{ roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
|
||||
|
||||
// Then
|
||||
expect(mockClient.sendMessage).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('Should send a message when the content is modified', async () => {
|
||||
// When
|
||||
const newMessage = `${mockEvent.getContent().body} new content`;
|
||||
await editMessage(
|
||||
newMessage,
|
||||
{ roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
|
||||
|
||||
// Then
|
||||
const { msgtype, format } = mockEvent.getContent();
|
||||
const expectedContent = {
|
||||
"body": newMessage,
|
||||
"formatted_body": ` * ${newMessage}`,
|
||||
"m.new_content": {
|
||||
"body": "Replying to this new content",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": "Replying to this new content",
|
||||
"msgtype": "m.text",
|
||||
},
|
||||
"m.relates_to": {
|
||||
"event_id": mockEvent.getId(),
|
||||
"rel_type": "m.replace",
|
||||
},
|
||||
msgtype,
|
||||
format,
|
||||
};
|
||||
expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent);
|
||||
expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -179,6 +179,7 @@ export function createTestClient(): MatrixClient {
|
|||
sendToDevice: jest.fn().mockResolvedValue(undefined),
|
||||
queueToDevice: jest.fn().mockResolvedValue(undefined),
|
||||
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
|
||||
cancelPendingEvent: jest.fn(),
|
||||
|
||||
getMediaHandler: jest.fn().mockReturnValue({
|
||||
setVideoInput: jest.fn(),
|
||||
|
|
|
@ -1660,10 +1660,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8"
|
||||
integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww==
|
||||
|
||||
"@matrix-org/matrix-wysiwyg@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.0.tgz#9a0b996c47fbb63fb235a0810b678158b253f721"
|
||||
integrity sha512-m33qOo64VIZRqzMZ5vJ9m2gYns+sCaFFy3R5Nn9JfDnldQ1oh+ra611I9keFmO/Ls6548ZN8hUkv+49Ua3iBHA==
|
||||
"@matrix-org/matrix-wysiwyg@^0.3.2":
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.2.tgz#586f3ad2f4a7bf39d8e2063630c52294c877bcd6"
|
||||
integrity sha512-Q6Ntj2q1/7rVUlro94snn9eZy/3EbrGqaq5nqNMbttXcnFzYtgligDV1avViB4Um6ZRdDOxnQEPkMca/SqYSmw==
|
||||
|
||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
|
||||
version "3.2.8"
|
||||
|
|
Loading…
Reference in New Issue