<commit_before>/*
Copyright 2019 - 2021 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 classNames from 'classnames';
import React, { createRef, ClipboardEvent } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';

import EditorModel from '../../../editor/model';
import HistoryManager from '../../../editor/history';
import { Caret, setSelection } from '../../../editor/caret';
import {
    formatRangeAsQuote,
    formatRangeAsCode,
    toggleInlineFormat,
    replaceRangeAndMoveCaret,
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
import SettingsStore from "../../../settings/SettingsStore";
import { 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";
import DocumentOffset from "../../../editor/offset";
import { IDiff } from "../../../editor/diff";
import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import { ICompletion } from "../../../autocomplete/Autocompleter";
import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
import { replaceableComponent } from "../../../utils/replaceableComponent";

// 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 = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');

const IS_MAC = navigator.platform.indexOf("Mac") !== -1;

const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
    ["(", ")"],
    ["[", "]"],
    ["{", "}"],
    ["<", ">"],
]);

function ctrlShortcutLabel(key: string): string {
    return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}

function cloneSelection(selection: Selection): Partial<Selection> {
    return {
        anchorNode: selection.anchorNode,
        anchorOffset: selection.anchorOffset,
        focusNode: selection.focusNode,
        focusOffset: selection.focusOffset,
        isCollapsed: selection.isCollapsed,
        rangeCount: selection.rangeCount,
        type: selection.type,
    };
}

function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
    return a.anchorNode === b.anchorNode &&
        a.anchorOffset === b.anchorOffset &&
        a.focusNode === b.focusNode &&
        a.focusOffset === b.focusOffset &&
        a.isCollapsed === b.isCollapsed &&
        a.rangeCount === b.rangeCount &&
        a.type === b.type;
}

interface IProps {
    model: EditorModel;
    room: Room;
    placeholder?: string;
    label?: string;
    initialCaret?: DocumentOffset;
    disabled?: boolean;

    onChange?();
    onPaste?(event: ClipboardEvent<HTMLDivElement>, model: EditorModel): boolean;
}

interface IState {
    showPillAvatar: boolean;
    query?: string;
    showVisualBell?: boolean;
    autoComplete?: AutocompleteWrapperModel;
    completionIndex?: number;
    surroundWith: boolean;
}

@replaceableComponent("views.rooms.BasicMessageEditor")
export default class BasicMessageEditor extends React.Component<IProps, IState> {
    public readonly editorRef = createRef<HTMLDivElement>();
    private autocompleteRef = createRef<Autocomplete>();
    private formatBarRef = createRef<MessageComposerFormatBar>();

    private modifiedFlag = false;
    private isIMEComposing = false;
    private hasTextSelected = false;

    private _isCaretAtEnd: boolean;
    private lastCaret: DocumentOffset;
    private lastSelection: ReturnType<typeof cloneSelection>;

    private readonly emoticonSettingHandle: string;
    private readonly shouldShowPillAvatarSettingHandle: string;
    private readonly surroundWithHandle: string;
    private readonly historyManager = new HistoryManager();

    constructor(props) {
        super(props);
        this.state = {
            showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
            surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
            showVisualBell: false,
        };

        this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
            this.configureEmoticonAutoReplace);
        this.configureEmoticonAutoReplace();
        this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
            this.configureShouldShowPillAvatar);
        this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
            this.surroundWithSettingChanged);
    }

    public componentDidUpdate(prevProps: IProps) {
        // We need to re-check the placeholder when the enabled state changes because it causes the
        // placeholder element to remount, which gets rid of the `::before` class. Re-evaluating the
        // placeholder means we get a proper `::before` with the placeholder.
        const enabledChange = this.props.disabled !== prevProps.disabled;
        const placeholderChanged = this.props.placeholder !== prevProps.placeholder;
        if (this.props.placeholder && (placeholderChanged || enabledChange)) {
            const { isEmpty } = this.props.model;
            if (isEmpty) {
                this.showPlaceholder();
            } else {
                this.hidePlaceholder();
            }
        }
    }

    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 moveStart = emoticonMatch[0][0] === " " ? 1 : 0;
                const moveEnd = emoticonMatch[0].length - emoticonMatch.length - moveStart;

                // 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);
                // and move end backwards so that we don't replace the trailing space/newline
                range.moveEndBackwards(moveEnd);

                // this returns the amount of added/removed characters during the replace
                // so the caret position can be adjusted.
                return range.replace([partCreator.plain(data.unicode)]);
            }
        }
    }

    private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
        renderModel(this.editorRef.current, this.props.model);
        if (selection) { // set the caret/selection
            try {
                setSelection(this.editorRef.current, this.props.model, selection);
            } catch (err) {
                console.error(err);
            }
            // if caret selection is a range, take the end position
            const position = selection instanceof Range ? selection.end : selection;
            this.setLastCaretFromPosition(position);
        }
        const { isEmpty } = this.props.model;
        if (this.props.placeholder) {
            if (isEmpty) {<commit_msg>Fix code to move end of range more simply and safely<commit_after>/*
Copyright 2019 - 2021 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 classNames from 'classnames';
import React, { createRef, ClipboardEvent } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';

import EditorModel from '../../../editor/model';
import HistoryManager from '../../../editor/history';
import { Caret, setSelection } from '../../../editor/caret';
import {
    formatRangeAsQuote,
    formatRangeAsCode,
    toggleInlineFormat,
    replaceRangeAndMoveCaret,
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
import SettingsStore from "../../../settings/SettingsStore";
import { 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";
import DocumentOffset from "../../../editor/offset";
import { IDiff } from "../../../editor/diff";
import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import { ICompletion } from "../../../autocomplete/Autocompleter";
import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager';
import { replaceableComponent } from "../../../utils/replaceableComponent";

// 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 = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');

const IS_MAC = navigator.platform.indexOf("Mac") !== -1;

const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
    ["(", ")"],
    ["[", "]"],
    ["{", "}"],
    ["<", ">"],
]);

function ctrlShortcutLabel(key: string): string {
    return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}

function cloneSelection(selection: Selection): Partial<Selection> {
    return {
        anchorNode: selection.anchorNode,
        anchorOffset: selection.anchorOffset,
        focusNode: selection.focusNode,
        focusOffset: selection.focusOffset,
        isCollapsed: selection.isCollapsed,
        rangeCount: selection.rangeCount,
        type: selection.type,
    };
}

function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
    return a.anchorNode === b.anchorNode &&
        a.anchorOffset === b.anchorOffset &&
        a.focusNode === b.focusNode &&
        a.focusOffset === b.focusOffset &&
        a.isCollapsed === b.isCollapsed &&
        a.rangeCount === b.rangeCount &&
        a.type === b.type;
}

interface IProps {
    model: EditorModel;
    room: Room;
    placeholder?: string;
    label?: string;
    initialCaret?: DocumentOffset;
    disabled?: boolean;

    onChange?();
    onPaste?(event: ClipboardEvent<HTMLDivElement>, model: EditorModel): boolean;
}

interface IState {
    showPillAvatar: boolean;
    query?: string;
    showVisualBell?: boolean;
    autoComplete?: AutocompleteWrapperModel;
    completionIndex?: number;
    surroundWith: boolean;
}

@replaceableComponent("views.rooms.BasicMessageEditor")
export default class BasicMessageEditor extends React.Component<IProps, IState> {
    public readonly editorRef = createRef<HTMLDivElement>();
    private autocompleteRef = createRef<Autocomplete>();
    private formatBarRef = createRef<MessageComposerFormatBar>();

    private modifiedFlag = false;
    private isIMEComposing = false;
    private hasTextSelected = false;

    private _isCaretAtEnd: boolean;
    private lastCaret: DocumentOffset;
    private lastSelection: ReturnType<typeof cloneSelection>;

    private readonly emoticonSettingHandle: string;
    private readonly shouldShowPillAvatarSettingHandle: string;
    private readonly surroundWithHandle: string;
    private readonly historyManager = new HistoryManager();

    constructor(props) {
        super(props);
        this.state = {
            showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
            surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
            showVisualBell: false,
        };

        this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
            this.configureEmoticonAutoReplace);
        this.configureEmoticonAutoReplace();
        this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
            this.configureShouldShowPillAvatar);
        this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
            this.surroundWithSettingChanged);
    }

    public componentDidUpdate(prevProps: IProps) {
        // We need to re-check the placeholder when the enabled state changes because it causes the
        // placeholder element to remount, which gets rid of the `::before` class. Re-evaluating the
        // placeholder means we get a proper `::before` with the placeholder.
        const enabledChange = this.props.disabled !== prevProps.disabled;
        const placeholderChanged = this.props.placeholder !== prevProps.placeholder;
        if (this.props.placeholder && (placeholderChanged || enabledChange)) {
            const { isEmpty } = this.props.model;
            if (isEmpty) {
                this.showPlaceholder();
            } else {
                this.hidePlaceholder();
            }
        }
    }

    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 moveStart = emoticonMatch[0][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(emoticonMatch[0][emoticonMatch[0].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.plain(data.unicode)]);
            }
        }
    }

    private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
        renderModel(this.editorRef.current, this.props.model);
        if (selection) { // set the caret/selection
            try {
                setSelection(this.editorRef.current, this.props.model, selection);
            } catch (err) {
                console.error(err);
            }
            // if caret selection is a range, take the end position
            const position = selection instanceof Range ? selection.end : selection;
            this.setLastCaretFromPosition(position);
        }
        const { isEmpty } = this.props.model;
        if (this.props.placeholder) {
            if (isEmpty) {
{'fraction_non_alphanumeric': 0.12347004509340777, 'fraction_numerical': 0.002737814043375564, 'mean_word_length': 2.867344820427652, 'pattern_counts': {'":': 0, '<': 21, '<?xml version=': 0, '>': 27, 'https://': 0, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2762418', 'n_tokens_mistral': 5017, 'n_tokens_neox': 4933, 'n_words': 1369}