<commit_before>/**
 * This file is part of Threema Web.
 *
 * Threema Web is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or (at
 * your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
 * General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
 */

import {ComposeArea} from '@threema/compose-area';

import {isActionTrigger} from '../helpers';
import {emojifyNew, shortnameToUnicode} from '../helpers/emoji';
import {BrowserService} from '../services/browser';
import {LogService} from '../services/log';
import {ReceiverService} from '../services/receiver';
import {StringService} from '../services/string';
import {TimeoutService} from '../services/timeout';
import {isEmojiInfo} from '../typeguards';

/**
 * The compose area where messages are written.
 */
export default [
    'BrowserService',
    'LogService',
    'StringService',
    'TimeoutService',
    'ReceiverService',
    '$timeout',
    '$translate',
    '$mdDialog',
    '$filter',
    '$rootScope',
    'CONFIG',
    function(browserService: BrowserService,
             logService: LogService,
             stringService: StringService,
             timeoutService: TimeoutService,
             receiverService: ReceiverService,
             $timeout: ng.ITimeoutService,
             $translate: ng.translate.ITranslateService,
             $mdDialog: ng.material.IDialogService,
             $filter: ng.IFilterService,
             $rootScope: ng.IRootScopeService,
             CONFIG: threema.Config) {
        const log = logService.getLogger('ComposeArea-C');
        return {
            restrict: 'EA',
            scope: {
                // Callback to get a reference to the initialized ComposeArea instance.
                onInit: '=',

                // Callback to submit text or file data
                submit: '=',

                // Callbacks to update typing information
                startTyping: '=',
                stopTyping: '=',
                onTyping: '=',
                onKeyDown: '=',

                // Reference to initial text and draft
                initialData: '=',

                // Callback that is called when uploading files
                onUploading: '=',
                maxTextLength: '=',

                receiver: '<receiver',
            },
            link: function(scope: any, wrapper: JQLite) {
                // Constants
                const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
                const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';

                // Elements
                const select = (selector) => angular.element(wrapper[0].querySelector(selector));
                const composeDiv = select('div.compose') as JQuery<HTMLElement>;
                const emojiTrigger = select('i.emoji-trigger') as JQuery<HTMLElement>;
                const emojiKeyboard = select('.emoji-keyboard') as JQuery<HTMLElement>;
                const sendTrigger = select('i.send-trigger') as JQuery<HTMLElement>;
                const fileTrigger = select('i.file-trigger') as JQuery<HTMLElement>;
                const fileInput = select('input.file-input') as JQuery<HTMLInputElement>;

                // Initialize compose area lib
                const composeArea = ComposeArea.bind_to(composeDiv[0], CONFIG.COMPOSE_AREA_LOG_LEVEL);
                if (scope.onInit) {
                    scope.onInit(composeArea);
                }

                // Set initial text
                if (scope.initialData.initialText) {
                    composeDiv[0].innerText = scope.initialData.initialText;
                    scope.initialData.initialText = '';
                } else if (scope.initialData.draft !== undefined) {
                    composeDiv[0].innerText = scope.initialData.draft;
                }

                let chatBlocked = false;

                // Function to update blocking state
                function setChatBlocked(blocked: boolean) {
                    chatBlocked = blocked;
                    log.debug('Receiver blocked:', blocked);
                    if (blocked) {
                        sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        emojiTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        fileTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        composeDiv.attr('contenteditable', 'false');
                        if (emojiKeyboard.hasClass('active')) {
                            hideEmojiPicker();
                        }
                    } else {
                        if (composeAreaIsEmpty()) {
                            sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        } else {
                            sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
                        }
                        emojiTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
                        fileTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
                        composeDiv.attr('contenteditable', 'true');
                    }
                }

                // Initialize blocking state
                setChatBlocked(receiverService.isBlocked(scope.receiver));

                // Watch `isBlocked` flag for changes
                scope.$watch(
                    (_scope) => receiverService.isBlocked(_scope.receiver),
                    (isBlocked: boolean, wasBlocked: boolean) => {
                        if (isBlocked !== wasBlocked) {
                            setChatBlocked(isBlocked);
                        }
                    },
                );

                // Typing events
                let stopTypingTimer: ng.IPromise<void> = null;

                function stopTyping() {
                    // We can only stop typing of the timer is set (meaning
                    // that we started typing earlier)
                    if (stopTypingTimer !== null) {
                        // Cancel timer
                        timeoutService.cancel(stopTypingTimer);
                        stopTypingTimer = null;

                        // Send stop typing message
                        scope.stopTyping();
                    }
                }

                function startTyping() {
                    if (stopTypingTimer === null) {
                        // If the timer wasn't set previously, we just
                        // started typing!
                        scope.startTyping();
                    } else {
                        // Cancel timer, we'll re-create it
                        timeoutService.cancel(stopTypingTimer);
                    }

                    // Define a timeout to send the stopTyping event
                    stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
                }

                // Determine whether field is empty
                function composeAreaIsEmpty() {
                    return composeArea.get_text().length === 0;
                }

                // Submit the text from the compose area.
                //
                // Emoji images are converted to their alt text in this process.
                function submitText(): Promise<any> {
                    const text = composeArea.get_text().replace(/\r/g, '');

                    return new Promise((resolve, reject) => {
                        const submitTexts = (strings: string[]) => {
                            const messages: threema.TextMessageData[] = [];
                            for (const piece of strings) {
                                messages.push({
                                    text: piece,
                                });
                            }

                            scope.submit('text', messages)
                                .then(resolve)
                                .catch(reject);
                        };

                        if (text.length > scope.maxTextLength) {
                            const pieces: string[] = stringService.byteChunk(text, scope.maxTextLength, 50);
                            const confirm = $mdDialog.confirm()
                                .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
                                .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
                                    max: scope.maxTextLength,
                                    count: pieces.length,
                                }))
                                .ok($translate.instant('common.YES'))
                                .cancel($translate.instant('common.NO'));

                            $mdDialog.show(confirm).then(function() {
                                submitTexts(pieces);
                            }, () => {
                                reject();
                            });
                        } else {
                            submitTexts([text]);
                        }
                    });
                }

                function sendText(): boolean {
                    if (!composeAreaIsEmpty()) {
                        submitText().then(() => {
                            // Clear compose div
                            composeArea.clear();
                            composeArea.focus();

                            // Send stopTyping event
                            stopTyping();

                            // Clear draft
                            scope.onTyping('');

                            updateView();
                        }).catch(() => {
                            // do nothing
                            log.warn('Failed to submit text');
                        });

                        return true;
                    }
                    return false;
                }

                // Handle typing events

                let isComposing = false;

                function onCompositionStart(ev: CompositionEvent): void {
                    isComposing = true;
                }

                function onCompositionEnd(ev: CompositionEvent): void {
                    isComposing = false;
                }

                function onKeyDown(ev: KeyboardEvent): void {
                    // If enter is pressed, prevent default event from being dispatched
                    if (!ev.shiftKey && ev.key === 'Enter') {
                        ev.preventDefault();
                    }

                    // If the keydown is handled and aborted outside
                    if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
                        ev.preventDefault();
                        return;
                    }

                    // If the enter key is part of a composition (e.g. when
                    // entering text with an IME), don't submit the text.
                    // See https://github.com/threema-ch/threema-web/issues/777
                    if ((ev as any).isComposing || isComposing) {
                        return;
                    }

                    // If a : is pressed, possibly insert emoji
                    if (ev.key === ':') {
                        const modified = onEmojiShortcodeKeyPressed(ev, ':', false);
                        if (modified) {
                            ev.preventDefault();
                            return;
                        }
                    }

                    // At link time, the element is not yet evaluated.
                    // Therefore add following code to end of event loop.
                    $timeout(() => {
                        // Shift + enter to insert a newline. Enter to send.
                        if (!ev.shiftKey && ev.key === 'Enter') {
                            if (sendText()) {
                                return;
                            }
                        }

                        updateView();
                    }, 0);
                }

                function onKeyUp(ev: KeyboardEvent): void {
                    // If the compose area contains only a single <br>, make it fully empty.
                    // See also: https://stackoverflow.com/q/14638887/284318
                    const text = composeArea.get_text(true);
                    if (text === '\n') {
                        composeDiv[0].innerText = '';
                    }

                    // Update typing information
                    if (text.trim().length === 0) {
                        stopTyping();<commit_msg>Add workaround for strange Safari composition event order (#879)<commit_after>/**
 * This file is part of Threema Web.
 *
 * Threema Web is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or (at
 * your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
 * General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
 */

import {ComposeArea} from '@threema/compose-area';

import {isActionTrigger} from '../helpers';
import {emojifyNew, shortnameToUnicode} from '../helpers/emoji';
import {BrowserService} from '../services/browser';
import {LogService} from '../services/log';
import {ReceiverService} from '../services/receiver';
import {StringService} from '../services/string';
import {TimeoutService} from '../services/timeout';
import {isEmojiInfo} from '../typeguards';

/**
 * The compose area where messages are written.
 */
export default [
    'BrowserService',
    'LogService',
    'StringService',
    'TimeoutService',
    'ReceiverService',
    '$timeout',
    '$translate',
    '$mdDialog',
    '$filter',
    '$rootScope',
    'CONFIG',
    function(browserService: BrowserService,
             logService: LogService,
             stringService: StringService,
             timeoutService: TimeoutService,
             receiverService: ReceiverService,
             $timeout: ng.ITimeoutService,
             $translate: ng.translate.ITranslateService,
             $mdDialog: ng.material.IDialogService,
             $filter: ng.IFilterService,
             $rootScope: ng.IRootScopeService,
             CONFIG: threema.Config) {
        const log = logService.getLogger('ComposeArea-C');
        return {
            restrict: 'EA',
            scope: {
                // Callback to get a reference to the initialized ComposeArea instance.
                onInit: '=',

                // Callback to submit text or file data
                submit: '=',

                // Callbacks to update typing information
                startTyping: '=',
                stopTyping: '=',
                onTyping: '=',
                onKeyDown: '=',

                // Reference to initial text and draft
                initialData: '=',

                // Callback that is called when uploading files
                onUploading: '=',
                maxTextLength: '=',

                receiver: '<receiver',
            },
            link: function(scope: any, wrapper: JQLite) {
                // Constants
                const TRIGGER_ENABLED_CSS_CLASS = 'is-enabled';
                const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';

                // Elements
                const select = (selector) => angular.element(wrapper[0].querySelector(selector));
                const composeDiv = select('div.compose') as JQuery<HTMLElement>;
                const emojiTrigger = select('i.emoji-trigger') as JQuery<HTMLElement>;
                const emojiKeyboard = select('.emoji-keyboard') as JQuery<HTMLElement>;
                const sendTrigger = select('i.send-trigger') as JQuery<HTMLElement>;
                const fileTrigger = select('i.file-trigger') as JQuery<HTMLElement>;
                const fileInput = select('input.file-input') as JQuery<HTMLInputElement>;

                // Initialize compose area lib
                const composeArea = ComposeArea.bind_to(composeDiv[0], CONFIG.COMPOSE_AREA_LOG_LEVEL);
                if (scope.onInit) {
                    scope.onInit(composeArea);
                }

                // Set initial text
                if (scope.initialData.initialText) {
                    composeDiv[0].innerText = scope.initialData.initialText;
                    scope.initialData.initialText = '';
                } else if (scope.initialData.draft !== undefined) {
                    composeDiv[0].innerText = scope.initialData.draft;
                }

                let chatBlocked = false;

                // Function to update blocking state
                function setChatBlocked(blocked: boolean) {
                    chatBlocked = blocked;
                    log.debug('Receiver blocked:', blocked);
                    if (blocked) {
                        sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        emojiTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        fileTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        composeDiv.attr('contenteditable', 'false');
                        if (emojiKeyboard.hasClass('active')) {
                            hideEmojiPicker();
                        }
                    } else {
                        if (composeAreaIsEmpty()) {
                            sendTrigger.removeClass(TRIGGER_ENABLED_CSS_CLASS);
                        } else {
                            sendTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
                        }
                        emojiTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
                        fileTrigger.addClass(TRIGGER_ENABLED_CSS_CLASS);
                        composeDiv.attr('contenteditable', 'true');
                    }
                }

                // Initialize blocking state
                setChatBlocked(receiverService.isBlocked(scope.receiver));

                // Watch `isBlocked` flag for changes
                scope.$watch(
                    (_scope) => receiverService.isBlocked(_scope.receiver),
                    (isBlocked: boolean, wasBlocked: boolean) => {
                        if (isBlocked !== wasBlocked) {
                            setChatBlocked(isBlocked);
                        }
                    },
                );

                // Typing events
                let stopTypingTimer: ng.IPromise<void> = null;

                function stopTyping() {
                    // We can only stop typing of the timer is set (meaning
                    // that we started typing earlier)
                    if (stopTypingTimer !== null) {
                        // Cancel timer
                        timeoutService.cancel(stopTypingTimer);
                        stopTypingTimer = null;

                        // Send stop typing message
                        scope.stopTyping();
                    }
                }

                function startTyping() {
                    if (stopTypingTimer === null) {
                        // If the timer wasn't set previously, we just
                        // started typing!
                        scope.startTyping();
                    } else {
                        // Cancel timer, we'll re-create it
                        timeoutService.cancel(stopTypingTimer);
                    }

                    // Define a timeout to send the stopTyping event
                    stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
                }

                // Determine whether field is empty
                function composeAreaIsEmpty() {
                    return composeArea.get_text().length === 0;
                }

                // Submit the text from the compose area.
                //
                // Emoji images are converted to their alt text in this process.
                function submitText(): Promise<any> {
                    const text = composeArea.get_text().replace(/\r/g, '');

                    return new Promise((resolve, reject) => {
                        const submitTexts = (strings: string[]) => {
                            const messages: threema.TextMessageData[] = [];
                            for (const piece of strings) {
                                messages.push({
                                    text: piece,
                                });
                            }

                            scope.submit('text', messages)
                                .then(resolve)
                                .catch(reject);
                        };

                        if (text.length > scope.maxTextLength) {
                            const pieces: string[] = stringService.byteChunk(text, scope.maxTextLength, 50);
                            const confirm = $mdDialog.confirm()
                                .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
                                .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
                                    max: scope.maxTextLength,
                                    count: pieces.length,
                                }))
                                .ok($translate.instant('common.YES'))
                                .cancel($translate.instant('common.NO'));

                            $mdDialog.show(confirm).then(function() {
                                submitTexts(pieces);
                            }, () => {
                                reject();
                            });
                        } else {
                            submitTexts([text]);
                        }
                    });
                }

                function sendText(): boolean {
                    if (!composeAreaIsEmpty()) {
                        submitText().then(() => {
                            // Clear compose div
                            composeArea.clear();
                            composeArea.focus();

                            // Send stopTyping event
                            stopTyping();

                            // Clear draft
                            scope.onTyping('');

                            updateView();
                        }).catch(() => {
                            // do nothing
                            log.warn('Failed to submit text');
                        });

                        return true;
                    }
                    return false;
                }

                // Handle typing events

                let isComposing = false;
                let compositionHasJustEnded = false; // We all love Safari!

                function onCompositionStart(ev: CompositionEvent): void {
                    isComposing = true;
                }

                function onCompositionEnd(ev: CompositionEvent): void {
                    isComposing = false;
                    compositionHasJustEnded = true;
                }

                function onKeyDown(ev: KeyboardEvent): void {
                    // If enter is pressed, prevent default event from being dispatched
                    if (!ev.shiftKey && ev.key === 'Enter') {
                        ev.preventDefault();
                    }

                    // If the keydown is handled and aborted outside
                    if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
                        ev.preventDefault();
                        return;
                    }

                    // If the enter key is part of a composition (e.g. when
                    // entering text with an IME), don't submit the text.
                    // See https://github.com/threema-ch/threema-web/issues/777
                    if ((ev as any).isComposing || isComposing || compositionHasJustEnded) {
                        return;
                    }

                    // If a : is pressed, possibly insert emoji
                    if (ev.key === ':') {
                        const modified = onEmojiShortcodeKeyPressed(ev, ':', false);
                        if (modified) {
                            ev.preventDefault();
                            return;
                        }
                    }

                    // At link time, the element is not yet evaluated.
                    // Therefore add following code to end of event loop.
                    $timeout(() => {
                        // Shift + enter to insert a newline. Enter to send.
                        if (!ev.shiftKey && ev.key === 'Enter') {
                            if (sendText()) {
                                return;
                            }
                        }

                        updateView();
                    }, 0);
                }

                function onKeyUp(ev: KeyboardEvent): void {
                    compositionHasJustEnded = false;

                    // If the compose area contains only a single <br>, make it fully empty.
                    // See also: https://stackoverflow.com/q/14638887/284318
                    const text = composeArea.get_text(true);
                    if (text === '\n') {
                        composeDiv[0].innerText = '';
                    }

                    // Update typing information
                    if (text.trim().length === 0) {
                        stopTyping();
{'fraction_non_alphanumeric': 0.08941480861426071, 'fraction_numerical': 0.002625370976333612, 'mean_word_length': 1.140832450924493, 'pattern_counts': {'":': 0, '<': 25, '<?xml version=': 0, '>': 43, 'https://': 4, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 8, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '6588750', 'n_tokens_mistral': 6180, 'n_tokens_neox': 5694, 'n_words': 1463}