Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Improve handling of the disabled state of the save button in the edit message composer #8601

Closed
wants to merge 11 commits into from
48 changes: 6 additions & 42 deletions src/components/views/rooms/BasicMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = ["\"", "_", "`", "'", "*", "~", "$"];
Expand Down Expand Up @@ -167,45 +166,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
}

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
Expand Down Expand Up @@ -419,6 +379,10 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
return this.lastCaret;
}

public getDocumentPositionAtCaret(): DocumentPosition {
return this.getCaret().asPosition(this.props.model);
}

public isSelectionCollapsed(): boolean {
return !this.lastSelection || this.lastSelection.isCollapsed;
}
Expand Down Expand Up @@ -665,7 +629,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>

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() {
Expand Down
35 changes: 20 additions & 15 deletions src/components/views/rooms/EditMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -295,6 +302,12 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
if (this.state.saveDisabled) return;

const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(
this.model,
editedEvent,
this.editorRef.current?.getDocumentPositionAtCaret(),
);
const newContent = editContent["m.new_content"];

PosthogAnalytics.instance.trackEvent<ComposerEvent>({
eventName: "Composer",
Expand All @@ -303,17 +316,6 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
isReply: !!editedEvent.replyEventId,
});

// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"];

let shouldSend = true;

if (newContent?.body === '') {
this.cancelPreviousPendingEdit();
createRedactEventDialog({
Expand All @@ -325,6 +327,7 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
return;
}

let shouldSend = true;
// If content is modified then send an updated event into the room
if (this.isContentModified(newContent)) {
const roomId = editedEvent.getRoomId();
Expand Down Expand Up @@ -420,12 +423,14 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
}

private onChange = (): void => {
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),
});
};

Expand Down
10 changes: 2 additions & 8 deletions src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -330,14 +329,9 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
}
PosthogAnalytics.instance.trackEvent<ComposerEvent>(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;
Expand Down
41 changes: 40 additions & 1 deletion src/editor/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]);
}
}
}
}
72 changes: 72 additions & 0 deletions test/components/views/rooms/EditMessageComposer-test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MatrixClientContext.Provider value={client}>
<EditMessageComposerWithMatrixClient editState={editState} />
</MatrixClientContext.Provider>,
);
};
22 changes: 22 additions & 0 deletions test/editor/model-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(" ");
});
});
});