From bfb1638ff3c3d5dd412b95b3754c0a456b664951 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 5 Oct 2022 12:01:41 +0200 Subject: [PATCH] Add wysisyg composer (can only send message, enable behind a labs flag) --- package.json | 1 + .../views/rooms/MessageComposer.tsx | 55 +++-- .../wysiwyg_composer/WysiwygComposer.tsx | 58 ++++++ .../views/rooms/wysiwyg_composer/message.ts | 190 ++++++++++++++++++ .../rooms/wysiwyg_composer/useLocalStorage.ts | 33 +++ .../rooms/wysiwyg_composer/useMatrixClient.ts | 36 ++++ src/contexts/MatrixClientContext.tsx | 4 + src/contexts/RoomContext.ts | 5 +- src/i18n/strings/en_EN.json | 1 + src/settings/Settings.tsx | 7 + yarn.lock | 26 +++ 11 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/message.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/useLocalStorage.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/useMatrixClient.ts diff --git a/package.json b/package.json index 82b26a93f5..6016b0904d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index cf0fe3fd6a..8d3de0f03b 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -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 { private voiceRecordingButton = createRef(); private ref: React.RefObject = createRef(); private instanceId: number; + private composerSendMessage?: () => void; private _voiceRecording: Optional; @@ -313,6 +315,7 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); + this.composerSendMessage?.(); }; private onChange = (model: EditorModel) => { @@ -321,6 +324,13 @@ export default class MessageComposer extends React.Component { }); }; + 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 { const canSendMessages = this.context.canSendMessages && !this.context.tombstone; if (canSendMessages) { - controls.push( - , - ); + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + + if (isWysiwygComposerEnabled) { + controls.push( + + { (sendMessage) => { + this.composerSendMessage = sendMessage; + } } + , + ); + } else { + controls.push( + , + ); + } controls.push( 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(); + const { ref, isWysiwygReady } = useWysiwyg({ onChange: (_content) => { + setContent(_content); + onChange(_content); + } }); + + const memoizedSendMessage = useCallback(() => sendMessage(content, mxClient, { roomContext, ...props }), + [content, mxClient, roomContext, props], + ); + + return ( +
+
+ { children?.(memoizedSendMessage) } +
+ ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/message.ts b/src/components/views/rooms/wysiwyg_composer/message.ts new file mode 100644 index 0000000000..a2113e4013 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/message.ts @@ -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, +): 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(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, + }); + } +} diff --git a/src/components/views/rooms/wysiwyg_composer/useLocalStorage.ts b/src/components/views/rooms/wysiwyg_composer/useLocalStorage.ts new file mode 100644 index 0000000000..315d0dbfef --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/useLocalStorage.ts @@ -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 }; +} diff --git a/src/components/views/rooms/wysiwyg_composer/useMatrixClient.ts b/src/components/views/rooms/wysiwyg_composer/useMatrixClient.ts new file mode 100644 index 0000000000..9d26acdc9e --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/useMatrixClient.ts @@ -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 void>>(); + useEffect(() => { + if (mxClient.isCryptoEnabled() && mxClient.isRoomEncrypted(room.roomId)) { + setPrepareToEncrypt(throttle(() => { + mxClient.prepareToEncrypt(room); + }, 60000, { leading: true, trailing: false })); + } + }, [mxClient, room]); + + return { mxClient, prepareToEncrypt }; +} diff --git a/src/contexts/MatrixClientContext.tsx b/src/contexts/MatrixClientContext.tsx index 292c1e34d8..4b89bc3213 100644 --- a/src/contexts/MatrixClientContext.tsx +++ b/src/contexts/MatrixClientContext.tsx @@ -25,6 +25,10 @@ export interface MatrixClientProps { mxClient: MatrixClient; } +export function useMatrixClientContext() { + return useContext(MatrixClientContext); +} + const matrixHOC = ( ComposedComponent: ComponentClass, ) => { diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index a66749a0cd..80bc18e13b 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -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({ }); RoomContext.displayName = "RoomContext"; export default RoomContext; +export function useRoomContext() { + return useContext(RoomContext); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 03d5517c84..498eaacc46 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 52538f7291..193e5b9647 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -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, diff --git a/yarn.lock b/yarn.lock index 965d66f09c..82c6636182 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"