From 898d1ac2b7f5ed475edbd9b17edd8dedde5ff5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 09:25:32 +0200 Subject: [PATCH 01/10] Move `replaceEmoticon()` to `EditorModel` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../views/rooms/BasicMessageComposer.tsx | 42 +------------------ .../views/rooms/EditMessageComposer.tsx | 2 +- .../views/rooms/SendMessageComposer.tsx | 2 +- src/editor/model.ts | 41 +++++++++++++++++- 4 files changed, 43 insertions(+), 44 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 667d5a42a4e..5632cd96cff 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"; @@ -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 @@ -665,7 +625,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(documentPosition, REGEX_EMOTICON_WHITESPACE); }; componentWillUnmount() { diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index de1bdc9c85b..9138fc888d2 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -307,7 +307,7 @@ class EditMessageComposer extends React.Component { + 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)]); + } + } + } } From 074d9f647f4dc9445447ca8b30c521cde3ef0dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 09:28:59 +0200 Subject: [PATCH 02/10] Make `documentPosition` optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/rooms/BasicMessageComposer.tsx | 2 +- src/components/views/rooms/EditMessageComposer.tsx | 4 +--- src/components/views/rooms/SendMessageComposer.tsx | 8 +------- src/editor/model.ts | 2 +- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 5632cd96cff..a1513486812 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -625,7 +625,7 @@ export default class BasicMessageEditor extends React.Component private transform = (documentPosition: DocumentPosition): void => { const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); - if (shouldReplace) this.props.model.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 9138fc888d2..8e4c4294485 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -305,9 +305,7 @@ class EditMessageComposer extends React.Component Date: Sun, 15 May 2022 09:36:03 +0200 Subject: [PATCH 03/10] `replaceEmoticon()` in `createEditContent()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../views/rooms/EditMessageComposer.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 8e4c4294485..8c0fbb3846f 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -71,6 +71,11 @@ function createEditContent( model: EditorModel, editedEvent: MatrixEvent, ): IContent { + // Replace emoticon at the end of the message + if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { + model.replaceEmoticon(REGEX_EMOTICON); + } + const isEmote = containsEmote(model); if (isEmote) { model = stripEmoteCommand(model); @@ -295,6 +300,8 @@ class EditMessageComposer extends React.Component({ eventName: "Composer", @@ -303,15 +310,6 @@ class EditMessageComposer extends React.Component Date: Sun, 15 May 2022 09:37:43 +0200 Subject: [PATCH 04/10] Correctly set `saveDisabled` based on `isContentModified()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/rooms/EditMessageComposer.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 8c0fbb3846f..452eeabcf55 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -416,12 +416,14 @@ 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), }); }; From d63003241e5e0597730082d2ef6852269138e461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 09:54:31 +0200 Subject: [PATCH 05/10] Replace emoticon at the caret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/rooms/EditMessageComposer.tsx | 10 +++++++--- src/components/views/rooms/SendMessageComposer.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 452eeabcf55..fd6b48138de 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 DocumentOffset from "../../../editor/offset"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -70,10 +71,12 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string { function createEditContent( model: EditorModel, editedEvent: MatrixEvent, + caret?: DocumentOffset, ): IContent { - // Replace emoticon at the end of the message + // Replace emoticon at the caret if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { - model.replaceEmoticon(REGEX_EMOTICON); + const position = caret ? model.positionForOffset(caret.offset, caret.atNodeEnd) : undefined; + model.replaceEmoticon(REGEX_EMOTICON, position); } const isEmote = containsEmote(model); @@ -300,7 +303,8 @@ class EditMessageComposer extends React.Component({ diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 9e241802126..7e6ced12a50 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -329,9 +329,11 @@ 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')) { - this.model.replaceEmoticon(REGEX_EMOTICON); + const caret = this.editorRef.current?.getCaret(); + const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd); + this.model.replaceEmoticon(REGEX_EMOTICON, position); } const replyToEvent = this.props.replyToEvent; From bd0dd1de43db5ca280e7e9269ccb0829c0c38d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 09:58:44 +0200 Subject: [PATCH 06/10] Add `getDocumentPositionAtCaret()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/rooms/BasicMessageComposer.tsx | 4 ++++ src/components/views/rooms/EditMessageComposer.tsx | 12 +++++++----- src/components/views/rooms/SendMessageComposer.tsx | 4 +--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index a1513486812..1e3bb8f14f8 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -378,6 +378,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; } diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index fd6b48138de..30612d77eb6 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -47,7 +47,7 @@ import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } fr import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { editorRoomKey, editorStateKey } from "../../../Editing"; -import DocumentOffset from "../../../editor/offset"; +import DocumentPosition from "../../../editor/position"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -71,11 +71,10 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string { function createEditContent( model: EditorModel, editedEvent: MatrixEvent, - caret?: DocumentOffset, + position?: DocumentPosition, ): IContent { // Replace emoticon at the caret if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { - const position = caret ? model.positionForOffset(caret.offset, caret.atNodeEnd) : undefined; model.replaceEmoticon(REGEX_EMOTICON, position); } @@ -303,8 +302,11 @@ class EditMessageComposer extends React.Component({ diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 7e6ced12a50..7322c57971c 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -331,9 +331,7 @@ export class SendMessageComposer extends React.Component Date: Sun, 15 May 2022 10:27:44 +0200 Subject: [PATCH 07/10] Remove unused `offset` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/editor/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/model.ts b/src/editor/model.ts index bcbfd86d874..a973776942b 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -468,7 +468,7 @@ export default class EditorModel { // expand range max 8 characters backwards from caretPosition, // as a space to look for an emoticon let n = 8; - range.expandBackwardsWhile((index, offset) => { + range.expandBackwardsWhile((index) => { const part = this.parts[index]; n -= 1; return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type); From 03987690ccfc65924e83c0f7f05d49ec87fee227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 11:40:08 +0200 Subject: [PATCH 08/10] Export `REGEX_EMOTICON_WHITESPACE` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/rooms/BasicMessageComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 1e3bb8f14f8..a8bc266cca7 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -48,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 = ["\"", "_", "`", "'", "*", "~", "$"]; From eccf46d18032f0db81790ab5aea562383fbdac60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 11:41:20 +0200 Subject: [PATCH 09/10] Add test for `replace emoticons` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- test/editor/model-test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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(" "); + }); + }); }); From b0fc96a7d38ecf9eb516605372ec65bd919070f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 15 May 2022 19:50:24 +0200 Subject: [PATCH 10/10] Add tests for `EditMessageComposer` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../views/rooms/EditMessageComposer-test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/components/views/rooms/EditMessageComposer-test.tsx 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( + + + , + ); +};