diff --git a/package.json b/package.json index d9f5fa31888..a8bee96ee4c 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.2.0", + "@matrix-org/matrix-wysiwyg": "^0.0.2", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 6f9eb010ec2..b2a3752628a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -295,6 +295,7 @@ @import "./views/rooms/_TopUnreadMessagesBar.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @import "./views/rooms/_WhoIsTypingTile.pcss"; +@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss"; @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; diff --git a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss new file mode 100644 index 00000000000..133b66388e7 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss @@ -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; + } + } +} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index cf0fe3fd6ad..b82a991f2eb 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,12 @@ export default class MessageComposer extends React.Component { }); }; + private onWysiwygChange = (content: string) => { + this.setState({ + isComposerEmpty: content?.length === 0, + }); + }; + private onVoiceStoreUpdate = () => { this.updateRecordingState(); }; @@ -394,20 +403,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, +) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + const [content, setContent] = useState(); + 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 ( +
+
+
+
+ { 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 00000000000..c1569324ef1 --- /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 { 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, +): 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(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; +} diff --git a/src/contexts/MatrixClientContext.tsx b/src/contexts/MatrixClientContext.tsx index 292c1e34d8d..4b89bc32133 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 4b09eb96015..5bc648e736a 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"; @@ -69,3 +69,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 7a6b984563d..737f3e8879b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -901,6 +901,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 (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", "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 52538f7291b..a4e55e6fcd9 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 (plain text mode coming soon) (under active development)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_state_counters": { isFeature: true, labsGroup: LabGroup.Rooms, diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index 7f5e9715a60..b8ff024cd88 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -39,6 +39,15 @@ 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"; + +// 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", () => { stubClient(); @@ -346,6 +355,16 @@ describe("MessageComposer", () => { 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( diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx new file mode 100644 index 00000000000..171455bfbc6 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -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( + + + + { (_sendMessage) => { + sendMessage = _sendMessage; + } } + + , + ); + }; + + 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 = 'html'; + customRender((content) => { + expect(content).toBe((html)); + done(); + }); + act(() => callOnChange(html)); + }); + + it('Should send message, call clear and focus the textbox', async () => { + // When + const html = 'html'; + 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(); + }); +}); + diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/message-test.ts new file mode 100644 index 00000000000..605b3e35a7e --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/message-test.ts @@ -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 = 'hello world'; + 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": "hello world", + "format": "org.matrix.custom.html", + "formatted_body": "hello world", + "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' }, + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index eced1c98cef..1b4678408a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1549,6 +1549,11 @@ 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.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": 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"