Merge pull request #9374 from matrix-org/feat/matrix-wysisyg-integration
First step of matrix-wysiwyg integration
This commit is contained in:
commit
b336e18eae
14 changed files with 693 additions and 15 deletions
|
@ -57,6 +57,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/analytics-events": "^0.2.0",
|
"@matrix-org/analytics-events": "^0.2.0",
|
||||||
|
"@matrix-org/matrix-wysiwyg": "^0.0.2",
|
||||||
"@matrix-org/react-sdk-module-api": "^0.0.3",
|
"@matrix-org/react-sdk-module-api": "^0.0.3",
|
||||||
"@sentry/browser": "^6.11.0",
|
"@sentry/browser": "^6.11.0",
|
||||||
"@sentry/tracing": "^6.11.0",
|
"@sentry/tracing": "^6.11.0",
|
||||||
|
|
|
@ -295,6 +295,7 @@
|
||||||
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
||||||
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
||||||
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
||||||
|
@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss";
|
||||||
@import "./views/settings/_AvatarSetting.pcss";
|
@import "./views/settings/_AvatarSetting.pcss";
|
||||||
@import "./views/settings/_CrossSigningPanel.pcss";
|
@import "./views/settings/_CrossSigningPanel.pcss";
|
||||||
@import "./views/settings/_CryptographyPanel.pcss";
|
@import "./views/settings/_CryptographyPanel.pcss";
|
||||||
|
|
53
res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss
Normal file
53
res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
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 {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: $font-14px;
|
||||||
|
/* fixed line height to prevent emoji from being taller than text */
|
||||||
|
line-height: $font-18px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 6px;
|
||||||
|
/* don't grow wider than available space */
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.mx_WysiwygComposer_container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* min-height at this level so the mx_BasicMessageComposer_input */
|
||||||
|
/* still stays vertically centered when less than 55px. */
|
||||||
|
/* We also set this to ensure the voice message recording widget */
|
||||||
|
/* doesn't cause a jump. */
|
||||||
|
min-height: 55px;
|
||||||
|
|
||||||
|
.mx_WysiwygComposer_content {
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
/* this will center the contenteditable */
|
||||||
|
/* in it's parent vertically */
|
||||||
|
/* while keeping the autocomplete at the top */
|
||||||
|
/* of the composer. The parent needs to be a flex container for this to work. */
|
||||||
|
margin: auto 0;
|
||||||
|
/* max-height at this level so autocomplete doesn't get scrolled too */
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,6 +58,7 @@ import {
|
||||||
startNewVoiceBroadcastRecording,
|
startNewVoiceBroadcastRecording,
|
||||||
VoiceBroadcastRecordingsStore,
|
VoiceBroadcastRecordingsStore,
|
||||||
} from '../../../voice-broadcast';
|
} from '../../../voice-broadcast';
|
||||||
|
import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer';
|
||||||
|
|
||||||
let instanceCount = 0;
|
let instanceCount = 0;
|
||||||
|
|
||||||
|
@ -105,6 +106,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
|
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
|
||||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||||
private instanceId: number;
|
private instanceId: number;
|
||||||
|
private composerSendMessage?: () => void;
|
||||||
|
|
||||||
private _voiceRecording: Optional<VoiceMessageRecording>;
|
private _voiceRecording: Optional<VoiceMessageRecording>;
|
||||||
|
|
||||||
|
@ -313,6 +315,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageComposerInput.current?.sendMessage();
|
this.messageComposerInput.current?.sendMessage();
|
||||||
|
this.composerSendMessage?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onChange = (model: EditorModel) => {
|
private onChange = (model: EditorModel) => {
|
||||||
|
@ -321,6 +324,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onWysiwygChange = (content: string) => {
|
||||||
|
this.setState({
|
||||||
|
isComposerEmpty: content?.length === 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private onVoiceStoreUpdate = () => {
|
private onVoiceStoreUpdate = () => {
|
||||||
this.updateRecordingState();
|
this.updateRecordingState();
|
||||||
};
|
};
|
||||||
|
@ -394,20 +403,37 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
|
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
|
||||||
if (canSendMessages) {
|
if (canSendMessages) {
|
||||||
controls.push(
|
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||||
<SendMessageComposer
|
|
||||||
ref={this.messageComposerInput}
|
if (isWysiwygComposerEnabled) {
|
||||||
key="controls_input"
|
controls.push(
|
||||||
room={this.props.room}
|
<WysiwygComposer key="controls_input"
|
||||||
placeholder={this.renderPlaceholderText()}
|
disabled={this.state.haveRecording}
|
||||||
permalinkCreator={this.props.permalinkCreator}
|
onChange={this.onWysiwygChange}
|
||||||
relation={this.props.relation}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
replyToEvent={this.props.replyToEvent}
|
relation={this.props.relation}
|
||||||
onChange={this.onChange}
|
replyToEvent={this.props.replyToEvent}>
|
||||||
disabled={this.state.haveRecording}
|
{ (sendMessage) => {
|
||||||
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
|
this.composerSendMessage = sendMessage;
|
||||||
/>,
|
} }
|
||||||
);
|
</WysiwygComposer>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
controls.push(
|
||||||
|
<SendMessageComposer
|
||||||
|
ref={this.messageComposerInput}
|
||||||
|
key="controls_input"
|
||||||
|
room={this.props.room}
|
||||||
|
placeholder={this.renderPlaceholderText()}
|
||||||
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
|
relation={this.props.relation}
|
||||||
|
replyToEvent={this.props.replyToEvent}
|
||||||
|
onChange={this.onChange}
|
||||||
|
disabled={this.state.haveRecording}
|
||||||
|
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
controls.push(<VoiceRecordComposerTile
|
controls.push(<VoiceRecordComposerTile
|
||||||
key="controls_voice_record"
|
key="controls_voice_record"
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
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, useState } from 'react';
|
||||||
|
import { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
|
||||||
|
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
|
|
||||||
|
import { useRoomContext } from '../../../../contexts/RoomContext';
|
||||||
|
import { sendMessage } from './message';
|
||||||
|
import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks';
|
||||||
|
import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext';
|
||||||
|
|
||||||
|
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 [content, setContent] = useState<string>();
|
||||||
|
const { ref, isWysiwygReady, wysiwyg } = useWysiwyg({ onChange: (_content) => {
|
||||||
|
setContent(_content);
|
||||||
|
onChange(_content);
|
||||||
|
} });
|
||||||
|
|
||||||
|
const memoizedSendMessage = useCallback(() => {
|
||||||
|
sendMessage(content, { mxClient, roomContext, ...props });
|
||||||
|
wysiwyg.clear();
|
||||||
|
ref.current?.focus();
|
||||||
|
}, [content, mxClient, roomContext, wysiwyg, props, ref]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_WysiwygComposer">
|
||||||
|
<div className="mx_WysiwygComposer_container">
|
||||||
|
<div className="mx_WysiwygComposer_content"
|
||||||
|
ref={ref}
|
||||||
|
contentEditable={!disabled && isWysiwygReady}
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
dir="auto"
|
||||||
|
aria-disabled={disabled || !isWysiwygReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ children?.(memoizedSendMessage) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
190
src/components/views/rooms/wysiwyg_composer/message.ts
Normal file
190
src/components/views/rooms/wysiwyg_composer/message.ts
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
/*
|
||||||
|
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 { 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 { 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 { attachRelation } from "../SendMessageComposer";
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface SendMessageParams {
|
||||||
|
mxClient: MatrixClient;
|
||||||
|
relation?: IEventRelation;
|
||||||
|
replyToEvent?: MatrixEvent;
|
||||||
|
roomContext: IRoomState;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// TODO reply
|
||||||
|
/*if (replyToEvent) {
|
||||||
|
addReplyToMessageContent(content, replyToEvent, {
|
||||||
|
permalinkCreator,
|
||||||
|
includeLegacyFallback: includeReplyLegacyFallback,
|
||||||
|
});
|
||||||
|
}*/
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendMessage(
|
||||||
|
message: string,
|
||||||
|
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||||
|
) {
|
||||||
|
const { relation, replyToEvent } = params;
|
||||||
|
const { room } = roomContext;
|
||||||
|
const { roomId } = room;
|
||||||
|
|
||||||
|
const posthogEvent: ComposerEvent = {
|
||||||
|
eventName: "Composer",
|
||||||
|
isEditing: false,
|
||||||
|
isReply: Boolean(replyToEvent),
|
||||||
|
inThread: relation?.rel_type === THREAD_RELATION_TYPE.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO thread
|
||||||
|
/*if (posthogEvent.inThread) {
|
||||||
|
const threadRoot = room.findEventById(relation?.event_id);
|
||||||
|
posthogEvent.startsThread = threadRoot?.getThread()?.events.length === 1;
|
||||||
|
}*/
|
||||||
|
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
|
||||||
|
|
||||||
|
let content: IContent;
|
||||||
|
|
||||||
|
// TODO slash comment
|
||||||
|
|
||||||
|
// TODO replace emotion end of message ?
|
||||||
|
|
||||||
|
// TODO quick reaction
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
content = createMessageContent(
|
||||||
|
message,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't bother sending an empty message
|
||||||
|
if (!content.body.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||||
|
decorateStartSendingTime(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name
|
||||||
|
? relation.event_id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const prom = doMaybeLocalRoomAction(
|
||||||
|
roomId,
|
||||||
|
(actualRoomId: string) => mxClient.sendMessage(actualRoomId, threadId, content),
|
||||||
|
mxClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO reply
|
||||||
|
/*if (replyToEvent) {
|
||||||
|
// Clear reply_to_event as we put the message into the queue
|
||||||
|
// if the send fails, retry will handle resending.
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'reply_to_event',
|
||||||
|
event: null,
|
||||||
|
context: roomContext.timelineRenderingType,
|
||||||
|
});
|
||||||
|
}*/
|
||||||
|
dis.dispatch({ action: "message_sent" });
|
||||||
|
CHAT_EFFECTS.forEach((effect) => {
|
||||||
|
if (containsEmoji(content, effect.emojis)) {
|
||||||
|
// For initial threads launch, chat effects are disabled
|
||||||
|
// see #19731
|
||||||
|
const isNotThread = relation?.rel_type !== THREAD_RELATION_TYPE.name;
|
||||||
|
if (!SettingsStore.getValue("feature_thread") || isNotThread) {
|
||||||
|
dis.dispatch({ action: `effects.${effect.command}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||||
|
prom.then(resp => {
|
||||||
|
sendRoundTripMetric(mxClient, roomId, resp.event_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO save history
|
||||||
|
// TODO save local state
|
||||||
|
|
||||||
|
//if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
||||||
|
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
||||||
|
dis.dispatch({
|
||||||
|
action: "scroll_to_bottom",
|
||||||
|
timelineRenderingType: roomContext.timelineRenderingType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prom;
|
||||||
|
}
|
|
@ -25,6 +25,10 @@ export interface MatrixClientProps {
|
||||||
mxClient: MatrixClient;
|
mxClient: MatrixClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMatrixClientContext() {
|
||||||
|
return useContext(MatrixClientContext);
|
||||||
|
}
|
||||||
|
|
||||||
const matrixHOC = <ComposedComponentProps extends {}>(
|
const matrixHOC = <ComposedComponentProps extends {}>(
|
||||||
ComposedComponent: ComponentClass<ComposedComponentProps>,
|
ComposedComponent: ComponentClass<ComposedComponentProps>,
|
||||||
) => {
|
) => {
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
import { IRoomState } from "../components/structures/RoomView";
|
import { IRoomState } from "../components/structures/RoomView";
|
||||||
import { Layout } from "../settings/enums/Layout";
|
import { Layout } from "../settings/enums/Layout";
|
||||||
|
@ -69,3 +69,6 @@ const RoomContext = createContext<IRoomState>({
|
||||||
});
|
});
|
||||||
RoomContext.displayName = "RoomContext";
|
RoomContext.displayName = "RoomContext";
|
||||||
export default RoomContext;
|
export default RoomContext;
|
||||||
|
export function useRoomContext() {
|
||||||
|
return useContext(RoomContext);
|
||||||
|
}
|
||||||
|
|
|
@ -901,6 +901,7 @@
|
||||||
"How can I leave the beta?": "How can I leave the beta?",
|
"How can I leave the beta?": "How can I leave the beta?",
|
||||||
"To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.",
|
"To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.",
|
||||||
"Leave the beta": "Leave the beta",
|
"Leave the beta": "Leave the beta",
|
||||||
|
"Wysiwyg composer (plain text mode coming soon) (under active development)": "Wysiwyg composer (plain text mode coming soon) (under active development)",
|
||||||
"Render simple counters in room header": "Render simple counters in room header",
|
"Render simple counters in room header": "Render simple counters in room header",
|
||||||
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
|
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
|
||||||
"Support adding custom themes": "Support adding custom themes",
|
"Support adding custom themes": "Support adding custom themes",
|
||||||
|
|
|
@ -303,6 +303,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
"feature_wysiwyg_composer": {
|
||||||
|
isFeature: true,
|
||||||
|
labsGroup: LabGroup.Messaging,
|
||||||
|
displayName: _td("Wysiwyg composer (plain text mode coming soon) (under active development)"),
|
||||||
|
supportedLevels: LEVELS_FEATURE,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
"feature_state_counters": {
|
"feature_state_counters": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
labsGroup: LabGroup.Rooms,
|
labsGroup: LabGroup.Rooms,
|
||||||
|
|
|
@ -39,6 +39,15 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send
|
||||||
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
|
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
|
||||||
import { addTextToComposer } from "../../../test-utils/composer";
|
import { addTextToComposer } from "../../../test-utils/composer";
|
||||||
import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore";
|
import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore";
|
||||||
|
import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer";
|
||||||
|
|
||||||
|
// 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: ({ onChange }) => {
|
||||||
|
return { ref: { current: null }, isWysiwygReady: true, wysiwyg: { clear: () => void 0 } };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe("MessageComposer", () => {
|
describe("MessageComposer", () => {
|
||||||
stubClient();
|
stubClient();
|
||||||
|
@ -346,6 +355,16 @@ describe("MessageComposer", () => {
|
||||||
expect(wrapper.find(MessageComposerButtons).props().showStickersButton).toBe(false);
|
expect(wrapper.find(MessageComposerButtons).props().showStickersButton).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render WysiwygComposer', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function wrapAndRender(
|
function wrapAndRender(
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
/*
|
||||||
|
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 from "react";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
|
import { IRoomState } from "../../../../../src/components/structures/RoomView";
|
||||||
|
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||||
|
import { Layout } from "../../../../../src/settings/enums/Layout";
|
||||||
|
import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";
|
||||||
|
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||||
|
import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer";
|
||||||
|
|
||||||
|
let callOnChange: (content: string) => void;
|
||||||
|
|
||||||
|
// 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: ({ onChange }) => {
|
||||||
|
callOnChange = onChange;
|
||||||
|
return { ref: { current: null }, isWysiwygReady: true, wysiwyg: { clear: () => void 0 } };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
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,
|
||||||
|
canSendVoiceBroadcasts: 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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(() => callOnChange(html));
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
149
test/components/views/rooms/wysiwyg_composer/message-test.ts
Normal file
149
test/components/views/rooms/wysiwyg_composer/message-test.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
/*
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
|
describe('message', () => {
|
||||||
|
const permalinkCreator = jest.fn() as any;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
canSendVoiceBroadcasts: 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(message, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const expectedContent = {
|
||||||
|
"body": "<i><b>hello</b> world</i>",
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": "<i><b>hello</b> world</i>",
|
||||||
|
"msgtype": "m.text",
|
||||||
|
};
|
||||||
|
expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
|
||||||
|
expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should send html message', async () => {
|
||||||
|
// When
|
||||||
|
await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(mockClient.sendMessage).toBeCalledTimes(0);
|
||||||
|
expect(spyDispatcher).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should scroll to bottom after sending a html message', async () => {
|
||||||
|
// When
|
||||||
|
SettingsStore.setValue("scrollToBottomOnMessageSent", null, SettingLevel.DEVICE, true);
|
||||||
|
await sendMessage(message, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(spyDispatcher).toBeCalledWith(
|
||||||
|
{ action: 'scroll_to_bottom', timelineRenderingType: defaultRoomContext.timelineRenderingType },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should handle emojis', async () => {
|
||||||
|
// When
|
||||||
|
await sendMessage('🎉', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(spyDispatcher).toBeCalledWith(
|
||||||
|
{ action: 'effects.confetti' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1549,6 +1549,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8"
|
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8"
|
||||||
integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww==
|
integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww==
|
||||||
|
|
||||||
|
"@matrix-org/matrix-wysiwyg@^0.0.2":
|
||||||
|
version "0.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.0.2.tgz#c1a18f5f9ac061c4147a0fbbf9303a3c82e626e6"
|
||||||
|
integrity sha512-AY4sbmgcaFZhNxJfn3Va1SiKH4/gIdvWV9c/iehcIi3/xFB7lKCIwe7NNxzPpFOp+b+fEIbdHf3fhS5vJBi7xg==
|
||||||
|
|
||||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
|
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
|
||||||
version "3.2.8"
|
version "3.2.8"
|
||||||
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856"
|
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856"
|
||||||
|
|
Loading…
Reference in a new issue