diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index fd3f5eed3dc..761cacd0cf4 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -34,7 +34,6 @@ import { renderModel } from '../../../editor/render'; import TypingStore from "../../../stores/TypingStore"; import SettingsStore from "../../../settings/SettingsStore"; import { IS_MAC, Key } from "../../../Keyboard"; -import { EMOTICON_TO_EMOJI } from "../../../emoji"; import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands"; import Range from "../../../editor/range"; import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar"; @@ -49,7 +48,7 @@ import { _t } from "../../../languageHandler"; import { linkify } from '../../../linkify-matrix'; // matches emoticons which follow the start of a line or whitespace -const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); +export const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$'); const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"]; @@ -167,45 +166,6 @@ export default class BasicMessageEditor extends React.Component } } - public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number { - const { model } = this.props; - const range = model.startRange(caretPosition); - // expand range max 8 characters backwards from caretPosition, - // as a space to look for an emoticon - let n = 8; - range.expandBackwardsWhile((index, offset) => { - const part = model.parts[index]; - n -= 1; - return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type); - }); - const emoticonMatch = regex.exec(range.text); - if (emoticonMatch) { - const query = emoticonMatch[1].replace("-", ""); - // try both exact match and lower-case, this means that xd won't match xD but :P will match :p - const data = EMOTICON_TO_EMOJI.get(query) || EMOTICON_TO_EMOJI.get(query.toLowerCase()); - - if (data) { - const { partCreator } = model; - const firstMatch = emoticonMatch[0]; - const moveStart = firstMatch[0] === " " ? 1 : 0; - - // we need the range to only comprise of the emoticon - // because we'll replace the whole range with an emoji, - // so move the start forward to the start of the emoticon. - // Take + 1 because index is reported without the possible preceding space. - range.moveStartForwards(emoticonMatch.index + moveStart); - // If the end is a trailing space/newline move end backwards, so that we don't replace it - if (["\n", " "].includes(firstMatch[firstMatch.length - 1])) { - range.moveEndBackwards(1); - } - - // this returns the amount of added/removed characters during the replace - // so the caret position can be adjusted. - return range.replace([partCreator.emoji(data.unicode)]); - } - } - } - private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { renderModel(this.editorRef.current, this.props.model); if (selection) { // set the caret/selection @@ -419,6 +379,10 @@ export default class BasicMessageEditor extends React.Component return this.lastCaret; } + public getDocumentPositionAtCaret(): DocumentPosition { + return this.getCaret().asPosition(this.props.model); + } + public isSelectionCollapsed(): boolean { return !this.lastSelection || this.lastSelection.isCollapsed; } @@ -665,7 +629,7 @@ export default class BasicMessageEditor extends React.Component private transform = (documentPosition: DocumentPosition): void => { const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); - if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE); + if (shouldReplace) this.props.model.replaceEmoticon(REGEX_EMOTICON_WHITESPACE, documentPosition); }; componentWillUnmount() { diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 9a0e4d1ea85..6cf500370f9 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -47,6 +47,7 @@ import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } fr import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { editorRoomKey, editorStateKey } from "../../../Editing"; +import DocumentPosition from "../../../editor/position"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -70,7 +71,13 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string { function createEditContent( model: EditorModel, editedEvent: MatrixEvent, + position?: DocumentPosition, ): IContent { + // Replace emoticon at the caret + if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { + model.replaceEmoticon(REGEX_EMOTICON, position); + } + const isEmote = containsEmote(model); if (isEmote) { model = stripEmoteCommand(model); @@ -295,6 +302,12 @@ class EditMessageComposer extends React.Component({ eventName: "Composer", @@ -303,17 +316,6 @@ class EditMessageComposer extends React.Component { - if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) { - return; - } + if (!this.editorRef.current?.isModified()) return; + + const editedEvent = this.props.editState.getEvent(); + const editContent = createEditContent(this.model, editedEvent); + const newContent = editContent["m.new_content"]; this.setState({ - saveDisabled: false, + saveDisabled: !this.isContentModified(newContent), }); }; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 281666b56f1..f96ad7987ec 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -52,7 +52,6 @@ import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { ActionPayload } from "../../../dispatcher/payloads"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; -import DocumentPosition from "../../../editor/position"; import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; @@ -330,14 +329,9 @@ export class SendMessageComposer extends React.Component(posthogEvent); - // Replace emoticon at the end of the message + // Replace emoticon at the caret if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { - const indexOfLastPart = model.parts.length - 1; - const positionInLastPart = model.parts[indexOfLastPart].text.length; - this.editorRef.current?.replaceEmoticon( - new DocumentPosition(indexOfLastPart, positionInLastPart), - REGEX_EMOTICON, - ); + this.model.replaceEmoticon(REGEX_EMOTICON, this.editorRef.current?.getDocumentPositionAtCaret()); } const replyToEvent = this.props.replyToEvent; diff --git a/src/editor/model.ts b/src/editor/model.ts index 59c7691d89f..a973776942b 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -18,10 +18,11 @@ limitations under the License. import { diffAtCaret, diffDeletion, IDiff } from "./diff"; import DocumentPosition, { IPosition } from "./position"; import Range from "./range"; -import { SerializedPart, Part, PartCreator } from "./parts"; +import { SerializedPart, Part, PartCreator, Type } from "./parts"; import AutocompleteWrapperModel, { ICallback } from "./autocomplete"; import DocumentOffset from "./offset"; import { Caret } from "./caret"; +import { EMOTICON_TO_EMOJI } from "../emoji"; /** * @callback ModelCallback @@ -461,4 +462,42 @@ export default class EditorModel { this.updateCallback(pos); return acPromise; } + + public replaceEmoticon(regex: RegExp, caretPosition: DocumentPosition = this.getPositionAtEnd()): number { + const range = this.startRange(caretPosition); + // expand range max 8 characters backwards from caretPosition, + // as a space to look for an emoticon + let n = 8; + range.expandBackwardsWhile((index) => { + const part = this.parts[index]; + n -= 1; + return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type); + }); + const emoticonMatch = regex.exec(range.text); + if (emoticonMatch) { + const query = emoticonMatch[1].replace("-", ""); + // try both exact match and lower-case, this means that xd won't match xD but :P will match :p + const data = EMOTICON_TO_EMOJI.get(query) || EMOTICON_TO_EMOJI.get(query.toLowerCase()); + + if (data) { + const { partCreator } = this; + const firstMatch = emoticonMatch[0]; + const moveStart = firstMatch[0] === " " ? 1 : 0; + + // we need the range to only comprise of the emoticon + // because we'll replace the whole range with an emoji, + // so move the start forward to the start of the emoticon. + // Take + 1 because index is reported without the possible preceding space. + range.moveStartForwards(emoticonMatch.index + moveStart); + // If the end is a trailing space/newline move end backwards, so that we don't replace it + if (["\n", " "].includes(firstMatch[firstMatch.length - 1])) { + range.moveEndBackwards(1); + } + + // this returns the amount of added/removed characters during the replace + // so the caret position can be adjusted. + return range.replace([partCreator.emoji(data.unicode)]); + } + } + } } diff --git a/test/components/views/rooms/EditMessageComposer-test.tsx b/test/components/views/rooms/EditMessageComposer-test.tsx new file mode 100644 index 00000000000..7ad6673895d --- /dev/null +++ b/test/components/views/rooms/EditMessageComposer-test.tsx @@ -0,0 +1,72 @@ +/* +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 { ReactWrapper, mount } from "enzyme"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MessageEvent } from 'matrix-events-sdk'; +import { act } from "react-test-renderer"; + +import EditMessageComposerWithMatrixClient from "../../../../src/components/views/rooms/EditMessageComposer"; +import EditorStateTransfer from "../../../../src/utils/EditorStateTransfer"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { stubClient } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; + +describe("EditMessageComposer", () => { + beforeAll(() => { + stubClient(); + }); + + describe("save button disabled state behaves correctly", () => { + it("should handle regular text changes", () => { + const mxEvent = new MatrixEvent(MessageEvent.from("world").serialize()); + const wrapper = createEditor(mxEvent); + let saveButton = wrapper.find(".mx_AccessibleButton_kind_primary"); + + expect(saveButton.props()["aria-disabled"]).toBeTruthy(); + addText(wrapper, "Hello "); + + saveButton = wrapper.find(".mx_AccessibleButton_kind_primary"); + expect(saveButton.props()["aria-disabled"]).toBeFalsy(); + }); + }); +}); + +const addText = (wrapper: ReactWrapper, text: string) => act(() => { + // couldn't get input event on contenteditable to work + // paste works without illegal private method access + const pasteEvent = { + clipboardData: { + types: [], + files: [], + getData: type => type === "text/plain" ? text : undefined, + }, + }; + wrapper.find('[role="textbox"]').simulate('paste', pasteEvent); + wrapper.update(); +}); + +const createEditor = (mxEvent: MatrixEvent): ReactWrapper => { + const client = MatrixClientPeg.get(); + const editState = new EditorStateTransfer(mxEvent); + + return mount( + + + , + ); +}; diff --git a/test/editor/model-test.ts b/test/editor/model-test.ts index 6b3bd8fb2ca..f78a2bde746 100644 --- a/test/editor/model-test.ts +++ b/test/editor/model-test.ts @@ -17,6 +17,7 @@ limitations under the License. import EditorModel from "../../src/editor/model"; import { createPartCreator, createRenderer } from "./mock"; import DocumentOffset from "../../src/editor/offset"; +import { REGEX_EMOTICON, REGEX_EMOTICON_WHITESPACE } from "../../src/components/views/rooms/BasicMessageComposer"; describe('editor/model', function() { describe('plain text manipulation', function() { @@ -321,4 +322,25 @@ describe('editor/model', function() { expect(model.parts[0].text).toBe("foo@a"); }); }); + describe('replace emoticons', () => { + it('without whitespace', () => { + const renderer = createRenderer(); + const model = new EditorModel([], createPartCreator(), renderer); + model.update("hello :D", "insertText", new DocumentOffset(8, true)); + model.replaceEmoticon(REGEX_EMOTICON); + + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].text).toBe("😄"); + }); + it('with whitespace', () => { + const renderer = createRenderer(); + const model = new EditorModel([], createPartCreator(), renderer); + model.update("hello :D ", "insertText", new DocumentOffset(9, true)); + model.replaceEmoticon(REGEX_EMOTICON_WHITESPACE); + + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].text).toBe("😄"); + expect(model.parts[2].text).toBe(" "); + }); + }); });