Add wysisyg composer (can only send message, enable behind a labs flag)

This commit is contained in:
Florian Duros 2022-10-05 12:01:41 +02:00
parent 16c3efead5
commit bfb1638ff3
No known key found for this signature in database
GPG key ID: 9700AA5870258A0B
11 changed files with 401 additions and 15 deletions

View file

@ -94,6 +94,7 @@
"matrix-events-sdk": "^0.0.1-beta.7",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.1.1",
"matrix-wysiwyg": "link:../matrix-wysiwyg/platforms/web",
"minimist": "^1.2.5",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",

View file

@ -58,6 +58,7 @@ import {
startNewVoiceBroadcastRecording,
VoiceBroadcastRecordingsStore,
} from '../../../voice-broadcast';
import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer';
let instanceCount = 0;
@ -105,6 +106,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
private composerSendMessage?: () => void;
private _voiceRecording: Optional<VoiceMessageRecording>;
@ -313,6 +315,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
this.messageComposerInput.current?.sendMessage();
this.composerSendMessage?.();
};
private onChange = (model: EditorModel) => {
@ -321,6 +324,13 @@ export default class MessageComposer extends React.Component<IProps, IState> {
});
};
private onWysiwygChange = (content: string) => {
console.log('content', content);
this.setState({
isComposerEmpty: content?.length === 0,
});
};
private onVoiceStoreUpdate = () => {
this.updateRecordingState();
};
@ -394,20 +404,37 @@ export default class MessageComposer extends React.Component<IProps, IState> {
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
if (canSendMessages) {
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}
/>,
);
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
if (isWysiwygComposerEnabled) {
controls.push(
<WysiwygComposer 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>,
);
} 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
key="controls_voice_record"

View file

@ -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.
*/
import React, { useCallback, useState } from 'react';
import { useWysiwyg } from "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, forwardRef,
) {
const roomContext = useRoomContext();
const mxClient = useMatrixClientContext();
const [content, setContent] = useState<string>();
const { ref, isWysiwygReady } = useWysiwyg({ onChange: (_content) => {
setContent(_content);
onChange(_content);
} });
const memoizedSendMessage = useCallback(() => sendMessage(content, mxClient, { roomContext, ...props }),
[content, mxClient, roomContext, props],
);
return (
<div className="mx_SendMessageComposer" style={{ minHeight: '30px' }} ref={ref} contentEditable={!disabled && isWysiwygReady} suppressContentEditableWarning={true}>
<br />
{ children?.(memoizedSendMessage) }
</div>
);
}

View 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 { addReplyToMessageContent } from "../../../../utils/Reply";
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 {
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'>,
): IContent {
const isEmote = false;
// TODO do somethings about 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 = {
msgtype: isEmote ? "m.emote" : "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,
mxClient: MatrixClient,
{ roomContext, ...params }: SendMessageParams,
) {
console.log('message', message);
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,
};
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,
);
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
// this.sendHistoryManager.save(model, replyToEvent);
// clear composer
// model.reset([]);
// this.editorRef.current?.clearUndoHistory();
// this.editorRef.current?.focus();
// this.clearStoredEditorState();
//if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({
action: "scroll_to_bottom",
timelineRenderingType: roomContext.timelineRenderingType,
});
}
}

View file

@ -0,0 +1,33 @@
/*
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 { IEventRelation, Room } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { useCallback, useMemo } from "react";
export function useWysiwygStoredState(room: Room, relation: IEventRelation) {
const editorStateKey = useMemo(() => {
let key = `mx_cider_state_${room.roomId}`;
if (relation?.rel_type === THREAD_RELATION_TYPE.name) {
key += `_${relation.event_id}`;
}
return key;
}, [room, relation]);
const clearStoredEditorState = useCallback(() => localStorage.removeItem(editorStateKey), [editorStateKey]);
return { clearStoredEditorState };
}

View file

@ -0,0 +1,36 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { DebouncedFunc, throttle } from "lodash";
import { Room } from "matrix-js-sdk/src/models/room";
import { useEffect, useState } from "react";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
export function useMatrixClient(room: Room) {
const mxClient = useMatrixClientContext();
const [prepareToEncrypt, setPrepareToEncrypt] = useState<DebouncedFunc<() => void>>();
useEffect(() => {
if (mxClient.isCryptoEnabled() && mxClient.isRoomEncrypted(room.roomId)) {
setPrepareToEncrypt(throttle(() => {
mxClient.prepareToEncrypt(room);
}, 60000, { leading: true, trailing: false }));
}
}, [mxClient, room]);
return { mxClient, prepareToEncrypt };
}

View file

@ -25,6 +25,10 @@ export interface MatrixClientProps {
mxClient: MatrixClient;
}
export function useMatrixClientContext() {
return useContext(MatrixClientContext);
}
const matrixHOC = <ComposedComponentProps extends {}>(
ComposedComponent: ComponentClass<ComposedComponentProps>,
) => {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createContext } from "react";
import { createContext, useContext } from "react";
import { IRoomState } from "../components/structures/RoomView";
import { Layout } from "../settings/enums/Layout";
@ -68,3 +68,6 @@ const RoomContext = createContext<IRoomState>({
});
RoomContext.displayName = "RoomContext";
export default RoomContext;
export function useRoomContext() {
return useContext(RoomContext);
}

View file

@ -896,6 +896,7 @@
"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.",
"Leave the beta": "Leave the beta",
"Wysiwyg composer (under active development)": "Wysiwyg composer (under active development)",
"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)",
"Support adding custom themes": "Support adding custom themes",

View file

@ -303,6 +303,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
},
},
"feature_wysiwyg_composer": {
isFeature: true,
labsGroup: LabGroup.Messaging,
displayName: _td("Wysiwyg composer (under active development)"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_state_counters": {
isFeature: true,
labsGroup: LabGroup.Rooms,

View file

@ -6970,6 +6970,10 @@ matrix-widget-api@^1.1.1:
"@types/events" "^3.0.0"
events "^3.2.0"
"matrix-wysiwyg@link:../matrix-wysiwyg/platforms/web":
version "0.0.0"
uid ""
mdurl@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
@ -7943,6 +7947,14 @@ react-dom@17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-dom@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-focus-lock@^2.5.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.9.1.tgz#094cfc19b4f334122c73bb0bff65d77a0c92dd16"
@ -8018,6 +8030,13 @@ react@17.0.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
read-pkg-up@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
@ -8403,6 +8422,13 @@ scheduler@^0.20.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
dependencies:
loose-envify "^1.1.0"
schema-utils@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281"